├── .github
└── workflows
│ └── test.yaml
├── .gitignore
├── CHANGELOG
├── LICENSE
├── MANIFEST.in
├── README.rst
├── docs
├── concurrency-challenges.rst
└── images
│ ├── example1.png
│ ├── file-completion.png
│ ├── ipython.png
│ ├── multiline.png
│ ├── ptpython-history-help.png
│ ├── ptpython-menu.png
│ ├── ptpython.png
│ ├── validation.png
│ └── windows.png
├── examples
├── asyncio-python-embed.py
├── asyncio-ssh-python-embed.py
├── ptpython_config
│ └── config.py
├── python-embed-with-custom-prompt.py
├── python-embed.py
├── python-input.py
├── ssh-and-telnet-embed.py
└── test-cases
│ └── ptpython-in-other-thread.py
├── pyproject.toml
├── src
└── ptpython
│ ├── __init__.py
│ ├── __main__.py
│ ├── completer.py
│ ├── contrib
│ ├── __init__.py
│ └── asyncssh_repl.py
│ ├── entry_points
│ ├── __init__.py
│ ├── run_ptipython.py
│ └── run_ptpython.py
│ ├── eventloop.py
│ ├── filters.py
│ ├── history_browser.py
│ ├── ipython.py
│ ├── key_bindings.py
│ ├── layout.py
│ ├── lexer.py
│ ├── printer.py
│ ├── prompt_style.py
│ ├── py.typed
│ ├── python_input.py
│ ├── repl.py
│ ├── signatures.py
│ ├── style.py
│ ├── utils.py
│ └── validator.py
└── tests
└── test_dummy.py
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: test
2 |
3 | on:
4 | push: # any branch
5 | pull_request:
6 | branches: [master]
7 |
8 | jobs:
9 | test-ubuntu:
10 | runs-on: ubuntu-latest
11 | strategy:
12 | matrix:
13 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
14 |
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: astral-sh/setup-uv@v5
18 | with:
19 | python-version: ${{ matrix.python-version }}
20 | - name: Type Checking
21 | run: |
22 | uvx --with . mypy src/ptpython/
23 | uvx --with . mypy examples/
24 | - name: Code formatting
25 | if: ${{ matrix.python-version == '3.13' }}
26 | run: |
27 | uvx ruff check .
28 | uvx ruff format --check .
29 | - name: Typos
30 | if: ${{ matrix.python-version == '3.13' }}
31 | run: |
32 | uvx typos .
33 | - name: Unit test
34 | run: |
35 | uvx --with . pytest tests/
36 | - name: Validate README.md
37 | if: ${{ matrix.python-version == '3.13' }}
38 | # Ensure that the README renders correctly (required for uploading to PyPI).
39 | run: |
40 | uv pip install readme_renderer
41 | python -m readme_renderer README.rst > /dev/null
42 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 |
5 | # C extensions
6 | *.so
7 |
8 | # Distribution / packaging
9 | .Python
10 | env/
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | lib/
17 | lib64/
18 | parts/
19 | sdist/
20 | var/
21 | *.egg-info/
22 | .installed.cfg
23 | *.egg
24 |
25 | # PyInstaller
26 | # Usually these files are written by a python script from a template
27 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
28 | *.manifest
29 | *.spec
30 |
31 | # Installer logs
32 | pip-log.txt
33 | pip-delete-this-directory.txt
34 |
35 | # Unit test / coverage reports
36 | htmlcov/
37 | .tox/
38 | .coverage
39 | .cache
40 | nosetests.xml
41 | coverage.xml
42 |
43 | # Translations
44 | *.mo
45 | *.pot
46 |
47 | # Django stuff:
48 | *.log
49 |
50 | # Sphinx documentation
51 | docs/_build/
52 |
53 | # PyBuilder
54 | target/
55 |
--------------------------------------------------------------------------------
/CHANGELOG:
--------------------------------------------------------------------------------
1 | CHANGELOG
2 | =========
3 |
4 | 3.0.31: 2025-08-27
5 | ------------------
6 |
7 | Fixes:
8 | - Add `patch_stdout_raw` parameter to `embed()`.
9 | - Fix repl.min_brightness and repl.max_brightness config in history.
10 | - Fix positioning of exit confirmation (compatibilitiy with latest prompt_toolkit).
11 |
12 |
13 | 3.0.30: 2025-04-15
14 | ------------------
15 |
16 | New features:
17 | - Show exception cause/context when printing chained exceptions.
18 | - Reworked project layout and use pyproject.toml instead of setup.py.
19 |
20 | Breaking changes:
21 | - Drop Python 3.7 support.
22 |
23 |
24 | 3.0.29: 2024-07-22
25 | ------------------
26 |
27 | Fixes:
28 | - Further improve performance of dictionary completions.
29 |
30 |
31 | 3.0.28: 2024-07-22
32 | ------------------
33 |
34 | New features:
35 | - Custom 'exit' function to return from REPL that
36 | * doesn't terminate `sys.stdin` when `exit` is called (important for
37 | `embed()`).
38 | * doesn't require to be called with parentheses.
39 |
40 | Fixes:
41 | - Clean up signatures on control-c.
42 |
43 |
44 | 3.0.27: 2024-05-27
45 | ------------------
46 |
47 | - Limit number of completions to 5k (for performance).
48 | - Several typing fixes.
49 |
50 |
51 | 3.0.26: 2024-02-06
52 | ------------------
53 |
54 | Fixes:
55 | - Handle `GeneratorExit` exception when leaving the paginator.
56 |
57 |
58 | 3.0.25: 2023-12-14
59 | ------------------
60 |
61 | Fixes:
62 | - Fix handling of 'config file does not exist' when embedding ptpython.
63 |
64 |
65 | 3.0.24: 2023-12-13
66 | ------------------
67 |
68 | Fixes:
69 | - Don't show "Impossible to read config file" warnings when no config file was
70 | passed to `run_config()`.
71 | - IPython integration fixes:
72 | * Fix top-level await in IPython.
73 | * Fix IPython `DeprecationWarning`.
74 | - Output printing fixes:
75 | * Paginate exceptions if pagination is enabled.
76 | * Handle big outputs without running out of memory.
77 | - Asyncio REPL improvements:
78 | * From now on, passing `--asyncio` is required to activate the asyncio-REPL.
79 | This will ensure that an event loop is created at the start in which we can
80 | run top-level await statements.
81 | * Use `get_running_loop()` instead of `get_event_loop()`.
82 | * Better handling of `SystemExit` and control-c in the async REPL.
83 |
84 |
85 | 3.0.23: 2023-02-22
86 | ------------------
87 |
88 | Fixes:
89 | - Don't print exception messages twice for unhandled exceptions.
90 | - Added cursor shape support.
91 |
92 | Breaking changes:
93 | - Drop Python 3.6 support.
94 |
95 |
96 | 3.0.22: 2022-12-06
97 | ------------------
98 |
99 | New features:
100 | - Improve rendering performance when there are many completions.
101 |
102 |
103 | 3.0.21: 2022-11-25
104 | ------------------
105 |
106 | New features:
107 | - Make ptipython respect more config changes.
108 | (See: https://github.com/prompt-toolkit/ptpython/pull/110 )
109 | - Improved performance of `DictionaryCompleter` for slow mappings.
110 |
111 | Fixes:
112 | - Call `super()` in `PythonInputFilter`. This will prevent potentially breakage
113 | with an upcoming prompt_toolkit change.
114 | (See: https://github.com/prompt-toolkit/python-prompt-toolkit/pull/1690 )
115 | - Improved type annotations.
116 | - Added `py.typed` to the `package_data`.
117 |
118 |
119 | 3.0.20: 2021-09-14
120 | ------------------
121 |
122 | New features:
123 | - For `DictionaryCompleter`: show parentheses after methods.
124 |
125 | Fixes:
126 | - Don't crash when trying to complete broken mappings in `DictionaryCompleter`.
127 | - Don't crash when an older version of `black` is installed that is not
128 | compatible.
129 |
130 |
131 | 3.0.19: 2021-07-08
132 | ------------------
133 |
134 | Fixes:
135 | - Fix handling of `SystemExit` (fixes "ValueError: I/O operation on closed
136 | file").
137 | - Allow usage of `await` in assignment expressions or for-loops.
138 |
139 |
140 | 3.0.18: 2021-06-26
141 | ------------------
142 |
143 | Fixes:
144 | - Made "black" an optional dependency.
145 |
146 |
147 | 3.0.17: 2021-03-22
148 | ------------------
149 |
150 | Fixes:
151 | - Fix leaking file descriptors due to not closing the asyncio event loop after
152 | reading input in a thread.
153 | - Fix race condition during retrieval of signatures.
154 |
155 |
156 | 3.0.16: 2021-02-11
157 | ------------------
158 |
159 | (Commit 7f619e was missing in previous release.)
160 |
161 | Fixes:
162 | - Several fixes to the completion code:
163 | * Give dictionary completions priority over path completions.
164 | * Always call non-fuzzy completer after fuzzy completer to prevent that some
165 | completions were missed out if the fuzzy completer doesn't find them.
166 |
167 |
168 | 3.0.15: 2021-02-11
169 | ------------------
170 |
171 | New features:
172 | - When pressing control-w, only delete characters until a punctuation.
173 |
174 | Fixes:
175 | - Fix `AttributeError` during retrieval of signatures with type annotations.
176 |
177 |
178 | 3.0.14: 2021-02-10
179 | ------------------
180 |
181 | New features:
182 | - Display of signature and completion drop down together.
183 | - If `DictionaryCompleter` is enabled, also retrieve signatures when Jedi
184 | fails, using the same logic.
185 | - List function parameters first and private attributes at the end in the
186 | completion menu.
187 | - Cleanup of the completion code.
188 |
189 | Fixes:
190 | - Handle exceptions raised when `repr()` is called.
191 | - Fix leakage of `exc_info` from eval to exec call.
192 | - Fix handling of `KeyboardInterrupt` in REPL during evaluation of `__repr__`.
193 | - Fix style for signature toolbar.
194 | - Hide signature when sidebar is visible.
195 |
196 |
197 | 3.0.13: 2021-01-26
198 | ------------------
199 |
200 | New features:
201 | - Added 'print all' option to pager.
202 | - Improve handling of indented code:
203 | * Allow multiline input to be indented as a whole (we will unindent before
204 | executing).
205 | * Correctly visualize tabs (instead of ^I, which happens when pasted in
206 | bracketed paste).
207 |
208 | Fixes:
209 | - Fix line ending bug in pager.
210 |
211 |
212 | 3.0.12: 2021-01-24
213 | ------------------
214 |
215 | New features:
216 | - Expose a `get_ptpython` function in the global namespace, to get programmatic
217 | access to the REPL.
218 | - Expose `embed()` at the top level of the package. Make it possible to do
219 | `from ptpython import embed`.
220 |
221 | Fixes:
222 | - Properly handle exceptions when trying to access `__pt_repr__`.
223 | - Properly handle `SystemExit`.
224 |
225 |
226 | 3.0.11: 2021-01-20
227 | ------------------
228 |
229 | New features:
230 | - Add support for top-level await.
231 | - Refactoring of event loop usage:
232 |
233 | * The ptpython input UI will now run in a separate thread. This makes it
234 | possible to properly embed ptpython in an asyncio application, without
235 | having to deal with nested event loops (which asyncio does not support).
236 |
237 | * The "eval" part doesn't anymore take place within a ptpython coroutine, so
238 | it can spawn its own loop if needed. This also fixes `asyncio.run()` usage
239 | in the REPL, which was broken before.
240 |
241 | - Added syntax highlighting and autocompletion for !-style system commands.
242 |
243 | Fixes:
244 | - Remove unexpected additional line after output.
245 | - Fix system prompt. Accept !-style inputs again.
246 | - Don't execute PYTHONSTARTUP when -i flag was given.
247 |
248 |
249 | 3.0.10: 2021-01-13
250 | ------------------
251 |
252 | Fixes:
253 | - Do dictionary completion on Sequence and Mapping objects (from
254 | collections.abc). Note that dictionary completion is still turned off by
255 | default.
256 |
257 |
258 | 3.0.9: 2021-01-10
259 | -----------------
260 |
261 | New features:
262 | - Allow replacing `PythonInput.completer` at runtime (useful for tools build on
263 | top of ptpython).
264 | - Show REPL title in pager.
265 |
266 |
267 | 3.0.8: 2021-01-05
268 | -----------------
269 |
270 | New features:
271 | - Optional output formatting using Black.
272 | - Optional pager for displaying outputs that don't fit on the screen.
273 | - Added --light-bg and --dark-bg flags to automatically optimize the brightness
274 | of the colors according to the terminal background.
275 | - Add `PTPYTHON_CONFIG_HOME` for explicitly setting the config directory.
276 | - Show completion suffixes (like '(' for functions).
277 |
278 | Fixes:
279 | - Fix dictionary completion on Pandas objects.
280 | - Stop using deprecated Jedi functions.
281 |
282 |
283 | 3.0.7: 2020-09-25
284 | -----------------
285 |
286 | New features:
287 | - Option to show/hide private attributes during a completion
288 | - Added `insert_blank_line_after_input` option similar to
289 | `insert_blank_line_after_output`.
290 |
291 | Fixes:
292 | - Fixed some formatting issues of `__pt_repr__`.
293 | - Abbreviate completion meta information for dictionary completer if needed.
294 |
295 |
296 | 3.0.6: 2020-09-23
297 | -----------------
298 |
299 | New features:
300 | - (Experimental) support for `__pt_repr__` methods. If objects implement this
301 | method, this will be used to print the result in the REPL instead of the
302 | normal `__repr__`.
303 | - Added py.typed file, to enable type checking for applications that are
304 | embedding ptpython.
305 |
306 |
307 | 3.0.5: 2020-08-10
308 | -----------------
309 |
310 | Fixes:
311 | - Handle bug in dictionary completion when numeric keys are used.
312 |
313 |
314 | 3.0.4: 2020-08-10
315 | -----------------
316 |
317 | New features:
318 | - Allow leading whitespace before single line expressions.
319 | - Show full syntax error in validator.
320 | - Added `vi_start_in_navigation_mode` and `vi_keep_last_used_mode` options.
321 |
322 | Fixes:
323 | - Improved dictionary completion: handle keys that contain spaces and don't
324 | recognize numbers as variable names.
325 | - Fix in exit confirmation.
326 |
327 |
328 | 3.0.3: 2020-07-10
329 | -----------------
330 |
331 | Fixes:
332 | - Sort attribute names for `DictionaryCompleter` and move underscored
333 | attributes to the end.
334 | - Handle unhandled exceptions in `get_compiler_flags`.
335 | - Improved `run_async` code.
336 | - Fix --version parameter.
337 |
338 |
339 | 3.0.2: 2020-04-14
340 | -----------------
341 |
342 | New features:
343 | - Improved custom dictionary completion:
344 | * Also complete list indexes.
345 | * Also complete attributes after doing a dictionary lookup.
346 | * Also complete iterators in a for-loop.
347 | - Added a 'title' option, so that applications embedding ptpython can set a
348 | title in the status bar.
349 |
350 |
351 | 3.0.1: 2020-02-24
352 | -----------------
353 |
354 | - Fix backwards-compatibility of the `run_config` function. (used by
355 | django-extensions).
356 | - Fix input mode in status bar for block selection.
357 |
358 |
359 | 3.0.0: 2020-01-29
360 | -----------------
361 |
362 | Upgrade to prompt_toolkit 3.0.
363 | Requires at least Python 3.6.
364 |
365 | New features:
366 | - Uses XDG base directory specification.
367 |
368 |
369 | 2.0.5: 2019-10-09
370 | -----------------
371 |
372 | New features:
373 | - Added dictionary completer (off by default).
374 | - Added fuzzy completion (off by default).
375 | - Highlight keywords in completion dropdown menu.
376 | - Enable universal wheels.
377 |
378 | Fixes:
379 | - Fixed embedding repl as asyncio coroutine.
380 | - Fixed patching stdout in embedded repl.
381 | - Fixed ResourceWarning in setup.py.
382 |
383 |
384 | 2.0.4: 2018-10-30
385 | -----------------
386 |
387 | - Fixed ptipython.
388 | - Fixed config: setting of color depth.
389 | - Fixed auto-suggest key bindings.
390 | - Fixed Control-D key binding for exiting REPL when (confirm_exit=False).
391 | - Correctly focus/unfocus sidebar.
392 | - Fixed open_in_editor and suspend key bindings.
393 |
394 |
395 | 2.0.3: 2018-10-12
396 | -----------------
397 |
398 | - Allow changing the min/max brightness.
399 | - Some changes for compatibility with the latest prompt_toolkit.
400 |
401 | 2.0.2: 2018-09-30
402 | -----------------
403 |
404 | Fixes:
405 | - Don't crash the history browser when there was no history.
406 | - Set last exception in the sys module, when an exception was raised.
407 | - Require prompt_toolkit 2.0.5.
408 |
409 |
410 | 2.0.1: 2018-09-30
411 | -----------------
412 |
413 | Upgrade to prompt_toolkit 2.0.x.
414 |
415 |
416 | 0.36: 2016-10-16
417 | ----------------
418 |
419 | New features:
420 | - Support for editing in Vi block mode. (Only enabled for
421 | prompt_toolkit>=1.0.8.)
422 |
423 | Fixes:
424 | - Handle two Jedi crashes. (GitHub ptpython issues #136 and #91.)
425 |
426 | 0.35: 2016-07-19
427 | ----------------
428 |
429 | Fixes:
430 | - Fix in completer. Don't hang when pasting a long string with many
431 | backslashes.
432 | - Fix Python2 bug: crash when filenames contain non-ascii characters.
433 | - Added `pt[i]pythonX` and `pt[i]pythonX.X` commands.
434 | - Compatibility with IPython 5.0.
435 |
436 |
437 | 0.34: 2016-05-06
438 | ---------------
439 |
440 | Bugfix in ptipython: reset input buffer before every read in run().
441 |
442 |
443 | 0.33: 2016-05-05
444 | ---------------
445 |
446 | Upgrade to prompt_toolkit 1.0.0
447 |
448 | Improvements:
449 | - Unindent after typing 'pass'.
450 | - Make it configurable whether or not a blank line has to be inserted after the output.
451 |
452 |
453 | 0.32: 2016-03-29
454 | ---------------
455 |
456 | Fixes:
457 | - Fixed bug when PYTHONSTARTUP was not found.
458 | - Support $PYTHONSTARTUP for ptipython.
459 |
460 |
461 | 0.31: 2016-03-14
462 | ---------------
463 |
464 | Upgrade to prompt_toolkit 0.60
465 |
466 |
467 | 0.30: 2016-02-27
468 | ---------------
469 |
470 | Upgrade to prompt_toolkit 0.59
471 |
472 |
473 | 0.29: 2016-02-24
474 | ----------------
475 |
476 | Upgrade to prompt_toolkit 0.58
477 |
478 | New features:
479 | - Improved mouse support
480 |
481 |
482 | 0.28: 2016-01-04
483 | ----------------
484 |
485 | Upgrade to prompt_toolkit 0.57
486 |
487 |
488 | 0.27: 2016-01-03
489 | ----------------
490 |
491 | Upgrade to prompt_toolkit 0.56
492 |
493 |
494 | 0.26: 2016-01-03
495 | ----------------
496 |
497 | Upgrade to prompt_toolkit 0.55
498 |
499 | Fixes:
500 | - Handle several bugs in Jedi.
501 | - Correctly handle sys.argv when pt(i)python is started with --interactive.
502 | - Support for 24bit true color.
503 | - Take compiler flags into account for ptipython.
504 |
505 |
506 | 0.25: 2015-10-29
507 | ----------------
508 |
509 | Upgrade to prompt_toolkit 0.54
510 |
511 | Fixes:
512 | - Consider input multiline when there's a colon at the end of the line.
513 | - Handle bug in Jedi.
514 | - Enable search bindings in history browser.
515 |
516 |
517 | 0.24: 2015-09-24
518 | ----------------
519 |
520 | Upgrade to prompt_toolkit 0.52
521 |
522 |
523 | 0.23: 2015-09-24
524 | ----------------
525 |
526 | Upgrade to prompt_toolkit 0.51
527 |
528 | New features:
529 | - Mouse support
530 | - Fish style auto suggestion.
531 | - Optionally disabling of line wraps.
532 | - Use Python3Lexer for Python 3.
533 |
534 |
535 | 0.22: 2015-09-06
536 | ----------------
537 |
538 | Upgrade to prompt_toolkit 0.50
539 |
540 | Fixes:
541 | - Correctly accept file parameter in the print function of
542 | asyncssh_repl.ReplSSHServerSession.
543 | - Create config directory if it doesn't exist yet (For IPython entry point.)
544 |
545 | New features:
546 | - Implementation of history-selection: a tool to select lines from the history.
547 | - Make exit message configurable.
548 | - Improve start-up time: Lazy load completer grammar and lazy-import Jedi.
549 | - Make multi-column the default completion visualisation.
550 | - Implementation of a custom prompts. In_tokens and out_tokens can be
551 | customized.
552 | - Made an option to show/hide highlighting for matching parenthesis.
553 | - Some styling improvements.
554 |
555 |
556 | 0.21: 2015-08-08
557 | ---------------
558 |
559 | Upgrade to prompt_toolkit 0.46
560 |
561 | Fixes:
562 | - Correctly add current directory to sys.path.
563 | - Only show search highlighting when the search is the current input buffer.
564 | - Styling fix.
565 |
566 |
567 | 0.20: 2015-07-30
568 | ---------------
569 |
570 | Upgrade to prompt_toolkit 0.45
571 |
572 |
573 | 0.19: 2015-07-30
574 | ---------------
575 |
576 | Upgrade to prompt_toolkit 0.44
577 |
578 | New features:
579 | - Added --interactive option for ptipython.
580 | - A few style improvements.
581 |
582 |
583 | 0.18: 2015-07-15
584 | ---------------
585 |
586 | Fixes:
587 | - Python 2.6 compatibility.
588 |
589 |
590 | 0.17: 2015-07-15
591 | ---------------
592 |
593 | Upgrade to prompt_toolkit 0.43
594 |
595 | New features:
596 | - Integration with Tk eventloop. (This makes turtle and other Tk stuff work
597 | again from the REPL.)
598 | - Multi column completion visualisation.
599 |
600 |
601 | 0.16: 2015-06-25
602 | ---------------
603 |
604 | Upgrade to prompt_toolkit 0.42
605 |
606 | Fixes:
607 | - Workaround for Jedi bug. (Signatures of functions with keyword-only arguments.)
608 | - Correctly show traceback on Python 3.
609 | - Better styling of the options sidebar.
610 |
611 | New features:
612 | - Exit REPL when input starts with Control-Z.
613 | - Set terminal title.
614 | - Display help text in options sidebar.
615 | - Better colorscheme for Windows.
616 |
617 |
618 | 0.15: 2015-06-20
619 | ---------------
620 |
621 | Upgrade to prompt_toolkit 0.41
622 |
623 | Fixes:
624 | - Correct tokens for IPython prompt.
625 | - Syntax fix in asyncssh_repl.
626 |
627 |
628 | 0.14: 2015-06-16
629 | ---------------
630 |
631 | Fix:
632 | - Correct dependency for prompt_toolkit.
633 |
634 | 0.13: 2015-06-15
635 | ---------------
636 |
637 | New features:
638 | - Upgrade to prompt_toolkit 0.40
639 | - Options sidebar.
640 | - Custom color schemes.
641 | - Syntax highlighting of the output.
642 | - Input validation can now be turned off.
643 | - Determine compiler flags dynamically. (Fixes importing unicode_literals).
644 | - Exit confirmation and dialog.
645 | - Autocompletion of IPython %cat command.
646 | - Correctly render tracebacks on Windows.
647 |
648 | 0.12: 2015-06-04
649 | ---------------
650 |
651 | Upgrade to prompt_toolkit 0.39
652 |
653 | 0.11: 2015-05-31
654 | ---------------
655 |
656 | New features:
657 | - Upgrade to prompt-toolkit 0.38.
658 | - Upgrade to Jedi 0.9.0
659 | - Fixed default globals for repl (correct __name, __builtins__, etc...)
660 | - Display deprecation warnings in the REPL.
661 | - Added configuration support.
662 | - Added asyncio-ssh-python-embed example.
663 |
664 |
665 | 0.10: 2015-05-11
666 | ---------------
667 |
668 | Upgrade to prompt-toolkit 0.37.
669 |
670 |
671 | 0.9: 2015-05-07
672 | ---------------
673 |
674 | Upgrade to prompt-toolkit 0.35.
675 |
676 |
677 | 0.8: 2015-04-26
678 | ---------------
679 |
680 | Fixed:
681 | - eval() doesn't run using unicode_literals anymore.
682 | - Upgrade to prompt-toolkit 0.34.
683 |
684 |
685 | 0.7: 2015-04-25
686 | ---------------
687 |
688 | Fixed:
689 | - Upgrade to prompt-toolkit 0.33.
690 |
691 | New features:
692 | - Added complete_while_typing option.
693 |
694 |
695 | 0.6: 2015-04-22
696 | ---------------
697 |
698 | Fixed:
699 | - Upgrade to prompt-toolkit 0.32 which has many new features.
700 |
701 | Changes:
702 | - Removal of tab pages + clean up.
703 | - Pressing enter twice will now always automatically execute the input.
704 | - Improved Control-D key binding.
705 | - Hide docstring by default.
706 |
707 |
708 | 0.5: 2015-01-30
709 | ---------------
710 |
711 | Fixed:
712 | - Tab autocompletion on first line.
713 | - Upgrade to prompt-toolkit 0.31
714 |
715 | New features:
716 | - Simple autocompletion for IPython magics.
717 |
718 | 0.4: 2015-01-26
719 | ---------------
720 |
721 | Fixed:
722 | - Upgrade to prompt-toolkit 0.30
723 |
724 | 0.3: 2015-01-25
725 | ---------------
726 |
727 | Fixed:
728 | - Upgrade to prompt-toolkit 0.28
729 |
730 | 0.2: 2015-01-25
731 | ---------------
732 |
733 | Moved ptpython code from prompt-toolkit inside this repository.
734 |
735 | 0.1: 2014-09-29
736 | ---------------
737 |
738 | Initial ptpython version. (Source code was still in the
739 | prompt-toolkit repository itself.)
740 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2015-2023, Jonathan Slenders
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without modification,
5 | are permitted provided that the following conditions are met:
6 |
7 | * Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 |
10 | * Redistributions in binary form must reproduce the above copyright notice, this
11 | list of conditions and the following disclaimer in the documentation and/or
12 | other materials provided with the distribution.
13 |
14 | * Neither the name of the {organization} nor the names of its
15 | contributors may be used to endorse or promote products derived from
16 | this software without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include *rst LICENSE CHANGELOG MANIFEST.in
2 | recursive-include examples *.py
3 | prune examples/sample?/build
4 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | ptpython
2 | ========
3 |
4 | |Build Status| |PyPI| |License|
5 |
6 | *A better Python REPL*
7 |
8 | ::
9 |
10 | pip install ptpython
11 |
12 | .. image :: ./docs/images/example1.png
13 |
14 | Ptpython is an advanced Python REPL. It should work on all
15 | Python versions from 2.6 up to 3.11 and work cross platform (Linux,
16 | BSD, OS X and Windows).
17 |
18 | Note: this version of ptpython requires at least Python 3.6. Install ptpython
19 | 2.0.5 for older Python versions.
20 |
21 |
22 | Installation
23 | ************
24 |
25 | Install it using pip:
26 |
27 | ::
28 |
29 | pip install ptpython
30 |
31 | Start it by typing ``ptpython``.
32 |
33 |
34 | Features
35 | ********
36 |
37 | - Syntax highlighting.
38 | - Multiline editing (the up arrow works).
39 | - Autocompletion.
40 | - Mouse support. [1]
41 | - Support for color schemes.
42 | - Support for `bracketed paste <https://cirw.in/blog/bracketed-paste>`_ [2].
43 | - Both Vi and Emacs key bindings.
44 | - Support for double width (Chinese) characters.
45 | - ... and many other things.
46 |
47 |
48 | [1] Disabled by default. (Enable in the menu.)
49 |
50 | [2] If the terminal supports it (most terminals do), this allows pasting
51 | without going into paste mode. It will keep the indentation.
52 |
53 | Command Line Options
54 | ********************
55 |
56 | The help menu shows basic command-line options.
57 |
58 | ::
59 |
60 | $ ptpython --help
61 | usage: ptpython [-h] [--vi] [-i] [--light-bg] [--dark-bg] [--config-file CONFIG_FILE]
62 | [--history-file HISTORY_FILE] [-V]
63 | [args ...]
64 |
65 | ptpython: Interactive Python shell.
66 |
67 | positional arguments:
68 | args Script and arguments
69 |
70 | optional arguments:
71 | -h, --help show this help message and exit
72 | --vi Enable Vi key bindings
73 | -i, --interactive Start interactive shell after executing this file.
74 | --asyncio Run an asyncio event loop to support top-level "await".
75 | --light-bg Run on a light background (use dark colors for text).
76 | --dark-bg Run on a dark background (use light colors for text).
77 | --config-file CONFIG_FILE
78 | Location of configuration file.
79 | --history-file HISTORY_FILE
80 | Location of history file.
81 | -V, --version show program's version number and exit
82 |
83 | environment variables:
84 | PTPYTHON_CONFIG_HOME: a configuration directory to use
85 | PYTHONSTARTUP: file executed on interactive startup (no default)
86 |
87 |
88 | __pt_repr__: A nicer repr with colors
89 | *************************************
90 |
91 | When classes implement a ``__pt_repr__`` method, this will be used instead of
92 | ``__repr__`` for printing. Any `prompt_toolkit "formatted text"
93 | <https://python-prompt-toolkit.readthedocs.io/en/master/pages/printing_text.html>`_
94 | can be returned from here. In order to avoid writing a ``__repr__`` as well,
95 | the ``ptpython.utils.ptrepr_to_repr`` decorator can be applied. For instance:
96 |
97 | .. code:: python
98 |
99 | from ptpython.utils import ptrepr_to_repr
100 | from prompt_toolkit.formatted_text import HTML
101 |
102 | @ptrepr_to_repr
103 | class MyClass:
104 | def __pt_repr__(self):
105 | return HTML('<yellow>Hello world!</yellow>')
106 |
107 | More screenshots
108 | ****************
109 |
110 | The configuration menu:
111 |
112 | .. image :: ./docs/images/ptpython-menu.png
113 |
114 | The history page and its help:
115 |
116 | .. image :: ./docs/images/ptpython-history-help.png
117 |
118 | Autocompletion:
119 |
120 | .. image :: ./docs/images/file-completion.png
121 |
122 |
123 | Embedding the REPL
124 | ******************
125 |
126 | Embedding the REPL in any Python application is easy:
127 |
128 | .. code:: python
129 |
130 | from ptpython.repl import embed
131 | embed(globals(), locals())
132 |
133 | You can make ptpython your default Python REPL by creating a `PYTHONSTARTUP file
134 | <https://docs.python.org/3/tutorial/appendix.html#the-interactive-startup-file>`_ containing code
135 | like this:
136 |
137 | .. code:: python
138 |
139 | import sys
140 | try:
141 | from ptpython.repl import embed
142 | except ImportError:
143 | print("ptpython is not available: falling back to standard prompt")
144 | else:
145 | sys.exit(embed(globals(), locals()))
146 |
147 | Note config file support currently only works when invoking `ptpython` directly.
148 | That it, the config file will be ignored when embedding ptpython in an application.
149 |
150 | Multiline editing
151 | *****************
152 |
153 | Multi-line editing mode will automatically turn on when you press enter after a
154 | colon.
155 |
156 | To execute the input in multi-line mode, you can either press ``Alt+Enter``, or
157 | ``Esc`` followed by ``Enter``. (If you want the first to work in the OS X
158 | terminal, you have to check the "Use option as meta key" checkbox in your
159 | terminal settings. For iTerm2, you have to check "Left option acts as +Esc" in
160 | the options.)
161 |
162 | .. image :: ./docs/images/multiline.png
163 |
164 |
165 | Syntax validation
166 | *****************
167 |
168 | Before execution, ``ptpython`` will see whether the input is syntactically
169 | correct Python code. If not, it will show a warning, and move the cursor to the
170 | error.
171 |
172 | .. image :: ./docs/images/validation.png
173 |
174 |
175 | Asyncio REPL and top level await
176 | ********************************
177 |
178 | In order to get top-level ``await`` support, start ptpython as follows:
179 |
180 | .. code::
181 |
182 | ptpython --asyncio
183 |
184 | This will spawn an asyncio event loop and embed the async REPL in the event
185 | loop. After this, top-level await will work and statements like ``await
186 | asyncio.sleep(10)`` will execute.
187 |
188 |
189 | Additional features
190 | *******************
191 |
192 | Running system commands: Press ``Meta-!`` in Emacs mode or just ``!`` in Vi
193 | navigation mode to see the "Shell command" prompt. There you can enter system
194 | commands without leaving the REPL.
195 |
196 | Selecting text: Press ``Control+Space`` in Emacs mode or ``V`` (major V) in Vi
197 | navigation mode.
198 |
199 |
200 | Configuration
201 | *************
202 |
203 | It is possible to create a ``config.py`` file to customize configuration.
204 | ptpython will look in an appropriate platform-specific directory via `appdirs
205 | <https://pypi.org/project/appdirs/>`. See the ``appdirs`` documentation for the
206 | precise location for your platform. A ``PTPYTHON_CONFIG_HOME`` environment
207 | variable, if set, can also be used to explicitly override where configuration
208 | is looked for.
209 |
210 | Have a look at this example to see what is possible:
211 | `config.py <https://github.com/prompt-toolkit/ptpython/blob/master/examples/ptpython_config/config.py>`_
212 |
213 | Note config file support currently only works when invoking `ptpython` directly.
214 | That it, the config file will be ignored when embedding ptpython in an application.
215 |
216 |
217 | IPython support
218 | ***************
219 |
220 | Run ``ptipython`` (prompt_toolkit - IPython), to get a nice interactive shell
221 | with all the power that IPython has to offer, like magic functions and shell
222 | integration. Make sure that IPython has been installed. (``pip install
223 | ipython``)
224 |
225 | .. image :: ./docs/images/ipython.png
226 |
227 | This is also available for embedding:
228 |
229 | .. code:: python
230 |
231 | from ptpython.ipython import embed
232 | embed(globals(), locals())
233 |
234 |
235 | Django support
236 | **************
237 |
238 | `django-extensions <https://github.com/django-extensions/django-extensions>`_
239 | has a ``shell_plus`` management command. When ``ptpython`` has been installed,
240 | it will by default use ``ptpython`` or ``ptipython``.
241 |
242 |
243 | PDB
244 | ***
245 |
246 | There is an experimental PDB replacement: `ptpdb
247 | <https://github.com/jonathanslenders/ptpdb>`_.
248 |
249 |
250 | Windows support
251 | ***************
252 |
253 | ``prompt_toolkit`` and ``ptpython`` works better on Linux and OS X than on
254 | Windows. Some things might not work, but it is usable:
255 |
256 | .. image :: ./docs/images/windows.png
257 |
258 | Windows terminal integration
259 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
260 |
261 | If you are using the `Windows Terminal <https://aka.ms/terminal>`_ and want to
262 | integrate ``ptpython`` as a profile, go to *Settings -> Open JSON file* and add the
263 | following profile under *profiles.list*:
264 |
265 | .. code-block:: JSON
266 |
267 | {
268 | "commandline": "%SystemRoot%\\System32\\cmd.exe /k ptpython",
269 | "guid": "{f91d49a3-741b-409c-8a15-c4360649121f}",
270 | "hidden": false,
271 | "icon": "https://upload.wikimedia.org/wikipedia/commons/e/e6/Python_Windows_interpreter_icon_2006%E2%80%932016_Tiny.png",
272 | "name": "ptpython@cmd"
273 | }
274 |
275 | FAQ
276 | ***
277 |
278 | **Q**: The ``Ctrl-S`` forward search doesn't work and freezes my terminal.
279 |
280 | **A**: Try to run ``stty -ixon`` in your terminal to disable flow control.
281 |
282 | **Q**: The ``Meta``-key doesn't work.
283 |
284 | **A**: For some terminals you have to enable the Alt-key to act as meta key, but you
285 | can also type ``Escape`` before any key instead.
286 |
287 |
288 | Alternatives
289 | ************
290 |
291 | - `BPython <http://bpython-interpreter.org/downloads.html>`_
292 | - `IPython <https://ipython.org/>`_
293 |
294 | If you find another alternative, you can create an issue and we'll list it
295 | here. If you find a nice feature somewhere that is missing in ``ptpython``,
296 | also create a GitHub issue and maybe we'll implement it.
297 |
298 |
299 | Special thanks to
300 | *****************
301 |
302 | - `Pygments <http://pygments.org/>`_: Syntax highlighter.
303 | - `Jedi <http://jedi.jedidjah.ch/en/latest/>`_: Autocompletion library.
304 | - `wcwidth <https://github.com/jquast/wcwidth>`_: Determine columns needed for a wide characters.
305 | - `prompt_toolkit <http://github.com/jonathanslenders/python-prompt-toolkit>`_ for the interface.
306 |
307 | .. |Build Status| image:: https://github.com/prompt-toolkit/ptpython/actions/workflows/test.yaml/badge.svg
308 | :target: https://github.com/prompt-toolkit/ptpython/actions/workflows/test.yaml
309 |
310 | .. |License| image:: https://img.shields.io/github/license/prompt-toolkit/ptpython.svg
311 | :target: https://github.com/prompt-toolkit/ptpython/blob/master/LICENSE
312 |
313 | .. |PyPI| image:: https://img.shields.io/pypi/v/ptpython.svg
314 | :target: https://pypi.org/project/ptpython/
315 | :alt: Latest Version
316 |
--------------------------------------------------------------------------------
/docs/concurrency-challenges.rst:
--------------------------------------------------------------------------------
1 |
2 | Concurrency-related challenges regarding embedding of ptpython in asyncio code
3 | ==============================================================================
4 |
5 | Things we want to be possible
6 | -----------------------------
7 |
8 | - Embed blocking ptpython in non-asyncio code (the normal use case).
9 | - Embed blocking ptpython in asyncio code (the event loop will block).
10 | - Embed awaitable ptpython in asyncio code (the loop will continue).
11 | - React to resize events (SIGWINCH).
12 | - Support top-level await.
13 | - Be able to patch_stdout, so that logging messages from another thread will be
14 | printed above the prompt.
15 | - It should be possible to handle `KeyboardInterrupt` during evaluation of an
16 | expression.
17 | - The "eval" should happen in the same thread from where embed() was called.
18 |
19 |
20 | Limitations of asyncio/python
21 | -----------------------------
22 |
23 | - We can only listen to SIGWINCH signal (resize) events in the main thread.
24 |
25 | - Usage of Control-C for triggering a `KeyboardInterrupt` only works for code
26 | running in the main thread. (And only if the terminal was not set in raw
27 | input mode).
28 |
29 | - Spawning a new event loop from within a coroutine, that's being executed in
30 | an existing event loop is not allowed in asyncio. We can however spawn any
31 | event loop in a separate thread, and wait for that thread to finish.
32 |
33 | - For patch_stdout to work correctly, we have to know what prompt_toolkit
34 | application is running on the terminal, then tell that application to print
35 | the output and redraw itself.
36 |
37 |
38 | Additional challenges for IPython
39 | ---------------------------------
40 |
41 | IPython supports integration of 3rd party event loops (for various GUI
42 | toolkits). These event loops are supposed to continue running while we are
43 | prompting for input. In an asyncio environment, it means that there are
44 | situations where we have to juggle three event loops:
45 |
46 | - The asyncio loop in which the code was embedded.
47 | - The asyncio loop from the prompt.
48 | - The 3rd party GUI loop.
49 |
50 | Approach taken in ptpython 3.0.11
51 | ---------------------------------
52 |
53 | For ptpython, the most reliable solution is to to run the prompt_toolkit input
54 | prompt in a separate background thread. This way it can use its own asyncio
55 | event loop without ever having to interfere with whatever runs in the main
56 | thread.
57 |
58 | Then, depending on how we embed, we do the following:
59 | When a normal blocking embed is used:
60 | * We start the UI thread for the input, and do a blocking wait on
61 | `thread.join()` here.
62 | * The "eval" happens in the main thread.
63 | * The "print" happens also in the main thread. Unless a pager is shown,
64 | which is also a prompt_toolkit application, then the pager itself is runs
65 | also in another thread, similar to the way we do the input.
66 |
67 | When an awaitable embed is used, for embedding in a coroutine, but having the
68 | event loop continue:
69 | * We run the input method from the blocking embed in an asyncio executor
70 | and do an `await loop.run_in_executor(...)`.
71 | * The "eval" happens again in the main thread.
72 | * "print" is also similar, except that the pager code (if used) runs in an
73 | executor too.
74 |
75 | This means that the prompt_toolkit application code will always run in a
76 | different thread. It means it won't be able to respond to SIGWINCH (window
77 | resize events), but prompt_toolkit's 3.0.11 has now terminal size polling which
78 | solves this.
79 |
80 | Control-C key presses won't interrupt the main thread while we wait for input,
81 | because the prompt_toolkit application turns the terminal in raw mode, while
82 | it's reading, which means that it will receive control-c key presses as raw
83 | data in its own thread.
84 |
85 | Top-level await works in most situations as expected.
86 | - If a blocking embed is used. We execute ``loop.run_until_complete(code)``.
87 | This assumes that the blocking embed is not used in a coroutine of a running
88 | event loop, otherwise, this will attempt to start a nested event loop, which
89 | asyncio does not support. In that case we will get an exception.
90 | - If an awaitable embed is used. We literally execute ``await code``. This will
91 | integrate nicely in the current event loop.
92 |
--------------------------------------------------------------------------------
/docs/images/example1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prompt-toolkit/ptpython/0066c7ee392d46103b8bd2968c7a645a4c04f02d/docs/images/example1.png
--------------------------------------------------------------------------------
/docs/images/file-completion.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prompt-toolkit/ptpython/0066c7ee392d46103b8bd2968c7a645a4c04f02d/docs/images/file-completion.png
--------------------------------------------------------------------------------
/docs/images/ipython.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prompt-toolkit/ptpython/0066c7ee392d46103b8bd2968c7a645a4c04f02d/docs/images/ipython.png
--------------------------------------------------------------------------------
/docs/images/multiline.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prompt-toolkit/ptpython/0066c7ee392d46103b8bd2968c7a645a4c04f02d/docs/images/multiline.png
--------------------------------------------------------------------------------
/docs/images/ptpython-history-help.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prompt-toolkit/ptpython/0066c7ee392d46103b8bd2968c7a645a4c04f02d/docs/images/ptpython-history-help.png
--------------------------------------------------------------------------------
/docs/images/ptpython-menu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prompt-toolkit/ptpython/0066c7ee392d46103b8bd2968c7a645a4c04f02d/docs/images/ptpython-menu.png
--------------------------------------------------------------------------------
/docs/images/ptpython.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prompt-toolkit/ptpython/0066c7ee392d46103b8bd2968c7a645a4c04f02d/docs/images/ptpython.png
--------------------------------------------------------------------------------
/docs/images/validation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prompt-toolkit/ptpython/0066c7ee392d46103b8bd2968c7a645a4c04f02d/docs/images/validation.png
--------------------------------------------------------------------------------
/docs/images/windows.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prompt-toolkit/ptpython/0066c7ee392d46103b8bd2968c7a645a4c04f02d/docs/images/windows.png
--------------------------------------------------------------------------------
/examples/asyncio-python-embed.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """
3 | (Python >3.3)
4 |
5 | This is an example of how we can embed a Python REPL into an asyncio
6 | application. In this example, we have one coroutine that runs in the
7 | background, prints some output and alters a global state. The REPL, which runs
8 | inside another coroutine can access and change this global state, interacting
9 | with the running asyncio application.
10 | The ``patch_stdout`` option makes sure that when another coroutine is writing
11 | to stdout, it won't break the input line, but instead writes nicely above the
12 | prompt.
13 | """
14 |
15 | import asyncio
16 |
17 | from ptpython.repl import embed
18 |
19 | loop = asyncio.get_event_loop()
20 | counter = [0]
21 |
22 |
23 | async def print_counter() -> None:
24 | """
25 | Coroutine that prints counters and saves it in a global variable.
26 | """
27 | while True:
28 | print(f"Counter: {counter[0]}")
29 | counter[0] += 1
30 | await asyncio.sleep(3)
31 |
32 |
33 | async def interactive_shell() -> None:
34 | """
35 | Coroutine that starts a Python REPL from which we can access the global
36 | counter variable.
37 | """
38 | print(
39 | 'You should be able to read and update the "counter[0]" variable from this shell.'
40 | )
41 | try:
42 | await embed(globals=globals(), return_asyncio_coroutine=True, patch_stdout=True)
43 | except EOFError:
44 | # Stop the loop when quitting the repl. (Ctrl-D press.)
45 | loop.stop()
46 |
47 |
48 | async def main() -> None:
49 | asyncio.create_task(print_counter())
50 | await interactive_shell()
51 |
52 |
53 | if __name__ == "__main__":
54 | asyncio.run(main())
55 |
--------------------------------------------------------------------------------
/examples/asyncio-ssh-python-embed.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """
3 | Example of running the Python REPL through an SSH connection in an asyncio process.
4 | This requires Python 3, asyncio and asyncssh.
5 |
6 | Run this example and then SSH to localhost, port 8222.
7 | """
8 |
9 | import asyncio
10 | import logging
11 |
12 | import asyncssh
13 |
14 | from ptpython.contrib.asyncssh_repl import ReplSSHServerSession
15 |
16 | logging.basicConfig()
17 | logging.getLogger().setLevel(logging.INFO)
18 |
19 |
20 | class MySSHServer(asyncssh.SSHServer):
21 | """
22 | Server without authentication, running `ReplSSHServerSession`.
23 | """
24 |
25 | def __init__(self, get_namespace):
26 | self.get_namespace = get_namespace
27 |
28 | def begin_auth(self, username):
29 | # No authentication.
30 | return False
31 |
32 | def session_requested(self):
33 | return ReplSSHServerSession(self.get_namespace)
34 |
35 |
36 | async def main(port: int = 8222) -> None:
37 | """
38 | Example that starts the REPL through an SSH server.
39 | """
40 | # Namespace exposed in the REPL.
41 | environ = {"hello": "world"}
42 |
43 | # Start SSH server.
44 | def create_server() -> MySSHServer:
45 | return MySSHServer(lambda: environ)
46 |
47 | print(f"Listening on: {port}")
48 | print(f'To connect, do "ssh localhost -p {port}"')
49 |
50 | await asyncssh.create_server(
51 | create_server, "", port, server_host_keys=["/etc/ssh/ssh_host_dsa_key"]
52 | )
53 | await asyncio.Future() # Wait forever.
54 |
55 |
56 | if __name__ == "__main__":
57 | asyncio.run(main())
58 |
--------------------------------------------------------------------------------
/examples/ptpython_config/config.py:
--------------------------------------------------------------------------------
1 | """
2 | Configuration example for ``ptpython``.
3 |
4 | Copy this file to $XDG_CONFIG_HOME/ptpython/config.py
5 | On Linux, this is: ~/.config/ptpython/config.py
6 | On macOS, this is: ~/Library/Application Support/ptpython/config.py
7 | """
8 |
9 | from prompt_toolkit.filters import ViInsertMode
10 | from prompt_toolkit.key_binding.key_processor import KeyPress
11 | from prompt_toolkit.keys import Keys
12 | from prompt_toolkit.styles import Style
13 |
14 | from ptpython.layout import CompletionVisualisation
15 |
16 | __all__ = ["configure"]
17 |
18 |
19 | def configure(repl):
20 | """
21 | Configuration method. This is called during the start-up of ptpython.
22 |
23 | :param repl: `PythonRepl` instance.
24 | """
25 | # Show function signature (bool).
26 | repl.show_signature = True
27 |
28 | # Show docstring (bool).
29 | repl.show_docstring = False
30 |
31 | # Show the "[Meta+Enter] Execute" message when pressing [Enter] only
32 | # inserts a newline instead of executing the code.
33 | repl.show_meta_enter_message = True
34 |
35 | # Show completions. (NONE, POP_UP, MULTI_COLUMN or TOOLBAR)
36 | repl.completion_visualisation = CompletionVisualisation.POP_UP
37 |
38 | # When CompletionVisualisation.POP_UP has been chosen, use this
39 | # scroll_offset in the completion menu.
40 | repl.completion_menu_scroll_offset = 0
41 |
42 | # Show line numbers (when the input contains multiple lines.)
43 | repl.show_line_numbers = False
44 |
45 | # Show status bar.
46 | repl.show_status_bar = True
47 |
48 | # When the sidebar is visible, also show the help text.
49 | repl.show_sidebar_help = True
50 |
51 | # Swap light/dark colors on or off
52 | repl.swap_light_and_dark = False
53 |
54 | # Highlight matching parentheses.
55 | repl.highlight_matching_parenthesis = True
56 |
57 | # Line wrapping. (Instead of horizontal scrolling.)
58 | repl.wrap_lines = True
59 |
60 | # Mouse support.
61 | repl.enable_mouse_support = True
62 |
63 | # Complete while typing. (Don't require tab before the
64 | # completion menu is shown.)
65 | repl.complete_while_typing = True
66 |
67 | # Fuzzy and dictionary completion.
68 | repl.enable_fuzzy_completion = False
69 | repl.enable_dictionary_completion = False
70 |
71 | # Vi mode.
72 | repl.vi_mode = False
73 |
74 | # Enable the modal cursor (when using Vi mode). Other options are 'Block', 'Underline', 'Beam', 'Blink under', 'Blink block', and 'Blink beam'
75 | repl.cursor_shape_config = "Modal (vi)"
76 |
77 | # Paste mode. (When True, don't insert whitespace after new line.)
78 | repl.paste_mode = False
79 |
80 | # Use the classic prompt. (Display '>>>' instead of 'In [1]'.)
81 | repl.prompt_style = "classic" # 'classic' or 'ipython'
82 |
83 | # Don't insert a blank line after the output.
84 | repl.insert_blank_line_after_output = False
85 |
86 | # History Search.
87 | # When True, going back in history will filter the history on the records
88 | # starting with the current input. (Like readline.)
89 | # Note: When enable, please disable the `complete_while_typing` option.
90 | # otherwise, when there is a completion available, the arrows will
91 | # browse through the available completions instead of the history.
92 | repl.enable_history_search = False
93 |
94 | # Enable auto suggestions. (Pressing right arrow will complete the input,
95 | # based on the history.)
96 | repl.enable_auto_suggest = False
97 |
98 | # Enable open-in-editor. Pressing C-x C-e in emacs mode or 'v' in
99 | # Vi navigation mode will open the input in the current editor.
100 | repl.enable_open_in_editor = True
101 |
102 | # Enable system prompt. Pressing meta-! will display the system prompt.
103 | # Also enables Control-Z suspend.
104 | repl.enable_system_bindings = True
105 |
106 | # Ask for confirmation on exit.
107 | repl.confirm_exit = True
108 |
109 | # Enable input validation. (Don't try to execute when the input contains
110 | # syntax errors.)
111 | repl.enable_input_validation = True
112 |
113 | # Use this colorscheme for the code.
114 | # Ptpython uses Pygments for code styling, so you can choose from Pygments'
115 | # color schemes. See:
116 | # https://pygments.org/docs/styles/
117 | # https://pygments.org/demo/
118 | repl.use_code_colorscheme("default")
119 | # A colorscheme that looks good on dark backgrounds is 'native':
120 | # repl.use_code_colorscheme("native")
121 |
122 | # Set color depth (keep in mind that not all terminals support true color).
123 |
124 | # repl.color_depth = "DEPTH_1_BIT" # Monochrome.
125 | # repl.color_depth = "DEPTH_4_BIT" # ANSI colors only.
126 | repl.color_depth = "DEPTH_8_BIT" # The default, 256 colors.
127 | # repl.color_depth = "DEPTH_24_BIT" # True color.
128 |
129 | # Min/max brightness
130 | repl.min_brightness = 0.0 # Increase for dark terminal backgrounds.
131 | repl.max_brightness = 1.0 # Decrease for light terminal backgrounds.
132 |
133 | # Syntax.
134 | repl.enable_syntax_highlighting = True
135 |
136 | # Get into Vi navigation mode at startup
137 | repl.vi_start_in_navigation_mode = False
138 |
139 | # Preserve last used Vi input mode between main loop iterations
140 | repl.vi_keep_last_used_mode = False
141 |
142 | # Install custom colorscheme named 'my-colorscheme' and use it.
143 | """
144 | repl.install_ui_colorscheme("my-colorscheme", Style.from_dict(_custom_ui_colorscheme))
145 | repl.use_ui_colorscheme("my-colorscheme")
146 | """
147 |
148 | # Add custom key binding for PDB.
149 | """
150 | @repl.add_key_binding("c-b")
151 | def _(event):
152 | " Pressing Control-B will insert "pdb.set_trace()" "
153 | event.cli.current_buffer.insert_text("\nimport pdb; pdb.set_trace()\n")
154 | """
155 |
156 | # Typing ControlE twice should also execute the current command.
157 | # (Alternative for Meta-Enter.)
158 | """
159 | @repl.add_key_binding("c-e", "c-e")
160 | def _(event):
161 | event.current_buffer.validate_and_handle()
162 | """
163 |
164 | # Typing 'jj' in Vi Insert mode, should send escape. (Go back to navigation
165 | # mode.)
166 | """
167 | @repl.add_key_binding("j", "j", filter=ViInsertMode())
168 | def _(event):
169 | " Map 'jj' to Escape. "
170 | event.cli.key_processor.feed(KeyPress(Keys("escape")))
171 | """
172 |
173 | # Custom key binding for some simple autocorrection while typing.
174 | """
175 | corrections = {
176 | "impotr": "import",
177 | "pritn": "print",
178 | }
179 |
180 | @repl.add_key_binding(" ")
181 | def _(event):
182 | " When a space is pressed. Check & correct word before cursor. "
183 | b = event.cli.current_buffer
184 | w = b.document.get_word_before_cursor()
185 |
186 | if w is not None:
187 | if w in corrections:
188 | b.delete_before_cursor(count=len(w))
189 | b.insert_text(corrections[w])
190 |
191 | b.insert_text(" ")
192 | """
193 |
194 | # Add a custom title to the status bar. This is useful when ptpython is
195 | # embedded in other applications.
196 | """
197 | repl.title = "My custom prompt."
198 | """
199 |
200 |
201 | # Custom colorscheme for the UI. See `ptpython/layout.py` and
202 | # `ptpython/style.py` for all possible tokens.
203 | _custom_ui_colorscheme = {
204 | # Blue prompt.
205 | "prompt": "bg:#eeeeff #000000 bold",
206 | # Make the status toolbar red.
207 | "status-toolbar": "bg:#ff0000 #000000",
208 | }
209 |
--------------------------------------------------------------------------------
/examples/python-embed-with-custom-prompt.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """
3 | Example of embedding a Python REPL, and setting a custom prompt.
4 | """
5 |
6 | from prompt_toolkit.formatted_text import HTML, AnyFormattedText
7 |
8 | from ptpython.prompt_style import PromptStyle
9 | from ptpython.repl import embed
10 |
11 |
12 | def configure(repl) -> None:
13 | # Probably, the best is to add a new PromptStyle to `all_prompt_styles` and
14 | # activate it. This way, the other styles are still selectable from the
15 | # menu.
16 | class CustomPrompt(PromptStyle):
17 | def in_prompt(self) -> AnyFormattedText:
18 | return HTML("<ansigreen>Input[%s]</ansigreen>: ") % (
19 | repl.current_statement_index,
20 | )
21 |
22 | def in2_prompt(self, width: int) -> AnyFormattedText:
23 | return "...: ".rjust(width)
24 |
25 | def out_prompt(self) -> AnyFormattedText:
26 | return HTML("<ansired>Result[%s]</ansired>: ") % (
27 | repl.current_statement_index,
28 | )
29 |
30 | repl.all_prompt_styles["custom"] = CustomPrompt()
31 | repl.prompt_style = "custom"
32 |
33 |
34 | def main() -> None:
35 | embed(globals(), locals(), configure=configure)
36 |
37 |
38 | if __name__ == "__main__":
39 | main()
40 |
--------------------------------------------------------------------------------
/examples/python-embed.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """ """
3 |
4 | from ptpython.repl import embed
5 |
6 |
7 | def main() -> None:
8 | embed(globals(), locals(), vi_mode=False)
9 |
10 |
11 | if __name__ == "__main__":
12 | main()
13 |
--------------------------------------------------------------------------------
/examples/python-input.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """ """
3 |
4 | from ptpython.python_input import PythonInput
5 |
6 |
7 | def main():
8 | prompt = PythonInput()
9 |
10 | text = prompt.app.run()
11 | print("You said: " + text)
12 |
13 |
14 | if __name__ == "__main__":
15 | main()
16 |
--------------------------------------------------------------------------------
/examples/ssh-and-telnet-embed.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """
3 | Serve a ptpython console using both telnet and ssh.
4 |
5 | Thanks to Vincent Michel for this!
6 | https://gist.github.com/vxgmichel/7685685b3e5ead04ada4a3ba75a48eef
7 | """
8 |
9 | from __future__ import annotations
10 |
11 | import asyncio
12 | import pathlib
13 |
14 | import asyncssh
15 | from prompt_toolkit import print_formatted_text
16 | from prompt_toolkit.contrib.ssh.server import (
17 | PromptToolkitSSHServer,
18 | PromptToolkitSSHSession,
19 | )
20 | from prompt_toolkit.contrib.telnet.server import TelnetConnection, TelnetServer
21 |
22 | from ptpython.repl import embed
23 |
24 |
25 | def ensure_key(filename: str = "ssh_host_key") -> str:
26 | path = pathlib.Path(filename)
27 | if not path.exists():
28 | rsa_key = asyncssh.generate_private_key("ssh-rsa")
29 | path.write_bytes(rsa_key.export_private_key())
30 | return str(path)
31 |
32 |
33 | async def interact(connection: PromptToolkitSSHSession | TelnetConnection) -> None:
34 | global_dict = {**globals(), "print": print_formatted_text}
35 | await embed(return_asyncio_coroutine=True, globals=global_dict)
36 |
37 |
38 | async def main(ssh_port: int = 8022, telnet_port: int = 8023) -> None:
39 | ssh_server = PromptToolkitSSHServer(interact=interact)
40 | await asyncssh.create_server(
41 | lambda: ssh_server, "", ssh_port, server_host_keys=[ensure_key()]
42 | )
43 | print(f"Running ssh server on port {ssh_port}...")
44 |
45 | telnet_server = TelnetServer(interact=interact, port=telnet_port)
46 | telnet_server.start()
47 | print(f"Running telnet server on port {telnet_port}...")
48 |
49 | while True:
50 | await asyncio.sleep(60)
51 |
52 |
53 | if __name__ == "__main__":
54 | asyncio.run(main())
55 |
--------------------------------------------------------------------------------
/examples/test-cases/ptpython-in-other-thread.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """
3 | Example of running ptpython in another thread.
4 |
5 | (For testing whether it's working fine if it's not embedded in the main
6 | thread.)
7 | """
8 |
9 | import threading
10 |
11 | from ptpython.repl import embed
12 |
13 |
14 | def in_thread():
15 | embed(globals(), locals(), vi_mode=False)
16 |
17 |
18 | def main():
19 | th = threading.Thread(target=in_thread)
20 | th.start()
21 | th.join()
22 |
23 |
24 | if __name__ == "__main__":
25 | main()
26 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "ptpython"
3 | version = "3.0.31"
4 | description = "Python REPL build on top of prompt_toolkit"
5 | readme = "README.rst"
6 | authors = [{ name = "Jonathan Slenders" }]
7 | classifiers = [
8 | "License :: OSI Approved :: BSD License",
9 | "Programming Language :: Python :: 3",
10 | "Programming Language :: Python :: 3.8",
11 | "Programming Language :: Python :: 3.9",
12 | "Programming Language :: Python :: 3.10",
13 | "Programming Language :: Python :: 3.11",
14 | "Programming Language :: Python :: 3.12",
15 | "Programming Language :: Python :: 3.13",
16 | "Programming Language :: Python :: 3 :: Only",
17 | "Programming Language :: Python",
18 | ]
19 | requires-python = ">=3.8"
20 | dependencies = [
21 | "appdirs",
22 | "jedi>=0.16.0",
23 | # Use prompt_toolkit 3.0.43, because of `OneStyleAndTextTuple` import.
24 | "prompt_toolkit>=3.0.43,<3.1.0",
25 | "pygments",
26 | ]
27 |
28 |
29 | [project.urls]
30 | Homepage = "https://github.com/prompt-toolkit/ptpython"
31 | Changelog = "https://github.com/prompt-toolkit/ptpython/blob/master/CHANGELOG"
32 | "Bug Tracker" = "https://github.com/prompt-toolkit/ptpython/issues"
33 | "Source Code" = "https://github.com/prompt-toolkit/ptpython"
34 |
35 |
36 | [project.scripts]
37 | ptpython = "ptpython.entry_points.run_ptpython:run"
38 | ptipython = "ptpython.entry_points.run_ptipython:run"
39 |
40 |
41 | [project.optional-dependencies]
42 | ptipython = ["ipython"] # For ptipython, we need to have IPython
43 |
44 |
45 | [tool.mypy]
46 | ignore_missing_imports = true
47 | no_implicit_optional = true
48 | platform = "win32"
49 | strict_equality = true
50 | strict_optional = true
51 |
52 |
53 | [tool.ruff]
54 | target-version = "py38"
55 | lint.select = [
56 | "E", # pycodestyle errors
57 | "W", # pycodestyle warnings
58 | "F", # pyflakes
59 | "C", # flake8-comprehensions
60 | "T", # Print.
61 | "I", # isort
62 | # "B", # flake8-bugbear
63 | "UP", # pyupgrade
64 | "RUF100", # unused-noqa
65 | "Q", # quotes
66 | ]
67 | lint.ignore = [
68 | "E501", # Line too long, handled by black
69 | "C901", # Too complex
70 | "E722", # bare except.
71 | ]
72 |
73 |
74 | [tool.ruff.lint.per-file-ignores]
75 | "examples/*" = ["T201"] # Print allowed in examples.
76 | "examples/ptpython_config/config.py" = ["F401"] # Unused imports in config.
77 | "src/ptpython/entry_points/run_ptipython.py" = ["T201", "F401"] # Print, import usage.
78 | "src/ptpython/entry_points/run_ptpython.py" = ["T201"] # Print usage.
79 | "src/ptpython/ipython.py" = ["T100"] # Import usage.
80 | "src/ptpython/repl.py" = ["T201"] # Print usage.
81 | "src/ptpython/printer.py" = ["T201"] # Print usage.
82 |
83 |
84 | [tool.ruff.lint.isort]
85 | known-first-party = ["ptpython"]
86 | known-third-party = ["prompt_toolkit", "pygments", "asyncssh"]
87 |
88 | [tool.typos.default]
89 | extend-ignore-re = [
90 | "impotr" # Intentional typo in: ./examples/ptpython_config/config.py
91 | ]
92 |
93 | [build-system]
94 | requires = ["setuptools>=68"]
95 | build-backend = "setuptools.build_meta"
96 |
--------------------------------------------------------------------------------
/src/ptpython/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from .repl import embed
4 |
5 | __all__ = ["embed"]
6 |
--------------------------------------------------------------------------------
/src/ptpython/__main__.py:
--------------------------------------------------------------------------------
1 | """
2 | Make `python -m ptpython` an alias for running `./ptpython`.
3 | """
4 |
5 | from __future__ import annotations
6 |
7 | from .entry_points.run_ptpython import run
8 |
9 | run()
10 |
--------------------------------------------------------------------------------
/src/ptpython/completer.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import ast
4 | import collections.abc as collections_abc
5 | import inspect
6 | import keyword
7 | import re
8 | from enum import Enum
9 | from itertools import islice
10 | from typing import TYPE_CHECKING, Any, Callable, Iterable
11 |
12 | from prompt_toolkit.completion import (
13 | CompleteEvent,
14 | Completer,
15 | Completion,
16 | PathCompleter,
17 | )
18 | from prompt_toolkit.contrib.completers.system import SystemCompleter
19 | from prompt_toolkit.contrib.regular_languages.compiler import compile as compile_grammar
20 | from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter
21 | from prompt_toolkit.document import Document
22 | from prompt_toolkit.formatted_text import fragment_list_to_text, to_formatted_text
23 |
24 | from ptpython.utils import get_jedi_script_from_document
25 |
26 | if TYPE_CHECKING:
27 | import jedi.api.classes
28 | from prompt_toolkit.contrib.regular_languages.compiler import _CompiledGrammar
29 |
30 | __all__ = ["PythonCompleter", "CompletePrivateAttributes", "HidePrivateCompleter"]
31 |
32 |
33 | class CompletePrivateAttributes(Enum):
34 | """
35 | Should we display private attributes in the completion pop-up?
36 | """
37 |
38 | NEVER = "NEVER"
39 | IF_NO_PUBLIC = "IF_NO_PUBLIC"
40 | ALWAYS = "ALWAYS"
41 |
42 |
43 | class PythonCompleter(Completer):
44 | """
45 | Completer for Python code.
46 | """
47 |
48 | def __init__(
49 | self,
50 | get_globals: Callable[[], dict[str, Any]],
51 | get_locals: Callable[[], dict[str, Any]],
52 | enable_dictionary_completion: Callable[[], bool],
53 | ) -> None:
54 | super().__init__()
55 |
56 | self.get_globals = get_globals
57 | self.get_locals = get_locals
58 | self.enable_dictionary_completion = enable_dictionary_completion
59 |
60 | self._system_completer = SystemCompleter()
61 | self._jedi_completer = JediCompleter(get_globals, get_locals)
62 | self._dictionary_completer = DictionaryCompleter(get_globals, get_locals)
63 |
64 | self._path_completer_cache: GrammarCompleter | None = None
65 | self._path_completer_grammar_cache: _CompiledGrammar | None = None
66 |
67 | @property
68 | def _path_completer(self) -> GrammarCompleter:
69 | if self._path_completer_cache is None:
70 | self._path_completer_cache = GrammarCompleter(
71 | self._path_completer_grammar,
72 | {
73 | "var1": PathCompleter(expanduser=True),
74 | "var2": PathCompleter(expanduser=True),
75 | },
76 | )
77 | return self._path_completer_cache
78 |
79 | @property
80 | def _path_completer_grammar(self) -> _CompiledGrammar:
81 | """
82 | Return the grammar for matching paths inside strings inside Python
83 | code.
84 | """
85 | # We make this lazy, because it delays startup time a little bit.
86 | # This way, the grammar is build during the first completion.
87 | if self._path_completer_grammar_cache is None:
88 | self._path_completer_grammar_cache = self._create_path_completer_grammar()
89 | return self._path_completer_grammar_cache
90 |
91 | def _create_path_completer_grammar(self) -> _CompiledGrammar:
92 | def unwrapper(text: str) -> str:
93 | return re.sub(r"\\(.)", r"\1", text)
94 |
95 | def single_quoted_wrapper(text: str) -> str:
96 | return text.replace("\\", "\\\\").replace("'", "\\'")
97 |
98 | def double_quoted_wrapper(text: str) -> str:
99 | return text.replace("\\", "\\\\").replace('"', '\\"')
100 |
101 | grammar = r"""
102 | # Text before the current string.
103 | (
104 | [^'"#] | # Not quoted characters.
105 | ''' ([^'\\]|'(?!')|''(?!')|\\.])* ''' | # Inside single quoted triple strings
106 | "" " ([^"\\]|"(?!")|""(?!^)|\\.])* "" " | # Inside double quoted triple strings
107 |
108 | \#[^\n]*(\n|$) | # Comment.
109 | "(?!"") ([^"\\]|\\.)*" | # Inside double quoted strings.
110 | '(?!'') ([^'\\]|\\.)*' # Inside single quoted strings.
111 |
112 | # Warning: The negative lookahead in the above two
113 | # statements is important. If we drop that,
114 | # then the regex will try to interpret every
115 | # triple quoted string also as a single quoted
116 | # string, making this exponentially expensive to
117 | # execute!
118 | )*
119 | # The current string that we're completing.
120 | (
121 | ' (?P<var1>([^\n'\\]|\\.)*) | # Inside a single quoted string.
122 | " (?P<var2>([^\n"\\]|\\.)*) # Inside a double quoted string.
123 | )
124 | """
125 |
126 | return compile_grammar(
127 | grammar,
128 | escape_funcs={"var1": single_quoted_wrapper, "var2": double_quoted_wrapper},
129 | unescape_funcs={"var1": unwrapper, "var2": unwrapper},
130 | )
131 |
132 | def _complete_path_while_typing(self, document: Document) -> bool:
133 | char_before_cursor = document.char_before_cursor
134 | return bool(
135 | document.text
136 | and (char_before_cursor.isalnum() or char_before_cursor in "/.~")
137 | )
138 |
139 | def _complete_python_while_typing(self, document: Document) -> bool:
140 | """
141 | When `complete_while_typing` is set, only return completions when this
142 | returns `True`.
143 | """
144 | text = document.text_before_cursor # .rstrip()
145 | char_before_cursor = text[-1:]
146 | return bool(
147 | text and (char_before_cursor.isalnum() or char_before_cursor in "_.([,")
148 | )
149 |
150 | def get_completions(
151 | self, document: Document, complete_event: CompleteEvent
152 | ) -> Iterable[Completion]:
153 | """
154 | Get Python completions.
155 | """
156 | # If the input starts with an exclamation mark. Use the system completer.
157 | if document.text.lstrip().startswith("!"):
158 | yield from self._system_completer.get_completions(
159 | Document(
160 | text=document.text[1:], cursor_position=document.cursor_position - 1
161 | ),
162 | complete_event,
163 | )
164 | return
165 |
166 | # Do dictionary key completions.
167 | if complete_event.completion_requested or self._complete_python_while_typing(
168 | document
169 | ):
170 | if self.enable_dictionary_completion():
171 | has_dict_completions = False
172 | for c in self._dictionary_completer.get_completions(
173 | document, complete_event
174 | ):
175 | if c.text not in "[.":
176 | # If we get the [ or . completion, still include the other
177 | # completions.
178 | has_dict_completions = True
179 | yield c
180 | if has_dict_completions:
181 | return
182 |
183 | # Do Path completions (if there were no dictionary completions).
184 | if complete_event.completion_requested or self._complete_path_while_typing(
185 | document
186 | ):
187 | yield from self._path_completer.get_completions(document, complete_event)
188 |
189 | # Do Jedi completions.
190 | if complete_event.completion_requested or self._complete_python_while_typing(
191 | document
192 | ):
193 | # If we are inside a string, Don't do Jedi completion.
194 | if not self._path_completer_grammar.match(document.text_before_cursor):
195 | # Do Jedi Python completions.
196 | yield from self._jedi_completer.get_completions(
197 | document, complete_event
198 | )
199 |
200 |
201 | class JediCompleter(Completer):
202 | """
203 | Autocompleter that uses the Jedi library.
204 | """
205 |
206 | def __init__(
207 | self,
208 | get_globals: Callable[[], dict[str, Any]],
209 | get_locals: Callable[[], dict[str, Any]],
210 | ) -> None:
211 | super().__init__()
212 |
213 | self.get_globals = get_globals
214 | self.get_locals = get_locals
215 |
216 | def get_completions(
217 | self, document: Document, complete_event: CompleteEvent
218 | ) -> Iterable[Completion]:
219 | script = get_jedi_script_from_document(
220 | document, self.get_locals(), self.get_globals()
221 | )
222 |
223 | if script:
224 | try:
225 | jedi_completions = script.complete(
226 | column=document.cursor_position_col,
227 | line=document.cursor_position_row + 1,
228 | )
229 | except TypeError:
230 | # Issue #9: bad syntax causes completions() to fail in jedi.
231 | # https://github.com/jonathanslenders/python-prompt-toolkit/issues/9
232 | pass
233 | except UnicodeDecodeError:
234 | # Issue #43: UnicodeDecodeError on OpenBSD
235 | # https://github.com/jonathanslenders/python-prompt-toolkit/issues/43
236 | pass
237 | except AttributeError:
238 | # Jedi issue #513: https://github.com/davidhalter/jedi/issues/513
239 | pass
240 | except ValueError:
241 | # Jedi issue: "ValueError: invalid \x escape"
242 | pass
243 | except KeyError:
244 | # Jedi issue: "KeyError: u'a_lambda'."
245 | # https://github.com/jonathanslenders/ptpython/issues/89
246 | pass
247 | except OSError:
248 | # Jedi issue: "IOError: No such file or directory."
249 | # https://github.com/jonathanslenders/ptpython/issues/71
250 | pass
251 | except AssertionError:
252 | # In jedi.parser.__init__.py: 227, in remove_last_newline,
253 | # the assertion "newline.value.endswith('\n')" can fail.
254 | pass
255 | except SystemError:
256 | # In jedi.api.helpers.py: 144, in get_stack_at_position
257 | # raise SystemError("This really shouldn't happen. There's a bug in Jedi.")
258 | pass
259 | except NotImplementedError:
260 | # See: https://github.com/jonathanslenders/ptpython/issues/223
261 | pass
262 | except Exception:
263 | # Suppress all other Jedi exceptions.
264 | pass
265 | else:
266 | # Move function parameters to the top.
267 | jedi_completions = sorted(
268 | jedi_completions,
269 | key=lambda jc: (
270 | # Params first.
271 | jc.type != "param",
272 | # Private at the end.
273 | jc.name.startswith("_"),
274 | # Then sort by name.
275 | jc.name_with_symbols.lower(),
276 | ),
277 | )
278 |
279 | for jc in jedi_completions:
280 | if jc.type == "function":
281 | suffix = "()"
282 | else:
283 | suffix = ""
284 |
285 | if jc.type == "param":
286 | suffix = "..."
287 |
288 | yield Completion(
289 | jc.name_with_symbols,
290 | len(jc.complete) - len(jc.name_with_symbols),
291 | display=jc.name_with_symbols + suffix,
292 | display_meta=jc.type,
293 | style=_get_style_for_jedi_completion(jc),
294 | )
295 |
296 |
297 | class DictionaryCompleter(Completer):
298 | """
299 | Experimental completer for Python dictionary keys.
300 |
301 | Warning: This does an `eval` and `repr` on some Python expressions before
302 | the cursor, which is potentially dangerous. It doesn't match on
303 | function calls, so it only triggers attribute access.
304 | """
305 |
306 | def __init__(
307 | self,
308 | get_globals: Callable[[], dict[str, Any]],
309 | get_locals: Callable[[], dict[str, Any]],
310 | ) -> None:
311 | super().__init__()
312 |
313 | self.get_globals = get_globals
314 | self.get_locals = get_locals
315 |
316 | # Pattern for expressions that are "safe" to eval for auto-completion.
317 | # These are expressions that contain only attribute and index lookups.
318 | varname = r"[a-zA-Z_][a-zA-Z0-9_]*"
319 |
320 | expression = rf"""
321 | # Any expression safe enough to eval while typing.
322 | # No operators, except dot, and only other dict lookups.
323 | # Technically, this can be unsafe of course, if bad code runs
324 | # in `__getattr__` or ``__getitem__``.
325 | (
326 | # Variable name
327 | {varname}
328 |
329 | \s*
330 |
331 | (?:
332 | # Attribute access.
333 | \s* \. \s* {varname} \s*
334 |
335 | |
336 |
337 | # Item lookup.
338 | # (We match the square brackets. The key can be anything.
339 | # We don't care about matching quotes here in the regex.
340 | # Nested square brackets are not supported.)
341 | \s* \[ [^\[\]]+ \] \s*
342 | )*
343 | )
344 | """
345 |
346 | # Pattern for recognizing for-loops, so that we can provide
347 | # autocompletion on the iterator of the for-loop. (According to the
348 | # first item of the collection we're iterating over.)
349 | self.for_loop_pattern = re.compile(
350 | rf"""
351 | for \s+ ([a-zA-Z0-9_]+) \s+ in \s+ {expression} \s* :
352 | """,
353 | re.VERBOSE,
354 | )
355 |
356 | # Pattern for matching a simple expression (for completing [ or .
357 | # operators).
358 | self.expression_pattern = re.compile(
359 | rf"""
360 | {expression}
361 | $
362 | """,
363 | re.VERBOSE,
364 | )
365 |
366 | # Pattern for matching item lookups.
367 | self.item_lookup_pattern = re.compile(
368 | rf"""
369 | {expression}
370 |
371 | # Dict lookup to complete (square bracket open + start of
372 | # string).
373 | \[
374 | \s* ([^\[\]]*)$
375 | """,
376 | re.VERBOSE,
377 | )
378 |
379 | # Pattern for matching attribute lookups.
380 | self.attribute_lookup_pattern = re.compile(
381 | rf"""
382 | {expression}
383 |
384 | # Attribute lookup to complete (dot + varname).
385 | \.
386 | \s* ([a-zA-Z0-9_]*)$
387 | """,
388 | re.VERBOSE,
389 | )
390 |
391 | def _lookup(self, expression: str, temp_locals: dict[str, Any]) -> object:
392 | """
393 | Do lookup of `object_var` in the context.
394 | `temp_locals` is a dictionary, used for the locals.
395 | """
396 | try:
397 | return eval(expression.strip(), self.get_globals(), temp_locals)
398 | except BaseException:
399 | return None # Many exception, like NameError can be thrown here.
400 |
401 | def get_completions(
402 | self, document: Document, complete_event: CompleteEvent
403 | ) -> Iterable[Completion]:
404 | # First, find all for-loops, and assign the first item of the
405 | # collections they're iterating to the iterator variable, so that we
406 | # can provide code completion on the iterators.
407 | temp_locals = self.get_locals().copy()
408 |
409 | for match in self.for_loop_pattern.finditer(document.text_before_cursor):
410 | varname, expression = match.groups()
411 | expression_val = self._lookup(expression, temp_locals)
412 |
413 | # We do this only for lists and tuples. Calling `next()` on any
414 | # collection would create undesired side effects.
415 | if isinstance(expression_val, (list, tuple)) and expression_val:
416 | temp_locals[varname] = expression_val[0]
417 |
418 | # Get all completions.
419 | yield from self._get_expression_completions(
420 | document, complete_event, temp_locals
421 | )
422 | yield from self._get_item_lookup_completions(
423 | document, complete_event, temp_locals
424 | )
425 | yield from self._get_attribute_completions(
426 | document, complete_event, temp_locals
427 | )
428 |
429 | def _do_repr(self, obj: object) -> str:
430 | try:
431 | return str(repr(obj))
432 | except BaseException:
433 | raise ReprFailedError
434 |
435 | def eval_expression(self, document: Document, locals: dict[str, Any]) -> object:
436 | """
437 | Evaluate
438 | """
439 | match = self.expression_pattern.search(document.text_before_cursor)
440 | if match is not None:
441 | object_var = match.groups()[0]
442 | return self._lookup(object_var, locals)
443 |
444 | return None
445 |
446 | def _get_expression_completions(
447 | self,
448 | document: Document,
449 | complete_event: CompleteEvent,
450 | temp_locals: dict[str, Any],
451 | ) -> Iterable[Completion]:
452 | """
453 | Complete the [ or . operator after an object.
454 | """
455 | result = self.eval_expression(document, temp_locals)
456 |
457 | if result is not None:
458 | if isinstance(
459 | result,
460 | (list, tuple, dict, collections_abc.Mapping, collections_abc.Sequence),
461 | ):
462 | yield Completion("[", 0)
463 |
464 | else:
465 | # Note: Don't call `if result` here. That can fail for types
466 | # that have custom truthness checks.
467 | yield Completion(".", 0)
468 |
469 | def _get_item_lookup_completions(
470 | self,
471 | document: Document,
472 | complete_event: CompleteEvent,
473 | temp_locals: dict[str, Any],
474 | ) -> Iterable[Completion]:
475 | """
476 | Complete dictionary keys.
477 | """
478 |
479 | def meta_repr(obj: object, key: object) -> Callable[[], str]:
480 | "Abbreviate meta text, make sure it fits on one line."
481 | cached_result: str | None = None
482 |
483 | # We return a function, so that it gets computed when it's needed.
484 | # When there are many completions, that improves the performance
485 | # quite a bit (for the multi-column completion menu, we only need
486 | # to display one meta text).
487 | # Note that we also do the lookup itself in here (`obj[key]`),
488 | # because this part can also be slow for some mapping
489 | # implementations.
490 | def get_value_repr() -> str:
491 | nonlocal cached_result
492 | if cached_result is not None:
493 | return cached_result
494 |
495 | try:
496 | value = obj[key] # type: ignore
497 |
498 | text = self._do_repr(value)
499 | except BaseException:
500 | return "-"
501 |
502 | # Take first line, if multiple lines.
503 | if "\n" in text:
504 | text = text.split("\n", 1)[0] + "..."
505 |
506 | cached_result = text
507 | return text
508 |
509 | return get_value_repr
510 |
511 | match = self.item_lookup_pattern.search(document.text_before_cursor)
512 | if match is not None:
513 | object_var, key = match.groups()
514 |
515 | # Do lookup of `object_var` in the context.
516 | result = self._lookup(object_var, temp_locals)
517 |
518 | # If this object is a dictionary, complete the keys.
519 | if isinstance(result, (dict, collections_abc.Mapping)):
520 | # Try to evaluate the key.
521 | key_obj_str = str(key)
522 | for k in [key, key + '"', key + "'"]:
523 | try:
524 | key_obj_str = str(ast.literal_eval(k))
525 | except (SyntaxError, ValueError):
526 | continue
527 | else:
528 | break
529 |
530 | for k in result:
531 | if str(k).startswith(key_obj_str):
532 | try:
533 | k_repr = self._do_repr(k)
534 | yield Completion(
535 | k_repr + "]",
536 | -len(key),
537 | display=f"[{k_repr}]",
538 | display_meta=meta_repr(result, k),
539 | )
540 | except ReprFailedError:
541 | pass
542 |
543 | # Complete list/tuple index keys.
544 | elif isinstance(result, (list, tuple, collections_abc.Sequence)):
545 | if not key or key.isdigit():
546 | for k in range(min(len(result), 1000)):
547 | if str(k).startswith(key):
548 | try:
549 | k_repr = self._do_repr(k)
550 | yield Completion(
551 | k_repr + "]",
552 | -len(key),
553 | display=f"[{k_repr}]",
554 | display_meta=meta_repr(result, k),
555 | )
556 | except KeyError:
557 | # `result[k]` lookup failed. Trying to complete
558 | # broken object.
559 | pass
560 | except ReprFailedError:
561 | pass
562 |
563 | def _get_attribute_completions(
564 | self,
565 | document: Document,
566 | complete_event: CompleteEvent,
567 | temp_locals: dict[str, Any],
568 | ) -> Iterable[Completion]:
569 | """
570 | Complete attribute names.
571 | """
572 | match = self.attribute_lookup_pattern.search(document.text_before_cursor)
573 | if match is not None:
574 | object_var, attr_name = match.groups()
575 |
576 | # Do lookup of `object_var` in the context.
577 | result = self._lookup(object_var, temp_locals)
578 |
579 | names = self._sort_attribute_names(dir(result))
580 |
581 | def get_suffix(name: str) -> str:
582 | try:
583 | obj = getattr(result, name, None)
584 | if inspect.isfunction(obj) or inspect.ismethod(obj):
585 | return "()"
586 | if isinstance(obj, collections_abc.Mapping):
587 | return "{}"
588 | if isinstance(obj, collections_abc.Sequence):
589 | return "[]"
590 | except:
591 | pass
592 | return ""
593 |
594 | for name in names:
595 | if name.startswith(attr_name):
596 | suffix = get_suffix(name)
597 | yield Completion(name, -len(attr_name), display=name + suffix)
598 |
599 | def _sort_attribute_names(self, names: list[str]) -> list[str]:
600 | """
601 | Sort attribute names alphabetically, but move the double underscore and
602 | underscore names to the end.
603 | """
604 |
605 | def sort_key(name: str) -> tuple[int, str]:
606 | if name.startswith("__"):
607 | return (2, name) # Double underscore comes latest.
608 | if name.startswith("_"):
609 | return (1, name) # Single underscore before that.
610 | return (0, name) # Other names first.
611 |
612 | return sorted(names, key=sort_key)
613 |
614 |
615 | class HidePrivateCompleter(Completer):
616 | """
617 | Wrapper around completer that hides private fields, depending on whether or
618 | not public fields are shown.
619 |
620 | (The reason this is implemented as a `Completer` wrapper is because this
621 | way it works also with `FuzzyCompleter`.)
622 | """
623 |
624 | def __init__(
625 | self,
626 | completer: Completer,
627 | complete_private_attributes: Callable[[], CompletePrivateAttributes],
628 | ) -> None:
629 | self.completer = completer
630 | self.complete_private_attributes = complete_private_attributes
631 |
632 | def get_completions(
633 | self, document: Document, complete_event: CompleteEvent
634 | ) -> Iterable[Completion]:
635 | completions = list(
636 | # Limit at 5k completions for performance.
637 | islice(self.completer.get_completions(document, complete_event), 0, 5000)
638 | )
639 | complete_private_attributes = self.complete_private_attributes()
640 | hide_private = False
641 |
642 | def is_private(completion: Completion) -> bool:
643 | text = fragment_list_to_text(to_formatted_text(completion.display))
644 | return text.startswith("_")
645 |
646 | if complete_private_attributes == CompletePrivateAttributes.NEVER:
647 | hide_private = True
648 |
649 | elif complete_private_attributes == CompletePrivateAttributes.IF_NO_PUBLIC:
650 | hide_private = any(not is_private(completion) for completion in completions)
651 |
652 | if hide_private:
653 | completions = [
654 | completion for completion in completions if not is_private(completion)
655 | ]
656 |
657 | return completions
658 |
659 |
660 | class ReprFailedError(Exception):
661 | "Raised when the repr() call in `DictionaryCompleter` fails."
662 |
663 |
664 | try:
665 | import builtins
666 |
667 | _builtin_names = dir(builtins)
668 | except ImportError: # Python 2.
669 | _builtin_names = []
670 |
671 |
672 | def _get_style_for_jedi_completion(
673 | jedi_completion: jedi.api.classes.Completion,
674 | ) -> str:
675 | """
676 | Return completion style to use for this name.
677 | """
678 | name = jedi_completion.name_with_symbols
679 |
680 | if jedi_completion.type == "param":
681 | return "class:completion.param"
682 |
683 | if name in _builtin_names:
684 | return "class:completion.builtin"
685 |
686 | if keyword.iskeyword(name):
687 | return "class:completion.keyword"
688 |
689 | return ""
690 |
--------------------------------------------------------------------------------
/src/ptpython/contrib/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prompt-toolkit/ptpython/0066c7ee392d46103b8bd2968c7a645a4c04f02d/src/ptpython/contrib/__init__.py
--------------------------------------------------------------------------------
/src/ptpython/contrib/asyncssh_repl.py:
--------------------------------------------------------------------------------
1 | """
2 | Tool for embedding a REPL inside a Python 3 asyncio process.
3 | See ./examples/asyncio-ssh-python-embed.py for a demo.
4 |
5 | Note that the code in this file is Python 3 only. However, we
6 | should make sure not to use Python 3-only syntax, because this
7 | package should be installable in Python 2 as well!
8 | """
9 |
10 | from __future__ import annotations
11 |
12 | import asyncio
13 | from typing import Any, AnyStr, TextIO, cast
14 |
15 | import asyncssh
16 | from prompt_toolkit.data_structures import Size
17 | from prompt_toolkit.input import create_pipe_input
18 | from prompt_toolkit.output.vt100 import Vt100_Output
19 |
20 | from ptpython.python_input import _GetNamespace, _Namespace
21 | from ptpython.repl import PythonRepl
22 |
23 | __all__ = ["ReplSSHServerSession"]
24 |
25 |
26 | class ReplSSHServerSession(asyncssh.SSHServerSession[str]):
27 | """
28 | SSH server session that runs a Python REPL.
29 |
30 | :param get_globals: callable that returns the current globals.
31 | :param get_locals: (optional) callable that returns the current locals.
32 | """
33 |
34 | def __init__(
35 | self, get_globals: _GetNamespace, get_locals: _GetNamespace | None = None
36 | ) -> None:
37 | self._chan: Any = None
38 |
39 | def _globals() -> _Namespace:
40 | data = get_globals()
41 | data.setdefault("print", self._print)
42 | return data
43 |
44 | # PipInput object, for sending input in the CLI.
45 | # (This is something that we can use in the prompt_toolkit event loop,
46 | # but still write date in manually.)
47 | self._input_pipe = create_pipe_input()
48 |
49 | # Output object. Don't render to the real stdout, but write everything
50 | # in the SSH channel.
51 | class Stdout:
52 | def write(s, data: str) -> None:
53 | if self._chan is not None:
54 | data = data.replace("\n", "\r\n")
55 | self._chan.write(data)
56 |
57 | def flush(s) -> None:
58 | pass
59 |
60 | self.repl = PythonRepl(
61 | get_globals=_globals,
62 | get_locals=get_locals or _globals,
63 | input=self._input_pipe,
64 | output=Vt100_Output(cast(TextIO, Stdout()), self._get_size),
65 | )
66 |
67 | # Disable open-in-editor and system prompt. Because it would run and
68 | # display these commands on the server side, rather than in the SSH
69 | # client.
70 | self.repl.enable_open_in_editor = False
71 | self.repl.enable_system_bindings = False
72 |
73 | def _get_size(self) -> Size:
74 | """
75 | Callable that returns the current `Size`, required by Vt100_Output.
76 | """
77 | if self._chan is None:
78 | return Size(rows=20, columns=79)
79 | else:
80 | width, height, pixwidth, pixheight = self._chan.get_terminal_size()
81 | return Size(rows=height, columns=width)
82 |
83 | def connection_made(self, chan: Any) -> None:
84 | """
85 | Client connected, run repl in coroutine.
86 | """
87 | self._chan = chan
88 |
89 | # Run REPL interface.
90 | f = asyncio.ensure_future(self.repl.run_async())
91 |
92 | # Close channel when done.
93 | def done(_: object) -> None:
94 | chan.close()
95 | self._chan = None
96 |
97 | f.add_done_callback(done)
98 |
99 | def shell_requested(self) -> bool:
100 | return True
101 |
102 | def terminal_size_changed(
103 | self, width: int, height: int, pixwidth: int, pixheight: int
104 | ) -> None:
105 | """
106 | When the terminal size changes, report back to CLI.
107 | """
108 | self.repl.app._on_resize()
109 |
110 | def data_received(self, data: AnyStr, datatype: int | None) -> None:
111 | """
112 | When data is received, send to inputstream of the CLI and repaint.
113 | """
114 | self._input_pipe.send(data) # type: ignore
115 |
116 | def _print(
117 | self, *data: object, sep: str = " ", end: str = "\n", file: Any = None
118 | ) -> None:
119 | """
120 | Alternative 'print' function that prints back into the SSH channel.
121 | """
122 | # Pop keyword-only arguments. (We cannot use the syntax from the
123 | # signature. Otherwise, Python2 will give a syntax error message when
124 | # installing.)
125 | data_as_str = sep.join(map(str, data))
126 | self._chan.write(data_as_str + end)
127 |
--------------------------------------------------------------------------------
/src/ptpython/entry_points/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prompt-toolkit/ptpython/0066c7ee392d46103b8bd2968c7a645a4c04f02d/src/ptpython/entry_points/__init__.py
--------------------------------------------------------------------------------
/src/ptpython/entry_points/run_ptipython.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | from __future__ import annotations
3 |
4 | import os
5 | import sys
6 |
7 | from .run_ptpython import create_parser, get_config_and_history_file
8 |
9 |
10 | def run(user_ns=None):
11 | a = create_parser().parse_args()
12 |
13 | config_file, history_file = get_config_and_history_file(a)
14 |
15 | # If IPython is not available, show message and exit here with error status
16 | # code.
17 | try:
18 | import IPython
19 | except ImportError:
20 | print("IPython not found. Please install IPython (pip install ipython).")
21 | sys.exit(1)
22 | else:
23 | from ptpython.ipython import embed
24 | from ptpython.repl import enable_deprecation_warnings, run_config
25 |
26 | # Add the current directory to `sys.path`.
27 | if sys.path[0] != "":
28 | sys.path.insert(0, "")
29 |
30 | # When a file has been given, run that, otherwise start the shell.
31 | if a.args and not a.interactive:
32 | sys.argv = a.args
33 | path = a.args[0]
34 | with open(path, "rb") as f:
35 | code = compile(f.read(), path, "exec")
36 | exec(code, {"__name__": "__main__", "__file__": path})
37 | else:
38 | enable_deprecation_warnings()
39 |
40 | # Create an empty namespace for this interactive shell. (If we don't do
41 | # that, all the variables from this function will become available in
42 | # the IPython shell.)
43 | if user_ns is None:
44 | user_ns = {}
45 |
46 | # Startup path
47 | startup_paths = []
48 | if "PYTHONSTARTUP" in os.environ:
49 | startup_paths.append(os.environ["PYTHONSTARTUP"])
50 |
51 | # --interactive
52 | if a.interactive:
53 | startup_paths.append(a.args[0])
54 | sys.argv = a.args
55 |
56 | # exec scripts from startup paths
57 | for path in startup_paths:
58 | if os.path.exists(path):
59 | with open(path, "rb") as f:
60 | code = compile(f.read(), path, "exec")
61 | exec(code, user_ns, user_ns)
62 | else:
63 | print(f"File not found: {path}\n\n")
64 | sys.exit(1)
65 |
66 | # Apply config file
67 | def configure(repl):
68 | if os.path.exists(config_file):
69 | run_config(repl, config_file)
70 |
71 | # Run interactive shell.
72 | embed(
73 | vi_mode=a.vi,
74 | history_filename=history_file,
75 | configure=configure,
76 | user_ns=user_ns,
77 | title="IPython REPL (ptipython)",
78 | )
79 |
80 |
81 | if __name__ == "__main__":
82 | run()
83 |
--------------------------------------------------------------------------------
/src/ptpython/entry_points/run_ptpython.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """
3 | ptpython: Interactive Python shell.
4 |
5 | positional arguments:
6 | args Script and arguments
7 |
8 | optional arguments:
9 | -h, --help show this help message and exit
10 | --vi Enable Vi key bindings
11 | -i, --interactive Start interactive shell after executing this file.
12 | --asyncio Run an asyncio event loop to support top-level "await".
13 | --light-bg Run on a light background (use dark colors for text).
14 | --dark-bg Run on a dark background (use light colors for text).
15 | --config-file CONFIG_FILE
16 | Location of configuration file.
17 | --history-file HISTORY_FILE
18 | Location of history file.
19 | -V, --version show program's version number and exit
20 |
21 | environment variables:
22 | PTPYTHON_CONFIG_HOME: a configuration directory to use
23 | PYTHONSTARTUP: file executed on interactive startup (no default)
24 | """
25 |
26 | from __future__ import annotations
27 |
28 | import argparse
29 | import asyncio
30 | import os
31 | import pathlib
32 | import sys
33 | from importlib import metadata
34 | from textwrap import dedent
35 | from typing import Protocol
36 |
37 | import appdirs
38 | from prompt_toolkit.formatted_text import HTML
39 | from prompt_toolkit.shortcuts import print_formatted_text
40 |
41 | from ptpython.repl import PythonRepl, embed, enable_deprecation_warnings, run_config
42 |
43 | __all__ = ["create_parser", "get_config_and_history_file", "run"]
44 |
45 |
46 | class _SupportsWrite(Protocol):
47 | def write(self, s: str, /) -> object: ...
48 |
49 |
50 | class _Parser(argparse.ArgumentParser):
51 | def print_help(self, file: _SupportsWrite | None = None) -> None:
52 | super().print_help()
53 | print(
54 | dedent(
55 | """
56 | environment variables:
57 | PTPYTHON_CONFIG_HOME: a configuration directory to use
58 | PYTHONSTARTUP: file executed on interactive startup (no default)
59 | """,
60 | ).rstrip(),
61 | )
62 |
63 |
64 | def create_parser() -> _Parser:
65 | parser = _Parser(description="ptpython: Interactive Python shell.")
66 | parser.add_argument("--vi", action="store_true", help="Enable Vi key bindings")
67 | parser.add_argument(
68 | "-i",
69 | "--interactive",
70 | action="store_true",
71 | help="Start interactive shell after executing this file.",
72 | )
73 | parser.add_argument(
74 | "--asyncio",
75 | action="store_true",
76 | help='Run an asyncio event loop to support top-level "await".',
77 | )
78 | parser.add_argument(
79 | "--light-bg",
80 | action="store_true",
81 | help="Run on a light background (use dark colors for text).",
82 | )
83 | parser.add_argument(
84 | "--dark-bg",
85 | action="store_true",
86 | help="Run on a dark background (use light colors for text).",
87 | )
88 | parser.add_argument(
89 | "--config-file", type=str, help="Location of configuration file."
90 | )
91 | parser.add_argument("--history-file", type=str, help="Location of history file.")
92 | parser.add_argument(
93 | "-V",
94 | "--version",
95 | action="version",
96 | version=metadata.version("ptpython"),
97 | )
98 | parser.add_argument("args", nargs="*", help="Script and arguments")
99 | return parser
100 |
101 |
102 | def get_config_and_history_file(namespace: argparse.Namespace) -> tuple[str, str]:
103 | """
104 | Check which config/history files to use, ensure that the directories for
105 | these files exist, and return the config and history path.
106 | """
107 | config_dir = os.environ.get(
108 | "PTPYTHON_CONFIG_HOME",
109 | appdirs.user_config_dir("ptpython", "prompt_toolkit"),
110 | )
111 | data_dir = appdirs.user_data_dir("ptpython", "prompt_toolkit")
112 |
113 | # Create directories.
114 | for d in (config_dir, data_dir):
115 | pathlib.Path(d).mkdir(parents=True, exist_ok=True)
116 |
117 | # Determine config file to be used.
118 | config_file = os.path.join(config_dir, "config.py")
119 | legacy_config_file = os.path.join(os.path.expanduser("~/.ptpython"), "config.py")
120 |
121 | warnings = []
122 |
123 | # Config file
124 | if namespace.config_file:
125 | # Override config_file.
126 | config_file = os.path.expanduser(namespace.config_file)
127 |
128 | elif os.path.isfile(legacy_config_file):
129 | # Warn about the legacy configuration file.
130 | warnings.append(
131 | HTML(
132 | " <i>~/.ptpython/config.py</i> is deprecated, move your configuration to <i>%s</i>\n"
133 | )
134 | % config_file
135 | )
136 | config_file = legacy_config_file
137 |
138 | # Determine history file to be used.
139 | history_file = os.path.join(data_dir, "history")
140 | legacy_history_file = os.path.join(os.path.expanduser("~/.ptpython"), "history")
141 |
142 | if namespace.history_file:
143 | # Override history_file.
144 | history_file = os.path.expanduser(namespace.history_file)
145 |
146 | elif os.path.isfile(legacy_history_file):
147 | # Warn about the legacy history file.
148 | warnings.append(
149 | HTML(
150 | " <i>~/.ptpython/history</i> is deprecated, move your history to <i>%s</i>\n"
151 | )
152 | % history_file
153 | )
154 | history_file = legacy_history_file
155 |
156 | # Print warnings.
157 | if warnings:
158 | print_formatted_text(HTML("<u>Warning:</u>"))
159 | for w in warnings:
160 | print_formatted_text(w)
161 |
162 | return config_file, history_file
163 |
164 |
165 | def run() -> None:
166 | a = create_parser().parse_args()
167 |
168 | config_file, history_file = get_config_and_history_file(a)
169 |
170 | # Startup path
171 | startup_paths = []
172 | if "PYTHONSTARTUP" in os.environ:
173 | startup_paths.append(os.environ["PYTHONSTARTUP"])
174 |
175 | # --interactive
176 | if a.interactive and a.args:
177 | # Note that we shouldn't run PYTHONSTARTUP when -i is given.
178 | startup_paths = [a.args[0]]
179 | sys.argv = a.args
180 |
181 | # Add the current directory to `sys.path`.
182 | if sys.path[0] != "":
183 | sys.path.insert(0, "")
184 |
185 | # When a file has been given, run that, otherwise start the shell.
186 | if a.args and not a.interactive:
187 | sys.argv = a.args
188 | path = a.args[0]
189 | with open(path, "rb") as f:
190 | code = compile(f.read(), path, "exec")
191 | # NOTE: We have to pass a dict as namespace. Omitting this argument
192 | # causes imports to not be found. See issue #326.
193 | # However, an empty dict sets __name__ to 'builtins', which
194 | # breaks `if __name__ == '__main__'` checks. See issue #444.
195 | exec(code, {"__name__": "__main__", "__file__": path})
196 |
197 | # Run interactive shell.
198 | else:
199 | enable_deprecation_warnings()
200 |
201 | # Apply config file
202 | def configure(repl: PythonRepl) -> None:
203 | if os.path.exists(config_file):
204 | run_config(repl, config_file)
205 |
206 | # Adjust colors if dark/light background flag has been given.
207 | if a.light_bg:
208 | repl.min_brightness = 0.0
209 | repl.max_brightness = 0.60
210 | elif a.dark_bg:
211 | repl.min_brightness = 0.60
212 | repl.max_brightness = 1.0
213 |
214 | import __main__
215 |
216 | embed_result = embed( # type: ignore
217 | vi_mode=a.vi,
218 | history_filename=history_file,
219 | configure=configure,
220 | locals=__main__.__dict__,
221 | globals=__main__.__dict__,
222 | startup_paths=startup_paths,
223 | title="Python REPL (ptpython)",
224 | return_asyncio_coroutine=a.asyncio,
225 | )
226 |
227 | if a.asyncio:
228 | print("Starting ptpython asyncio REPL")
229 | print('Use "await" directly instead of "asyncio.run()".')
230 | asyncio.run(embed_result)
231 |
232 |
233 | if __name__ == "__main__":
234 | run()
235 |
--------------------------------------------------------------------------------
/src/ptpython/eventloop.py:
--------------------------------------------------------------------------------
1 | """
2 | Wrapper around the eventloop that gives some time to the Tkinter GUI to process
3 | events when it's loaded and while we are waiting for input at the REPL. This
4 | way we don't block the UI of for instance ``turtle`` and other Tk libraries.
5 |
6 | (Normally Tkinter registers it's callbacks in ``PyOS_InputHook`` to integrate
7 | in readline. ``prompt-toolkit`` doesn't understand that input hook, but this
8 | will fix it for Tk.)
9 | """
10 |
11 | from __future__ import annotations
12 |
13 | import sys
14 | import time
15 |
16 | from prompt_toolkit.eventloop import InputHookContext
17 |
18 | __all__ = ["inputhook"]
19 |
20 |
21 | def _inputhook_tk(inputhook_context: InputHookContext) -> None:
22 | """
23 | Inputhook for Tk.
24 | Run the Tk eventloop until prompt-toolkit needs to process the next input.
25 | """
26 | # Get the current TK application.
27 | import _tkinter # Keep this imports inline!
28 | import tkinter
29 |
30 | root = tkinter._default_root # type: ignore
31 |
32 | def wait_using_filehandler() -> None:
33 | """
34 | Run the TK eventloop until the file handler that we got from the
35 | inputhook becomes readable.
36 | """
37 | # Add a handler that sets the stop flag when `prompt-toolkit` has input
38 | # to process.
39 | stop = [False]
40 |
41 | def done(*a: object) -> None:
42 | stop[0] = True
43 |
44 | root.createfilehandler(inputhook_context.fileno(), _tkinter.READABLE, done)
45 |
46 | # Run the TK event loop as long as we don't receive input.
47 | while root.dooneevent(_tkinter.ALL_EVENTS):
48 | if stop[0]:
49 | break
50 |
51 | root.deletefilehandler(inputhook_context.fileno())
52 |
53 | def wait_using_polling() -> None:
54 | """
55 | Windows TK doesn't support 'createfilehandler'.
56 | So, run the TK eventloop and poll until input is ready.
57 | """
58 | while not inputhook_context.input_is_ready():
59 | while root.dooneevent(_tkinter.ALL_EVENTS | _tkinter.DONT_WAIT):
60 | pass
61 | # Sleep to make the CPU idle, but not too long, so that the UI
62 | # stays responsive.
63 | time.sleep(0.01)
64 |
65 | if root is not None:
66 | if hasattr(root, "createfilehandler"):
67 | wait_using_filehandler()
68 | else:
69 | wait_using_polling()
70 |
71 |
72 | def inputhook(inputhook_context: InputHookContext) -> None:
73 | # Only call the real input hook when the 'Tkinter' library was loaded.
74 | if "Tkinter" in sys.modules or "tkinter" in sys.modules:
75 | _inputhook_tk(inputhook_context)
76 |
--------------------------------------------------------------------------------
/src/ptpython/filters.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | from prompt_toolkit.filters import Filter
6 |
7 | if TYPE_CHECKING:
8 | from .python_input import PythonInput
9 |
10 | __all__ = ["HasSignature", "ShowSidebar", "ShowSignature", "ShowDocstring"]
11 |
12 |
13 | class PythonInputFilter(Filter):
14 | def __init__(self, python_input: PythonInput) -> None:
15 | super().__init__()
16 | self.python_input = python_input
17 |
18 | def __call__(self) -> bool:
19 | raise NotImplementedError
20 |
21 |
22 | class HasSignature(PythonInputFilter):
23 | def __call__(self) -> bool:
24 | return bool(self.python_input.signatures)
25 |
26 |
27 | class ShowSidebar(PythonInputFilter):
28 | def __call__(self) -> bool:
29 | return self.python_input.show_sidebar
30 |
31 |
32 | class ShowSignature(PythonInputFilter):
33 | def __call__(self) -> bool:
34 | return self.python_input.show_signature
35 |
36 |
37 | class ShowDocstring(PythonInputFilter):
38 | def __call__(self) -> bool:
39 | return self.python_input.show_docstring
40 |
--------------------------------------------------------------------------------
/src/ptpython/history_browser.py:
--------------------------------------------------------------------------------
1 | """
2 | Utility to easily select lines from the history and execute them again.
3 |
4 | `create_history_application` creates an `Application` instance that runs will
5 | run as a sub application of the Repl/PythonInput.
6 | """
7 |
8 | from __future__ import annotations
9 |
10 | from functools import partial
11 | from typing import TYPE_CHECKING, Callable
12 |
13 | from prompt_toolkit.application import Application
14 | from prompt_toolkit.application.current import get_app
15 | from prompt_toolkit.buffer import Buffer
16 | from prompt_toolkit.document import Document
17 | from prompt_toolkit.enums import DEFAULT_BUFFER
18 | from prompt_toolkit.filters import Condition, has_focus
19 | from prompt_toolkit.formatted_text.base import StyleAndTextTuples
20 | from prompt_toolkit.formatted_text.utils import fragment_list_to_text
21 | from prompt_toolkit.history import History
22 | from prompt_toolkit.key_binding import KeyBindings
23 | from prompt_toolkit.key_binding.key_processor import KeyPressEvent
24 | from prompt_toolkit.layout.containers import (
25 | ConditionalContainer,
26 | Container,
27 | Float,
28 | FloatContainer,
29 | HSplit,
30 | ScrollOffsets,
31 | VSplit,
32 | Window,
33 | WindowAlign,
34 | WindowRenderInfo,
35 | )
36 | from prompt_toolkit.layout.controls import (
37 | BufferControl,
38 | FormattedTextControl,
39 | UIContent,
40 | )
41 | from prompt_toolkit.layout.dimension import Dimension as D
42 | from prompt_toolkit.layout.layout import Layout
43 | from prompt_toolkit.layout.margins import Margin, ScrollbarMargin
44 | from prompt_toolkit.layout.processors import (
45 | Processor,
46 | Transformation,
47 | TransformationInput,
48 | )
49 | from prompt_toolkit.lexers import PygmentsLexer
50 | from prompt_toolkit.mouse_events import MouseEvent
51 | from prompt_toolkit.widgets import Frame
52 | from prompt_toolkit.widgets.toolbars import ArgToolbar, SearchToolbar
53 | from pygments.lexers import Python3Lexer as PythonLexer
54 | from pygments.lexers import RstLexer
55 |
56 | from ptpython.layout import get_inputmode_fragments
57 |
58 | from .utils import if_mousedown
59 |
60 | if TYPE_CHECKING:
61 | from typing_extensions import TypeAlias
62 |
63 | from .python_input import PythonInput
64 |
65 | HISTORY_COUNT = 2000
66 |
67 | __all__ = ["HistoryLayout", "PythonHistory"]
68 |
69 | E: TypeAlias = KeyPressEvent
70 |
71 | HELP_TEXT = """
72 | This interface is meant to select multiple lines from the
73 | history and execute them together.
74 |
75 | Typical usage
76 | -------------
77 |
78 | 1. Move the ``cursor up`` in the history pane, until the
79 | cursor is on the first desired line.
80 | 2. Hold down the ``space bar``, or press it multiple
81 | times. Each time it will select one line and move to
82 | the next one. Each selected line will appear on the
83 | right side.
84 | 3. When all the required lines are displayed on the right
85 | side, press ``Enter``. This will go back to the Python
86 | REPL and show these lines as the current input. They
87 | can still be edited from there.
88 |
89 | Key bindings
90 | ------------
91 |
92 | Many Emacs and Vi navigation key bindings should work.
93 | Press ``F4`` to switch between Emacs and Vi mode.
94 |
95 | Additional bindings:
96 |
97 | - ``Space``: Select or delect a line.
98 | - ``Tab``: Move the focus between the history and input
99 | pane. (Alternative: ``Ctrl-W``)
100 | - ``Ctrl-C``: Cancel. Ignore the result and go back to
101 | the REPL. (Alternatives: ``q`` and ``Control-G``.)
102 | - ``Enter``: Accept the result and go back to the REPL.
103 | - ``F1``: Show/hide help. Press ``Enter`` to quit this
104 | help message.
105 |
106 | Further, remember that searching works like in Emacs
107 | (using ``Ctrl-R``) or Vi (using ``/``).
108 | """
109 |
110 |
111 | class BORDER:
112 | "Box drawing characters."
113 |
114 | HORIZONTAL = "\u2501"
115 | VERTICAL = "\u2503"
116 | TOP_LEFT = "\u250f"
117 | TOP_RIGHT = "\u2513"
118 | BOTTOM_LEFT = "\u2517"
119 | BOTTOM_RIGHT = "\u251b"
120 | LIGHT_VERTICAL = "\u2502"
121 |
122 |
123 | def _create_popup_window(title: str, body: Container) -> Frame:
124 | """
125 | Return the layout for a pop-up window. It consists of a title bar showing
126 | the `title` text, and a body layout. The window is surrounded by borders.
127 | """
128 | return Frame(body=body, title=title)
129 |
130 |
131 | class HistoryLayout:
132 | """
133 | Create and return a `Container` instance for the history
134 | application.
135 | """
136 |
137 | def __init__(self, history: PythonHistory) -> None:
138 | search_toolbar = SearchToolbar()
139 |
140 | self.help_buffer_control = BufferControl(
141 | buffer=history.help_buffer, lexer=PygmentsLexer(RstLexer)
142 | )
143 |
144 | help_window = _create_popup_window(
145 | title="History Help",
146 | body=Window(
147 | content=self.help_buffer_control,
148 | right_margins=[ScrollbarMargin(display_arrows=True)],
149 | scroll_offsets=ScrollOffsets(top=2, bottom=2),
150 | ),
151 | )
152 |
153 | self.default_buffer_control = BufferControl(
154 | buffer=history.default_buffer,
155 | input_processors=[GrayExistingText(history.history_mapping)],
156 | lexer=PygmentsLexer(PythonLexer),
157 | )
158 |
159 | self.history_buffer_control = BufferControl(
160 | buffer=history.history_buffer,
161 | lexer=PygmentsLexer(PythonLexer),
162 | search_buffer_control=search_toolbar.control,
163 | preview_search=True,
164 | )
165 |
166 | history_window = Window(
167 | content=self.history_buffer_control,
168 | wrap_lines=False,
169 | left_margins=[HistoryMargin(history)],
170 | scroll_offsets=ScrollOffsets(top=2, bottom=2),
171 | )
172 |
173 | self.root_container = HSplit(
174 | [
175 | # Top title bar.
176 | Window(
177 | content=FormattedTextControl(_get_top_toolbar_fragments),
178 | align=WindowAlign.CENTER,
179 | style="class:status-toolbar",
180 | ),
181 | FloatContainer(
182 | content=VSplit(
183 | [
184 | # Left side: history.
185 | history_window,
186 | # Separator.
187 | Window(
188 | width=D.exact(1),
189 | char=BORDER.LIGHT_VERTICAL,
190 | style="class:separator",
191 | ),
192 | # Right side: result.
193 | Window(
194 | content=self.default_buffer_control,
195 | wrap_lines=False,
196 | left_margins=[ResultMargin(history)],
197 | scroll_offsets=ScrollOffsets(top=2, bottom=2),
198 | ),
199 | ]
200 | ),
201 | floats=[
202 | # Help text as a float.
203 | Float(
204 | width=60,
205 | top=3,
206 | bottom=2,
207 | content=ConditionalContainer(
208 | content=help_window,
209 | filter=has_focus(history.help_buffer),
210 | ),
211 | )
212 | ],
213 | ),
214 | # Bottom toolbars.
215 | ArgToolbar(),
216 | search_toolbar,
217 | Window(
218 | content=FormattedTextControl(
219 | partial(_get_bottom_toolbar_fragments, history=history)
220 | ),
221 | style="class:status-toolbar",
222 | ),
223 | ]
224 | )
225 |
226 | self.layout = Layout(self.root_container, history_window)
227 |
228 |
229 | def _get_top_toolbar_fragments() -> StyleAndTextTuples:
230 | return [("class:status-bar.title", "History browser - Insert from history")]
231 |
232 |
233 | def _get_bottom_toolbar_fragments(history: PythonHistory) -> StyleAndTextTuples:
234 | python_input = history.python_input
235 |
236 | @if_mousedown
237 | def f1(mouse_event: MouseEvent) -> None:
238 | _toggle_help(history)
239 |
240 | @if_mousedown
241 | def tab(mouse_event: MouseEvent) -> None:
242 | _select_other_window(history)
243 |
244 | return (
245 | [("class:status-toolbar", " ")]
246 | + get_inputmode_fragments(python_input)
247 | + [
248 | ("class:status-toolbar", " "),
249 | ("class:status-toolbar.key", "[Space]"),
250 | ("class:status-toolbar", " Toggle "),
251 | ("class:status-toolbar.key", "[Tab]", tab),
252 | ("class:status-toolbar", " Focus ", tab),
253 | ("class:status-toolbar.key", "[Enter]"),
254 | ("class:status-toolbar", " Accept "),
255 | ("class:status-toolbar.key", "[F1]", f1),
256 | ("class:status-toolbar", " Help ", f1),
257 | ]
258 | )
259 |
260 |
261 | class HistoryMargin(Margin):
262 | """
263 | Margin for the history buffer.
264 | This displays a green bar for the selected entries.
265 | """
266 |
267 | def __init__(self, history: PythonHistory) -> None:
268 | self.history_buffer = history.history_buffer
269 | self.history_mapping = history.history_mapping
270 |
271 | def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
272 | return 2
273 |
274 | def create_margin(
275 | self, window_render_info: WindowRenderInfo, width: int, height: int
276 | ) -> StyleAndTextTuples:
277 | document = self.history_buffer.document
278 |
279 | lines_starting_new_entries = self.history_mapping.lines_starting_new_entries
280 | selected_lines = self.history_mapping.selected_lines
281 |
282 | current_lineno = document.cursor_position_row
283 |
284 | visible_line_to_input_line = window_render_info.visible_line_to_input_line
285 | result: StyleAndTextTuples = []
286 |
287 | for y in range(height):
288 | line_number = visible_line_to_input_line.get(y)
289 |
290 | # Show stars at the start of each entry.
291 | # (Visualises multiline entries.)
292 | if line_number in lines_starting_new_entries:
293 | char = "*"
294 | else:
295 | char = " "
296 |
297 | if line_number in selected_lines:
298 | t = "class:history-line,selected"
299 | else:
300 | t = "class:history-line"
301 |
302 | if line_number == current_lineno:
303 | t = t + ",current"
304 |
305 | result.append((t, char))
306 | result.append(("", "\n"))
307 |
308 | return result
309 |
310 |
311 | class ResultMargin(Margin):
312 | """
313 | The margin to be shown in the result pane.
314 | """
315 |
316 | def __init__(self, history: PythonHistory) -> None:
317 | self.history_mapping = history.history_mapping
318 | self.history_buffer = history.history_buffer
319 |
320 | def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
321 | return 2
322 |
323 | def create_margin(
324 | self, window_render_info: WindowRenderInfo, width: int, height: int
325 | ) -> StyleAndTextTuples:
326 | document = self.history_buffer.document
327 |
328 | current_lineno = document.cursor_position_row
329 | offset = (
330 | self.history_mapping.result_line_offset
331 | ) # original_document.cursor_position_row
332 |
333 | visible_line_to_input_line = window_render_info.visible_line_to_input_line
334 |
335 | result: StyleAndTextTuples = []
336 |
337 | for y in range(height):
338 | line_number = visible_line_to_input_line.get(y)
339 |
340 | if (
341 | line_number is None
342 | or line_number < offset
343 | or line_number >= offset + len(self.history_mapping.selected_lines)
344 | ):
345 | t = ""
346 | elif line_number == current_lineno:
347 | t = "class:history-line,selected,current"
348 | else:
349 | t = "class:history-line,selected"
350 |
351 | result.append((t, " "))
352 | result.append(("", "\n"))
353 |
354 | return result
355 |
356 | def invalidation_hash(self, document: Document) -> int:
357 | return document.cursor_position_row
358 |
359 |
360 | class GrayExistingText(Processor):
361 | """
362 | Turn the existing input, before and after the inserted code gray.
363 | """
364 |
365 | def __init__(self, history_mapping: HistoryMapping) -> None:
366 | self.history_mapping = history_mapping
367 | self._lines_before = len(
368 | history_mapping.original_document.text_before_cursor.splitlines()
369 | )
370 |
371 | def apply_transformation(
372 | self, transformation_input: TransformationInput
373 | ) -> Transformation:
374 | lineno = transformation_input.lineno
375 | fragments = transformation_input.fragments
376 |
377 | if lineno < self._lines_before or lineno >= self._lines_before + len(
378 | self.history_mapping.selected_lines
379 | ):
380 | text = fragment_list_to_text(fragments)
381 | return Transformation(fragments=[("class:history.existing-input", text)])
382 | else:
383 | return Transformation(fragments=fragments)
384 |
385 |
386 | class HistoryMapping:
387 | """
388 | Keep a list of all the lines from the history and the selected lines.
389 | """
390 |
391 | def __init__(
392 | self,
393 | history: PythonHistory,
394 | python_history: History,
395 | original_document: Document,
396 | ) -> None:
397 | self.history = history
398 | self.python_history = python_history
399 | self.original_document = original_document
400 |
401 | self.lines_starting_new_entries = set()
402 | self.selected_lines: set[int] = set()
403 |
404 | # Process history.
405 | history_strings = python_history.get_strings()
406 | history_lines: list[str] = []
407 |
408 | for entry_nr, entry in list(enumerate(history_strings))[-HISTORY_COUNT:]:
409 | self.lines_starting_new_entries.add(len(history_lines))
410 |
411 | for line in entry.splitlines():
412 | history_lines.append(line)
413 |
414 | if len(history_strings) > HISTORY_COUNT:
415 | history_lines[0] = (
416 | f"# *** History has been truncated to {HISTORY_COUNT} lines ***"
417 | )
418 |
419 | self.history_lines = history_lines
420 | self.concatenated_history = "\n".join(history_lines)
421 |
422 | # Line offset.
423 | if self.original_document.text_before_cursor:
424 | self.result_line_offset = self.original_document.cursor_position_row + 1
425 | else:
426 | self.result_line_offset = 0
427 |
428 | def get_new_document(self, cursor_pos: int | None = None) -> Document:
429 | """
430 | Create a `Document` instance that contains the resulting text.
431 | """
432 | lines = []
433 |
434 | # Original text, before cursor.
435 | if self.original_document.text_before_cursor:
436 | lines.append(self.original_document.text_before_cursor)
437 |
438 | # Selected entries from the history.
439 | for line_no in sorted(self.selected_lines):
440 | lines.append(self.history_lines[line_no])
441 |
442 | # Original text, after cursor.
443 | if self.original_document.text_after_cursor:
444 | lines.append(self.original_document.text_after_cursor)
445 |
446 | # Create `Document` with cursor at the right position.
447 | text = "\n".join(lines)
448 | if cursor_pos is not None and cursor_pos > len(text):
449 | cursor_pos = len(text)
450 | return Document(text, cursor_pos)
451 |
452 | def update_default_buffer(self) -> None:
453 | b = self.history.default_buffer
454 |
455 | b.set_document(self.get_new_document(b.cursor_position), bypass_readonly=True)
456 |
457 |
458 | def _toggle_help(history: PythonHistory) -> None:
459 | "Display/hide help."
460 | help_buffer_control = history.history_layout.help_buffer_control
461 |
462 | if history.app.layout.current_control == help_buffer_control:
463 | history.app.layout.focus_previous()
464 | else:
465 | history.app.layout.current_control = help_buffer_control
466 |
467 |
468 | def _select_other_window(history: PythonHistory) -> None:
469 | "Toggle focus between left/right window."
470 | current_buffer = history.app.current_buffer
471 | layout = history.history_layout.layout
472 |
473 | if current_buffer == history.history_buffer:
474 | layout.current_control = history.history_layout.default_buffer_control
475 |
476 | elif current_buffer == history.default_buffer:
477 | layout.current_control = history.history_layout.history_buffer_control
478 |
479 |
480 | def create_key_bindings(
481 | history: PythonHistory,
482 | python_input: PythonInput,
483 | history_mapping: HistoryMapping,
484 | ) -> KeyBindings:
485 | """
486 | Key bindings.
487 | """
488 | bindings = KeyBindings()
489 | handle = bindings.add
490 |
491 | @handle(" ", filter=has_focus(history.history_buffer))
492 | def _(event: E) -> None:
493 | """
494 | Space: select/deselect line from history pane.
495 | """
496 | b = event.current_buffer
497 | line_no = b.document.cursor_position_row
498 |
499 | if not history_mapping.history_lines:
500 | # If we've no history, then nothing to do
501 | return
502 |
503 | if line_no in history_mapping.selected_lines:
504 | # Remove line.
505 | history_mapping.selected_lines.remove(line_no)
506 | history_mapping.update_default_buffer()
507 | else:
508 | # Add line.
509 | history_mapping.selected_lines.add(line_no)
510 | history_mapping.update_default_buffer()
511 |
512 | # Update cursor position
513 | default_buffer = history.default_buffer
514 | default_lineno = (
515 | sorted(history_mapping.selected_lines).index(line_no)
516 | + history_mapping.result_line_offset
517 | )
518 | default_buffer.cursor_position = (
519 | default_buffer.document.translate_row_col_to_index(default_lineno, 0)
520 | )
521 |
522 | # Also move the cursor to the next line. (This way they can hold
523 | # space to select a region.)
524 | b.cursor_position = b.document.translate_row_col_to_index(line_no + 1, 0)
525 |
526 | @handle(" ", filter=has_focus(DEFAULT_BUFFER))
527 | @handle("delete", filter=has_focus(DEFAULT_BUFFER))
528 | @handle("c-h", filter=has_focus(DEFAULT_BUFFER))
529 | def _(event: E) -> None:
530 | """
531 | Space: remove line from default pane.
532 | """
533 | b = event.current_buffer
534 | line_no = b.document.cursor_position_row - history_mapping.result_line_offset
535 |
536 | if line_no >= 0:
537 | try:
538 | history_lineno = sorted(history_mapping.selected_lines)[line_no]
539 | except IndexError:
540 | pass # When `selected_lines` is an empty set.
541 | else:
542 | history_mapping.selected_lines.remove(history_lineno)
543 |
544 | history_mapping.update_default_buffer()
545 |
546 | help_focussed = has_focus(history.help_buffer)
547 | main_buffer_focussed = has_focus(history.history_buffer) | has_focus(
548 | history.default_buffer
549 | )
550 |
551 | @handle("tab", filter=main_buffer_focussed)
552 | @handle("c-x", filter=main_buffer_focussed, eager=True)
553 | # Eager: ignore the Emacs [Ctrl-X Ctrl-X] binding.
554 | @handle("c-w", filter=main_buffer_focussed)
555 | def _(event: E) -> None:
556 | "Select other window."
557 | _select_other_window(history)
558 |
559 | @handle("f4")
560 | def _(event: E) -> None:
561 | "Switch between Emacs/Vi mode."
562 | python_input.vi_mode = not python_input.vi_mode
563 |
564 | @handle("f1")
565 | def _(event: E) -> None:
566 | "Display/hide help."
567 | _toggle_help(history)
568 |
569 | @handle("enter", filter=help_focussed)
570 | @handle("c-c", filter=help_focussed)
571 | @handle("c-g", filter=help_focussed)
572 | @handle("escape", filter=help_focussed)
573 | def _(event: E) -> None:
574 | "Leave help."
575 | event.app.layout.focus_previous()
576 |
577 | @handle("q", filter=main_buffer_focussed)
578 | @handle("f3", filter=main_buffer_focussed)
579 | @handle("c-c", filter=main_buffer_focussed)
580 | @handle("c-g", filter=main_buffer_focussed)
581 | def _(event: E) -> None:
582 | "Cancel and go back."
583 | event.app.exit(result=None)
584 |
585 | @handle("enter", filter=main_buffer_focussed)
586 | def _(event: E) -> None:
587 | "Accept input."
588 | event.app.exit(result=history.default_buffer.text)
589 |
590 | enable_system_bindings = Condition(lambda: python_input.enable_system_bindings)
591 |
592 | @handle("c-z", filter=enable_system_bindings)
593 | def _(event: E) -> None:
594 | "Suspend to background."
595 | event.app.suspend_to_background()
596 |
597 | return bindings
598 |
599 |
600 | class PythonHistory:
601 | def __init__(self, python_input: PythonInput, original_document: Document) -> None:
602 | """
603 | Create an `Application` for the history screen.
604 | This has to be run as a sub application of `python_input`.
605 |
606 | When this application runs and returns, it returns the selected lines.
607 | """
608 | self.python_input = python_input
609 |
610 | history_mapping = HistoryMapping(self, python_input.history, original_document)
611 | self.history_mapping = history_mapping
612 |
613 | document = Document(history_mapping.concatenated_history)
614 | document = Document(
615 | document.text,
616 | cursor_position=document.cursor_position
617 | + document.get_start_of_line_position(),
618 | )
619 |
620 | def accept_handler(buffer: Buffer) -> bool:
621 | get_app().exit(result=self.default_buffer.text)
622 | return False
623 |
624 | self.history_buffer = Buffer(
625 | document=document,
626 | on_cursor_position_changed=self._history_buffer_pos_changed,
627 | accept_handler=accept_handler,
628 | read_only=True,
629 | )
630 |
631 | self.default_buffer = Buffer(
632 | name=DEFAULT_BUFFER,
633 | document=history_mapping.get_new_document(),
634 | on_cursor_position_changed=self._default_buffer_pos_changed,
635 | read_only=True,
636 | )
637 |
638 | self.help_buffer = Buffer(document=Document(HELP_TEXT, 0), read_only=True)
639 |
640 | self.history_layout = HistoryLayout(self)
641 |
642 | self.app: Application[str] = Application(
643 | layout=self.history_layout.layout,
644 | full_screen=True,
645 | style=python_input._current_style,
646 | style_transformation=python_input.style_transformation,
647 | mouse_support=Condition(lambda: python_input.enable_mouse_support),
648 | key_bindings=create_key_bindings(self, python_input, history_mapping),
649 | )
650 |
651 | def _default_buffer_pos_changed(self, _: Buffer) -> None:
652 | """When the cursor changes in the default buffer. Synchronize with
653 | history buffer."""
654 | # Only when this buffer has the focus.
655 | if self.app.current_buffer == self.default_buffer:
656 | try:
657 | line_no = (
658 | self.default_buffer.document.cursor_position_row
659 | - self.history_mapping.result_line_offset
660 | )
661 |
662 | if line_no < 0: # When the cursor is above the inserted region.
663 | raise IndexError
664 |
665 | history_lineno = sorted(self.history_mapping.selected_lines)[line_no]
666 | except IndexError:
667 | pass
668 | else:
669 | self.history_buffer.cursor_position = (
670 | self.history_buffer.document.translate_row_col_to_index(
671 | history_lineno, 0
672 | )
673 | )
674 |
675 | def _history_buffer_pos_changed(self, _: Buffer) -> None:
676 | """When the cursor changes in the history buffer. Synchronize."""
677 | # Only when this buffer has the focus.
678 | if self.app.current_buffer == self.history_buffer:
679 | line_no = self.history_buffer.document.cursor_position_row
680 |
681 | if line_no in self.history_mapping.selected_lines:
682 | default_lineno = (
683 | sorted(self.history_mapping.selected_lines).index(line_no)
684 | + self.history_mapping.result_line_offset
685 | )
686 |
687 | self.default_buffer.cursor_position = (
688 | self.default_buffer.document.translate_row_col_to_index(
689 | default_lineno, 0
690 | )
691 | )
692 |
--------------------------------------------------------------------------------
/src/ptpython/ipython.py:
--------------------------------------------------------------------------------
1 | """
2 |
3 | Adaptor for using the input system of `prompt_toolkit` with the IPython
4 | backend.
5 |
6 | This gives a powerful interactive shell that has a nice user interface, but
7 | also the power of for instance all the %-magic functions that IPython has to
8 | offer.
9 |
10 | """
11 |
12 | from __future__ import annotations
13 |
14 | from typing import Iterable
15 | from warnings import warn
16 |
17 | from IPython import utils as ipy_utils
18 | from IPython.core.inputtransformer2 import TransformerManager
19 | from IPython.terminal.embed import InteractiveShellEmbed as _InteractiveShellEmbed
20 | from IPython.terminal.ipapp import load_default_config
21 | from prompt_toolkit.completion import (
22 | CompleteEvent,
23 | Completer,
24 | Completion,
25 | PathCompleter,
26 | WordCompleter,
27 | )
28 | from prompt_toolkit.contrib.completers import SystemCompleter
29 | from prompt_toolkit.contrib.regular_languages.compiler import compile
30 | from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter
31 | from prompt_toolkit.contrib.regular_languages.lexer import GrammarLexer
32 | from prompt_toolkit.document import Document
33 | from prompt_toolkit.formatted_text import AnyFormattedText, PygmentsTokens
34 | from prompt_toolkit.lexers import PygmentsLexer, SimpleLexer
35 | from prompt_toolkit.styles import Style
36 | from pygments.lexers import BashLexer, PythonLexer
37 |
38 | from ptpython.prompt_style import PromptStyle
39 |
40 | from .completer import PythonCompleter
41 | from .python_input import PythonInput
42 | from .repl import PyCF_ALLOW_TOP_LEVEL_AWAIT
43 | from .style import default_ui_style
44 | from .validator import PythonValidator
45 |
46 | __all__ = ["embed"]
47 |
48 |
49 | class IPythonPrompt(PromptStyle):
50 | """
51 | Style for IPython >5.0, use the prompt_toolkit tokens directly.
52 | """
53 |
54 | def __init__(self, prompts):
55 | self.prompts = prompts
56 |
57 | def in_prompt(self) -> AnyFormattedText:
58 | return PygmentsTokens(self.prompts.in_prompt_tokens())
59 |
60 | def in2_prompt(self, width: int) -> AnyFormattedText:
61 | return PygmentsTokens(self.prompts.continuation_prompt_tokens())
62 |
63 | def out_prompt(self) -> AnyFormattedText:
64 | return []
65 |
66 |
67 | class IPythonValidator(PythonValidator):
68 | def __init__(self, *args, **kwargs):
69 | super().__init__(*args, **kwargs)
70 | self.isp = TransformerManager()
71 |
72 | def validate(self, document: Document) -> None:
73 | document = Document(text=self.isp.transform_cell(document.text))
74 | super().validate(document)
75 |
76 |
77 | def create_ipython_grammar():
78 | """
79 | Return compiled IPython grammar.
80 | """
81 | return compile(
82 | r"""
83 | \s*
84 | (
85 | (?P<percent>%)(
86 | (?P<magic>pycat|run|loadpy|load) \s+ (?P<py_filename>[^\s]+) |
87 | (?P<magic>cat) \s+ (?P<filename>[^\s]+) |
88 | (?P<magic>pushd|cd|ls) \s+ (?P<directory>[^\s]+) |
89 | (?P<magic>pdb) \s+ (?P<pdb_arg>[^\s]+) |
90 | (?P<magic>autocall) \s+ (?P<autocall_arg>[^\s]+) |
91 | (?P<magic>time|timeit|prun) \s+ (?P<python>.+) |
92 | (?P<magic>psource|pfile|pinfo|pinfo2) \s+ (?P<python>.+) |
93 | (?P<magic>system) \s+ (?P<system>.+) |
94 | (?P<magic>unalias) \s+ (?P<alias_name>.+) |
95 | (?P<magic>[^\s]+) .* |
96 | ) .* |
97 | !(?P<system>.+) |
98 | (?![%!]) (?P<python>.+)
99 | )
100 | \s*
101 | """
102 | )
103 |
104 |
105 | def create_completer(
106 | get_globals,
107 | get_locals,
108 | magics_manager,
109 | alias_manager,
110 | get_enable_dictionary_completion,
111 | ):
112 | g = create_ipython_grammar()
113 |
114 | return GrammarCompleter(
115 | g,
116 | {
117 | "python": PythonCompleter(
118 | get_globals, get_locals, get_enable_dictionary_completion
119 | ),
120 | "magic": MagicsCompleter(magics_manager),
121 | "alias_name": AliasCompleter(alias_manager),
122 | "pdb_arg": WordCompleter(["on", "off"], ignore_case=True),
123 | "autocall_arg": WordCompleter(["0", "1", "2"], ignore_case=True),
124 | "py_filename": PathCompleter(
125 | only_directories=False, file_filter=lambda name: name.endswith(".py")
126 | ),
127 | "filename": PathCompleter(only_directories=False),
128 | "directory": PathCompleter(only_directories=True),
129 | "system": SystemCompleter(),
130 | },
131 | )
132 |
133 |
134 | def create_lexer():
135 | g = create_ipython_grammar()
136 |
137 | return GrammarLexer(
138 | g,
139 | lexers={
140 | "percent": SimpleLexer("class:pygments.operator"),
141 | "magic": SimpleLexer("class:pygments.keyword"),
142 | "filename": SimpleLexer("class:pygments.name"),
143 | "python": PygmentsLexer(PythonLexer),
144 | "system": PygmentsLexer(BashLexer),
145 | },
146 | )
147 |
148 |
149 | class MagicsCompleter(Completer):
150 | def __init__(self, magics_manager):
151 | self.magics_manager = magics_manager
152 |
153 | def get_completions(
154 | self, document: Document, complete_event: CompleteEvent
155 | ) -> Iterable[Completion]:
156 | text = document.text_before_cursor.lstrip()
157 |
158 | for m in sorted(self.magics_manager.magics["line"]):
159 | if m.startswith(text):
160 | yield Completion(f"{m}", -len(text))
161 |
162 |
163 | class AliasCompleter(Completer):
164 | def __init__(self, alias_manager):
165 | self.alias_manager = alias_manager
166 |
167 | def get_completions(
168 | self, document: Document, complete_event: CompleteEvent
169 | ) -> Iterable[Completion]:
170 | text = document.text_before_cursor.lstrip()
171 | # aliases = [a for a, _ in self.alias_manager.aliases]
172 | aliases = self.alias_manager.aliases
173 |
174 | for a, cmd in sorted(aliases, key=lambda a: a[0]):
175 | if a.startswith(text):
176 | yield Completion(f"{a}", -len(text), display_meta=cmd)
177 |
178 |
179 | class IPythonInput(PythonInput):
180 | """
181 | Override our `PythonCommandLineInterface` to add IPython specific stuff.
182 | """
183 |
184 | def __init__(self, ipython_shell, *a, **kw):
185 | kw["_completer"] = create_completer(
186 | kw["get_globals"],
187 | kw["get_globals"],
188 | ipython_shell.magics_manager,
189 | ipython_shell.alias_manager,
190 | lambda: self.enable_dictionary_completion,
191 | )
192 | kw["_lexer"] = create_lexer()
193 | kw["_validator"] = IPythonValidator(get_compiler_flags=self.get_compiler_flags)
194 |
195 | super().__init__(*a, **kw)
196 | self.ipython_shell = ipython_shell
197 |
198 | self.all_prompt_styles["ipython"] = IPythonPrompt(ipython_shell.prompts)
199 | self.prompt_style = "ipython"
200 |
201 | # UI style for IPython. Add tokens that are used by IPython>5.0
202 | style_dict = {}
203 | style_dict.update(default_ui_style)
204 | style_dict.update(
205 | {
206 | "pygments.prompt": "#009900",
207 | "pygments.prompt-num": "#00ff00 bold",
208 | "pygments.out-prompt": "#990000",
209 | "pygments.out-prompt-num": "#ff0000 bold",
210 | }
211 | )
212 |
213 | self.ui_styles = {"default": Style.from_dict(style_dict)}
214 | self.use_ui_colorscheme("default")
215 |
216 | def get_compiler_flags(self):
217 | flags = super().get_compiler_flags()
218 | if self.ipython_shell.autoawait:
219 | flags |= PyCF_ALLOW_TOP_LEVEL_AWAIT
220 | return flags
221 |
222 |
223 | class InteractiveShellEmbed(_InteractiveShellEmbed):
224 | """
225 | Override the `InteractiveShellEmbed` from IPython, to replace the front-end
226 | with our input shell.
227 |
228 | :param configure: Callable for configuring the repl.
229 | """
230 |
231 | def __init__(self, *a, **kw):
232 | vi_mode = kw.pop("vi_mode", False)
233 | history_filename = kw.pop("history_filename", None)
234 | configure = kw.pop("configure", None)
235 | title = kw.pop("title", None)
236 |
237 | # Don't ask IPython to confirm for exit. We have our own exit prompt.
238 | self.confirm_exit = False
239 |
240 | super().__init__(*a, **kw)
241 |
242 | def get_globals():
243 | return self.user_ns
244 |
245 | python_input = IPythonInput(
246 | self,
247 | get_globals=get_globals,
248 | vi_mode=vi_mode,
249 | history_filename=history_filename,
250 | )
251 |
252 | if title:
253 | python_input.terminal_title = title
254 |
255 | if configure:
256 | configure(python_input)
257 | python_input.prompt_style = "ipython" # Don't take from config.
258 |
259 | self.python_input = python_input
260 |
261 | def prompt_for_code(self) -> str:
262 | try:
263 | return self.python_input.app.run()
264 | except KeyboardInterrupt:
265 | self.python_input.default_buffer.document = Document()
266 | return ""
267 |
268 |
269 | def initialize_extensions(shell, extensions):
270 | """
271 | Partial copy of `InteractiveShellApp.init_extensions` from IPython.
272 | """
273 | try:
274 | iter(extensions)
275 | except TypeError:
276 | pass # no extensions found
277 | else:
278 | for ext in extensions:
279 | try:
280 | shell.extension_manager.load_extension(ext)
281 | except:
282 | warn(
283 | f"Error in loading extension: {ext}"
284 | + f"\nCheck your config files in {ipy_utils.path.get_ipython_dir()}"
285 | )
286 | shell.showtraceback()
287 |
288 |
289 | def run_exec_lines(shell, exec_lines):
290 | """
291 | Partial copy of run_exec_lines code from IPython.core.shellapp .
292 | """
293 | try:
294 | iter(exec_lines)
295 | except TypeError:
296 | pass
297 | else:
298 | try:
299 | for line in exec_lines:
300 | try:
301 | shell.run_cell(line, store_history=False)
302 | except:
303 | shell.showtraceback()
304 | except:
305 | shell.showtraceback()
306 |
307 |
308 | def embed(**kwargs):
309 | """
310 | Copied from `IPython/terminal/embed.py`, but using our `InteractiveShellEmbed` instead.
311 | """
312 | config = kwargs.get("config")
313 | header = kwargs.pop("header", "")
314 | compile_flags = kwargs.pop("compile_flags", None)
315 | if config is None:
316 | config = load_default_config()
317 | config.InteractiveShellEmbed = config.TerminalInteractiveShell
318 | kwargs["config"] = config
319 | shell = InteractiveShellEmbed.instance(**kwargs)
320 | initialize_extensions(shell, config["InteractiveShellApp"]["extensions"])
321 | run_exec_lines(shell, config["InteractiveShellApp"]["exec_lines"])
322 | run_startup_scripts(shell)
323 | shell(header=header, stack_depth=2, compile_flags=compile_flags)
324 |
325 |
326 | def run_startup_scripts(shell):
327 | """
328 | Contributed by linyuxu:
329 | https://github.com/prompt-toolkit/ptpython/issues/126#issue-161242480
330 | """
331 | import glob
332 | import os
333 |
334 | startup_dir = shell.profile_dir.startup_dir
335 | startup_files = []
336 | startup_files += glob.glob(os.path.join(startup_dir, "*.py"))
337 | startup_files += glob.glob(os.path.join(startup_dir, "*.ipy"))
338 | for file in startup_files:
339 | shell.run_cell(open(file).read())
340 |
--------------------------------------------------------------------------------
/src/ptpython/key_bindings.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | from prompt_toolkit.application import get_app
6 | from prompt_toolkit.buffer import Buffer
7 | from prompt_toolkit.document import Document
8 | from prompt_toolkit.enums import DEFAULT_BUFFER
9 | from prompt_toolkit.filters import (
10 | Condition,
11 | emacs_insert_mode,
12 | emacs_mode,
13 | has_focus,
14 | has_selection,
15 | vi_insert_mode,
16 | )
17 | from prompt_toolkit.key_binding import KeyBindings
18 | from prompt_toolkit.key_binding.bindings.named_commands import get_by_name
19 | from prompt_toolkit.key_binding.key_processor import KeyPressEvent
20 | from prompt_toolkit.keys import Keys
21 |
22 | from .utils import document_is_multiline_python
23 |
24 | if TYPE_CHECKING:
25 | from typing_extensions import TypeAlias
26 |
27 | from .python_input import PythonInput
28 |
29 | __all__ = [
30 | "load_python_bindings",
31 | "load_sidebar_bindings",
32 | "load_confirm_exit_bindings",
33 | ]
34 |
35 | E: TypeAlias = KeyPressEvent
36 |
37 |
38 | @Condition
39 | def tab_should_insert_whitespace() -> bool:
40 | """
41 | When the 'tab' key is pressed with only whitespace character before the
42 | cursor, do autocompletion. Otherwise, insert indentation.
43 |
44 | Except for the first character at the first line. Then always do a
45 | completion. It doesn't make sense to start the first line with
46 | indentation.
47 | """
48 | b = get_app().current_buffer
49 | before_cursor = b.document.current_line_before_cursor
50 |
51 | return bool(b.text and (not before_cursor or before_cursor.isspace()))
52 |
53 |
54 | def load_python_bindings(python_input: PythonInput) -> KeyBindings:
55 | """
56 | Custom key bindings.
57 | """
58 | bindings = KeyBindings()
59 |
60 | sidebar_visible = Condition(lambda: python_input.show_sidebar)
61 | handle = bindings.add
62 |
63 | @handle("c-l")
64 | def _(event: E) -> None:
65 | """
66 | Clear whole screen and render again -- also when the sidebar is visible.
67 | """
68 | event.app.renderer.clear()
69 |
70 | @handle("c-z")
71 | def _(event: E) -> None:
72 | """
73 | Suspend.
74 | """
75 | if python_input.enable_system_bindings:
76 | event.app.suspend_to_background()
77 |
78 | # Delete word before cursor, but use all Python symbols as separators
79 | # (WORD=False).
80 | handle("c-w")(get_by_name("backward-kill-word"))
81 |
82 | @handle("f2")
83 | def _(event: E) -> None:
84 | """
85 | Show/hide sidebar.
86 | """
87 | python_input.show_sidebar = not python_input.show_sidebar
88 | if python_input.show_sidebar:
89 | event.app.layout.focus(python_input.ptpython_layout.sidebar)
90 | else:
91 | event.app.layout.focus_last()
92 |
93 | @handle("f3")
94 | def _(event: E) -> None:
95 | """
96 | Select from the history.
97 | """
98 | python_input.enter_history()
99 |
100 | @handle("f4")
101 | def _(event: E) -> None:
102 | """
103 | Toggle between Vi and Emacs mode.
104 | """
105 | python_input.vi_mode = not python_input.vi_mode
106 |
107 | @handle("f6")
108 | def _(event: E) -> None:
109 | """
110 | Enable/Disable paste mode.
111 | """
112 | python_input.paste_mode = not python_input.paste_mode
113 |
114 | @handle(
115 | "tab", filter=~sidebar_visible & ~has_selection & tab_should_insert_whitespace
116 | )
117 | def _(event: E) -> None:
118 | """
119 | When tab should insert whitespace, do that instead of completion.
120 | """
121 | event.app.current_buffer.insert_text(" ")
122 |
123 | @Condition
124 | def is_multiline() -> bool:
125 | return document_is_multiline_python(python_input.default_buffer.document)
126 |
127 | @handle(
128 | "enter",
129 | filter=~sidebar_visible
130 | & ~has_selection
131 | & (vi_insert_mode | emacs_insert_mode)
132 | & has_focus(DEFAULT_BUFFER)
133 | & ~is_multiline,
134 | )
135 | @handle(Keys.Escape, Keys.Enter, filter=~sidebar_visible & emacs_mode)
136 | def _(event: E) -> None:
137 | """
138 | Accept input (for single line input).
139 | """
140 | b = event.current_buffer
141 |
142 | if b.validate():
143 | # When the cursor is at the end, and we have an empty line:
144 | # drop the empty lines, but return the value.
145 | b.document = Document(
146 | text=b.text.rstrip(), cursor_position=len(b.text.rstrip())
147 | )
148 |
149 | b.validate_and_handle()
150 |
151 | @handle(
152 | "enter",
153 | filter=~sidebar_visible
154 | & ~has_selection
155 | & (vi_insert_mode | emacs_insert_mode)
156 | & has_focus(DEFAULT_BUFFER)
157 | & is_multiline,
158 | )
159 | def _(event: E) -> None:
160 | """
161 | Behaviour of the Enter key.
162 |
163 | Auto indent after newline/Enter.
164 | (When not in Vi navigation mode, and when multiline is enabled.)
165 | """
166 | b = event.current_buffer
167 | empty_lines_required = python_input.accept_input_on_enter or 10000
168 |
169 | def at_the_end(b: Buffer) -> bool:
170 | """we consider the cursor at the end when there is no text after
171 | the cursor, or only whitespace."""
172 | text = b.document.text_after_cursor
173 | return text == "" or (text.isspace() and "\n" not in text)
174 |
175 | if python_input.paste_mode:
176 | # In paste mode, always insert text.
177 | b.insert_text("\n")
178 |
179 | elif at_the_end(b) and b.document.text.replace(" ", "").endswith(
180 | "\n" * (empty_lines_required - 1)
181 | ):
182 | # When the cursor is at the end, and we have an empty line:
183 | # drop the empty lines, but return the value.
184 | if b.validate():
185 | b.document = Document(
186 | text=b.text.rstrip(), cursor_position=len(b.text.rstrip())
187 | )
188 |
189 | b.validate_and_handle()
190 | else:
191 | auto_newline(b)
192 |
193 | @handle(
194 | "c-d",
195 | filter=~sidebar_visible
196 | & has_focus(python_input.default_buffer)
197 | & Condition(
198 | lambda:
199 | # The current buffer is empty.
200 | not get_app().current_buffer.text
201 | ),
202 | )
203 | def _(event: E) -> None:
204 | """
205 | Override Control-D exit, to ask for confirmation.
206 | """
207 | if python_input.confirm_exit:
208 | # Show exit confirmation and focus it (focusing is important for
209 | # making sure the default buffer key bindings are not active).
210 | python_input.show_exit_confirmation = True
211 | python_input.app.layout.focus(
212 | python_input.ptpython_layout.exit_confirmation
213 | )
214 | else:
215 | event.app.exit(exception=EOFError)
216 |
217 | @handle("c-c", filter=has_focus(python_input.default_buffer))
218 | def _(event: E) -> None:
219 | "Abort when Control-C has been pressed."
220 | event.app.exit(exception=KeyboardInterrupt, style="class:aborting")
221 |
222 | return bindings
223 |
224 |
225 | def load_sidebar_bindings(python_input: PythonInput) -> KeyBindings:
226 | """
227 | Load bindings for the navigation in the sidebar.
228 | """
229 | bindings = KeyBindings()
230 |
231 | handle = bindings.add
232 | sidebar_visible = Condition(lambda: python_input.show_sidebar)
233 |
234 | @handle("up", filter=sidebar_visible)
235 | @handle("c-p", filter=sidebar_visible)
236 | @handle("k", filter=sidebar_visible)
237 | def _(event: E) -> None:
238 | "Go to previous option."
239 | python_input.selected_option_index = (
240 | python_input.selected_option_index - 1
241 | ) % python_input.option_count
242 |
243 | @handle("down", filter=sidebar_visible)
244 | @handle("c-n", filter=sidebar_visible)
245 | @handle("j", filter=sidebar_visible)
246 | def _(event: E) -> None:
247 | "Go to next option."
248 | python_input.selected_option_index = (
249 | python_input.selected_option_index + 1
250 | ) % python_input.option_count
251 |
252 | @handle("right", filter=sidebar_visible)
253 | @handle("l", filter=sidebar_visible)
254 | @handle(" ", filter=sidebar_visible)
255 | def _(event: E) -> None:
256 | "Select next value for current option."
257 | option = python_input.selected_option
258 | option.activate_next()
259 |
260 | @handle("left", filter=sidebar_visible)
261 | @handle("h", filter=sidebar_visible)
262 | def _(event: E) -> None:
263 | "Select previous value for current option."
264 | option = python_input.selected_option
265 | option.activate_previous()
266 |
267 | @handle("c-c", filter=sidebar_visible)
268 | @handle("c-d", filter=sidebar_visible)
269 | @handle("c-d", filter=sidebar_visible)
270 | @handle("enter", filter=sidebar_visible)
271 | @handle("escape", filter=sidebar_visible)
272 | def _(event: E) -> None:
273 | "Hide sidebar."
274 | python_input.show_sidebar = False
275 | event.app.layout.focus_last()
276 |
277 | return bindings
278 |
279 |
280 | def load_confirm_exit_bindings(python_input: PythonInput) -> KeyBindings:
281 | """
282 | Handle yes/no key presses when the exit confirmation is shown.
283 | """
284 | bindings = KeyBindings()
285 |
286 | handle = bindings.add
287 | confirmation_visible = Condition(lambda: python_input.show_exit_confirmation)
288 |
289 | @handle("y", filter=confirmation_visible)
290 | @handle("Y", filter=confirmation_visible)
291 | @handle("enter", filter=confirmation_visible)
292 | @handle("c-d", filter=confirmation_visible)
293 | def _(event: E) -> None:
294 | """
295 | Really quit.
296 | """
297 | event.app.exit(exception=EOFError, style="class:exiting")
298 |
299 | @handle(Keys.Any, filter=confirmation_visible)
300 | def _(event: E) -> None:
301 | """
302 | Cancel exit.
303 | """
304 | python_input.show_exit_confirmation = False
305 | python_input.app.layout.focus_previous()
306 |
307 | return bindings
308 |
309 |
310 | def auto_newline(buffer: Buffer) -> None:
311 | r"""
312 | Insert \n at the cursor position. Also add necessary padding.
313 | """
314 | insert_text = buffer.insert_text
315 |
316 | if buffer.document.current_line_after_cursor:
317 | # When we are in the middle of a line. Always insert a newline.
318 | insert_text("\n")
319 | else:
320 | # Go to new line, but also add indentation.
321 | current_line = buffer.document.current_line_before_cursor.rstrip()
322 | insert_text("\n")
323 |
324 | # Unident if the last line ends with 'pass', remove four spaces.
325 | unindent = current_line.rstrip().endswith(" pass")
326 |
327 | # Copy whitespace from current line
328 | current_line2 = current_line[4:] if unindent else current_line
329 |
330 | for c in current_line2:
331 | if c.isspace():
332 | insert_text(c)
333 | else:
334 | break
335 |
336 | # If the last line ends with a colon, add four extra spaces.
337 | if current_line[-1:] == ":":
338 | for x in range(4):
339 | insert_text(" ")
340 |
--------------------------------------------------------------------------------
/src/ptpython/lexer.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Callable
4 |
5 | from prompt_toolkit.document import Document
6 | from prompt_toolkit.formatted_text import StyleAndTextTuples
7 | from prompt_toolkit.lexers import Lexer, PygmentsLexer
8 | from pygments.lexers import BashLexer
9 | from pygments.lexers import Python3Lexer as PythonLexer
10 |
11 | __all__ = ["PtpythonLexer"]
12 |
13 |
14 | class PtpythonLexer(Lexer):
15 | """
16 | Lexer for ptpython input.
17 |
18 | If the input starts with an exclamation mark, use a Bash lexer, otherwise,
19 | use a Python 3 lexer.
20 | """
21 |
22 | def __init__(self, python_lexer: Lexer | None = None) -> None:
23 | self.python_lexer = python_lexer or PygmentsLexer(PythonLexer)
24 | self.system_lexer = PygmentsLexer(BashLexer)
25 |
26 | def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]:
27 | if document.text.startswith("!"):
28 | return self.system_lexer.lex_document(document)
29 |
30 | return self.python_lexer.lex_document(document)
31 |
--------------------------------------------------------------------------------
/src/ptpython/printer.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import traceback
4 | from dataclasses import dataclass
5 | from enum import Enum
6 | from typing import Generator, Iterable
7 |
8 | from prompt_toolkit.formatted_text import (
9 | HTML,
10 | AnyFormattedText,
11 | FormattedText,
12 | OneStyleAndTextTuple,
13 | StyleAndTextTuples,
14 | fragment_list_width,
15 | merge_formatted_text,
16 | to_formatted_text,
17 | )
18 | from prompt_toolkit.formatted_text.utils import split_lines
19 | from prompt_toolkit.input import Input
20 | from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
21 | from prompt_toolkit.output import Output
22 | from prompt_toolkit.shortcuts import PromptSession, print_formatted_text
23 | from prompt_toolkit.styles import BaseStyle, StyleTransformation
24 | from prompt_toolkit.styles.pygments import pygments_token_to_classname
25 | from prompt_toolkit.utils import get_cwidth
26 | from pygments.lexers import PythonLexer, PythonTracebackLexer
27 |
28 | __all__ = ["OutputPrinter"]
29 |
30 | # Never reformat results larger than this:
31 | MAX_REFORMAT_SIZE = 1_000_000
32 |
33 |
34 | @dataclass
35 | class OutputPrinter:
36 | """
37 | Result printer.
38 |
39 | Usage::
40 |
41 | printer = OutputPrinter(...)
42 | printer.display_result(...)
43 | printer.display_exception(...)
44 | """
45 |
46 | output: Output
47 | input: Input
48 | style: BaseStyle
49 | title: AnyFormattedText
50 | style_transformation: StyleTransformation
51 |
52 | def display_result(
53 | self,
54 | result: object,
55 | *,
56 | out_prompt: AnyFormattedText,
57 | reformat: bool,
58 | highlight: bool,
59 | paginate: bool,
60 | ) -> None:
61 | """
62 | Show __repr__ (or `__pt_repr__`) for an `eval` result and print to output.
63 |
64 | :param reformat: Reformat result using 'black' before printing if the
65 | result is parsable as Python code.
66 | :param highlight: Syntax highlight the result.
67 | :param paginate: Show paginator when the result does not fit on the
68 | screen.
69 | """
70 | out_prompt = to_formatted_text(out_prompt)
71 | out_prompt_width = fragment_list_width(out_prompt)
72 |
73 | result = self._insert_out_prompt_and_split_lines(
74 | self._format_result_output(
75 | result,
76 | reformat=reformat,
77 | highlight=highlight,
78 | line_length=self.output.get_size().columns - out_prompt_width,
79 | paginate=paginate,
80 | ),
81 | out_prompt=out_prompt,
82 | )
83 | self._display_result(result, paginate=paginate)
84 |
85 | def display_exception(
86 | self, e: BaseException, *, highlight: bool, paginate: bool
87 | ) -> None:
88 | """
89 | Render an exception.
90 | """
91 | result = self._insert_out_prompt_and_split_lines(
92 | self._format_exception_output(e, highlight=highlight),
93 | out_prompt="",
94 | )
95 | self._display_result(result, paginate=paginate)
96 |
97 | def display_style_and_text_tuples(
98 | self,
99 | result: Iterable[OneStyleAndTextTuple],
100 | *,
101 | paginate: bool,
102 | ) -> None:
103 | self._display_result(
104 | self._insert_out_prompt_and_split_lines(result, out_prompt=""),
105 | paginate=paginate,
106 | )
107 |
108 | def _display_result(
109 | self,
110 | lines: Iterable[StyleAndTextTuples],
111 | *,
112 | paginate: bool,
113 | ) -> None:
114 | if paginate:
115 | self._print_paginated_formatted_text(lines)
116 | else:
117 | for line in lines:
118 | self._print_formatted_text(line)
119 |
120 | self.output.flush()
121 |
122 | def _print_formatted_text(self, line: StyleAndTextTuples, end: str = "\n") -> None:
123 | print_formatted_text(
124 | FormattedText(line),
125 | style=self.style,
126 | style_transformation=self.style_transformation,
127 | include_default_pygments_style=False,
128 | output=self.output,
129 | end=end,
130 | )
131 |
132 | def _format_result_output(
133 | self,
134 | result: object,
135 | *,
136 | reformat: bool,
137 | highlight: bool,
138 | line_length: int,
139 | paginate: bool,
140 | ) -> Generator[OneStyleAndTextTuple, None, None]:
141 | """
142 | Format __repr__ for an `eval` result.
143 |
144 | Note: this can raise `KeyboardInterrupt` if either calling `__repr__`,
145 | `__pt_repr__` or formatting the output with "Black" takes to long
146 | and the user presses Control-C.
147 | """
148 | # If __pt_repr__ is present, take this. This can return prompt_toolkit
149 | # formatted text.
150 | try:
151 | if hasattr(result, "__pt_repr__"):
152 | formatted_result_repr = to_formatted_text(
153 | getattr(result, "__pt_repr__")()
154 | )
155 | yield from formatted_result_repr
156 | return
157 | except (GeneratorExit, KeyboardInterrupt):
158 | raise # Don't catch here.
159 | except:
160 | # For bad code, `__getattr__` can raise something that's not an
161 | # `AttributeError`. This happens already when calling `hasattr()`.
162 | pass
163 |
164 | # Call `__repr__` of given object first, to turn it in a string.
165 | try:
166 | result_repr = repr(result)
167 | except KeyboardInterrupt:
168 | raise # Don't catch here.
169 | except BaseException as e:
170 | # Calling repr failed.
171 | self.display_exception(e, highlight=highlight, paginate=paginate)
172 | return
173 |
174 | # Determine whether it's valid Python code. If not,
175 | # reformatting/highlighting won't be applied.
176 | if len(result_repr) < MAX_REFORMAT_SIZE:
177 | try:
178 | compile(result_repr, "", "eval")
179 | except SyntaxError:
180 | valid_python = False
181 | else:
182 | valid_python = True
183 | else:
184 | valid_python = False
185 |
186 | if valid_python and reformat:
187 | # Inline import. Slightly speed up start-up time if black is
188 | # not used.
189 | try:
190 | import black
191 |
192 | if not hasattr(black, "Mode"):
193 | raise ImportError
194 | except ImportError:
195 | pass # no Black package in your installation
196 | else:
197 | result_repr = black.format_str(
198 | result_repr,
199 | mode=black.Mode(line_length=line_length),
200 | )
201 |
202 | if valid_python and highlight:
203 | yield from _lex_python_result(result_repr)
204 | else:
205 | yield ("", result_repr)
206 |
207 | def _insert_out_prompt_and_split_lines(
208 | self, result: Iterable[OneStyleAndTextTuple], out_prompt: AnyFormattedText
209 | ) -> Iterable[StyleAndTextTuples]:
210 | r"""
211 | Split styled result in lines (based on the \n characters in the result)
212 | an insert output prompt on whitespace in front of each line. (This does
213 | not yet do the soft wrapping.)
214 |
215 | Yield lines as a result.
216 | """
217 | out_prompt = to_formatted_text(out_prompt)
218 | out_prompt_width = fragment_list_width(out_prompt)
219 | prefix = ("", " " * out_prompt_width)
220 |
221 | for i, line in enumerate(split_lines(result)):
222 | if i == 0:
223 | line = [*out_prompt, *line]
224 | else:
225 | line = [prefix, *line]
226 | yield line
227 |
228 | def _apply_soft_wrapping(
229 | self, lines: Iterable[StyleAndTextTuples]
230 | ) -> Iterable[StyleAndTextTuples]:
231 | """
232 | Apply soft wrapping to the given lines. Wrap according to the terminal
233 | width. Insert whitespace in front of each wrapped line to align it with
234 | the output prompt.
235 | """
236 | line_length = self.output.get_size().columns
237 |
238 | # Iterate over hard wrapped lines.
239 | for lineno, line in enumerate(lines):
240 | columns_in_buffer = 0
241 | current_line: list[OneStyleAndTextTuple] = []
242 |
243 | for style, text, *_ in line:
244 | for c in text:
245 | width = get_cwidth(c)
246 |
247 | # (Soft) wrap line if it doesn't fit.
248 | if columns_in_buffer + width > line_length:
249 | yield current_line
250 | columns_in_buffer = 0
251 | current_line = []
252 |
253 | columns_in_buffer += width
254 | current_line.append((style, c))
255 |
256 | yield current_line
257 |
258 | def _print_paginated_formatted_text(
259 | self, lines: Iterable[StyleAndTextTuples]
260 | ) -> None:
261 | """
262 | Print formatted text, using --MORE-- style pagination.
263 | (Avoid filling up the terminal's scrollback buffer.)
264 | """
265 | lines = self._apply_soft_wrapping(lines)
266 | pager_prompt = create_pager_prompt(
267 | self.style, self.title, output=self.output, input=self.input
268 | )
269 |
270 | abort = False
271 | print_all = False
272 |
273 | # Max number of lines allowed in the buffer before painting.
274 | size = self.output.get_size()
275 | max_rows = size.rows - 1
276 |
277 | # Page buffer.
278 | page: StyleAndTextTuples = []
279 |
280 | def show_pager() -> None:
281 | nonlocal abort, max_rows, print_all
282 |
283 | # Run pager prompt in another thread.
284 | # Same as for the input. This prevents issues with nested event
285 | # loops.
286 | pager_result = pager_prompt.prompt(in_thread=True)
287 |
288 | if pager_result == PagerResult.ABORT:
289 | print("...")
290 | abort = True
291 |
292 | elif pager_result == PagerResult.NEXT_LINE:
293 | max_rows = 1
294 |
295 | elif pager_result == PagerResult.NEXT_PAGE:
296 | max_rows = size.rows - 1
297 |
298 | elif pager_result == PagerResult.PRINT_ALL:
299 | print_all = True
300 |
301 | # Loop over lines. Show --MORE-- prompt when page is filled.
302 | rows = 0
303 |
304 | for lineno, line in enumerate(lines):
305 | page.extend(line)
306 | page.append(("", "\n"))
307 | rows += 1
308 |
309 | if rows >= max_rows:
310 | self._print_formatted_text(page, end="")
311 | page = []
312 | rows = 0
313 |
314 | if not print_all:
315 | show_pager()
316 | if abort:
317 | return
318 |
319 | self._print_formatted_text(page)
320 |
321 | def _format_exception_output(
322 | self, e: BaseException, highlight: bool
323 | ) -> Generator[OneStyleAndTextTuple, None, None]:
324 | if e.__cause__:
325 | yield from self._format_exception_output(e.__cause__, highlight=highlight)
326 | yield (
327 | "",
328 | "\nThe above exception was the direct cause of the following exception:\n\n",
329 | )
330 | elif e.__context__:
331 | yield from self._format_exception_output(e.__context__, highlight=highlight)
332 | yield (
333 | "",
334 | "\nDuring handling of the above exception, another exception occurred:\n\n",
335 | )
336 |
337 | tblist = list(traceback.extract_tb(e.__traceback__))
338 |
339 | for line_nr, tb_tuple in enumerate(tblist):
340 | if tb_tuple[0] == "<stdin>":
341 | tblist = tblist[line_nr:]
342 | break
343 |
344 | tb_list = traceback.format_list(tblist)
345 | if tb_list:
346 | tb_list.insert(0, "Traceback (most recent call last):\n")
347 | tb_list.extend(traceback.format_exception_only(type(e), e))
348 |
349 | tb_str = "".join(tb_list)
350 |
351 | # Format exception and write to output.
352 | # (We use the default style. Most other styles result
353 | # in unreadable colors for the traceback.)
354 | if highlight:
355 | for index, tokentype, text in PythonTracebackLexer().get_tokens_unprocessed(
356 | tb_str
357 | ):
358 | yield ("class:" + pygments_token_to_classname(tokentype), text)
359 | else:
360 | yield ("", tb_str)
361 |
362 |
363 | class PagerResult(Enum):
364 | ABORT = "ABORT"
365 | NEXT_LINE = "NEXT_LINE"
366 | NEXT_PAGE = "NEXT_PAGE"
367 | PRINT_ALL = "PRINT_ALL"
368 |
369 |
370 | def create_pager_prompt(
371 | style: BaseStyle,
372 | title: AnyFormattedText = "",
373 | input: Input | None = None,
374 | output: Output | None = None,
375 | ) -> PromptSession[PagerResult]:
376 | """
377 | Create a "--MORE--" prompt for paginated output.
378 | """
379 | bindings = KeyBindings()
380 |
381 | @bindings.add("enter")
382 | @bindings.add("down")
383 | def next_line(event: KeyPressEvent) -> None:
384 | event.app.exit(result=PagerResult.NEXT_LINE)
385 |
386 | @bindings.add("space")
387 | def next_page(event: KeyPressEvent) -> None:
388 | event.app.exit(result=PagerResult.NEXT_PAGE)
389 |
390 | @bindings.add("a")
391 | def print_all(event: KeyPressEvent) -> None:
392 | event.app.exit(result=PagerResult.PRINT_ALL)
393 |
394 | @bindings.add("q")
395 | @bindings.add("c-c")
396 | @bindings.add("c-d")
397 | @bindings.add("escape", eager=True)
398 | def no(event: KeyPressEvent) -> None:
399 | event.app.exit(result=PagerResult.ABORT)
400 |
401 | @bindings.add("<any>")
402 | def _(event: KeyPressEvent) -> None:
403 | "Disallow inserting other text."
404 | pass
405 |
406 | session: PromptSession[PagerResult] = PromptSession(
407 | merge_formatted_text(
408 | [
409 | title,
410 | HTML(
411 | "<status-toolbar>"
412 | "<more> -- MORE -- </more> "
413 | "<key>[Enter]</key> Scroll "
414 | "<key>[Space]</key> Next page "
415 | "<key>[a]</key> Print all "
416 | "<key>[q]</key> Quit "
417 | "</status-toolbar>: "
418 | ),
419 | ]
420 | ),
421 | key_bindings=bindings,
422 | erase_when_done=True,
423 | style=style,
424 | input=input,
425 | output=output,
426 | )
427 | return session
428 |
429 |
430 | def _lex_python_result(result: str) -> Generator[tuple[str, str], None, None]:
431 | "Return token list for Python string."
432 | lexer = PythonLexer()
433 | # Use `get_tokens_unprocessed`, so that we get exactly the same string,
434 | # without line endings appended. `print_formatted_text` already appends a
435 | # line ending, and otherwise we'll have two line endings.
436 | tokens = lexer.get_tokens_unprocessed(result)
437 |
438 | for index, tokentype, text in tokens:
439 | yield ("class:" + pygments_token_to_classname(tokentype), text)
440 |
--------------------------------------------------------------------------------
/src/ptpython/prompt_style.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from abc import ABCMeta, abstractmethod
4 | from typing import TYPE_CHECKING
5 |
6 | from prompt_toolkit.formatted_text import AnyFormattedText
7 |
8 | if TYPE_CHECKING:
9 | from .python_input import PythonInput
10 |
11 | __all__ = ["PromptStyle", "IPythonPrompt", "ClassicPrompt"]
12 |
13 |
14 | class PromptStyle(metaclass=ABCMeta):
15 | """
16 | Base class for all prompts.
17 | """
18 |
19 | @abstractmethod
20 | def in_prompt(self) -> AnyFormattedText:
21 | "Return the input tokens."
22 | return []
23 |
24 | @abstractmethod
25 | def in2_prompt(self, width: int) -> AnyFormattedText:
26 | """
27 | Tokens for every following input line.
28 |
29 | :param width: The available width. This is coming from the width taken
30 | by `in_prompt`.
31 | """
32 | return []
33 |
34 | @abstractmethod
35 | def out_prompt(self) -> AnyFormattedText:
36 | "Return the output tokens."
37 | return []
38 |
39 |
40 | class IPythonPrompt(PromptStyle):
41 | """
42 | A prompt resembling the IPython prompt.
43 | """
44 |
45 | def __init__(self, python_input: PythonInput) -> None:
46 | self.python_input = python_input
47 |
48 | def in_prompt(self) -> AnyFormattedText:
49 | return [
50 | ("class:in", "In ["),
51 | ("class:in.number", f"{self.python_input.current_statement_index}"),
52 | ("class:in", "]: "),
53 | ]
54 |
55 | def in2_prompt(self, width: int) -> AnyFormattedText:
56 | return [("class:in", "...: ".rjust(width))]
57 |
58 | def out_prompt(self) -> AnyFormattedText:
59 | return [
60 | ("class:out", "Out["),
61 | ("class:out.number", f"{self.python_input.current_statement_index}"),
62 | ("class:out", "]:"),
63 | ("", " "),
64 | ]
65 |
66 |
67 | class ClassicPrompt(PromptStyle):
68 | """
69 | The classic Python prompt.
70 | """
71 |
72 | def in_prompt(self) -> AnyFormattedText:
73 | return [("class:prompt", ">>> ")]
74 |
75 | def in2_prompt(self, width: int) -> AnyFormattedText:
76 | return [("class:prompt.dots", "...")]
77 |
78 | def out_prompt(self) -> AnyFormattedText:
79 | return []
80 |
--------------------------------------------------------------------------------
/src/ptpython/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prompt-toolkit/ptpython/0066c7ee392d46103b8bd2968c7a645a4c04f02d/src/ptpython/py.typed
--------------------------------------------------------------------------------
/src/ptpython/repl.py:
--------------------------------------------------------------------------------
1 | """
2 | Utility for creating a Python repl.
3 |
4 | ::
5 |
6 | from ptpython.repl import embed
7 | embed(globals(), locals(), vi_mode=False)
8 |
9 | """
10 |
11 | from __future__ import annotations
12 |
13 | import asyncio
14 | import builtins
15 | import os
16 | import signal
17 | import sys
18 | import traceback
19 | import types
20 | import warnings
21 | from dis import COMPILER_FLAG_NAMES
22 | from pathlib import Path
23 | from typing import (
24 | Any,
25 | Callable,
26 | ContextManager,
27 | Coroutine,
28 | Iterable,
29 | Literal,
30 | NoReturn,
31 | Sequence,
32 | overload,
33 | )
34 |
35 | from prompt_toolkit.formatted_text import OneStyleAndTextTuple
36 | from prompt_toolkit.patch_stdout import patch_stdout as patch_stdout_context
37 | from prompt_toolkit.shortcuts import (
38 | clear_title,
39 | set_title,
40 | )
41 | from prompt_toolkit.utils import DummyContext
42 | from pygments.lexers import PythonTracebackLexer # noqa: F401
43 |
44 | from .printer import OutputPrinter
45 | from .python_input import PythonInput
46 |
47 | PyCF_ALLOW_TOP_LEVEL_AWAIT: int
48 | try:
49 | from ast import PyCF_ALLOW_TOP_LEVEL_AWAIT # type: ignore
50 | except ImportError:
51 | PyCF_ALLOW_TOP_LEVEL_AWAIT = 0
52 |
53 |
54 | __all__ = [
55 | "PythonRepl",
56 | "enable_deprecation_warnings",
57 | "run_config",
58 | "embed",
59 | "exit",
60 | "ReplExit",
61 | ]
62 |
63 |
64 | def _get_coroutine_flag() -> int | None:
65 | for k, v in COMPILER_FLAG_NAMES.items():
66 | if v == "COROUTINE":
67 | return k
68 |
69 | # Flag not found.
70 | return None
71 |
72 |
73 | COROUTINE_FLAG: int | None = _get_coroutine_flag()
74 |
75 |
76 | def _has_coroutine_flag(code: types.CodeType) -> bool:
77 | if COROUTINE_FLAG is None:
78 | # Not supported on this Python version.
79 | return False
80 |
81 | return bool(code.co_flags & COROUTINE_FLAG)
82 |
83 |
84 | class PythonRepl(PythonInput):
85 | def __init__(self, *a, **kw) -> None:
86 | self._startup_paths: Sequence[str | Path] | None = kw.pop("startup_paths", None)
87 | super().__init__(*a, **kw)
88 | self._load_start_paths()
89 |
90 | def _load_start_paths(self) -> None:
91 | "Start the Read-Eval-Print Loop."
92 | if self._startup_paths:
93 | for path in self._startup_paths:
94 | if os.path.exists(path):
95 | with open(path, "rb") as f:
96 | code = compile(f.read(), path, "exec")
97 | exec(code, self.get_globals(), self.get_locals())
98 | else:
99 | output = self.app.output
100 | output.write(f"WARNING | File not found: {path}\n\n")
101 |
102 | def run_and_show_expression(self, expression: str) -> None:
103 | try:
104 | # Eval.
105 | try:
106 | result = self.eval(expression)
107 | except KeyboardInterrupt:
108 | # KeyboardInterrupt doesn't inherit from Exception.
109 | raise
110 | except SystemExit:
111 | raise
112 | except ReplExit:
113 | raise
114 | except BaseException as e:
115 | self._handle_exception(e)
116 | else:
117 | if isinstance(result, exit):
118 | # When `exit` is evaluated without parentheses.
119 | # Automatically trigger the `ReplExit` exception.
120 | raise ReplExit
121 |
122 | # Print.
123 | if result is not None:
124 | self._show_result(result)
125 | if self.insert_blank_line_after_output:
126 | self.app.output.write("\n")
127 |
128 | # Loop.
129 | self.current_statement_index += 1
130 | self.signatures = []
131 |
132 | except KeyboardInterrupt as e:
133 | # Handle all possible `KeyboardInterrupt` errors. This can
134 | # happen during the `eval`, but also during the
135 | # `show_result` if something takes too long.
136 | # (Try/catch is around the whole block, because we want to
137 | # prevent that a Control-C keypress terminates the REPL in
138 | # any case.)
139 | self._handle_keyboard_interrupt(e)
140 |
141 | def _get_output_printer(self) -> OutputPrinter:
142 | return OutputPrinter(
143 | output=self.app.output,
144 | input=self.app.input,
145 | style=self._current_style,
146 | style_transformation=self.style_transformation,
147 | title=self.title,
148 | )
149 |
150 | def _show_result(self, result: object) -> None:
151 | self._get_output_printer().display_result(
152 | result=result,
153 | out_prompt=self.get_output_prompt(),
154 | reformat=self.enable_output_formatting,
155 | highlight=self.enable_syntax_highlighting,
156 | paginate=self.enable_pager,
157 | )
158 |
159 | def run(self) -> None:
160 | """
161 | Run the REPL loop.
162 | """
163 | if self.terminal_title:
164 | set_title(self.terminal_title)
165 |
166 | self._add_to_namespace()
167 |
168 | try:
169 | while True:
170 | # Pull text from the user.
171 | try:
172 | text = self.read()
173 | except EOFError:
174 | return
175 | except BaseException:
176 | # Something went wrong while reading input.
177 | # (E.g., a bug in the completer that propagates. Don't
178 | # crash the REPL.)
179 | traceback.print_exc()
180 | continue
181 |
182 | # Run it; display the result (or errors if applicable).
183 | try:
184 | self.run_and_show_expression(text)
185 | except ReplExit:
186 | return
187 | finally:
188 | if self.terminal_title:
189 | clear_title()
190 | self._remove_from_namespace()
191 |
192 | async def run_and_show_expression_async(self, text: str) -> Any:
193 | loop = asyncio.get_running_loop()
194 | system_exit: SystemExit | None = None
195 |
196 | try:
197 | try:
198 | # Create `eval` task. Ensure that control-c will cancel this
199 | # task.
200 | async def eval() -> Any:
201 | nonlocal system_exit
202 | try:
203 | return await self.eval_async(text)
204 | except SystemExit as e:
205 | # Don't propagate SystemExit in `create_task()`. That
206 | # will kill the event loop. We want to handle it
207 | # gracefully.
208 | system_exit = e
209 |
210 | task = asyncio.create_task(eval())
211 | loop.add_signal_handler(signal.SIGINT, lambda *_: task.cancel())
212 | result = await task
213 |
214 | if system_exit is not None:
215 | raise system_exit
216 | except KeyboardInterrupt:
217 | # KeyboardInterrupt doesn't inherit from Exception.
218 | raise
219 | except SystemExit:
220 | raise
221 | except BaseException as e:
222 | self._handle_exception(e)
223 | else:
224 | # Print.
225 | if result is not None:
226 | await loop.run_in_executor(None, lambda: self._show_result(result))
227 |
228 | # Loop.
229 | self.current_statement_index += 1
230 | self.signatures = []
231 | # Return the result for future consumers.
232 | return result
233 | finally:
234 | loop.remove_signal_handler(signal.SIGINT)
235 |
236 | except KeyboardInterrupt as e:
237 | # Handle all possible `KeyboardInterrupt` errors. This can
238 | # happen during the `eval`, but also during the
239 | # `show_result` if something takes too long.
240 | # (Try/catch is around the whole block, because we want to
241 | # prevent that a Control-C keypress terminates the REPL in
242 | # any case.)
243 | self._handle_keyboard_interrupt(e)
244 |
245 | async def run_async(self) -> None:
246 | """
247 | Run the REPL loop, but run the blocking parts in an executor, so that
248 | we don't block the event loop. Both the input and output (which can
249 | display a pager) will run in a separate thread with their own event
250 | loop, this way ptpython's own event loop won't interfere with the
251 | asyncio event loop from where this is called.
252 |
253 | The "eval" however happens in the current thread, which is important.
254 | (Both for control-C to work, as well as for the code to see the right
255 | thread in which it was embedded).
256 | """
257 | loop = asyncio.get_running_loop()
258 |
259 | if self.terminal_title:
260 | set_title(self.terminal_title)
261 |
262 | self._add_to_namespace()
263 |
264 | try:
265 | while True:
266 | try:
267 | # Read.
268 | try:
269 | text = await loop.run_in_executor(None, self.read)
270 | except EOFError:
271 | return
272 | except BaseException:
273 | # Something went wrong while reading input.
274 | # (E.g., a bug in the completer that propagates. Don't
275 | # crash the REPL.)
276 | traceback.print_exc()
277 | continue
278 |
279 | # Eval.
280 | await self.run_and_show_expression_async(text)
281 |
282 | except KeyboardInterrupt as e:
283 | # XXX: This does not yet work properly. In some situations,
284 | # `KeyboardInterrupt` exceptions can end up in the event
285 | # loop selector.
286 | self._handle_keyboard_interrupt(e)
287 | except SystemExit:
288 | return
289 | finally:
290 | if self.terminal_title:
291 | clear_title()
292 | self._remove_from_namespace()
293 |
294 | def eval(self, line: str) -> object:
295 | """
296 | Evaluate the line and print the result.
297 | """
298 | # WORKAROUND: Due to a bug in Jedi, the current directory is removed
299 | # from sys.path. See: https://github.com/davidhalter/jedi/issues/1148
300 | if "" not in sys.path:
301 | sys.path.insert(0, "")
302 |
303 | if line.lstrip().startswith("!"):
304 | # Run as shell command
305 | os.system(line[1:])
306 | else:
307 | # Try eval first
308 | try:
309 | code = self._compile_with_flags(line, "eval")
310 | except SyntaxError:
311 | pass
312 | else:
313 | # No syntax errors for eval. Do eval.
314 | result = eval(code, self.get_globals(), self.get_locals())
315 |
316 | if _has_coroutine_flag(code):
317 | result = asyncio.get_running_loop().run_until_complete(result)
318 |
319 | self._store_eval_result(result)
320 | return result
321 |
322 | # If not a valid `eval` expression, run using `exec` instead.
323 | # Note that we shouldn't run this in the `except SyntaxError` block
324 | # above, then `sys.exc_info()` would not report the right error.
325 | # See issue: https://github.com/prompt-toolkit/ptpython/issues/435
326 | code = self._compile_with_flags(line, "exec")
327 | result = eval(code, self.get_globals(), self.get_locals())
328 |
329 | if _has_coroutine_flag(code):
330 | result = asyncio.get_running_loop().run_until_complete(result)
331 |
332 | return None
333 |
334 | async def eval_async(self, line: str) -> object:
335 | """
336 | Evaluate the line and print the result.
337 | """
338 | # WORKAROUND: Due to a bug in Jedi, the current directory is removed
339 | # from sys.path. See: https://github.com/davidhalter/jedi/issues/1148
340 | if "" not in sys.path:
341 | sys.path.insert(0, "")
342 |
343 | if line.lstrip().startswith("!"):
344 | # Run as shell command
345 | os.system(line[1:])
346 | else:
347 | # Try eval first
348 | try:
349 | code = self._compile_with_flags(line, "eval")
350 | except SyntaxError:
351 | pass
352 | else:
353 | # No syntax errors for eval. Do eval.
354 | result = eval(code, self.get_globals(), self.get_locals())
355 |
356 | if _has_coroutine_flag(code):
357 | result = await result
358 |
359 | self._store_eval_result(result)
360 | return result
361 |
362 | # If not a valid `eval` expression, compile as `exec` expression
363 | # but still run with eval to get an awaitable in case of a
364 | # awaitable expression.
365 | code = self._compile_with_flags(line, "exec")
366 | result = eval(code, self.get_globals(), self.get_locals())
367 |
368 | if _has_coroutine_flag(code):
369 | result = await result
370 |
371 | return None
372 |
373 | def _store_eval_result(self, result: object) -> None:
374 | locals: dict[str, Any] = self.get_locals()
375 | locals["_"] = locals[f"_{self.current_statement_index}"] = result
376 |
377 | def get_compiler_flags(self) -> int:
378 | return super().get_compiler_flags() | PyCF_ALLOW_TOP_LEVEL_AWAIT
379 |
380 | def _compile_with_flags(self, code: str, mode: str) -> Any:
381 | "Compile code with the right compiler flags."
382 | return compile(
383 | code,
384 | "<stdin>",
385 | mode,
386 | flags=self.get_compiler_flags(),
387 | dont_inherit=True,
388 | )
389 |
390 | def _handle_exception(self, e: BaseException) -> None:
391 | # Required for pdb.post_mortem() to work.
392 | t, v, tb = sys.exc_info()
393 | sys.last_type, sys.last_value, sys.last_traceback = t, v, tb
394 |
395 | self._get_output_printer().display_exception(
396 | e,
397 | highlight=self.enable_syntax_highlighting,
398 | paginate=self.enable_pager,
399 | )
400 |
401 | def _handle_keyboard_interrupt(self, e: KeyboardInterrupt) -> None:
402 | output = self.app.output
403 |
404 | output.write("\rKeyboardInterrupt\n\n")
405 | output.flush()
406 |
407 | def _add_to_namespace(self) -> None:
408 | """
409 | Add ptpython built-ins to global namespace.
410 | """
411 | globals = self.get_globals()
412 |
413 | # Add a 'get_ptpython', similar to 'get_ipython'
414 | def get_ptpython() -> PythonInput:
415 | return self
416 |
417 | globals["get_ptpython"] = get_ptpython
418 | globals["exit"] = exit()
419 |
420 | def _remove_from_namespace(self) -> None:
421 | """
422 | Remove added symbols from the globals.
423 | """
424 | globals = self.get_globals()
425 | del globals["get_ptpython"]
426 |
427 | def print_paginated_formatted_text(
428 | self,
429 | formatted_text: Iterable[OneStyleAndTextTuple],
430 | end: str = "\n",
431 | ) -> None:
432 | # Warning: This is mainly here backwards-compatibility. Some projects
433 | # call `print_paginated_formatted_text` on the Repl object.
434 | self._get_output_printer().display_style_and_text_tuples(
435 | formatted_text, paginate=True
436 | )
437 |
438 |
439 | def enable_deprecation_warnings() -> None:
440 | """
441 | Show deprecation warnings, when they are triggered directly by actions in
442 | the REPL. This is recommended to call, before calling `embed`.
443 |
444 | e.g. This will show an error message when the user imports the 'sha'
445 | library on Python 2.7.
446 | """
447 | warnings.filterwarnings("default", category=DeprecationWarning, module="__main__")
448 |
449 |
450 | DEFAULT_CONFIG_FILE = "~/.config/ptpython/config.py"
451 |
452 |
453 | def run_config(repl: PythonInput, config_file: str | None = None) -> None:
454 | """
455 | Execute REPL config file.
456 |
457 | :param repl: `PythonInput` instance.
458 | :param config_file: Path of the configuration file.
459 | """
460 | explicit_config_file = config_file is not None
461 |
462 | # Expand tildes.
463 | config_file = os.path.expanduser(
464 | config_file if config_file is not None else DEFAULT_CONFIG_FILE
465 | )
466 |
467 | def enter_to_continue() -> None:
468 | input("\nPress ENTER to continue...")
469 |
470 | # Check whether this file exists.
471 | if not os.path.exists(config_file):
472 | if explicit_config_file:
473 | print(f"Impossible to read {config_file}")
474 | enter_to_continue()
475 | return
476 |
477 | # Run the config file in an empty namespace.
478 | try:
479 | namespace: dict[str, Any] = {}
480 |
481 | with open(config_file, "rb") as f:
482 | code = compile(f.read(), config_file, "exec")
483 | exec(code, namespace, namespace)
484 |
485 | # Now we should have a 'configure' method in this namespace. We call this
486 | # method with the repl as an argument.
487 | if "configure" in namespace:
488 | namespace["configure"](repl)
489 |
490 | except Exception:
491 | traceback.print_exc()
492 | enter_to_continue()
493 |
494 |
495 | class exit:
496 | """
497 | Exit the ptpython REPL.
498 | """
499 |
500 | # This custom exit function ensures that the `embed` function returns from
501 | # where we are embedded, and Python doesn't close `sys.stdin` like
502 | # the default `exit` from `_sitebuiltins.Quitter` does.
503 |
504 | def __call__(self) -> NoReturn:
505 | raise ReplExit
506 |
507 | def __repr__(self) -> str:
508 | # (Same message as the built-in Python REPL.)
509 | return "Use exit() or Ctrl-D (i.e. EOF) to exit"
510 |
511 |
512 | class ReplExit(Exception):
513 | """
514 | Exception raised by ptpython's exit function.
515 | """
516 |
517 |
518 | @overload
519 | def embed(
520 | globals: dict[str, Any] | None = ...,
521 | locals: dict[str, Any] | None = ...,
522 | configure: Callable[[PythonRepl], None] | None = ...,
523 | vi_mode: bool = ...,
524 | history_filename: str | None = ...,
525 | title: str | None = ...,
526 | startup_paths: Sequence[str | Path] | None = ...,
527 | patch_stdout: bool = ...,
528 | patch_stdout_raw: bool = ...,
529 | return_asyncio_coroutine: Literal[False] = ...,
530 | ) -> None: ...
531 |
532 |
533 | @overload
534 | def embed(
535 | globals: dict[str, Any] | None = ...,
536 | locals: dict[str, Any] | None = ...,
537 | configure: Callable[[PythonRepl], None] | None = ...,
538 | vi_mode: bool = ...,
539 | history_filename: str | None = ...,
540 | title: str | None = ...,
541 | startup_paths: Sequence[str | Path] | None = ...,
542 | patch_stdout: bool = ...,
543 | patch_stdout_raw: bool = ...,
544 | return_asyncio_coroutine: Literal[True] = ...,
545 | ) -> Coroutine[Any, Any, None]: ...
546 |
547 |
548 | def embed(
549 | globals: dict[str, Any] | None = None,
550 | locals: dict[str, Any] | None = None,
551 | configure: Callable[[PythonRepl], None] | None = None,
552 | vi_mode: bool = False,
553 | history_filename: str | None = None,
554 | title: str | None = None,
555 | startup_paths: Sequence[str | Path] | None = None,
556 | patch_stdout: bool = False,
557 | patch_stdout_raw: bool = False,
558 | return_asyncio_coroutine: bool = False,
559 | ) -> None | Coroutine[Any, Any, None]:
560 | """
561 | Call this to embed Python shell at the current point in your program.
562 | It's similar to `IPython.embed` and `bpython.embed`. ::
563 |
564 | from prompt_toolkit.contrib.repl import embed
565 | embed(globals(), locals())
566 |
567 | :param vi_mode: Boolean. Use Vi instead of Emacs key bindings.
568 | :param configure: Callable that will be called with the `PythonRepl` as a first
569 | argument, to trigger configuration.
570 | :param title: Title to be displayed in the terminal titlebar. (None or string.)
571 | :param patch_stdout: When true, patch `sys.stdout` so that background
572 | threads that are printing will print nicely above the prompt.
573 | :param patch_stdout_raw: When true, patch_stdout will not escape/remove vt100 terminal escape sequences.
574 | """
575 | # Default globals/locals
576 | if globals is None:
577 | globals = {
578 | "__name__": "__main__",
579 | "__package__": None,
580 | "__doc__": None,
581 | "__builtins__": builtins,
582 | }
583 |
584 | locals = locals or globals
585 |
586 | def get_globals() -> dict[str, Any]:
587 | return globals
588 |
589 | def get_locals() -> dict[str, Any]:
590 | return locals
591 |
592 | # Create REPL.
593 | repl = PythonRepl(
594 | get_globals=get_globals,
595 | get_locals=get_locals,
596 | vi_mode=vi_mode,
597 | history_filename=history_filename,
598 | startup_paths=startup_paths,
599 | )
600 |
601 | if title:
602 | repl.terminal_title = title
603 |
604 | if configure:
605 | configure(repl)
606 |
607 | # Start repl.
608 | patch_context: ContextManager[None] = (
609 | patch_stdout_context(raw=patch_stdout_raw) if patch_stdout else DummyContext()
610 | )
611 |
612 | if return_asyncio_coroutine:
613 |
614 | async def coroutine() -> None:
615 | with patch_context:
616 | await repl.run_async()
617 |
618 | return coroutine() # type: ignore
619 | else:
620 | with patch_context:
621 | repl.run()
622 | return None
623 |
--------------------------------------------------------------------------------
/src/ptpython/signatures.py:
--------------------------------------------------------------------------------
1 | """
2 | Helpers for retrieving the function signature of the function call that we are
3 | editing.
4 |
5 | Either with the Jedi library, or using `inspect.signature` if Jedi fails and we
6 | can use `eval()` to evaluate the function object.
7 | """
8 |
9 | from __future__ import annotations
10 |
11 | import inspect
12 | from inspect import Signature as InspectSignature
13 | from inspect import _ParameterKind as ParameterKind
14 | from typing import TYPE_CHECKING, Any, Sequence
15 |
16 | from prompt_toolkit.document import Document
17 |
18 | from .completer import DictionaryCompleter
19 | from .utils import get_jedi_script_from_document
20 |
21 | if TYPE_CHECKING:
22 | import jedi.api.classes
23 |
24 | __all__ = ["Signature", "get_signatures_using_jedi", "get_signatures_using_eval"]
25 |
26 |
27 | class Parameter:
28 | def __init__(
29 | self,
30 | name: str,
31 | annotation: str | None,
32 | default: str | None,
33 | kind: ParameterKind,
34 | ) -> None:
35 | self.name = name
36 | self.kind = kind
37 |
38 | self.annotation = annotation
39 | self.default = default
40 |
41 | def __repr__(self) -> str:
42 | return f"Parameter(name={self.name!r})"
43 |
44 | @property
45 | def description(self) -> str:
46 | """
47 | Name + annotation.
48 | """
49 | description = self.name
50 |
51 | if self.annotation is not None:
52 | description += f": {self.annotation}"
53 |
54 | return description
55 |
56 |
57 | class Signature:
58 | """
59 | Signature definition used wrap around both Jedi signatures and
60 | python-inspect signatures.
61 |
62 | :param index: Parameter index of the current cursor position.
63 | :param bracket_start: (line, column) tuple for the open bracket that starts
64 | the function call.
65 | """
66 |
67 | def __init__(
68 | self,
69 | name: str,
70 | docstring: str,
71 | parameters: Sequence[Parameter],
72 | index: int | None = None,
73 | returns: str = "",
74 | bracket_start: tuple[int, int] = (0, 0),
75 | ) -> None:
76 | self.name = name
77 | self.docstring = docstring
78 | self.parameters = parameters
79 | self.index = index
80 | self.returns = returns
81 | self.bracket_start = bracket_start
82 |
83 | @classmethod
84 | def from_inspect_signature(
85 | cls,
86 | name: str,
87 | docstring: str,
88 | signature: InspectSignature,
89 | index: int,
90 | ) -> Signature:
91 | parameters = []
92 |
93 | def get_annotation_name(annotation: object) -> str:
94 | """
95 | Get annotation as string from inspect signature.
96 | """
97 | try:
98 | # In case the annotation is a class like "int", "float", ...
99 | return str(annotation.__name__) # type: ignore
100 | except AttributeError:
101 | pass # No attribute `__name__`, e.g., in case of `List[int]`.
102 |
103 | annotation = str(annotation)
104 | if annotation.startswith("typing."):
105 | annotation = annotation[len("typing:") :]
106 | return annotation
107 |
108 | for p in signature.parameters.values():
109 | parameters.append(
110 | Parameter(
111 | name=p.name,
112 | annotation=get_annotation_name(p.annotation),
113 | default=repr(p.default)
114 | if p.default is not inspect.Parameter.empty
115 | else None,
116 | kind=p.kind,
117 | )
118 | )
119 |
120 | return cls(
121 | name=name,
122 | docstring=docstring,
123 | parameters=parameters,
124 | index=index,
125 | returns="",
126 | )
127 |
128 | @classmethod
129 | def from_jedi_signature(cls, signature: jedi.api.classes.Signature) -> Signature:
130 | parameters = []
131 |
132 | for p in signature.params:
133 | if p is None:
134 | # We just hit the "*".
135 | continue
136 |
137 | parameters.append(
138 | Parameter(
139 | name=p.to_string(), # p.name, (`to_string()` already includes the annotation).
140 | annotation=None, # p.infer_annotation()
141 | default=None, # p.infer_default()
142 | kind=p.kind,
143 | )
144 | )
145 |
146 | docstring = signature.docstring()
147 | if not isinstance(docstring, str):
148 | docstring = docstring.decode("utf-8")
149 |
150 | return cls(
151 | name=signature.name,
152 | docstring=docstring,
153 | parameters=parameters,
154 | index=signature.index,
155 | returns="",
156 | bracket_start=signature.bracket_start,
157 | )
158 |
159 | def __repr__(self) -> str:
160 | return f"Signature({self.name!r}, parameters={self.parameters!r})"
161 |
162 |
163 | def get_signatures_using_jedi(
164 | document: Document, locals: dict[str, Any], globals: dict[str, Any]
165 | ) -> list[Signature]:
166 | script = get_jedi_script_from_document(document, locals, globals)
167 |
168 | # Show signatures in help text.
169 | if not script:
170 | return []
171 |
172 | try:
173 | signatures = script.get_signatures()
174 | except ValueError:
175 | # e.g. in case of an invalid \\x escape.
176 | signatures = []
177 | except Exception:
178 | # Sometimes we still get an exception (TypeError), because
179 | # of probably bugs in jedi. We can silence them.
180 | # See: https://github.com/davidhalter/jedi/issues/492
181 | signatures = []
182 | else:
183 | # Try to access the params attribute just once. For Jedi
184 | # signatures containing the keyword-only argument star,
185 | # this will crash when retrieving it the first time with
186 | # AttributeError. Every following time it works.
187 | # See: https://github.com/jonathanslenders/ptpython/issues/47
188 | # https://github.com/davidhalter/jedi/issues/598
189 | try:
190 | if signatures:
191 | signatures[0].params
192 | except AttributeError:
193 | pass
194 |
195 | return [Signature.from_jedi_signature(sig) for sig in signatures]
196 |
197 |
198 | def get_signatures_using_eval(
199 | document: Document, locals: dict[str, Any], globals: dict[str, Any]
200 | ) -> list[Signature]:
201 | """
202 | Look for the signature of the function before the cursor position without
203 | use of Jedi. This uses a similar approach as the `DictionaryCompleter` of
204 | running `eval()` over the detected function name.
205 | """
206 | # Look for open parenthesis, before cursor position.
207 | pos = document.cursor_position - 1
208 |
209 | paren_mapping = {")": "(", "}": "{", "]": "["}
210 | paren_stack = [
211 | ")"
212 | ] # Start stack with closing ')'. We are going to look for the matching open ')'.
213 | comma_count = 0 # Number of comma's between start of function call and cursor pos.
214 | found_start = False # Found something.
215 |
216 | while pos >= 0:
217 | char = document.text[pos]
218 | if char in ")]}":
219 | paren_stack.append(char)
220 | elif char in "([{":
221 | if not paren_stack:
222 | # Open paren, while no closing paren was found. Mouse cursor is
223 | # positioned in nested parentheses. Not at the "top-level" of a
224 | # function call.
225 | break
226 | if paren_mapping[paren_stack[-1]] != char:
227 | # Unmatching parentheses: syntax error?
228 | break
229 |
230 | paren_stack.pop()
231 |
232 | if len(paren_stack) == 0:
233 | found_start = True
234 | break
235 |
236 | elif char == "," and len(paren_stack) == 1:
237 | comma_count += 1
238 |
239 | pos -= 1
240 |
241 | if not found_start:
242 | return []
243 |
244 | # We found the start of the function call. Now look for the object before
245 | # this position on which we can do an 'eval' to retrieve the function
246 | # object.
247 | obj = DictionaryCompleter(lambda: globals, lambda: locals).eval_expression(
248 | Document(document.text, cursor_position=pos), locals
249 | )
250 | if obj is None:
251 | return []
252 |
253 | try:
254 | name = obj.__name__ # type:ignore
255 | except Exception:
256 | name = obj.__class__.__name__
257 |
258 | try:
259 | signature = inspect.signature(obj) # type: ignore
260 | except TypeError:
261 | return [] # Not a callable object.
262 | except ValueError:
263 | return [] # No signature found, like for build-ins like "print".
264 |
265 | try:
266 | doc = obj.__doc__ or ""
267 | except:
268 | doc = ""
269 |
270 | # TODO: `index` is not yet correct when dealing with keyword-only arguments.
271 | return [Signature.from_inspect_signature(name, doc, signature, index=comma_count)]
272 |
--------------------------------------------------------------------------------
/src/ptpython/style.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from prompt_toolkit.styles import BaseStyle, Style, merge_styles
4 | from prompt_toolkit.styles.pygments import style_from_pygments_cls
5 | from prompt_toolkit.utils import is_conemu_ansi, is_windows, is_windows_vt100_supported
6 | from pygments.styles import get_all_styles, get_style_by_name
7 |
8 | __all__ = ["get_all_code_styles", "get_all_ui_styles", "generate_style"]
9 |
10 |
11 | def get_all_code_styles() -> dict[str, BaseStyle]:
12 | """
13 | Return a mapping from style names to their classes.
14 | """
15 | result: dict[str, BaseStyle] = {
16 | name: style_from_pygments_cls(get_style_by_name(name))
17 | for name in get_all_styles()
18 | }
19 | result["win32"] = Style.from_dict(win32_code_style)
20 | return result
21 |
22 |
23 | def get_all_ui_styles() -> dict[str, BaseStyle]:
24 | """
25 | Return a dict mapping {ui_style_name -> style_dict}.
26 | """
27 | return {
28 | "default": Style.from_dict(default_ui_style),
29 | "blue": Style.from_dict(blue_ui_style),
30 | }
31 |
32 |
33 | def generate_style(python_style: BaseStyle, ui_style: BaseStyle) -> BaseStyle:
34 | """
35 | Generate Pygments Style class from two dictionaries
36 | containing style rules.
37 | """
38 | return merge_styles([python_style, ui_style])
39 |
40 |
41 | # Code style for Windows consoles. They support only 16 colors,
42 | # so we choose a combination that displays nicely.
43 | win32_code_style = {
44 | "pygments.comment": "#00ff00",
45 | "pygments.keyword": "#44ff44",
46 | "pygments.number": "",
47 | "pygments.operator": "",
48 | "pygments.string": "#ff44ff",
49 | "pygments.name": "",
50 | "pygments.name.decorator": "#ff4444",
51 | "pygments.name.class": "#ff4444",
52 | "pygments.name.function": "#ff4444",
53 | "pygments.name.builtin": "#ff4444",
54 | "pygments.name.attribute": "",
55 | "pygments.name.constant": "",
56 | "pygments.name.entity": "",
57 | "pygments.name.exception": "",
58 | "pygments.name.label": "",
59 | "pygments.name.namespace": "",
60 | "pygments.name.tag": "",
61 | "pygments.name.variable": "",
62 | }
63 |
64 |
65 | default_ui_style = {
66 | "control-character": "ansiblue",
67 | # Classic prompt.
68 | "prompt": "bold",
69 | "prompt.dots": "noinherit",
70 | # (IPython <5.0) Prompt: "In [1]:"
71 | "in": "bold #008800",
72 | "in.number": "",
73 | # Return value.
74 | "out": "#ff0000",
75 | "out.number": "#ff0000",
76 | # Completions.
77 | "completion.builtin": "",
78 | "completion.param": "#006666 italic",
79 | "completion.keyword": "fg:#008800",
80 | "completion.keyword fuzzymatch.inside": "fg:#008800",
81 | "completion.keyword fuzzymatch.outside": "fg:#44aa44",
82 | # Separator between windows. (Used above docstring.)
83 | "separator": "#bbbbbb",
84 | # System toolbar
85 | "system-toolbar": "#22aaaa noinherit",
86 | # "arg" toolbar.
87 | "arg-toolbar": "#22aaaa noinherit",
88 | "arg-toolbar.text": "noinherit",
89 | # Signature toolbar.
90 | "signature-toolbar": "bg:#44bbbb #000000",
91 | "signature-toolbar current-name": "bg:#008888 #ffffff bold",
92 | "signature-toolbar operator": "#000000 bold",
93 | "docstring": "#888888",
94 | # Validation toolbar.
95 | "validation-toolbar": "bg:#440000 #aaaaaa",
96 | # Status toolbar.
97 | "status-toolbar": "bg:#222222 #aaaaaa",
98 | "status-toolbar.title": "underline",
99 | "status-toolbar.inputmode": "bg:#222222 #ffffaa",
100 | "status-toolbar.key": "bg:#000000 #888888",
101 | "status-toolbar key": "bg:#000000 #888888",
102 | "status-toolbar.pastemodeon": "bg:#aa4444 #ffffff",
103 | "status-toolbar.pythonversion": "bg:#222222 #ffffff bold",
104 | "status-toolbar paste-mode-on": "bg:#aa4444 #ffffff",
105 | "record": "bg:#884444 white",
106 | "status-toolbar more": "#ffff44",
107 | "status-toolbar.input-mode": "#ffff44",
108 | # The options sidebar.
109 | "sidebar": "bg:#bbbbbb #000000",
110 | "sidebar.title": "bg:#668866 #ffffff",
111 | "sidebar.label": "bg:#bbbbbb #222222",
112 | "sidebar.status": "bg:#dddddd #000011",
113 | "sidebar.label selected": "bg:#222222 #eeeeee",
114 | "sidebar.status selected": "bg:#444444 #ffffff bold",
115 | "sidebar.separator": "underline",
116 | "sidebar.key": "bg:#bbddbb #000000 bold",
117 | "sidebar.key.description": "bg:#bbbbbb #000000",
118 | "sidebar.helptext": "bg:#fdf6e3 #000011",
119 | # # Styling for the history layout.
120 | # history.line: '',
121 | # history.line.selected: 'bg:#008800 #000000',
122 | # history.line.current: 'bg:#ffffff #000000',
123 | # history.line.selected.current: 'bg:#88ff88 #000000',
124 | # history.existinginput: '#888888',
125 | # Help Window.
126 | "window-border": "#aaaaaa",
127 | "window-title": "bg:#bbbbbb #000000",
128 | # Meta-enter message.
129 | "accept-message": "bg:#ffff88 #444444",
130 | # Exit confirmation.
131 | "exit-confirmation": "bg:#884444 #ffffff",
132 | }
133 |
134 |
135 | # Some changes to get a bit more contrast on Windows consoles.
136 | # (They only support 16 colors.)
137 | if is_windows() and not is_conemu_ansi() and not is_windows_vt100_supported():
138 | default_ui_style.update(
139 | {
140 | "sidebar.title": "bg:#00ff00 #ffffff",
141 | "exitconfirmation": "bg:#ff4444 #ffffff",
142 | "toolbar.validation": "bg:#ff4444 #ffffff",
143 | "menu.completions.completion": "bg:#ffffff #000000",
144 | "menu.completions.completion.current": "bg:#aaaaaa #000000",
145 | }
146 | )
147 |
148 |
149 | blue_ui_style = {}
150 | blue_ui_style.update(default_ui_style)
151 | # blue_ui_style.update({
152 | # # Line numbers.
153 | # Token.LineNumber: '#aa6666',
154 | #
155 | # # Highlighting of search matches in document.
156 | # Token.SearchMatch: '#ffffff bg:#4444aa',
157 | # Token.SearchMatch.Current: '#ffffff bg:#44aa44',
158 | #
159 | # # Highlighting of select text in document.
160 | # Token.SelectedText: '#ffffff bg:#6666aa',
161 | #
162 | # # Completer toolbar.
163 | # Token.Toolbar.Completions: 'bg:#44bbbb #000000',
164 | # Token.Toolbar.Completions.Arrow: 'bg:#44bbbb #000000 bold',
165 | # Token.Toolbar.Completions.Completion: 'bg:#44bbbb #000000',
166 | # Token.Toolbar.Completions.Completion.Current: 'bg:#008888 #ffffff',
167 | #
168 | # # Completer menu.
169 | # Token.Menu.Completions.Completion: 'bg:#44bbbb #000000',
170 | # Token.Menu.Completions.Completion.Current: 'bg:#008888 #ffffff',
171 | # Token.Menu.Completions.Meta: 'bg:#449999 #000000',
172 | # Token.Menu.Completions.Meta.Current: 'bg:#00aaaa #000000',
173 | # Token.Menu.Completions.ProgressBar: 'bg:#aaaaaa',
174 | # Token.Menu.Completions.ProgressButton: 'bg:#000000',
175 | # })
176 |
--------------------------------------------------------------------------------
/src/ptpython/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | For internal use only.
3 | """
4 |
5 | from __future__ import annotations
6 |
7 | import re
8 | from typing import TYPE_CHECKING, Any, Callable, Iterable, TypeVar, cast
9 |
10 | from prompt_toolkit.document import Document
11 | from prompt_toolkit.formatted_text import to_formatted_text
12 | from prompt_toolkit.formatted_text.utils import fragment_list_to_text
13 | from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
14 |
15 | if TYPE_CHECKING:
16 | from jedi import Interpreter
17 |
18 | # See: prompt_toolkit/key_binding/key_bindings.py
19 | # Annotating these return types as `object` is what works best, because
20 | # `NotImplemented` is typed `Any`.
21 | NotImplementedOrNone = object
22 |
23 | __all__ = [
24 | "has_unclosed_brackets",
25 | "get_jedi_script_from_document",
26 | "document_is_multiline_python",
27 | "unindent_code",
28 | ]
29 |
30 |
31 | def has_unclosed_brackets(text: str) -> bool:
32 | """
33 | Starting at the end of the string. If we find an opening bracket
34 | for which we didn't had a closing one yet, return True.
35 | """
36 | stack = []
37 |
38 | # Ignore braces inside strings
39 | text = re.sub(r"""('[^']*'|"[^"]*")""", "", text) # XXX: handle escaped quotes.!
40 |
41 | for c in reversed(text):
42 | if c in "])}":
43 | stack.append(c)
44 |
45 | elif c in "[({":
46 | if stack:
47 | if (
48 | (c == "[" and stack[-1] == "]")
49 | or (c == "{" and stack[-1] == "}")
50 | or (c == "(" and stack[-1] == ")")
51 | ):
52 | stack.pop()
53 | else:
54 | # Opening bracket for which we didn't had a closing one.
55 | return True
56 |
57 | return False
58 |
59 |
60 | def get_jedi_script_from_document(
61 | document: Document, locals: dict[str, Any], globals: dict[str, Any]
62 | ) -> Interpreter:
63 | import jedi # We keep this import in-line, to improve start-up time.
64 |
65 | # Importing Jedi is 'slow'.
66 |
67 | try:
68 | return jedi.Interpreter(
69 | document.text,
70 | path="input-text",
71 | namespaces=[locals, globals],
72 | )
73 | except ValueError:
74 | # Invalid cursor position.
75 | # ValueError('`column` parameter is not in a valid range.')
76 | return None
77 | except AttributeError:
78 | # Workaround for #65: https://github.com/jonathanslenders/python-prompt-toolkit/issues/65
79 | # See also: https://github.com/davidhalter/jedi/issues/508
80 | return None
81 | except IndexError:
82 | # Workaround Jedi issue #514: for https://github.com/davidhalter/jedi/issues/514
83 | return None
84 | except KeyError:
85 | # Workaround for a crash when the input is "u'", the start of a unicode string.
86 | return None
87 | except Exception:
88 | # Workaround for: https://github.com/jonathanslenders/ptpython/issues/91
89 | return None
90 |
91 |
92 | _multiline_string_delims = re.compile("""[']{3}|["]{3}""")
93 |
94 |
95 | def document_is_multiline_python(document: Document) -> bool:
96 | """
97 | Determine whether this is a multiline Python document.
98 | """
99 |
100 | def ends_in_multiline_string() -> bool:
101 | """
102 | ``True`` if we're inside a multiline string at the end of the text.
103 | """
104 | delims = _multiline_string_delims.findall(document.text)
105 | opening = None
106 | for delim in delims:
107 | if opening is None:
108 | opening = delim
109 | elif delim == opening:
110 | opening = None
111 | return bool(opening)
112 |
113 | if "\n" in document.text or ends_in_multiline_string():
114 | return True
115 |
116 | def line_ends_with_colon() -> bool:
117 | return document.current_line.rstrip()[-1:] == ":"
118 |
119 | # If we just typed a colon, or still have open brackets, always insert a real newline.
120 | if (
121 | line_ends_with_colon()
122 | or (
123 | document.is_cursor_at_the_end
124 | and has_unclosed_brackets(document.text_before_cursor)
125 | )
126 | or document.text.startswith("@")
127 | ):
128 | return True
129 |
130 | # If the character before the cursor is a backslash (line continuation
131 | # char), insert a new line.
132 | elif document.text_before_cursor[-1:] == "\\":
133 | return True
134 |
135 | return False
136 |
137 |
138 | _T = TypeVar("_T", bound=Callable[[MouseEvent], None])
139 |
140 |
141 | def if_mousedown(handler: _T) -> _T:
142 | """
143 | Decorator for mouse handlers.
144 | Only handle event when the user pressed mouse down.
145 |
146 | (When applied to a token list. Scroll events will bubble up and are handled
147 | by the Window.)
148 | """
149 |
150 | def handle_if_mouse_down(mouse_event: MouseEvent) -> NotImplementedOrNone:
151 | if mouse_event.event_type == MouseEventType.MOUSE_DOWN:
152 | return handler(mouse_event)
153 | else:
154 | return NotImplemented
155 |
156 | return cast(_T, handle_if_mouse_down)
157 |
158 |
159 | _T_type = TypeVar("_T_type", bound=type)
160 |
161 |
162 | def ptrepr_to_repr(cls: _T_type) -> _T_type:
163 | """
164 | Generate a normal `__repr__` method for classes that have a `__pt_repr__`.
165 | """
166 | if not hasattr(cls, "__pt_repr__"):
167 | raise TypeError(
168 | "@ptrepr_to_repr can only be applied to classes that have a `__pt_repr__` method."
169 | )
170 |
171 | def __repr__(self: object) -> str:
172 | assert hasattr(cls, "__pt_repr__")
173 | return fragment_list_to_text(to_formatted_text(cls.__pt_repr__(self)))
174 |
175 | cls.__repr__ = __repr__ # type:ignore
176 | return cls
177 |
178 |
179 | def unindent_code(text: str) -> str:
180 | """
181 | Remove common leading whitespace when all lines are indented.
182 | """
183 | lines = text.splitlines(keepends=True)
184 |
185 | # Look for common prefix.
186 | common_prefix = _common_whitespace_prefix(lines)
187 |
188 | # Remove indentation.
189 | lines = [line[len(common_prefix) :] for line in lines]
190 |
191 | return "".join(lines)
192 |
193 |
194 | def _common_whitespace_prefix(strings: Iterable[str]) -> str:
195 | """
196 | Return common prefix for a list of lines.
197 | This will ignore lines that contain whitespace only.
198 | """
199 | # Ignore empty lines and lines that have whitespace only.
200 | strings = [s for s in strings if not s.isspace() and not len(s) == 0]
201 |
202 | if not strings:
203 | return ""
204 |
205 | else:
206 | s1 = min(strings)
207 | s2 = max(strings)
208 |
209 | for i, c in enumerate(s1):
210 | if c != s2[i] or c not in " \t":
211 | return s1[:i]
212 |
213 | return s1
214 |
--------------------------------------------------------------------------------
/src/ptpython/validator.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Callable
4 |
5 | from prompt_toolkit.document import Document
6 | from prompt_toolkit.validation import ValidationError, Validator
7 |
8 | from .utils import unindent_code
9 |
10 | __all__ = ["PythonValidator"]
11 |
12 |
13 | class PythonValidator(Validator):
14 | """
15 | Validation of Python input.
16 |
17 | :param get_compiler_flags: Callable that returns the currently
18 | active compiler flags.
19 | """
20 |
21 | def __init__(self, get_compiler_flags: Callable[[], int] | None = None) -> None:
22 | self.get_compiler_flags = get_compiler_flags
23 |
24 | def validate(self, document: Document) -> None:
25 | """
26 | Check input for Python syntax errors.
27 | """
28 | text = unindent_code(document.text)
29 |
30 | # When the input starts with Ctrl-Z, always accept. This means EOF in a
31 | # Python REPL.
32 | if text.startswith("\x1a"):
33 | return
34 |
35 | # When the input starts with an exclamation mark. Accept as shell
36 | # command.
37 | if text.lstrip().startswith("!"):
38 | return
39 |
40 | try:
41 | if self.get_compiler_flags:
42 | flags = self.get_compiler_flags()
43 | else:
44 | flags = 0
45 |
46 | compile(text, "<input>", "exec", flags=flags, dont_inherit=True)
47 | except SyntaxError as e:
48 | # Note, the 'or 1' for offset is required because Python 2.7
49 | # gives `None` as offset in case of '4=4' as input. (Looks like
50 | # fixed in Python 3.)
51 | # TODO: This is not correct if indentation was removed.
52 | index = document.translate_row_col_to_index(
53 | (e.lineno or 1) - 1, (e.offset or 1) - 1
54 | )
55 | raise ValidationError(index, f"Syntax Error: {e}")
56 | except TypeError as e:
57 | # e.g. "compile() expected string without null bytes"
58 | raise ValidationError(0, str(e))
59 | except ValueError as e:
60 | # In Python 2, compiling "\x9" (an invalid escape sequence) raises
61 | # ValueError instead of SyntaxError.
62 | raise ValidationError(0, f"Syntax Error: {e}")
63 |
--------------------------------------------------------------------------------
/tests/test_dummy.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | from __future__ import annotations
3 |
4 | import ptpython.completer
5 | import ptpython.eventloop
6 | import ptpython.filters
7 | import ptpython.history_browser
8 | import ptpython.key_bindings
9 | import ptpython.layout
10 | import ptpython.python_input
11 | import ptpython.repl
12 | import ptpython.style
13 | import ptpython.utils
14 | import ptpython.validator
15 |
16 | # For now there are no tests here.
17 | # However this is sufficient to do at least a syntax check.
18 |
19 |
20 | def test_dummy() -> None:
21 | assert ptpython.completer
22 | assert ptpython.eventloop
23 | assert ptpython.filters
24 | assert ptpython.history_browser
25 | assert ptpython.key_bindings
26 | assert ptpython.layout
27 | assert ptpython.python_input
28 | assert ptpython.repl
29 | assert ptpython.style
30 | assert ptpython.utils
31 | assert ptpython.validator
32 |
--------------------------------------------------------------------------------