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 7061cb5d..00000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -sudo: false -language: python - -matrix: - include: - - python: 3.6 - - python: 3.7 - -install: - - travis_retry pip install . pytest isort black mypy - - pip list - -script: - - echo "$TRAVIS_PYTHON_VERSION" - - ./tests/run_tests.py - - # Check wheather the imports were sorted correctly. - - isort -c -rc ptpython tests setup.py examples - - - black --check ptpython setup.py examples - - # Type checking - - mypy ptpython diff --git a/CHANGELOG b/CHANGELOG index d6220bda..7706260d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,300 @@ 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 ----------------- 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 aa0c8eaa..06c1e02b 100644 --- a/README.rst +++ b/README.rst @@ -12,7 +12,7 @@ ptpython .. image :: https://github.com/jonathanslenders/ptpython/raw/master/docs/images/example1.png Ptpython is an advanced Python REPL. It should work on all -Python versions from 2.6 up to 3.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 @@ -50,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 **************** @@ -91,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 ***************** @@ -117,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 ******************* @@ -131,11 +200,19 @@ navigation mode. Configuration ************* -It is possible to create a ``$XDG_CONFIG_HOME/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 *************** @@ -151,7 +228,7 @@ This is also available for embedding: .. code:: python - from ptpython.ipython.repl import embed + from ptpython.ipython import embed embed(globals(), locals()) @@ -178,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 *** @@ -188,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. @@ -208,16 +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/prompt-toolkit/ptpython.svg?branch=master - :target: https://travis-ci.org/prompt-toolkit/ptpython# +.. |Build Status| image:: https://github.com/prompt-toolkit/ptpython/actions/workflows/test.yaml/badge.svg + :target: https://github.com/prompt-toolkit/ptpython/actions/workflows/test.yaml .. |License| image:: https://img.shields.io/github/license/prompt-toolkit/ptpython.svg :target: https://github.com/prompt-toolkit/ptpython/blob/master/LICENSE -.. |PyPI| image:: https://pypip.in/version/ptpython/badge.svg - :target: https://pypi.python.org/pypi/ptpython/ +.. |PyPI| image:: https://img.shields.io/pypi/v/ptpython.svg + :target: https://pypi.org/project/ptpython/ :alt: Latest Version diff --git a/docs/concurrency-challenges.rst b/docs/concurrency-challenges.rst 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 4dbbbcdd..cb909731 100755 --- a/examples/asyncio-python-embed.py +++ b/examples/asyncio-python-embed.py @@ -11,6 +11,7 @@ to stdout, it won't break the input line, but instead writes nicely above the prompt. """ + import asyncio from ptpython.repl import embed @@ -19,19 +20,17 @@ 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. @@ -40,21 +39,16 @@ def interactive_shell(): '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() + asyncio.run(main()) diff --git a/examples/asyncio-ssh-python-embed.py b/examples/asyncio-ssh-python-embed.py index 86b56073..bf79df78 100755 --- a/examples/asyncio-ssh-python-embed.py +++ b/examples/asyncio-ssh-python-embed.py @@ -5,6 +5,7 @@ Run this example and then SSH to localhost, port 8222. """ + import asyncio import logging @@ -32,31 +33,25 @@ def session_requested(self): return ReplSSHServerSession(self.get_namespace) -def main(port=8222): +async def main(port: int = 8222) -> None: """ Example that starts the REPL through an SSH server. """ - loop = asyncio.get_event_loop() - # Namespace exposed in the REPL. environ = {"hello": "world"} # Start SSH server. - def create_server(): + def create_server() -> MySSHServer: return MySSHServer(lambda: environ) - print("Listening on :%i" % port) - print('To connect, do "ssh localhost -p %i"' % port) + print(f"Listening on: {port}") + print(f'To connect, do "ssh localhost -p {port}"') - loop.run_until_complete( - asyncssh.create_server( - create_server, "", port, server_host_keys=["/etc/ssh/ssh_host_dsa_key"] - ) + await asyncssh.create_server( + create_server, "", port, server_host_keys=["/etc/ssh/ssh_host_dsa_key"] ) - - # Run eventloop. - loop.run_forever() + await asyncio.Future() # Wait forever. if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/examples/ptpython_config/config.py b/examples/ptpython_config/config.py index 1a009018..bfd3914e 100644 --- a/examples/ptpython_config/config.py +++ b/examples/ptpython_config/config.py @@ -2,8 +2,9 @@ Configuration example for ``ptpython``. Copy this file to $XDG_CONFIG_HOME/ptpython/config.py +On Linux, this is: ~/.config/ptpython/config.py +On macOS, this is: ~/Library/Application Support/ptpython/config.py """ -from __future__ import unicode_literals from prompt_toolkit.filters import ViInsertMode from prompt_toolkit.key_binding.key_processor import KeyPress @@ -12,7 +13,7 @@ from ptpython.layout import CompletionVisualisation -__all__ = ("configure",) +__all__ = ["configure"] def configure(repl): @@ -50,7 +51,7 @@ def configure(repl): # Swap light/dark colors on or off repl.swap_light_and_dark = False - # Highlight matching parethesis. + # Highlight matching parentheses. repl.highlight_matching_parenthesis = True # Line wrapping. (Instead of horizontal scrolling.) @@ -70,6 +71,9 @@ def configure(repl): # Vi mode. repl.vi_mode = False + # Enable the modal cursor (when using Vi mode). Other options are 'Block', 'Underline', 'Beam', 'Blink under', 'Blink block', and 'Blink beam' + repl.cursor_shape_config = "Modal (vi)" + # Paste mode. (When True, don't insert whitespace after new line.) repl.paste_mode = False @@ -107,14 +111,24 @@ 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_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_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 @@ -127,22 +141,22 @@ def configure(repl): # Install custom colorscheme named 'my-colorscheme' and use it. """ - repl.install_ui_colorscheme('my-colorscheme', Style.from_dict(_custom_ui_colorscheme)) - repl.use_ui_colorscheme('my-colorscheme') + 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() """ @@ -150,22 +164,22 @@ def _(event): # 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() @@ -174,7 +188,7 @@ 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 diff --git a/examples/python-embed-with-custom-prompt.py b/examples/python-embed-with-custom-prompt.py index f9f68cc2..5e8c7079 100755 --- a/examples/python-embed-with-custom-prompt.py +++ b/examples/python-embed-with-custom-prompt.py @@ -2,27 +2,27 @@ """ Example of embedding a Python REPL, and setting a custom prompt. """ -from prompt_toolkit.formatted_text import HTML -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): +def configure(repl) -> None: # Probably, the best is to add a new PromptStyle to `all_prompt_styles` and # activate it. This way, the other styles are still selectable from the # menu. class CustomPrompt(PromptStyle): - def in_prompt(self): + def in_prompt(self) -> AnyFormattedText: return HTML("Input[%s]: ") % ( repl.current_statement_index, ) - def in2_prompt(self, width): + def in2_prompt(self, width: int) -> AnyFormattedText: return "...: ".rjust(width) - def out_prompt(self): + def out_prompt(self) -> AnyFormattedText: return HTML("Result[%s]: ") % ( repl.current_statement_index, ) @@ -31,7 +31,7 @@ def out_prompt(self): repl.prompt_style = "custom" -def main(): +def main() -> None: embed(globals(), locals(), configure=configure) diff --git a/examples/python-embed.py b/examples/python-embed.py index ac2cd06f..a7481011 100755 --- a/examples/python-embed.py +++ b/examples/python-embed.py @@ -1,10 +1,10 @@ #!/usr/bin/env python -""" -""" +""" """ + from ptpython.repl import embed -def main(): +def main() -> None: embed(globals(), locals(), vi_mode=False) diff --git a/examples/python-input.py b/examples/python-input.py index 567c2ee6..d586d0f5 100755 --- a/examples/python-input.py +++ b/examples/python-input.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -""" -""" +""" """ + from ptpython.python_input import PythonInput diff --git a/examples/ssh-and-telnet-embed.py b/examples/ssh-and-telnet-embed.py index 541b885c..2b293e6f 100755 --- a/examples/ssh-and-telnet-embed.py +++ b/examples/ssh-and-telnet-embed.py @@ -6,19 +6,23 @@ https://gist.github.com/vxgmichel/7685685b3e5ead04ada4a3ba75a48eef """ -import pathlib +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 -from prompt_toolkit import print_formatted_text -from prompt_toolkit.contrib.telnet.server import TelnetServer -from prompt_toolkit.contrib.ssh.server import PromptToolkitSSHServer - -def ensure_key(filename="ssh_host_key"): +def ensure_key(filename: str = "ssh_host_key") -> str: path = pathlib.Path(filename) if not path.exists(): rsa_key = asyncssh.generate_private_key("ssh-rsa") @@ -26,12 +30,12 @@ def ensure_key(filename="ssh_host_key"): return str(path) -async def interact(connection=None): +async def interact(connection: PromptToolkitSSHSession | TelnetConnection) -> None: global_dict = {**globals(), "print": print_formatted_text} await embed(return_asyncio_coroutine=True, globals=global_dict) -async def main(ssh_port=8022, telnet_port=8023): +async def main(ssh_port: int = 8022, telnet_port: int = 8023) -> None: ssh_server = PromptToolkitSSHServer(interact=interact) await asyncssh.create_server( lambda: ssh_server, "", ssh_port, server_host_keys=[ensure_key()] diff --git a/examples/test-cases/ptpython-in-other-thread.py b/examples/test-cases/ptpython-in-other-thread.py 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/mypy.ini b/mypy.ini deleted file mode 100644 index 5a7ef2eb..00000000 --- a/mypy.ini +++ /dev/null @@ -1,6 +0,0 @@ -[mypy] -ignore_missing_imports = True -no_implicit_optional = True -platform = win32 -strict_equality = True -strict_optional = True diff --git a/ptpython/repl.py b/ptpython/repl.py deleted file mode 100644 index ba95a3d5..00000000 --- a/ptpython/repl.py +++ /dev/null @@ -1,392 +0,0 @@ -""" -Utility for creating a Python repl. - -:: - - from ptpython.repl import embed - embed(globals(), locals(), vi_mode=False) - -""" -import asyncio -import builtins -import os -import sys -import traceback -import warnings -from typing import Any, Callable, ContextManager, Dict, Optional - -from prompt_toolkit.document import Document -from prompt_toolkit.formatted_text import ( - FormattedText, - PygmentsTokens, - fragment_list_width, - merge_formatted_text, - to_formatted_text, -) -from prompt_toolkit.formatted_text.utils import fragment_list_width -from prompt_toolkit.key_binding.vi_state import InputMode -from prompt_toolkit.patch_stdout import patch_stdout as patch_stdout_context -from prompt_toolkit.shortcuts import clear_title, print_formatted_text, set_title -from prompt_toolkit.utils import DummyContext -from pygments.lexers import PythonLexer, PythonTracebackLexer -from pygments.token import Token - -from .eventloop import inputhook -from .python_input import PythonInput - -__all__ = ["PythonRepl", "enable_deprecation_warnings", "run_config", "embed"] - - -class PythonRepl(PythonInput): - def __init__(self, *a, **kw) -> None: - self._startup_paths = kw.pop("startup_paths", None) - super().__init__(*a, **kw) - self._load_start_paths() - self.pt_loop = asyncio.new_event_loop() - - def _load_start_paths(self) -> None: - " Start the Read-Eval-Print Loop. " - if self._startup_paths: - for path in self._startup_paths: - if os.path.exists(path): - with open(path, "rb") as f: - code = compile(f.read(), path, "exec") - exec(code, self.get_globals(), self.get_locals()) - else: - output = self.app.output - output.write("WARNING | File not found: {}\n\n".format(path)) - - def run(self) -> None: - # In order to make sure that asyncio code written in the - # interactive shell doesn't interfere with the prompt, we run the - # prompt in a different event loop. - # If we don't do this, people could spawn coroutine with a - # while/true inside which will freeze the prompt. - - try: - old_loop: Optional[asyncio.AbstractEventLoop] = asyncio.get_event_loop() - except RuntimeError: - # This happens when the user used `asyncio.run()`. - old_loop = None - - asyncio.set_event_loop(self.pt_loop) - try: - return self.pt_loop.run_until_complete(self.run_async()) - finally: - # Restore the original event loop. - asyncio.set_event_loop(old_loop) - - async def run_async(self) -> None: - if self.terminal_title: - set_title(self.terminal_title) - - while True: - # 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. - try: - text = await self.app.run_async(pre_run=pre_run) - 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, line: str) -> None: - - 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: str) -> None: - """ - 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: str, mode: str): - " Compile code with the right compiler flags. " - return compile( - code, - "", - mode, - flags=self.get_compiler_flags(), - dont_inherit=True, - ) - - # If the input is single line, remove leading whitespace. - # (This doesn't have to be a syntax error.) - if len(line.splitlines()) == 1: - line = line.strip() - - 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: Dict[str, Any] = self.get_locals() - locals["_"] = locals["_%i" % self.current_statement_index] = result - - if result is not None: - out_prompt = to_formatted_text(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( # type: ignore - "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, - output=output, - ) - - # If not a valid `eval` expression, run using `exec` instead. - except SyntaxError: - code = compile_with_flags(line, "exec") - exec(code, self.get_globals(), self.get_locals()) - - output.flush() - - def _handle_exception(self, e: Exception) -> None: - output = self.app.output - - # Instead of just calling ``traceback.format_exc``, we take the - # traceback and skip the bottom calls of this framework. - t, v, tb = sys.exc_info() - - # Required for pdb.post_mortem() to work. - sys.last_type, sys.last_value, sys.last_traceback = t, v, tb - - tblist = list(traceback.extract_tb(tb)) - - for line_nr, tb_tuple in enumerate(tblist): - if tb_tuple[0] == "": - tblist = tblist[line_nr:] - break - - l = traceback.format_list(tblist) - if l: - l.insert(0, "Traceback (most recent call last):\n") - l.extend(traceback.format_exception_only(t, v)) - - tb_str = "".join(l) - - # Format exception and write to output. - # (We use the default style. Most other styles result - # in unreadable colors for the traceback.) - if self.enable_syntax_highlighting: - tokens = list(_lex_python_traceback(tb_str)) - else: - tokens = [(Token, tb_str)] - - print_formatted_text( - PygmentsTokens(tokens), - style=self._current_style, - style_transformation=self.style_transformation, - include_default_pygments_style=False, - output=output, - ) - - output.write("%s\n" % e) - output.flush() - - def _handle_keyboard_interrupt(self, e: KeyboardInterrupt) -> None: - output = self.app.output - - output.write("\rKeyboardInterrupt\n\n") - output.flush() - - -def _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() -> None: - """ - Show deprecation warnings, when they are triggered directly by actions in - the REPL. This is recommended to call, before calling `embed`. - - e.g. This will show an error message when the user imports the 'sha' - library on Python 2.7. - """ - warnings.filterwarnings("default", category=DeprecationWarning, module="__main__") - - -def run_config(repl: PythonInput, config_file: str = "~/.ptpython/config.py") -> None: - """ - Execute REPL config file. - - :param repl: `PythonInput` instance. - :param config_file: Path of the configuration file. - """ - # Expand tildes. - config_file = os.path.expanduser(config_file) - - def enter_to_continue() -> None: - input("\nPress ENTER to continue...") - - # Check whether this file exists. - if not os.path.exists(config_file): - print("Impossible to read %r" % config_file) - enter_to_continue() - return - - # Run the config file in an empty namespace. - try: - namespace: Dict[str, Any] = {} - - with open(config_file, "rb") as f: - code = compile(f.read(), config_file, "exec") - exec(code, namespace, namespace) - - # Now we should have a 'configure' method in this namespace. We call this - # method with the repl as an argument. - if "configure" in namespace: - namespace["configure"](repl) - - except Exception: - traceback.print_exc() - enter_to_continue() - - -def embed( - globals=None, - locals=None, - configure: Optional[Callable[[PythonRepl], None]] = None, - vi_mode: bool = False, - history_filename: Optional[str] = None, - title: Optional[str] = None, - startup_paths=None, - patch_stdout: bool = False, - return_asyncio_coroutine: bool = False, -) -> None: - """ - Call this to embed Python shell at the current point in your program. - It's similar to `IPython.embed` and `bpython.embed`. :: - - from prompt_toolkit.contrib.repl import embed - embed(globals(), locals()) - - :param vi_mode: Boolean. Use Vi instead of Emacs key bindings. - :param configure: Callable that will be called with the `PythonRepl` as a first - argument, to trigger configuration. - :param title: Title to be displayed in the terminal titlebar. (None or string.) - """ - # Default globals/locals - if globals is None: - globals = { - "__name__": "__main__", - "__package__": None, - "__doc__": None, - "__builtins__": builtins, - } - - locals = locals or globals - - def get_globals(): - return globals - - def get_locals(): - return locals - - # Create REPL. - repl = PythonRepl( - get_globals=get_globals, - get_locals=get_locals, - vi_mode=vi_mode, - history_filename=history_filename, - startup_paths=startup_paths, - ) - - if title: - repl.terminal_title = title - - if configure: - configure(repl) - - # Start repl. - patch_context: ContextManager = patch_stdout_context() if patch_stdout else DummyContext() - - if return_asyncio_coroutine: - - async def coroutine(): - with patch_context: - await repl.run_async() - - return coroutine() - else: - with patch_context: - repl.run() diff --git a/pyproject.toml b/pyproject.toml index b356239f..00e2d5f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,95 @@ -[tool.black] -target-version = ['py36'] - - -[tool.isort] -# isort configuration that is compatible with Black. -multi_line_output = 3 -include_trailing_comma = true -known_first_party = "ptpython" -known_third_party = "prompt_toolkit,pygments,asyncssh" -force_grid_wrap = 0 -use_parentheses = true -line_length = 88 +[project] +name = "ptpython" +version = "3.0.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.cfg b/setup.cfg deleted file mode 100644 index 3c6e79cf..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal=1 diff --git a/setup.py b/setup.py deleted file mode 100644 index e2bf89ba..00000000 --- a/setup.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -from setuptools import find_packages, setup - -with open(os.path.join(os.path.dirname(__file__), "README.rst")) as f: - long_description = f.read() - - -setup( - name="ptpython", - author="Jonathan Slenders", - version="3.0.5", - url="https://github.com/prompt-toolkit/ptpython", - description="Python REPL build on top of prompt_toolkit", - long_description=long_description, - packages=find_packages("."), - install_requires=[ - "appdirs", - "importlib_metadata;python_version<'3.8'", - "jedi>=0.9.0", - "prompt_toolkit>=3.0.0,<3.1.0", - "pygments", - ], - python_requires=">=3.6", - classifiers=[ - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python", - ], - entry_points={ - "console_scripts": [ - "ptpython = ptpython.entry_points.run_ptpython:run", - "ptipython = ptpython.entry_points.run_ptipython:run", - "ptpython%s = ptpython.entry_points.run_ptpython:run" % sys.version_info[0], - "ptpython%s.%s = ptpython.entry_points.run_ptpython:run" - % sys.version_info[:2], - "ptipython%s = ptpython.entry_points.run_ptipython:run" - % sys.version_info[0], - "ptipython%s.%s = ptpython.entry_points.run_ptipython:run" - % sys.version_info[:2], - ] - }, - extras_require={"ptipython": ["ipython"]}, # For ptipython, we need to have IPython -) 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 76% rename from ptpython/__main__.py rename to src/ptpython/__main__.py index 83340a7b..3a2f7ddf 100644 --- a/ptpython/__main__.py +++ b/src/ptpython/__main__.py @@ -1,6 +1,9 @@ """ Make `python -m ptpython` an alias for running `./ptpython`. """ + +from __future__ import annotations + from .entry_points.run_ptpython import run run() diff --git a/ptpython/completer.py b/src/ptpython/completer.py similarity index 50% rename from ptpython/completer.py rename to src/ptpython/completer.py index 9f36aab3..e8bab285 100644 --- a/ptpython/completer.py +++ b/src/ptpython/completer.py @@ -1,7 +1,13 @@ +from __future__ import annotations + import ast +import collections.abc as collections_abc +import inspect import keyword import re -from typing import TYPE_CHECKING, Any, Dict, Iterable, List +from enum import Enum +from itertools import islice +from typing import TYPE_CHECKING, Any, Callable, Iterable from prompt_toolkit.completion import ( CompleteEvent, @@ -9,16 +15,29 @@ 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"] +__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): @@ -26,17 +45,24 @@ class PythonCompleter(Completer): Completer for Python code. """ - def __init__(self, get_globals, get_locals, get_enable_dictionary_completion): + 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.get_enable_dictionary_completion = get_enable_dictionary_completion + self.enable_dictionary_completion = enable_dictionary_completion - self.dictionary_completer = DictionaryCompleter(get_globals, get_locals) + self._system_completer = SystemCompleter() + self._jedi_completer = JediCompleter(get_globals, get_locals) + self._dictionary_completer = DictionaryCompleter(get_globals, get_locals) - self._path_completer_cache = None - self._path_completer_grammar_cache = None + self._path_completer_cache: GrammarCompleter | None = None + self._path_completer_grammar_cache: _CompiledGrammar | None = None @property def _path_completer(self) -> GrammarCompleter: @@ -51,7 +77,7 @@ def _path_completer(self) -> GrammarCompleter: return self._path_completer_cache @property - def _path_completer_grammar(self) -> "_CompiledGrammar": + def _path_completer_grammar(self) -> _CompiledGrammar: """ Return the grammar for matching paths inside strings inside Python code. @@ -62,7 +88,7 @@ def _path_completer_grammar(self) -> "_CompiledGrammar": self._path_completer_grammar_cache = self._create_path_completer_grammar() return self._path_completer_grammar_cache - def _create_path_completer_grammar(self) -> "_CompiledGrammar": + def _create_path_completer_grammar(self) -> _CompiledGrammar: def unwrapper(text: str) -> str: return re.sub(r"\\(.)", r"\1", text) @@ -111,10 +137,14 @@ def _complete_path_while_typing(self, document: Document) -> bool: ) def _complete_python_while_typing(self, document: Document) -> bool: - char_before_cursor = document.char_before_cursor + """ + 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( - document.text - and (char_before_cursor.isalnum() or char_before_cursor in "_.") + text and (char_before_cursor.isalnum() or char_before_cursor in "_.([,") ) def get_completions( @@ -123,86 +153,145 @@ def get_completions( """ 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 self.get_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 + 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 ): - 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 + yield from self._path_completer.get_completions(document, complete_event) - # Do Jedi Python completions. + # Do Jedi 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 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 + ) - 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, - style=_get_style_for_name(c.name_with_symbols), - ) + +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): @@ -214,7 +303,11 @@ class DictionaryCompleter(Completer): function calls, so it only triggers attribute access. """ - def __init__(self, get_globals, get_locals): + def __init__( + self, + get_globals: Callable[[], dict[str, Any]], + get_locals: Callable[[], dict[str, Any]], + ) -> None: super().__init__() self.get_globals = get_globals @@ -275,7 +368,7 @@ def __init__(self, get_globals, get_locals): rf""" {expression} - # Dict loopup to complete (square bracket open + start of + # Dict lookup to complete (square bracket open + start of # string). \[ \s* ([^\[\]]*)$ @@ -288,14 +381,14 @@ def __init__(self, get_globals, get_locals): rf""" {expression} - # Attribute loopup to complete (dot + varname). + # Attribute lookup to complete (dot + varname). \. \s* ([a-zA-Z0-9_]*)$ """, re.VERBOSE, ) - def _lookup(self, expression: str, temp_locals: Dict[str, Any]) -> object: + def _lookup(self, expression: str, temp_locals: dict[str, Any]) -> object: """ Do lookup of `object_var` in the context. `temp_locals` is a dictionary, used for the locals. @@ -308,8 +401,7 @@ def _lookup(self, expression: str, temp_locals: Dict[str, Any]) -> object: def get_completions( self, document: Document, complete_event: CompleteEvent ) -> Iterable[Completion]: - - # First, find all for-loops, and assing the first item of the + # 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() @@ -340,34 +432,82 @@ def _do_repr(self, obj: object) -> str: 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], + temp_locals: dict[str, Any], ) -> Iterable[Completion]: """ Complete the [ or . operator after an object. """ - match = self.expression_pattern.search(document.text_before_cursor) - if match is not None: - object_var = match.groups()[0] - result = self._lookup(object_var, temp_locals) + result = self.eval_expression(document, temp_locals) - if isinstance(result, (list, tuple, dict)): + if result is not None: + if isinstance( + result, + (list, tuple, dict, collections_abc.Mapping, collections_abc.Sequence), + ): yield Completion("[", 0) - elif result: + + 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], + 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() @@ -376,32 +516,32 @@ def _get_item_lookup_completions( result = self._lookup(object_var, temp_locals) # If this object is a dictionary, complete the keys. - if isinstance(result, dict): + if isinstance(result, (dict, collections_abc.Mapping)): # Try to evaluate the key. - key_obj = key + key_obj_str = str(key) for k in [key, key + '"', key + "'"]: try: - key_obj = ast.literal_eval(k) + key_obj_str = str(ast.literal_eval(k)) except (SyntaxError, ValueError): continue else: break for k in result: - if str(k).startswith(str(key_obj)): + if str(k).startswith(key_obj_str): try: k_repr = self._do_repr(k) yield Completion( k_repr + "]", -len(key), display=f"[{k_repr}]", - display_meta=self._do_repr(result[k]), + display_meta=meta_repr(result, k), ) except ReprFailedError: pass # Complete list/tuple index keys. - elif isinstance(result, (list, tuple)): + 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): @@ -411,8 +551,12 @@ def _get_item_lookup_completions( k_repr + "]", -len(key), display=f"[{k_repr}]", - display_meta=self._do_repr(result[k]), + display_meta=meta_repr(result, k), ) + except KeyError: + # `result[k]` lookup failed. Trying to complete + # broken object. + pass except ReprFailedError: pass @@ -420,7 +564,7 @@ def _get_attribute_completions( self, document: Document, complete_event: CompleteEvent, - temp_locals: Dict[str, Any], + temp_locals: dict[str, Any], ) -> Iterable[Completion]: """ Complete attribute names. @@ -434,19 +578,31 @@ def _get_attribute_completions( 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): - yield Completion( - name, -len(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]: + def _sort_attribute_names(self, names: list[str]) -> list[str]: """ Sort attribute names alphabetically, but move the double underscore and underscore names to the end. """ - def sort_key(name: str): + def sort_key(name: str) -> tuple[int, str]: if name.startswith("__"): return (2, name) # Double underscore comes latest. if name.startswith("_"): @@ -456,8 +612,53 @@ def sort_key(name: str): 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. " + "Raised when the repr() call in `DictionaryCompleter` fails." try: @@ -468,10 +669,17 @@ class ReprFailedError(Exception): _builtin_names = [] -def _get_style_for_name(name: str) -> str: +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" 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 79% rename from ptpython/contrib/asyncssh_repl.py rename to src/ptpython/contrib/asyncssh_repl.py index 4c36217d..a86737b6 100644 --- a/ptpython/contrib/asyncssh_repl.py +++ b/src/ptpython/contrib/asyncssh_repl.py @@ -6,21 +6,24 @@ should make sure not to use Python 3-only syntax, because this package should be installable in Python 2 as well! """ + +from __future__ import annotations + import asyncio -from typing import Any, Optional, TextIO, cast +from typing import Any, AnyStr, TextIO, cast import asyncssh from prompt_toolkit.data_structures import Size from prompt_toolkit.input import create_pipe_input from prompt_toolkit.output.vt100 import Vt100_Output -from ptpython.python_input import _GetNamespace +from ptpython.python_input import _GetNamespace, _Namespace from ptpython.repl import PythonRepl __all__ = ["ReplSSHServerSession"] -class ReplSSHServerSession(asyncssh.SSHServerSession): +class ReplSSHServerSession(asyncssh.SSHServerSession[str]): """ SSH server session that runs a Python REPL. @@ -29,11 +32,11 @@ class ReplSSHServerSession(asyncssh.SSHServerSession): """ def __init__( - self, get_globals: _GetNamespace, get_locals: Optional[_GetNamespace] = None + self, get_globals: _GetNamespace, get_locals: _GetNamespace | None = None ) -> None: self._chan: Any = None - def _globals() -> dict: + def _globals() -> _Namespace: data = get_globals() data.setdefault("print", self._print) return data @@ -77,7 +80,7 @@ def _get_size(self) -> Size: width, height, pixwidth, pixheight = self._chan.get_terminal_size() return Size(rows=height, columns=width) - def connection_made(self, chan): + def connection_made(self, chan: Any) -> None: """ Client connected, run repl in coroutine. """ @@ -87,7 +90,7 @@ def connection_made(self, chan): f = asyncio.ensure_future(self.repl.run_async()) # Close channel when done. - def done(_) -> None: + def done(_: object) -> None: chan.close() self._chan = None @@ -96,24 +99,28 @@ def done(_) -> None: def shell_requested(self) -> bool: return True - def terminal_size_changed(self, width, height, pixwidth, pixheight): + def terminal_size_changed( + self, width: int, height: int, pixwidth: int, pixheight: int + ) -> None: """ When the terminal size changes, report back to CLI. """ self.repl.app._on_resize() - def data_received(self, data, datatype): + def data_received(self, data: AnyStr, datatype: int | None) -> None: """ When data is received, send to inputstream of the CLI and repaint. """ - self._input_pipe.send(data) + self._input_pipe.send(data) # type: ignore - def _print(self, *data, sep=" ", end="\n", file=None) -> None: + def _print( + self, *data: object, sep: str = " ", end: str = "\n", file: Any = None + ) -> None: """ Alternative 'print' function that prints back into the SSH channel. """ # Pop keyword-only arguments. (We cannot use the syntax from the # signature. Otherwise, Python2 will give a syntax error message when # installing.) - data = sep.join(map(str, data)) - self._chan.write(data + end) + data_as_str = sep.join(map(str, data)) + self._chan.write(data_as_str + end) diff --git a/ptpython/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/ptpython/entry_points/run_ptipython.py b/src/ptpython/entry_points/run_ptipython.py similarity index 93% rename from ptpython/entry_points/run_ptipython.py rename to src/ptpython/entry_points/run_ptipython.py index 650633ec..b660a0ac 100644 --- a/ptpython/entry_points/run_ptipython.py +++ b/src/ptpython/entry_points/run_ptipython.py @@ -1,4 +1,6 @@ #!/usr/bin/env python +from __future__ import annotations + import os import sys @@ -31,7 +33,7 @@ def run(user_ns=None): path = a.args[0] with open(path, "rb") as f: code = compile(f.read(), path, "exec") - exec(code, {}) + exec(code, {"__name__": "__main__", "__file__": path}) else: enable_deprecation_warnings() @@ -58,7 +60,7 @@ def run(user_ns=None): code = compile(f.read(), path, "exec") exec(code, user_ns, user_ns) else: - print("File not found: {}\n\n".format(path)) + print(f"File not found: {path}\n\n") sys.exit(1) # Apply config file diff --git a/ptpython/entry_points/run_ptpython.py b/src/ptpython/entry_points/run_ptpython.py similarity index 56% rename from ptpython/entry_points/run_ptpython.py rename to src/ptpython/entry_points/run_ptpython.py index aeb5c26d..d083858d 100644 --- a/ptpython/entry_points/run_ptpython.py +++ b/src/ptpython/entry_points/run_ptpython.py @@ -1,45 +1,64 @@ #!/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 '$XDG_CONFIG_HOME/ptpython'. - -i, --interactive= : Start interactive shell after executing this file. - -Other environment variables: -PYTHONSTARTUP: file executed on interactive startup (no default) + +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 typing import Tuple - -try: - from importlib import metadata -except ImportError: - import importlib_metadata as metadata # type: ignore +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 embed, enable_deprecation_warnings, run_config +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): + def print_help(self, file: _SupportsWrite | None = None) -> None: super().print_help() - print("Other environment variables:") - print("PYTHONSTARTUP: file executed on interactive startup (no default)") + 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: @@ -51,6 +70,21 @@ def create_parser() -> _Parser: action="store_true", help="Start interactive shell after executing this file.", ) + parser.add_argument( + "--asyncio", + action="store_true", + help='Run an asyncio event loop to support top-level "await".', + ) + parser.add_argument( + "--light-bg", + action="store_true", + help="Run on a light background (use dark colors for text).", + ) + parser.add_argument( + "--dark-bg", + action="store_true", + help="Run on a dark background (use light colors for text).", + ) parser.add_argument( "--config-file", type=str, help="Location of configuration file." ) @@ -59,18 +93,21 @@ def create_parser() -> _Parser: "-V", "--version", action="version", - version=metadata.version("ptpython"), # type: ignore + version=metadata.version("ptpython"), ) parser.add_argument("args", nargs="*", help="Script and arguments") return parser -def get_config_and_history_file(namespace: argparse.Namespace) -> Tuple[str, str]: +def get_config_and_history_file(namespace: argparse.Namespace) -> tuple[str, str]: """ Check which config/history files to use, ensure that the directories for these files exist, and return the config and history path. """ - config_dir = appdirs.user_config_dir("ptpython", "prompt_toolkit") + 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. @@ -137,7 +174,8 @@ def run() -> None: # --interactive if a.interactive and a.args: - startup_paths.append(a.args[0]) + # 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`. @@ -150,22 +188,32 @@ def run() -> None: path = a.args[0] with open(path, "rb") as f: code = compile(f.read(), path, "exec") - # NOTE: We have to pass an empty dictionary as namespace. Omitting - # this argument causes imports to not be found. See issue #326. - exec(code, {}) + # NOTE: We have to pass a dict as namespace. Omitting this argument + # causes imports to not be found. See issue #326. + # However, an empty dict sets __name__ to 'builtins', which + # breaks `if __name__ == '__main__'` checks. See issue #444. + exec(code, {"__name__": "__main__", "__file__": path}) # Run interactive shell. else: enable_deprecation_warnings() # Apply config file - def configure(repl) -> None: + def configure(repl: PythonRepl) -> None: if os.path.exists(config_file): run_config(repl, config_file) + # 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( + embed_result = embed( # type: ignore vi_mode=a.vi, history_filename=history_file, configure=configure, @@ -173,8 +221,14 @@ def configure(repl) -> None: globals=__main__.__dict__, startup_paths=startup_paths, title="Python REPL (ptpython)", + return_asyncio_coroutine=a.asyncio, ) + if a.asyncio: + print("Starting ptpython asyncio REPL") + print('Use "await" directly instead of "asyncio.run()".') + asyncio.run(embed_result) + if __name__ == "__main__": run() diff --git a/ptpython/eventloop.py b/src/ptpython/eventloop.py similarity index 84% rename from ptpython/eventloop.py rename to src/ptpython/eventloop.py index c841972d..a6462748 100644 --- a/ptpython/eventloop.py +++ b/src/ptpython/eventloop.py @@ -7,25 +7,29 @@ in readline. ``prompt-toolkit`` doesn't understand that input hook, but this will fix it for Tk.) """ + +from __future__ import annotations + import sys import time +from prompt_toolkit.eventloop import InputHookContext + __all__ = ["inputhook"] -def _inputhook_tk(inputhook_context): +def _inputhook_tk(inputhook_context: InputHookContext) -> None: """ Inputhook for Tk. Run the Tk eventloop until prompt-toolkit needs to process the next input. """ # Get the current TK application. - import tkinter - import _tkinter # Keep this imports inline! + import tkinter - root = tkinter._default_root + root = tkinter._default_root # type: ignore - def wait_using_filehandler(): + def wait_using_filehandler() -> None: """ Run the TK eventloop until the file handler that we got from the inputhook becomes readable. @@ -34,7 +38,7 @@ def wait_using_filehandler(): # to process. stop = [False] - def done(*a): + def done(*a: object) -> None: stop[0] = True root.createfilehandler(inputhook_context.fileno(), _tkinter.READABLE, done) @@ -46,7 +50,7 @@ def done(*a): root.deletefilehandler(inputhook_context.fileno()) - def wait_using_polling(): + def wait_using_polling() -> None: """ Windows TK doesn't support 'createfilehandler'. So, run the TK eventloop and poll until input is ready. @@ -65,7 +69,7 @@ def wait_using_polling(): wait_using_polling() -def inputhook(inputhook_context): +def inputhook(inputhook_context: InputHookContext) -> None: # Only call the real input hook when the 'Tkinter' library was loaded. if "Tkinter" in sys.modules or "tkinter" in sys.modules: _inputhook_tk(inputhook_context) diff --git a/ptpython/filters.py b/src/ptpython/filters.py similarity index 87% rename from ptpython/filters.py rename to src/ptpython/filters.py index 1adac135..a2079fd3 100644 --- a/ptpython/filters.py +++ b/src/ptpython/filters.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from prompt_toolkit.filters import Filter @@ -9,7 +11,8 @@ class PythonInputFilter(Filter): - def __init__(self, python_input: "PythonInput") -> None: + def __init__(self, python_input: PythonInput) -> None: + super().__init__() self.python_input = python_input def __call__(self) -> bool: diff --git a/ptpython/history_browser.py b/src/ptpython/history_browser.py similarity index 81% rename from ptpython/history_browser.py rename to src/ptpython/history_browser.py index 6d8ede43..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 annotations + from functools import partial +from typing import TYPE_CHECKING, Callable from prompt_toolkit.application import Application from prompt_toolkit.application.current import get_app @@ -12,8 +16,11 @@ from prompt_toolkit.document import Document from prompt_toolkit.enums import DEFAULT_BUFFER from prompt_toolkit.filters import Condition, has_focus +from prompt_toolkit.formatted_text.base import StyleAndTextTuples from prompt_toolkit.formatted_text.utils import fragment_list_to_text +from prompt_toolkit.history import History from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPressEvent from prompt_toolkit.layout.containers import ( ConditionalContainer, Container, @@ -24,13 +31,23 @@ VSplit, Window, WindowAlign, + WindowRenderInfo, +) +from prompt_toolkit.layout.controls import ( + BufferControl, + FormattedTextControl, + UIContent, ) -from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl from prompt_toolkit.layout.dimension import Dimension as D from prompt_toolkit.layout.layout import Layout from prompt_toolkit.layout.margins import Margin, ScrollbarMargin -from prompt_toolkit.layout.processors import Processor, Transformation +from prompt_toolkit.layout.processors import ( + Processor, + Transformation, + TransformationInput, +) from prompt_toolkit.lexers import PygmentsLexer +from prompt_toolkit.mouse_events import MouseEvent from prompt_toolkit.widgets import Frame from prompt_toolkit.widgets.toolbars import ArgToolbar, SearchToolbar from pygments.lexers import Python3Lexer as PythonLexer @@ -40,10 +57,17 @@ from .utils import if_mousedown +if TYPE_CHECKING: + from typing_extensions import TypeAlias + + from .python_input import PythonInput + HISTORY_COUNT = 2000 __all__ = ["HistoryLayout", "PythonHistory"] +E: TypeAlias = KeyPressEvent + HELP_TEXT = """ This interface is meant to select multiple lines from the history and execute them together. @@ -85,7 +109,8 @@ class BORDER: - " Box drawing characters. " + "Box drawing characters." + HORIZONTAL = "\u2501" VERTICAL = "\u2503" TOP_LEFT = "\u250f" @@ -109,7 +134,7 @@ class HistoryLayout: application. """ - def __init__(self, history): + def __init__(self, history: PythonHistory) -> None: search_toolbar = SearchToolbar() self.help_buffer_control = BufferControl( @@ -201,19 +226,19 @@ def __init__(self, history): self.layout = Layout(self.root_container, history_window) -def _get_top_toolbar_fragments(): +def _get_top_toolbar_fragments() -> StyleAndTextTuples: return [("class:status-bar.title", "History browser - Insert from history")] -def _get_bottom_toolbar_fragments(history): +def _get_bottom_toolbar_fragments(history: PythonHistory) -> StyleAndTextTuples: python_input = history.python_input @if_mousedown - def f1(mouse_event): + def f1(mouse_event: MouseEvent) -> None: _toggle_help(history) @if_mousedown - def tab(mouse_event): + def tab(mouse_event: MouseEvent) -> None: _select_other_window(history) return ( @@ -239,14 +264,16 @@ class HistoryMargin(Margin): This displays a green bar for the selected entries. """ - def __init__(self, history): + def __init__(self, history: PythonHistory) -> None: self.history_buffer = history.history_buffer self.history_mapping = history.history_mapping - def get_width(self, ui_content): + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: return 2 - def create_margin(self, window_render_info, width, height): + def create_margin( + self, window_render_info: WindowRenderInfo, width: int, height: int + ) -> StyleAndTextTuples: document = self.history_buffer.document lines_starting_new_entries = self.history_mapping.lines_starting_new_entries @@ -255,7 +282,7 @@ def create_margin(self, window_render_info, width, height): current_lineno = document.cursor_position_row visible_line_to_input_line = window_render_info.visible_line_to_input_line - result = [] + result: StyleAndTextTuples = [] for y in range(height): line_number = visible_line_to_input_line.get(y) @@ -286,14 +313,16 @@ class ResultMargin(Margin): The margin to be shown in the result pane. """ - def __init__(self, history): + def __init__(self, history: PythonHistory) -> None: self.history_mapping = history.history_mapping self.history_buffer = history.history_buffer - def get_width(self, ui_content): + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: return 2 - def create_margin(self, window_render_info, width, height): + def create_margin( + self, window_render_info: WindowRenderInfo, width: int, height: int + ) -> StyleAndTextTuples: document = self.history_buffer.document current_lineno = document.cursor_position_row @@ -303,7 +332,7 @@ def create_margin(self, window_render_info, width, height): visible_line_to_input_line = window_render_info.visible_line_to_input_line - result = [] + result: StyleAndTextTuples = [] for y in range(height): line_number = visible_line_to_input_line.get(y) @@ -324,7 +353,7 @@ def create_margin(self, window_render_info, width, height): return result - def invalidation_hash(self, document): + def invalidation_hash(self, document: Document) -> int: return document.cursor_position_row @@ -333,13 +362,15 @@ class GrayExistingText(Processor): Turn the existing input, before and after the inserted code gray. """ - def __init__(self, history_mapping): + def __init__(self, history_mapping: HistoryMapping) -> None: self.history_mapping = history_mapping self._lines_before = len( history_mapping.original_document.text_before_cursor.splitlines() ) - def apply_transformation(self, transformation_input): + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: lineno = transformation_input.lineno fragments = transformation_input.fragments @@ -357,17 +388,22 @@ class HistoryMapping: Keep a list of all the lines from the history and the selected lines. """ - def __init__(self, history, python_history, original_document): + def __init__( + self, + history: PythonHistory, + python_history: History, + original_document: Document, + ) -> None: self.history = history self.python_history = python_history self.original_document = original_document self.lines_starting_new_entries = set() - self.selected_lines = set() + self.selected_lines: set[int] = set() # Process history. history_strings = python_history.get_strings() - history_lines = [] + history_lines: list[str] = [] for entry_nr, entry in list(enumerate(history_strings))[-HISTORY_COUNT:]: self.lines_starting_new_entries.add(len(history_lines)) @@ -377,7 +413,7 @@ def __init__(self, history, python_history, original_document): if len(history_strings) > HISTORY_COUNT: history_lines[0] = ( - "# *** History has been truncated to %s lines ***" % HISTORY_COUNT + f"# *** History has been truncated to {HISTORY_COUNT} lines ***" ) self.history_lines = history_lines @@ -389,7 +425,7 @@ def __init__(self, history, python_history, original_document): else: self.result_line_offset = 0 - def get_new_document(self, cursor_pos=None): + def get_new_document(self, cursor_pos: int | None = None) -> Document: """ Create a `Document` instance that contains the resulting text. """ @@ -413,14 +449,14 @@ def get_new_document(self, cursor_pos=None): cursor_pos = len(text) return Document(text, cursor_pos) - def update_default_buffer(self): + def update_default_buffer(self) -> None: b = self.history.default_buffer b.set_document(self.get_new_document(b.cursor_position), bypass_readonly=True) -def _toggle_help(history): - " Display/hide help. " +def _toggle_help(history: PythonHistory) -> None: + "Display/hide help." help_buffer_control = history.history_layout.help_buffer_control if history.app.layout.current_control == help_buffer_control: @@ -429,8 +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 @@ -441,7 +477,11 @@ def _select_other_window(history): layout.current_control = history.history_layout.history_buffer_control -def create_key_bindings(history, python_input, history_mapping): +def create_key_bindings( + history: PythonHistory, + python_input: PythonInput, + history_mapping: HistoryMapping, +) -> KeyBindings: """ Key bindings. """ @@ -449,7 +489,7 @@ def create_key_bindings(history, python_input, history_mapping): handle = bindings.add @handle(" ", filter=has_focus(history.history_buffer)) - def _(event): + def _(event: E) -> None: """ Space: select/deselect line from history pane. """ @@ -475,8 +515,8 @@ def _(event): 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 + 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 @@ -486,7 +526,7 @@ def _(event): @handle(" ", filter=has_focus(DEFAULT_BUFFER)) @handle("delete", filter=has_focus(DEFAULT_BUFFER)) @handle("c-h", filter=has_focus(DEFAULT_BUFFER)) - def _(event): + def _(event: E) -> None: """ Space: remove line from default pane. """ @@ -512,58 +552,58 @@ def _(event): @handle("c-x", filter=main_buffer_focussed, eager=True) # Eager: ignore the Emacs [Ctrl-X Ctrl-X] binding. @handle("c-w", filter=main_buffer_focussed) - def _(event): - " Select other window. " + def _(event: E) -> None: + "Select other window." _select_other_window(history) @handle("f4") - def _(event): - " Switch between Emacs/Vi mode. " + def _(event: E) -> None: + "Switch between Emacs/Vi mode." python_input.vi_mode = not python_input.vi_mode @handle("f1") - def _(event): - " Display/hide help. " + def _(event: E) -> None: + "Display/hide help." _toggle_help(history) @handle("enter", filter=help_focussed) @handle("c-c", filter=help_focussed) @handle("c-g", filter=help_focussed) @handle("escape", filter=help_focussed) - def _(event): - " Leave help. " + def _(event: E) -> None: + "Leave help." event.app.layout.focus_previous() @handle("q", filter=main_buffer_focussed) @handle("f3", filter=main_buffer_focussed) @handle("c-c", filter=main_buffer_focussed) @handle("c-g", filter=main_buffer_focussed) - def _(event): - " Cancel and go back. " + def _(event: E) -> None: + "Cancel and go back." event.app.exit(result=None) @handle("enter", filter=main_buffer_focussed) - def _(event): - " Accept input. " + def _(event: E) -> None: + "Accept input." event.app.exit(result=history.default_buffer.text) enable_system_bindings = Condition(lambda: python_input.enable_system_bindings) @handle("c-z", filter=enable_system_bindings) - def _(event): - " Suspend to background. " + def _(event: E) -> None: + "Suspend to background." event.app.suspend_to_background() return bindings class PythonHistory: - def __init__(self, python_input, original_document): + def __init__(self, python_input: PythonInput, original_document: Document) -> None: """ Create an `Application` for the history screen. This has to be run as a sub application of `python_input`. - When this application runs and returns, it retuns the selected lines. + When this application runs and returns, it returns the selected lines. """ self.python_input = python_input @@ -577,12 +617,14 @@ def __init__(self, python_input, original_document): + document.get_start_of_line_position(), ) + def accept_handler(buffer: Buffer) -> bool: + get_app().exit(result=self.default_buffer.text) + return False + self.history_buffer = Buffer( document=document, on_cursor_position_changed=self._history_buffer_pos_changed, - accept_handler=( - lambda buff: get_app().exit(result=self.default_buffer.text) - ), + accept_handler=accept_handler, read_only=True, ) @@ -597,7 +639,7 @@ def __init__(self, python_input, original_document): self.history_layout = HistoryLayout(self) - self.app = Application( + self.app: Application[str] = Application( layout=self.history_layout.layout, full_screen=True, style=python_input._current_style, @@ -605,9 +647,9 @@ def __init__(self, python_input, original_document): 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: @@ -623,12 +665,14 @@ 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 + 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. """ + 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 @@ -639,6 +683,8 @@ def _history_buffer_pos_changed(self, _): + self.history_mapping.result_line_offset ) - self.default_buffer.cursor_position = self.default_buffer.document.translate_row_col_to_index( - default_lineno, 0 + self.default_buffer.cursor_position = ( + self.default_buffer.document.translate_row_col_to_index( + default_lineno, 0 + ) ) diff --git a/ptpython/ipython.py b/src/ptpython/ipython.py similarity index 77% rename from ptpython/ipython.py rename to src/ptpython/ipython.py index 2e8d1195..0692214d 100644 --- a/ptpython/ipython.py +++ b/src/ptpython/ipython.py @@ -8,13 +8,18 @@ offer. """ + +from __future__ import annotations + +from typing import Iterable from warnings import warn from IPython import utils as ipy_utils -from IPython.core.inputsplitter import IPythonInputSplitter +from IPython.core.inputtransformer2 import TransformerManager from IPython.terminal.embed import InteractiveShellEmbed as _InteractiveShellEmbed from IPython.terminal.ipapp import load_default_config from prompt_toolkit.completion import ( + CompleteEvent, Completer, Completion, PathCompleter, @@ -25,15 +30,18 @@ from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter from prompt_toolkit.contrib.regular_languages.lexer import GrammarLexer from prompt_toolkit.document import Document -from prompt_toolkit.formatted_text import PygmentsTokens +from prompt_toolkit.formatted_text import AnyFormattedText, PygmentsTokens from prompt_toolkit.lexers import PygmentsLexer, SimpleLexer from prompt_toolkit.styles import Style from pygments.lexers import BashLexer, PythonLexer from ptpython.prompt_style import PromptStyle -from .python_input import PythonCompleter, PythonInput, PythonValidator +from .completer import PythonCompleter +from .python_input import PythonInput +from .repl import PyCF_ALLOW_TOP_LEVEL_AWAIT from .style import default_ui_style +from .validator import PythonValidator __all__ = ["embed"] @@ -46,24 +54,24 @@ class IPythonPrompt(PromptStyle): def __init__(self, prompts): self.prompts = prompts - def in_prompt(self): + def in_prompt(self) -> AnyFormattedText: return PygmentsTokens(self.prompts.in_prompt_tokens()) - def in2_prompt(self, width): + def in2_prompt(self, width: int) -> AnyFormattedText: return PygmentsTokens(self.prompts.continuation_prompt_tokens()) - def out_prompt(self): + def out_prompt(self) -> AnyFormattedText: return [] class IPythonValidator(PythonValidator): def __init__(self, *args, **kwargs): - super(IPythonValidator, self).__init__(*args, **kwargs) - self.isp = IPythonInputSplitter() + super().__init__(*args, **kwargs) + self.isp = TransformerManager() - def validate(self, document): + def validate(self, document: Document) -> None: document = Document(text=self.isp.transform_cell(document.text)) - super(IPythonValidator, self).validate(document) + super().validate(document) def create_ipython_grammar(): @@ -142,26 +150,30 @@ class MagicsCompleter(Completer): def __init__(self, magics_manager): self.magics_manager = magics_manager - def get_completions(self, document, complete_event): + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: text = document.text_before_cursor.lstrip() for m in sorted(self.magics_manager.magics["line"]): if m.startswith(text): - yield Completion("%s" % m, -len(text)) + yield Completion(f"{m}", -len(text)) class AliasCompleter(Completer): def __init__(self, alias_manager): self.alias_manager = alias_manager - def get_completions(self, document, complete_event): + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: text = document.text_before_cursor.lstrip() # aliases = [a for a, _ in self.alias_manager.aliases] aliases = self.alias_manager.aliases for a, cmd in sorted(aliases, key=lambda a: a[0]): if a.startswith(text): - yield Completion("%s" % a, -len(text), display_meta=cmd) + yield Completion(f"{a}", -len(text), display_meta=cmd) class IPythonInput(PythonInput): @@ -201,6 +213,12 @@ def __init__(self, ipython_shell, *a, **kw): self.ui_styles = {"default": Style.from_dict(style_dict)} self.use_ui_colorscheme("default") + def get_compiler_flags(self): + flags = super().get_compiler_flags() + if self.ipython_shell.autoawait: + flags |= PyCF_ALLOW_TOP_LEVEL_AWAIT + return flags + class InteractiveShellEmbed(_InteractiveShellEmbed): """ @@ -240,7 +258,7 @@ def get_globals(): self.python_input = python_input - def prompt_for_code(self): + def prompt_for_code(self) -> str: try: return self.python_input.app.run() except KeyboardInterrupt: @@ -262,13 +280,31 @@ def initialize_extensions(shell, extensions): shell.extension_manager.load_extension(ext) except: warn( - "Error in loading extension: %s" % ext - + "\nCheck your config files in %s" - % ipy_utils.path.get_ipython_dir() + f"Error in loading extension: {ext}" + + f"\nCheck your config files in {ipy_utils.path.get_ipython_dir()}" ) shell.showtraceback() +def run_exec_lines(shell, exec_lines): + """ + Partial copy of run_exec_lines code from IPython.core.shellapp . + """ + try: + iter(exec_lines) + except TypeError: + pass + else: + try: + for line in exec_lines: + try: + shell.run_cell(line, store_history=False) + except: + shell.showtraceback() + except: + shell.showtraceback() + + def embed(**kwargs): """ Copied from `IPython/terminal/embed.py`, but using our `InteractiveShellEmbed` instead. @@ -282,4 +318,22 @@ def embed(**kwargs): kwargs["config"] = config shell = InteractiveShellEmbed.instance(**kwargs) initialize_extensions(shell, config["InteractiveShellApp"]["extensions"]) + run_exec_lines(shell, config["InteractiveShellApp"]["exec_lines"]) + run_startup_scripts(shell) shell(header=header, stack_depth=2, compile_flags=compile_flags) + + +def run_startup_scripts(shell): + """ + Contributed by linyuxu: + https://github.com/prompt-toolkit/ptpython/issues/126#issue-161242480 + """ + import glob + import os + + startup_dir = shell.profile_dir.startup_dir + startup_files = [] + startup_files += glob.glob(os.path.join(startup_dir, "*.py")) + startup_files += glob.glob(os.path.join(startup_dir, "*.ipy")) + for file in startup_files: + shell.run_cell(open(file).read()) diff --git a/ptpython/key_bindings.py b/src/ptpython/key_bindings.py similarity index 81% rename from ptpython/key_bindings.py rename to src/ptpython/key_bindings.py index d5171cc9..48c5f5ae 100644 --- a/ptpython/key_bindings.py +++ b/src/ptpython/key_bindings.py @@ -1,4 +1,9 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from prompt_toolkit.application import get_app +from prompt_toolkit.buffer import Buffer from prompt_toolkit.document import Document from prompt_toolkit.enums import DEFAULT_BUFFER from prompt_toolkit.filters import ( @@ -10,19 +15,28 @@ 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(): +def tab_should_insert_whitespace() -> bool: """ When the 'tab' key is pressed with only whitespace character before the cursor, do autocompletion. Otherwise, insert indentation. @@ -37,7 +51,7 @@ def tab_should_insert_whitespace(): return bool(b.text and (not before_cursor or before_cursor.isspace())) -def load_python_bindings(python_input): +def load_python_bindings(python_input: PythonInput) -> KeyBindings: """ Custom key bindings. """ @@ -47,22 +61,26 @@ def load_python_bindings(python_input): handle = bindings.add @handle("c-l") - def _(event): + def _(event: E) -> None: """ Clear whole screen and render again -- also when the sidebar is visible. """ event.app.renderer.clear() @handle("c-z") - def _(event): + def _(event: E) -> None: """ Suspend. """ 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): + def _(event: E) -> None: """ Show/hide sidebar. """ @@ -73,21 +91,21 @@ def _(event): event.app.layout.focus_last() @handle("f3") - def _(event): + def _(event: E) -> None: """ Select from the history. """ python_input.enter_history() @handle("f4") - def _(event): + def _(event: E) -> None: """ Toggle between Vi and Emacs mode. """ python_input.vi_mode = not python_input.vi_mode @handle("f6") - def _(event): + def _(event: E) -> None: """ Enable/Disable paste mode. """ @@ -96,14 +114,14 @@ def _(event): @handle( "tab", filter=~sidebar_visible & ~has_selection & tab_should_insert_whitespace ) - def _(event): + def _(event: E) -> None: """ When tab should insert whitespace, do that instead of completion. """ event.app.current_buffer.insert_text(" ") @Condition - def is_multiline(): + def is_multiline() -> bool: return document_is_multiline_python(python_input.default_buffer.document) @handle( @@ -115,7 +133,7 @@ def is_multiline(): & ~is_multiline, ) @handle(Keys.Escape, Keys.Enter, filter=~sidebar_visible & emacs_mode) - def _(event): + def _(event: E) -> None: """ Accept input (for single line input). """ @@ -138,21 +156,21 @@ def _(event): & has_focus(DEFAULT_BUFFER) & is_multiline, ) - def _(event): + def _(event: E) -> None: """ Behaviour of the Enter key. Auto indent after newline/Enter. - (When not in Vi navigaton mode, and when multiline is enabled.) + (When not in Vi navigation mode, and when multiline is enabled.) """ b = event.current_buffer empty_lines_required = python_input.accept_input_on_enter or 10000 - def at_the_end(b): - """ we consider the cursor at the end when there is no text after - the cursor, or only whitespace. """ + def at_the_end(b: Buffer) -> bool: + """we consider the cursor at the end when there is no text after + the cursor, or only whitespace.""" text = b.document.text_after_cursor - return text == "" or (text.isspace() and not "\n" in text) + return text == "" or (text.isspace() and "\n" not in text) if python_input.paste_mode: # In paste mode, always insert text. @@ -182,7 +200,7 @@ def at_the_end(b): not get_app().current_buffer.text ), ) - def _(event): + def _(event: E) -> None: """ Override Control-D exit, to ask for confirmation. """ @@ -197,14 +215,14 @@ def _(event): event.app.exit(exception=EOFError) @handle("c-c", filter=has_focus(python_input.default_buffer)) - def _(event): - " Abort when Control-C has been pressed. " + def _(event: E) -> None: + "Abort when Control-C has been pressed." event.app.exit(exception=KeyboardInterrupt, style="class:aborting") return bindings -def load_sidebar_bindings(python_input): +def load_sidebar_bindings(python_input: PythonInput) -> KeyBindings: """ Load bindings for the navigation in the sidebar. """ @@ -216,8 +234,8 @@ def load_sidebar_bindings(python_input): @handle("up", filter=sidebar_visible) @handle("c-p", filter=sidebar_visible) @handle("k", filter=sidebar_visible) - def _(event): - " Go to previous option. " + def _(event: E) -> None: + "Go to previous option." python_input.selected_option_index = ( python_input.selected_option_index - 1 ) % python_input.option_count @@ -225,8 +243,8 @@ def _(event): @handle("down", filter=sidebar_visible) @handle("c-n", filter=sidebar_visible) @handle("j", filter=sidebar_visible) - def _(event): - " Go to next option. " + def _(event: E) -> None: + "Go to next option." python_input.selected_option_index = ( python_input.selected_option_index + 1 ) % python_input.option_count @@ -234,15 +252,15 @@ def _(event): @handle("right", filter=sidebar_visible) @handle("l", filter=sidebar_visible) @handle(" ", filter=sidebar_visible) - def _(event): - " Select next value for current option. " + def _(event: E) -> None: + "Select next value for current option." option = python_input.selected_option option.activate_next() @handle("left", filter=sidebar_visible) @handle("h", filter=sidebar_visible) - def _(event): - " Select previous value for current option. " + def _(event: E) -> None: + "Select previous value for current option." option = python_input.selected_option option.activate_previous() @@ -251,15 +269,15 @@ def _(event): @handle("c-d", filter=sidebar_visible) @handle("enter", filter=sidebar_visible) @handle("escape", filter=sidebar_visible) - def _(event): - " Hide sidebar. " + def _(event: E) -> None: + "Hide sidebar." python_input.show_sidebar = False event.app.layout.focus_last() return bindings -def load_confirm_exit_bindings(python_input): +def load_confirm_exit_bindings(python_input: PythonInput) -> KeyBindings: """ Handle yes/no key presses when the exit confirmation is shown. """ @@ -272,14 +290,14 @@ def load_confirm_exit_bindings(python_input): @handle("Y", filter=confirmation_visible) @handle("enter", filter=confirmation_visible) @handle("c-d", filter=confirmation_visible) - def _(event): + def _(event: E) -> None: """ Really quit. """ event.app.exit(exception=EOFError, style="class:exiting") @handle(Keys.Any, filter=confirmation_visible) - def _(event): + def _(event: E) -> None: """ Cancel exit. """ @@ -289,7 +307,7 @@ def _(event): return bindings -def auto_newline(buffer): +def auto_newline(buffer: Buffer) -> None: r""" Insert \n at the cursor position. Also add necessary padding. """ diff --git a/ptpython/layout.py b/src/ptpython/layout.py similarity index 77% rename from ptpython/layout.py rename to src/ptpython/layout.py index d50a3a53..9768598e 100644 --- a/ptpython/layout.py +++ b/src/ptpython/layout.py @@ -1,16 +1,19 @@ """ Creation of the `Layout` instance for the Python input/REPL. """ + +from __future__ import annotations + import platform import sys from enum import Enum -from typing import TYPE_CHECKING, Optional +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_completions, has_focus, is_done, renderer_height_is_known, @@ -19,6 +22,7 @@ from prompt_toolkit.formatted_text.base import StyleAndTextTuples from prompt_toolkit.key_binding.vi_state import InputMode from prompt_toolkit.layout.containers import ( + AnyContainer, ConditionalContainer, Container, Float, @@ -40,8 +44,10 @@ HighlightIncrementalSearchProcessor, HighlightMatchingBracketProcessor, HighlightSelectionProcessor, + Processor, + TabsProcessor, ) -from prompt_toolkit.lexers import SimpleLexer +from prompt_toolkit.lexers import Lexer, SimpleLexer from prompt_toolkit.mouse_events import MouseEvent from prompt_toolkit.selection import SelectionType from prompt_toolkit.widgets.toolbars import ( @@ -51,9 +57,9 @@ SystemToolbar, ValidationToolbar, ) -from pygments.lexers import PythonLexer from .filters import HasSignature, ShowDocstring, ShowSidebar, ShowSignature +from .prompt_style import PromptStyle from .utils import if_mousedown if TYPE_CHECKING: @@ -63,33 +69,34 @@ class CompletionVisualisation(Enum): - " Visualisation method for the completions. " + "Visualisation method for the completions." + NONE = "none" POP_UP = "pop-up" MULTI_COLUMN = "multi-column" TOOLBAR = "toolbar" -def show_completions_toolbar(python_input: "PythonInput") -> Condition: +def show_completions_toolbar(python_input: PythonInput) -> Condition: return Condition( lambda: python_input.completion_visualisation == CompletionVisualisation.TOOLBAR ) -def show_completions_menu(python_input: "PythonInput") -> Condition: +def show_completions_menu(python_input: PythonInput) -> Condition: return Condition( lambda: python_input.completion_visualisation == CompletionVisualisation.POP_UP ) -def show_multi_column_completions_menu(python_input: "PythonInput") -> Condition: +def show_multi_column_completions_menu(python_input: PythonInput) -> Condition: return Condition( lambda: python_input.completion_visualisation == CompletionVisualisation.MULTI_COLUMN ) -def python_sidebar(python_input: "PythonInput") -> Window: +def python_sidebar(python_input: PythonInput) -> Window: """ Create the `Layout` for the sidebar with the configurable options. """ @@ -97,11 +104,11 @@ def python_sidebar(python_input: "PythonInput") -> Window: def get_text_fragments() -> StyleAndTextTuples: tokens: StyleAndTextTuples = [] - def append_category(category: "OptionCategory") -> None: + def append_category(category: OptionCategory[Any]) -> None: tokens.extend( [ ("class:sidebar", " "), - ("class:sidebar.title", " %-36s" % category.title), + ("class:sidebar.title", f" {category.title:36}"), ("class:sidebar", "\n"), ] ) @@ -115,7 +122,7 @@ def select_item(mouse_event: MouseEvent) -> None: @if_mousedown def goto_next(mouse_event: MouseEvent) -> None: - " Select item and go to next value. " + "Select item and go to next value." python_input.selected_option_index = index option = python_input.selected_option option.activate_next() @@ -123,9 +130,9 @@ def goto_next(mouse_event: MouseEvent) -> None: sel = ",selected" if selected else "" tokens.append(("class:sidebar" + sel, " >" if selected else " ")) - tokens.append(("class:sidebar.label" + sel, "%-24s" % label, select_item)) + tokens.append(("class:sidebar.label" + sel, f"{label:24}", select_item)) tokens.append(("class:sidebar.status" + sel, " ", select_item)) - tokens.append(("class:sidebar.status" + sel, "%s" % status, goto_next)) + tokens.append(("class:sidebar.status" + sel, f"{status}", goto_next)) if selected: tokens.append(("[SetCursorPosition]", "")) @@ -141,7 +148,7 @@ def goto_next(mouse_event: MouseEvent) -> None: append_category(category) for option in category.options: - append(i, option.title, "%s" % option.get_current_value()) + append(i, option.title, str(option.get_current_value())) i += 1 tokens.pop() # Remove last newline. @@ -149,10 +156,10 @@ def goto_next(mouse_event: MouseEvent) -> None: return tokens class Control(FormattedTextControl): - def move_cursor_down(self): + def move_cursor_down(self) -> None: python_input.selected_option_index += 1 - def move_cursor_up(self): + def move_cursor_up(self) -> None: python_input.selected_option_index -= 1 return Window( @@ -164,12 +171,12 @@ def move_cursor_up(self): ) -def python_sidebar_navigation(python_input): +def python_sidebar_navigation(python_input: PythonInput) -> Window: """ Create the `Layout` showing the navigation information for the sidebar. """ - def get_text_fragments(): + def get_text_fragments() -> StyleAndTextTuples: # Show navigation info. return [ ("class:sidebar", " "), @@ -190,13 +197,13 @@ def get_text_fragments(): ) -def python_sidebar_help(python_input): +def python_sidebar_help(python_input: PythonInput) -> Container: """ Create the `Layout` for the help text for the current item in the sidebar. """ token = "class:sidebar.helptext" - def get_current_description(): + def get_current_description() -> str: """ Return the description of the selected option. """ @@ -208,12 +215,15 @@ def get_current_description(): i += 1 return "" - def get_help_text(): + def get_help_text() -> StyleAndTextTuples: return [(token, get_current_description())] return ConditionalContainer( content=Window( - FormattedTextControl(get_help_text), style=token, height=Dimension(min=3) + FormattedTextControl(get_help_text), + style=token, + height=Dimension(min=3), + wrap_lines=True, ), filter=ShowSidebar(python_input) & Condition(lambda: python_input.show_sidebar_help) @@ -221,13 +231,13 @@ def get_help_text(): ) -def signature_toolbar(python_input): +def signature_toolbar(python_input: PythonInput) -> Container: """ Return the `Layout` for the signature. """ - def get_text_fragments(): - result = [] + def get_text_fragments() -> StyleAndTextTuples: + result: StyleAndTextTuples = [] append = result.append Signature = "class:signature-toolbar" @@ -236,7 +246,7 @@ def get_text_fragments(): append((Signature, " ")) try: - append((Signature, sig.full_name)) + 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 @@ -244,30 +254,40 @@ def get_text_fragments(): 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 [] + 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", ", ")) - 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))) + append((Signature + ",current-name", p.description)) else: - append((Signature, str(description))) + 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.params: + if sig.parameters: # Pop last comma result.pop() @@ -279,19 +299,15 @@ def get_text_fragments(): 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) - ) - ) + filter=HasSignature(python_input) + & # Signature needs to be shown. - & ShowSignature(python_input) & + ShowSignature(python_input) + & + # And no sidebar is visible. + ~ShowSidebar(python_input) + & # Not done yet. ~is_done, ) @@ -303,26 +319,28 @@ class PythonPromptMargin(PromptMargin): It shows something like "In [1]:". """ - def __init__(self, python_input) -> None: + def __init__(self, python_input: PythonInput) -> None: self.python_input = python_input - def get_prompt_style(): + def get_prompt_style() -> PromptStyle: return python_input.all_prompt_styles[python_input.prompt_style] def get_prompt() -> StyleAndTextTuples: return to_formatted_text(get_prompt_style().in_prompt()) - def get_continuation(width, line_number, is_soft_wrap): + def get_continuation( + width: int, line_number: int, is_soft_wrap: bool + ) -> StyleAndTextTuples: if python_input.show_line_numbers and not is_soft_wrap: - text = ("%i " % (line_number + 1)).rjust(width) + text = f"{line_number + 1} ".rjust(width) return [("class:line-number", text)] else: - return get_prompt_style().in2_prompt(width) + return to_formatted_text(get_prompt_style().in2_prompt(width)) super().__init__(get_prompt, get_continuation) -def status_bar(python_input: "PythonInput") -> Container: +def status_bar(python_input: PythonInput) -> Container: """ Create the `Layout` for the status bar. """ @@ -350,8 +368,7 @@ def get_text_fragments() -> StyleAndTextTuples: append( ( TB, - "%i/%i " - % (python_buffer.working_index + 1, len(python_buffer._working_lines)), + f"{python_buffer.working_index + 1}/{len(python_buffer._working_lines)} ", ) ) @@ -368,9 +385,9 @@ def get_text_fragments() -> StyleAndTextTuples: else: result.extend( [ - (TB + " class:key", "[F3]", enter_history), + (TB + " class:status-toolbar.key", "[F3]", enter_history), (TB, " History ", enter_history), - (TB + " class:key", "[F6]", toggle_paste_mode), + (TB + " class:status-toolbar.key", "[F6]", toggle_paste_mode), (TB, " ", toggle_paste_mode), ] ) @@ -395,7 +412,7 @@ def get_text_fragments() -> StyleAndTextTuples: ) -def get_inputmode_fragments(python_input: "PythonInput") -> StyleAndTextTuples: +def get_inputmode_fragments(python_input: PythonInput) -> StyleAndTextTuples: """ Return current input mode as a list of (token, text) tuples for use in a toolbar. @@ -423,7 +440,7 @@ def toggle_vi_mode(mouse_event: MouseEvent) -> None: recording_register = app.vi_state.recording_register if recording_register: append((token, " ")) - append((token + " class:record", "RECORD({})".format(recording_register))) + append((token + " class:record", f"RECORD({recording_register})")) append((token, " - ")) if app.current_buffer.selection_state is not None: @@ -456,7 +473,7 @@ def toggle_vi_mode(mouse_event: MouseEvent) -> None: return result -def show_sidebar_button_info(python_input: "PythonInput") -> Container: +def show_sidebar_button_info(python_input: PythonInput) -> Container: """ Create `Layout` for the information in the right-bottom corner. (The right part of the status bar.) @@ -464,7 +481,7 @@ def show_sidebar_button_info(python_input: "PythonInput") -> Container: @if_mousedown def toggle_sidebar(mouse_event: MouseEvent) -> None: - " Click handler for the menu. " + "Click handler for the menu." python_input.show_sidebar = not python_input.show_sidebar version = sys.version_info @@ -474,8 +491,7 @@ def toggle_sidebar(mouse_event: MouseEvent) -> None: ("class:status-toolbar", " - "), ( "class:status-toolbar.python-version", - "%s %i.%i.%i" - % (platform.python_implementation(), version[0], version[1], version[2]), + f"{platform.python_implementation()} {version[0]}.{version[1]}.{version[2]}", ), ("class:status-toolbar", " "), ] @@ -502,7 +518,7 @@ def get_text_fragments() -> StyleAndTextTuples: def create_exit_confirmation( - python_input: "PythonInput", style="class:exit-confirmation" + python_input: PythonInput, style: str = "class:exit-confirmation" ) -> Container: """ Create `Layout` for the exit message. @@ -511,7 +527,7 @@ def create_exit_confirmation( def get_text_fragments() -> StyleAndTextTuples: # Show "Do you really want to exit?" return [ - (style, "\n %s ([y]/n) " % python_input.exit_message), + (style, f"\n {python_input.exit_message} ([y]/n) "), ("[SetCursorPosition]", ""), (style, " \n"), ] @@ -526,7 +542,7 @@ def get_text_fragments() -> StyleAndTextTuples: ) -def meta_enter_message(python_input: "PythonInput") -> Container: +def meta_enter_message(python_input: PythonInput) -> Container: """ Create the `Layout` for the 'Meta+Enter` message. """ @@ -536,7 +552,7 @@ def get_text_fragments() -> StyleAndTextTuples: @Condition def extra_condition() -> bool: - " Only show when... " + "Only show when..." b = python_input.default_buffer return ( @@ -558,33 +574,34 @@ def extra_condition() -> bool: class PtPythonLayout: def __init__( self, - python_input: "PythonInput", - lexer=PythonLexer, - extra_body=None, - extra_toolbars=None, - extra_buffer_processors=None, - input_buffer_height: Optional[AnyDimension] = None, + python_input: PythonInput, + lexer: Lexer, + extra_body: AnyContainer | None = None, + extra_toolbars: list[AnyContainer] | None = None, + extra_buffer_processors: list[Processor] | None = None, + input_buffer_height: AnyDimension | None = None, ) -> None: D = Dimension - extra_body = [extra_body] if extra_body else [] + extra_body_list: list[AnyContainer] = [extra_body] if extra_body else [] extra_toolbars = extra_toolbars or [] - extra_buffer_processors = extra_buffer_processors or [] + input_buffer_height = input_buffer_height or D(min=6) search_toolbar = SearchToolbar(python_input.search_buffer) - def create_python_input_window(): - def menu_position(): + def create_python_input_window() -> Window: + def menu_position() -> int | None: """ When there is no autocompletion menu to be shown, and we have a signature, set the pop-up position at `bracket_start`. """ b = python_input.default_buffer - if b.complete_state is None and python_input.signatures: + 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( @@ -600,6 +617,7 @@ def menu_position(): ), HighlightSelectionProcessor(), DisplayMultipleCursors(), + TabsProcessor(), # Show matching parentheses, but only while editing. ConditionalProcessor( processor=HighlightMatchingBracketProcessor(chars="[](){}"), @@ -613,7 +631,7 @@ def menu_position(): processor=AppendAutoSuggestion(), filter=~is_done ), ] - + extra_buffer_processors, + + (extra_buffer_processors or []), menu_position=menu_position, # Make sure that we always see the result of an reverse-i-search: preview_search=True, @@ -637,7 +655,7 @@ def menu_position(): sidebar = python_sidebar(python_input) self.exit_confirmation = create_exit_confirmation(python_input) - root_container = HSplit( + self.root_container = HSplit( [ VSplit( [ @@ -645,39 +663,35 @@ def menu_position(): [ FloatContainer( content=HSplit( - [create_python_input_window()] + extra_body + [create_python_input_window()] + extra_body_list ), floats=[ Float( xcursor=True, ycursor=True, - content=ConditionalContainer( - content=CompletionsMenu( - scroll_offset=( - lambda: python_input.completion_menu_scroll_offset + 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 + ), ), - 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 - ), + 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, @@ -754,5 +768,5 @@ def menu_position(): ] ) - self.layout = Layout(root_container) + 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/ptpython/prompt_style.py b/src/ptpython/prompt_style.py similarity index 83% rename from ptpython/prompt_style.py rename to src/ptpython/prompt_style.py index 24e5f883..465c3dbe 100644 --- a/ptpython/prompt_style.py +++ b/src/ptpython/prompt_style.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING @@ -16,7 +18,7 @@ class PromptStyle(metaclass=ABCMeta): @abstractmethod def in_prompt(self) -> AnyFormattedText: - " Return the input tokens. " + "Return the input tokens." return [] @abstractmethod @@ -31,7 +33,7 @@ def in2_prompt(self, width: int) -> AnyFormattedText: @abstractmethod def out_prompt(self) -> AnyFormattedText: - " Return the output tokens. " + "Return the output tokens." return [] @@ -40,13 +42,13 @@ class IPythonPrompt(PromptStyle): A prompt resembling the IPython prompt. """ - def __init__(self, python_input: "PythonInput") -> None: + def __init__(self, python_input: PythonInput) -> None: self.python_input = python_input def in_prompt(self) -> AnyFormattedText: return [ ("class:in", "In ["), - ("class:in.number", "%s" % self.python_input.current_statement_index), + ("class:in.number", f"{self.python_input.current_statement_index}"), ("class:in", "]: "), ] @@ -56,7 +58,7 @@ def in2_prompt(self, width: int) -> AnyFormattedText: def out_prompt(self) -> AnyFormattedText: return [ ("class:out", "Out["), - ("class:out.number", "%s" % self.python_input.current_statement_index), + ("class:out.number", f"{self.python_input.current_statement_index}"), ("class:out", "]:"), ("", " "), ] diff --git a/ptpython/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/ptpython/python_input.py b/src/ptpython/python_input.py similarity index 66% rename from ptpython/python_input.py rename to src/ptpython/python_input.py index 18b9ef69..b1773643 100644 --- a/ptpython/python_input.py +++ b/src/ptpython/python_input.py @@ -2,11 +2,12 @@ Application for reading Python input. This can be used for creation of Python REPLs. """ -import __future__ -from asyncio import get_event_loop +from __future__ import annotations + +from asyncio import get_running_loop from functools import partial -from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Dict, Generic, Mapping, TypeVar, Union from prompt_toolkit.application import Application, get_app from prompt_toolkit.auto_suggest import ( @@ -15,10 +16,23 @@ ThreadedAutoSuggest, ) from prompt_toolkit.buffer import Buffer -from prompt_toolkit.completion import Completer, FuzzyCompleter, ThreadedCompleter +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 +from prompt_toolkit.filters import Condition, FilterOrBool from prompt_toolkit.formatted_text import AnyFormattedText from prompt_toolkit.history import ( FileHistory, @@ -36,8 +50,14 @@ 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.lexers import DynamicLexer, Lexer, PygmentsLexer, SimpleLexer +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, @@ -49,9 +69,8 @@ ) from prompt_toolkit.utils import is_windows from prompt_toolkit.validation import ConditionalValidator, Validator -from pygments.lexers import Python3Lexer as PythonLexer -from .completer import PythonCompleter +from .completer import CompletePrivateAttributes, HidePrivateCompleter, PythonCompleter from .history_browser import PythonHistory from .key_bindings import ( load_confirm_exit_bindings, @@ -59,23 +78,41 @@ 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 get_jedi_script_from_document +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"] -_T = TypeVar("_T") +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: ... -class OptionCategory: - def __init__(self, title: str, options: List["Option"]) -> None: + +_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]): +class Option(Generic[_T_lt]): """ Ptpython configuration option that can be shown and modified from the sidebar. @@ -91,10 +128,10 @@ def __init__( self, title: str, description: str, - get_current_value: Callable[[], _T], + get_current_value: Callable[[], _T_lt], # We accept `object` as return type for the select functions, because # often they return an unused boolean. Maybe this can be improved. - get_values: Callable[[], Dict[_T, Callable[[], object]]], + get_values: Callable[[], Mapping[_T_lt, Callable[[], object]]], ) -> None: self.title = title self.description = description @@ -102,7 +139,7 @@ def __init__( self.get_values = get_values @property - def values(self) -> Dict[_T, Callable[[], object]]: + def values(self) -> Mapping[_T_lt, Callable[[], object]]: return self.get_values() def activate_next(self, _previous: bool = False) -> None: @@ -154,42 +191,63 @@ class PythonInput: python_input = PythonInput(...) python_code = python_input.app.run() + + :param create_app: When `False`, don't create and manage a prompt_toolkit + application. The default is `True` and should only be set + to false if PythonInput is being embedded in a separate + prompt_toolkit application. """ def __init__( self, - get_globals: Optional[_GetNamespace] = None, - get_locals: Optional[_GetNamespace] = None, - history_filename: Optional[str] = None, + get_globals: _GetNamespace | None = None, + get_locals: _GetNamespace | None = None, + history_filename: str | None = None, vi_mode: bool = False, - color_depth: Optional[ColorDepth] = None, + color_depth: ColorDepth | None = None, # Input/output. - input: Optional[Input] = None, - output: Optional[Output] = None, + input: Input | None = None, + output: Output | None = None, # For internal use. - extra_key_bindings: Optional[KeyBindings] = None, - _completer: Optional[Completer] = None, - _validator: Optional[Validator] = None, - _lexer: Optional[Lexer] = None, - _extra_buffer_processors=None, - _extra_layout_body=None, - _extra_toolbars=None, - _input_buffer_height=None, + extra_key_bindings: KeyBindings | None = None, + create_app: bool = True, + _completer: Completer | None = None, + _validator: Validator | None = None, + _lexer: Lexer | None = None, + _extra_buffer_processors: list[Processor] | None = None, + _extra_layout_body: AnyContainer | None = None, + _extra_toolbars: list[AnyContainer] | None = None, + _input_buffer_height: AnyDimension | None = None, ) -> None: - self.get_globals: _GetNamespace = get_globals or (lambda: {}) self.get_locals: _GetNamespace = get_locals or self.get_globals - self._completer = _completer or FuzzyCompleter( - PythonCompleter( - self.get_globals, - self.get_locals, - lambda: self.enable_dictionary_completion, + 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, ), - enable_fuzzy=Condition(lambda: self.enable_fuzzy_completion), + lambda: self.complete_private_attributes, ) self._validator = _validator or PythonValidator(self.get_compiler_flags) - self._lexer = _lexer or PygmentsLexer(PythonLexer) + self._lexer = PtpythonLexer(_lexer) self.history: History if history_filename: @@ -198,7 +256,7 @@ def __init__( self.history = InMemoryHistory() self._input_buffer_height = _input_buffer_height - self._extra_layout_body = _extra_layout_body or [] + self._extra_layout_body = _extra_layout_body self._extra_toolbars = _extra_toolbars or [] self._extra_buffer_processors = _extra_buffer_processors or [] @@ -209,15 +267,21 @@ def __init__( 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_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.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 @@ -232,11 +296,18 @@ def __init__( self.enable_syntax_highlighting: bool = True self.enable_fuzzy_completion: bool = False - self.enable_dictionary_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 @@ -244,35 +315,40 @@ def __init__( self.show_exit_confirmation: bool = False # The title to be displayed in the terminal. (None or string.) - self.terminal_title: Optional[str] = None + self.terminal_title: str | None = None self.exit_message: str = "Do you really want to exit?" self.insert_blank_line_after_output: bool = True # (For the REPL.) + 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] = { + self.all_prompt_styles: dict[str, PromptStyle] = { "ipython": IPythonPrompt(self), "classic": ClassicPrompt(), } - self.get_input_prompt = lambda: self.all_prompt_styles[ - self.prompt_style - ].in_prompt() - - self.get_output_prompt = lambda: self.all_prompt_styles[ - self.prompt_style - ].out_prompt() - #: Load styles. - self.code_styles: Dict[str, BaseStyle] = get_all_code_styles() + self.code_styles: dict[str, BaseStyle] = get_all_code_styles() self.ui_styles = get_all_ui_styles() self._current_code_style_name: str = "default" self._current_ui_style_name: str = "default" @@ -290,11 +366,11 @@ def __init__( self.options = self._create_options() self.selected_option_index: int = 0 - #: Incremeting integer counting the current statement. + #: Incrementing integer counting the current statement. self.current_statement_index: int = 1 # Code signatures. (This is set asynchronously after a timeout.) - self.signatures: List[Any] = [] + self.signatures: list[Signature] = [] # Boolean indicating whether we have a signatures thread running. # (Never run more than one at the same time.) @@ -330,10 +406,22 @@ def __init__( extra_toolbars=self._extra_toolbars, ) - self.app = self._create_application(input, output) + # 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() - if vi_mode: - self.app.editing_mode = EditingMode.VI + 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() @@ -343,12 +431,12 @@ def _accept_handler(self, buff: Buffer) -> bool: @property def option_count(self) -> int: - " Return the total amount of options. (In all categories together.) " + "Return the total amount of options. (In all categories together.)" return sum(len(category.options) for category in self.options) @property - def selected_option(self) -> Option: - " Return the currently selected option. " + def selected_option(self) -> Option[Any]: + "Return the currently selected option." i = 0 for category in self.options: for o in category.options: @@ -382,24 +470,36 @@ def get_compiler_flags(self) -> int: return flags - @property - def add_key_binding(self) -> Callable[[_T], _T]: + def add_key_binding( + self, + *keys: Keys | str, + filter: FilterOrBool = True, + eager: FilterOrBool = False, + is_global: FilterOrBool = False, + save_before: Callable[[KeyPressEvent], bool] = (lambda e: True), + record_in_macro: FilterOrBool = True, + ) -> Callable[[_T_kh], _T_kh]: """ Shortcut for adding new key bindings. (Mostly useful for a config.py file, that receives a PythonInput/Repl instance as input.) + All arguments are identical to prompt_toolkit's `KeyBindings.add`. + :: @python_input.add_key_binding(Keys.ControlX, filter=...) def handler(event): ... """ - - def add_binding_decorator(*k, **kw): - return self.extra_key_bindings.add(*k, **kw) - - return add_binding_decorator + return self.extra_key_bindings.add( + *keys, + filter=filter, + eager=eager, + is_global=is_global, + save_before=save_before, + record_in_macro=record_in_macro, + ) def install_code_colorscheme(self, name: str, style: BaseStyle) -> None: """ @@ -453,7 +553,7 @@ def _generate_style(self) -> BaseStyle: self.ui_styles[self._current_ui_style_name], ) - def _create_options(self) -> List[OptionCategory]: + def _create_options(self) -> list[OptionCategory[Any]]: """ Create a list of `Option` instances for the options sidebar. """ @@ -469,15 +569,17 @@ def disable(attribute: str) -> bool: return True def simple_option( - title: str, description: str, field_name: str, values: Optional[List] = None - ) -> Option: - " Create Simple on/of option. " - values = values or ["off", "on"] - - def get_current_value(): + title: str, + description: str, + field_name: str, + values: tuple[str, str] = ("off", "on"), + ) -> Option[str]: + "Create Simple on/of option." + + def get_current_value() -> str: return values[bool(getattr(self, field_name))] - def get_values(): + def get_values() -> dict[str, Callable[[], bool]]: return { values[1]: lambda: enable(field_name), values[0]: lambda: disable(field_name), @@ -505,6 +607,16 @@ def get_values(): "Vi": lambda: enable("vi_mode"), }, ), + Option( + title="Cursor shape", + description="Change the cursor style, possibly according " + "to the Vi input mode.", + get_current_value=lambda: self.cursor_shape_config, + get_values=lambda: { + s: partial(enable, "cursor_shape_config", s) + for s in self.all_cursor_shape_configs + }, + ), simple_option( title="Paste mode", description="When enabled, don't indent automatically.", @@ -523,6 +635,31 @@ def get_values(): "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.", @@ -629,10 +766,15 @@ def get_values(): title="Prompt", description="Visualisation of the prompt. ('>>>' or 'In [1]:')", get_current_value=lambda: self.prompt_style, - get_values=lambda: dict( - (s, partial(enable, "prompt_style", s)) + get_values=lambda: { + s: partial(enable, "prompt_style", s) for s in self.all_prompt_styles - ), + }, + ), + simple_option( + title="Blank line after input", + description="Insert a blank line after the input.", + field_name="insert_blank_line_after_input", ), simple_option( title="Blank line after output", @@ -680,6 +822,17 @@ def get_values(): 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( @@ -687,7 +840,7 @@ def get_values(): [ simple_option( title="Syntax highlighting", - description="Use colors for syntax highligthing", + description="Use colors for syntax highlighting", field_name="enable_syntax_highlighting", ), simple_option( @@ -708,10 +861,10 @@ def get_values(): title="User interface", description="Color scheme to use for the user interface.", get_current_value=lambda: self._current_ui_style_name, - get_values=lambda: dict( - (name, partial(self.use_ui_colorscheme, name)) + get_values=lambda: { + name: partial(self.use_ui_colorscheme, name) for name in self.ui_styles - ), + }, ), Option( title="Color depth", @@ -725,18 +878,18 @@ def get_values(): Option( title="Min brightness", description="Minimum brightness for the color scheme (default=0.0).", - get_current_value=lambda: "%.2f" % self.min_brightness, + get_current_value=lambda: f"{self.min_brightness:.2f}", get_values=lambda: { - "%.2f" % value: partial(self._set_min_brightness, value) + f"{value:.2f}": partial(self._set_min_brightness, value) for value in brightness_values }, ), Option( title="Max brightness", description="Maximum brightness for the color scheme (default=1.0).", - get_current_value=lambda: "%.2f" % self.max_brightness, + get_current_value=lambda: f"{self.max_brightness:.2f}", get_values=lambda: { - "%.2f" % value: partial(self._set_max_brightness, value) + f"{value:.2f}": partial(self._set_max_brightness, value) for value in brightness_values }, ), @@ -745,8 +898,8 @@ def get_values(): ] def _create_application( - self, input: Optional[Input], output: Optional[Output] - ) -> Application: + self, input: Input | None, output: Output | None + ) -> Application[str]: """ Create an `Application` instance. """ @@ -776,6 +929,9 @@ def _create_application( style_transformation=self.style_transformation, include_default_pygments_style=False, reverse_vi_search_direction=True, + cursor=DynamicCursorShapeConfig( + lambda: self.all_cursor_shape_configs[self.cursor_shape_config] + ), input=input, output=output, ) @@ -823,77 +979,71 @@ def vi_mode(self, value: bool) -> None: else: self.editing_mode = EditingMode.EMACS - def _on_input_timeout(self, buff: Buffer, loop=None) -> None: + @property + def app(self) -> Application[str]: + if self._app is None: + return get_app() + return self._app + + def _on_input_timeout(self, buff: Buffer) -> None: """ When there is no input activity, in another thread, get the signature of the current code. """ - app = self.app - # Never run multiple get-signature threads. - if self._get_signatures_thread_running: - return - self._get_signatures_thread_running = True + 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() + ) - document = buff.document + return signatures - loop = loop or get_event_loop() + app = self.app - def run(): - script = get_jedi_script_from_document( - document, self.get_locals(), self.get_globals() - ) + async def on_timeout_task() -> None: + loop = get_running_loop() - # 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, str): - string = string.decode("utf-8") - self.docstring_buffer.reset( - document=Document(string, cursor_position=0) + # Never run multiple get-signature threads. + if self._get_signatures_thread_running: + return + self._get_signatures_thread_running = True + + try: + while True: + document = buff.document + signatures = await loop.run_in_executor( + None, get_signatures_in_executor, document ) - else: - self.docstring_buffer.reset() - app.invalidate() + # If the text didn't change in the meantime, take these + # signatures. Otherwise, try again. + if buff.text == document.text: + break + finally: + self._get_signatures_thread_running = False + + # Set signatures and redraw. + self.signatures = signatures + + # Set docstring in docstring buffer. + if signatures: + self.docstring_buffer.reset( + document=Document(signatures[0].docstring, cursor_position=0) + ) else: - self._on_input_timeout(buff, loop=loop) + self.docstring_buffer.reset() - loop.run_in_executor(None, run) + app.invalidate() + + if app.is_running: + app.create_background_task(on_timeout_task()) def on_reset(self) -> None: self.signatures = [] @@ -902,7 +1052,7 @@ def enter_history(self) -> None: """ Display the history. """ - app = get_app() + app = self.app app.vi_state.input_mode = InputMode.NAVIGATION history = PythonHistory(self, self.default_buffer.document) @@ -920,3 +1070,51 @@ async def do_in_terminal() -> None: 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/ptpython/style.py b/src/ptpython/style.py similarity index 93% rename from ptpython/style.py rename to src/ptpython/style.py index a084c076..c5a04e58 100644 --- a/ptpython/style.py +++ b/src/ptpython/style.py @@ -1,4 +1,4 @@ -from typing import Dict +from __future__ import annotations from prompt_toolkit.styles import BaseStyle, Style, merge_styles from prompt_toolkit.styles.pygments import style_from_pygments_cls @@ -8,11 +8,11 @@ __all__ = ["get_all_code_styles", "get_all_ui_styles", "generate_style"] -def get_all_code_styles() -> Dict[str, BaseStyle]: +def get_all_code_styles() -> dict[str, BaseStyle]: """ Return a mapping from style names to their classes. """ - result: Dict[str, BaseStyle] = { + result: dict[str, BaseStyle] = { name: style_from_pygments_cls(get_style_by_name(name)) for name in get_all_styles() } @@ -20,7 +20,7 @@ def get_all_code_styles() -> Dict[str, BaseStyle]: return result -def get_all_ui_styles() -> Dict[str, BaseStyle]: +def get_all_ui_styles() -> dict[str, BaseStyle]: """ Return a dict mapping {ui_style_name -> style_dict}. """ @@ -75,6 +75,7 @@ def generate_style(python_style: BaseStyle, ui_style: BaseStyle) -> BaseStyle: "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", @@ -87,8 +88,8 @@ def generate_style(python_style: BaseStyle, ui_style: BaseStyle) -> BaseStyle: "arg-toolbar.text": "noinherit", # Signature toolbar. "signature-toolbar": "bg:#44bbbb #000000", - "signature-toolbar.currentname": "bg:#008888 #ffffff bold", - "signature-toolbar.operator": "#000000 bold", + "signature-toolbar current-name": "bg:#008888 #ffffff bold", + "signature-toolbar operator": "#000000 bold", "docstring": "#888888", # Validation toolbar. "validation-toolbar": "bg:#440000 #aaaaaa", @@ -97,10 +98,12 @@ def generate_style(python_style: BaseStyle, ui_style: BaseStyle) -> BaseStyle: "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", diff --git a/ptpython/utils.py b/src/ptpython/utils.py similarity index 60% rename from ptpython/utils.py rename to src/ptpython/utils.py index 130da34f..92cfc2a1 100644 --- a/ptpython/utils.py +++ b/src/ptpython/utils.py @@ -1,15 +1,30 @@ """ For internal use only. """ + +from __future__ import annotations + import re -from typing import Callable, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, Iterable, TypeVar, cast +from prompt_toolkit.document import Document +from prompt_toolkit.formatted_text import to_formatted_text +from prompt_toolkit.formatted_text.utils import fragment_list_to_text from prompt_toolkit.mouse_events import MouseEvent, MouseEventType +if TYPE_CHECKING: + from jedi import Interpreter + + # See: prompt_toolkit/key_binding/key_bindings.py + # Annotating these return types as `object` is what works best, because + # `NotImplemented` is typed `Any`. + NotImplementedOrNone = object + __all__ = [ "has_unclosed_brackets", "get_jedi_script_from_document", "document_is_multiline_python", + "unindent_code", ] @@ -42,7 +57,9 @@ def has_unclosed_brackets(text: str) -> bool: return False -def get_jedi_script_from_document(document, locals, globals): +def get_jedi_script_from_document( + document: Document, locals: dict[str, Any], globals: dict[str, Any] +) -> Interpreter: import jedi # We keep this import in-line, to improve start-up time. # Importing Jedi is 'slow'. @@ -50,8 +67,6 @@ def get_jedi_script_from_document(document, locals, globals): try: return jedi.Interpreter( document.text, - column=document.cursor_position_col, - line=document.cursor_position_row + 1, path="input-text", namespaces=[locals, globals], ) @@ -67,7 +82,7 @@ def get_jedi_script_from_document(document, locals, globals): # Workaround Jedi issue #514: for https://github.com/davidhalter/jedi/issues/514 return None except KeyError: - # Workaroud for a crash when the input is "u'", the start of a unicode string. + # Workaround for a crash when the input is "u'", the start of a unicode string. return None except Exception: # Workaround for: https://github.com/jonathanslenders/ptpython/issues/91 @@ -77,7 +92,7 @@ def get_jedi_script_from_document(document, locals, globals): _multiline_string_delims = re.compile("""[']{3}|["]{3}""") -def document_is_multiline_python(document): +def document_is_multiline_python(document: Document) -> bool: """ Determine whether this is a multiline Python document. """ @@ -132,10 +147,67 @@ def if_mousedown(handler: _T) -> _T: by the Window.) """ - def handle_if_mouse_down(mouse_event: MouseEvent): + def handle_if_mouse_down(mouse_event: MouseEvent) -> NotImplementedOrNone: if mouse_event.event_type == MouseEventType.MOUSE_DOWN: return handler(mouse_event) else: 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 68% rename from ptpython/validator.py rename to src/ptpython/validator.py index b63bedcb..cf2ee542 100644 --- a/ptpython/validator.py +++ b/src/ptpython/validator.py @@ -1,5 +1,12 @@ +from __future__ import annotations + +from typing import Callable + +from prompt_toolkit.document import Document from prompt_toolkit.validation import ValidationError, Validator +from .utils import unindent_code + __all__ = ["PythonValidator"] @@ -11,25 +18,25 @@ class PythonValidator(Validator): active compiler flags. """ - def __init__(self, get_compiler_flags=None): + def __init__(self, get_compiler_flags: Callable[[], int] | None = None) -> None: self.get_compiler_flags = get_compiler_flags - def validate(self, document): + def validate(self, document: Document) -> None: """ Check input for Python syntax errors. """ - text = document.text - - # If the input is single line, remove leading whitespace. - # (This doesn't have to be a syntax error.) - if len(text.splitlines()) == 1: - text = text.strip() + text = unindent_code(document.text) # When the input starts with Ctrl-Z, always accept. This means EOF in a # Python REPL. if text.startswith("\x1a"): return + # When the input starts with an exclamation mark. Accept as shell + # command. + if text.lstrip().startswith("!"): + return + try: if self.get_compiler_flags: flags = self.get_compiler_flags() @@ -41,8 +48,9 @@ def validate(self, document): # Note, the 'or 1' for offset is required because Python 2.7 # gives `None` as offset in case of '4=4' as input. (Looks like # fixed in Python 3.) + # TODO: This is not correct if indentation was removed. index = document.translate_row_col_to_index( - e.lineno - 1, (e.offset or 1) - 1 + (e.lineno or 1) - 1, (e.offset or 1) - 1 ) raise ValidationError(index, f"Syntax Error: {e}") except TypeError as e: @@ -51,4 +59,4 @@ def validate(self, document): except ValueError as e: # In Python 2, compiling "\x9" (an invalid escape sequence) raises # ValueError instead of SyntaxError. - raise ValidationError(0, "Syntax Error: %s" % e) + raise ValidationError(0, f"Syntax Error: {e}") diff --git a/tests/run_tests.py b/tests/run_tests.py deleted file mode 100755 index 2f945163..00000000 --- a/tests/run_tests.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python -import unittest - -import ptpython.completer -import ptpython.eventloop -import ptpython.filters -import ptpython.history_browser -import ptpython.key_bindings -import ptpython.layout -import ptpython.python_input -import ptpython.repl -import ptpython.style -import ptpython.utils -import ptpython.validator - -# For now there are no tests here. -# However this is sufficient for Travis to do at least a syntax check. -# That way we are at least sure to restrict to the Python 2.6 syntax. - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_dummy.py b/tests/test_dummy.py new file mode 100755 index 00000000..922c6a39 --- /dev/null +++ b/tests/test_dummy.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +from __future__ import annotations + +import ptpython.completer +import ptpython.eventloop +import ptpython.filters +import ptpython.history_browser +import ptpython.key_bindings +import ptpython.layout +import ptpython.python_input +import ptpython.repl +import ptpython.style +import ptpython.utils +import ptpython.validator + +# For now there are no tests here. +# However this is sufficient to do at least a syntax check. + + +def test_dummy() -> None: + assert ptpython.completer + assert ptpython.eventloop + assert ptpython.filters + assert ptpython.history_browser + assert ptpython.key_bindings + assert ptpython.layout + assert ptpython.python_input + assert ptpython.repl + assert ptpython.style + assert ptpython.utils + assert ptpython.validator