diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..c321e71c9 --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +ignore = E203, E266, E501, W503 +max-line-length = 80 +max-complexity = 18 +select = B,C,E,F,W,T4,B9 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..8c139c7be --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 000000000..8e04fae2c --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + pull_request: + schedule: + # run at 7:00 on the first of every month + - cron: "0 7 1 * *" + +jobs: + build: + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.python-version == 'pypy-3.9' }} + strategy: + fail-fast: false + matrix: + python-version: + - "3.11" + - "3.12" + - "3.13" + - "3.14" + - "pypy-3.11" + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install "urwid >= 1.0" twisted watchdog "jedi >=0.16" babel "sphinx >=1.5" + pip install pytest pytest-cov numpy + - name: Build with Python ${{ matrix.python-version }} + run: | + python setup.py build + - name: Build documentation + run: | + python setup.py build_sphinx + python setup.py build_sphinx_man + - name: Test with pytest + run: | + pytest --cov=bpython --cov-report=xml -v + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + env: + PYTHON_VERSION: ${{ matrix.python-version }} + with: + file: ./coverage.xml + env_vars: PYTHON_VERSION + if: ${{ always() }} diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 000000000..8caf95623 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,45 @@ +name: Linters + +on: + push: + pull_request: + +jobs: + black: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install black codespell + - name: Check with black + run: black --check . + + codespell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: codespell-project/actions-codespell@master + with: + skip: "*.po,encoding_latin1.py,test_repl.py" + ignore_words_list: ba,te,deltion,dedent,dedented,assertIn + + mypy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install mypy + pip install -r requirements.txt + pip install urwid twisted watchdog "jedi >=0.16" babel "sphinx >=1.5" numpy + pip install types-backports types-requests types-setuptools types-toml types-pygments + - name: Check with mypy + # for now only run on a few files to avoid slipping backward + run: mypy diff --git a/.gitignore b/.gitignore index 5cbac053e..7a81cbfe2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ build/* env .DS_Store .idea/ +doc/sphinx/build/* +bpython/_version.py +venv/ +.venv/ +.mypy_cache/ diff --git a/.pycheckrc b/.pycheckrc deleted file mode 100644 index e7050fad1..000000000 --- a/.pycheckrc +++ /dev/null @@ -1 +0,0 @@ -blacklist = ['pyparsing', 'code', 'pygments/lexer'] diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..a19293daa --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,14 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3" + +sphinx: + configuration: doc/sphinx/source/conf.py + +python: + install: + - method: pip + path: . diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d081b9302..000000000 --- a/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -language: python - -python: - - "2.6" - - "2.7" - - "3.3" - - "3.4" - -install: - - "python setup.py install" - - "pip install curtsies greenlet pygments" - -script: - - cd build/lib/ - - "nosetests bpython/test" diff --git a/AUTHORS b/AUTHORS.rst similarity index 76% rename from AUTHORS rename to AUTHORS.rst index 956a2e284..230128c96 100644 --- a/AUTHORS +++ b/AUTHORS.rst @@ -11,13 +11,19 @@ Other contributors are (in alphabetical order): * Eike Hein * Allison Kaptur * Jason Laster +* Miriam Lauter +* Mary Mokuolu * Brandon Navra * Michele Orrù * Pavel Panchekha +* Keyan Pishdadian * Sebastian Ramacher * Amjith Ramanujam * Andreas Stührk * Simon de Vlieger +* Tarek Ziade * Marien Zwart -Many thanks for all contributions! +A big thanks goes out to all the people who help us out by either submitting +patches, helping us determine problems, our package maintainers, and of course +everybody who creates issues for us to fix. diff --git a/CHANGELOG b/CHANGELOG.rst similarity index 58% rename from CHANGELOG rename to CHANGELOG.rst index 33a74bd5d..34dd4fb54 100644 --- a/CHANGELOG +++ b/CHANGELOG.rst @@ -1,7 +1,497 @@ Changelog ========= -v0.13.1 +0.27 +---- + +General information: + + +New features: + + +Fixes: + + +Changes to dependencies: + + +0.26 +---- + +General information: + +* This release is focused on Python 3.14 support. + +New features: + + +Fixes: +* #1027: Handle unspecified config paths +* #1035: Align simple_eval with Python 3.10+ +* #1036: Make -q hide the welcome message +* #1041: Convert sys.ps1 to a string to work-around non-str sys.ps1 from vscode + +Changes to dependencies: + + +Support for Python 3.14 has been added. Support for Python 3.9 has been dropped. + +0.25 +---- + +General information: + +* The `bpython-cli` rendering backend has been removed following deprecation in + version 0.19. +* This release is focused on Python 3.13 support. + +New features: + + +Fixes: + +* Fix __signature__ support + Thanks to gpotter2 +* #995: Fix handling of `SystemExit` +* #996: Improve order of completion results + Thanks to gpotter2 +* Fix build of documentation and manpages with Sphinx >= 7 +* #1001: Do not fail if modules don't have __version__ + +Changes to dependencies: + +* Remove use of distutils + Thanks to Anderson Bravalheri + +Support for Python 3.12 and 3.13 has been added. Support for Python 3.7 and 3.8 has been dropped. + +0.24 +---- + +General information: + +* This release is focused on Python 3.11 support. + +New features: + +* #980: Add more keywords to trigger auto-deindent. + Thanks to Eric Burgess + +Fixes: + +* Improve inspection of builtin functions. + +Changes to dependencies: + +* wheel is not required as part of pyproject.toml's build dependencies + +Support for Python 3.11 has been added. + +0.23 +---- + +General information: + +* More and more type annotations have been added to the bpython code base. + +New features: + +* #905: Auto-closing brackets option added. To enable, add `brackets_completion = True` in the bpython config + Thanks to samuelgregorovic + +Fixes: + +* Improve handling of SyntaxErrors +* #948: Fix crash on Ctrl-Z +* #952: Fix tests for Python 3.10.1 and newer +* #955: Handle optional `readline` parameters in `stdin` emulation + Thanks to thevibingcat +* #959: Fix handling of `__name__` +* #966: Fix function signature completion for `classmethod` + +Changes to dependencies: + +* curtsies 0.4 or newer is now required + +Support for Python 3.6 has been dropped. + +0.22.1 +------ + +Fixes: + +* #938: Fix missing dependency on typing_extensions. + Thanks to Dustin Rodrigues + +0.22 +---- + +General information: + +* The #bpython channel has moved to OFTC. +* Type annotations have been added to the bpython code base. +* Declarative build configuration is used as much as possible. + +New features: + +* #883: Allow auto-completion to be disabled +* #841: Respect locals when using bpython.embed +* Use pyperclip for better clipboard handling + +Fixes: + +* #700, #884: Fix writing of b"" on fake stdout +* #879: Iterate over all completers until a successful one is found +* #882: Handle errors in theme configuration without crashing +* #888: Read PYTHONSTARTUP with utf8 as encoding +* #896: Use default sys.ps1 and sys.ps2 if user specified ones are not usable +* #902: Do not crash when encountering unreadable files while processing modules for import completion +* #909: Fix sys.stdin.readline +* #917: Fix tab completion for dict keys +* #919: Replicate python behavior when running with -i and a non-existing file +* #932: Fix handling of __signature__ for completion. + Thanks to gpotter2 + +Changes to dependencies: + +* pyperclip is a new optional dependency for clipboard support +* backports.cached-property is now required for Python < 3.8 +* dataclasses is now required for Python < 3.7 + +Support for Python 3.10 has been added. + +0.21 +---- + +General information: + +* Support for Python 2 has been dropped. + +New features: + +* #643: Provide bpython._version if built from Github tarballs +* #849: Make import completion skip list configurable +* #876: Check spelling with codespell + Thanks to Christian Clauss + +Fixes: + +* #847: Fix import completion of modules +* #857: Replace remaining use of deprecated imp with importlib +* #862: Upgrade curtsies version requirements + Thanks to Kelsey Blair +* #863: State correct default config file directory + Thanks to niloct +* #866: Add more directories to the default import completion skip list +* #873: Handle 'd' when mapping colors +* #874: Avoid breakage with six's importer + +Changes to dependencies: + +* curtsies >= 0.3.5 is now required +* pyxdg is now required +* wcwidth has been replaced with cwcwidth + +0.20.1 +------ + +Fixes: + +* Fix check of key code (fixes #859) + +0.20 +---- + +General information: + +* The next release of bpython (0.20) will drop support for Python 2. +* Support for Python 3.9 has been added. Support for Python 3.5 has been + dropped. + +New features: + +* #802: Provide redo. + Thanks to Evan. +* #835: Add support for importing namespace packages. + Thanks to Thomas Babej. + +Fixes: + +* #622: Provide encoding attribute for FakeOutput. +* #806: Prevent symbolic link loops in import completion. + Thanks to Etienne Richart. +* #807: Support packages using importlib.metadata API. + Thanks to uriariel. +* #809: Fix support for Python 3.9's ast module. +* #817: Fix cursor position with full-width characters. + Thanks to Jack Rybarczyk. +* #853: Fix invalid escape sequences. + +0.19 +---- + +General information: + +* The bpython-cli and bpython-urwid rendering backends have been deprecated and + will show a warning that they'll be removed in a future release when started. +* Usage in combination with Python 2 has been deprecated. This does not mean that + support is dropped instantly but rather that at some point in the future we will + stop running our testcases against Python 2. +* The new pinnwand API is used for the pastebin functionality. We have dropped + two configuration options: `pastebin_show_url` and `pastebin_removal_url`. If + you have your bpython configured to run against an old version of `pinnwand` + please update it. + +New features: + +Fixes: + +* #765: Display correct signature for decorated functions. + Thanks to Benedikt Rascher-Friesenhausen. +* #776: Protect get_args from user code exceptions +* Improve lock file handling on Windows +* #791: Use importlib instead of deprecated imp when running under Python 3 + +Support for Python 3.8 has been added. Support for Python 3.4 has been dropped. + +0.18 +---- + +New features: + +* #713 expose globals in bpdb debugging. + Thanks to toejough. + +Fixes: + +* Fix file locking on Windows. +* Exit gracefully if config file fails to be loaded due to encoding errors. +* #744: Fix newline handling. + Thanks to Attila Szöllősi. +* #731: Fix exit code. + Thanks to benkrig. +* #767: Fix crash when matching certain lines in history. + +Support for Python 3.3 has been dropped. + +0.17.1 +------ + +Fixes: + +* Reverted #670 temporarily due to performance impact + on large strings being output. + +0.17 +---- + +New features: + +* #641: Implement Ctrl+O. +* Add default_autoreload config option. + Thanks to Alex Frieder. + +Fixes: + +* Fix deprecation warnings. +* Do not call signal outside of main thread. + Thanks to Max Nordlund. +* Fix option-backspace behavior. + Thanks to Alex Frieder. +* #648: Fix paste helper. + Thanks to Jakob Bowyer. +* #653: Handle docstrings more carefully. +* #654: Do not modify history file during tests. +* #658: Fix newline handling. + Thanks to Attila Szöllősi. +* #670: Fix handling of ANSI escape codes. + Thanks to Attila Szöllősi. +* #687: Fix encoding of jedi completions. + +0.16 +---- + +New features: + +* #466: Improve handling of completion box height. + +Fixes: + +* Fix various spelling mistakes. + Thanks to Josh Soref and Simeon Visser. +* #601: Fix Python 2 issues on Windows. + Thanks to Aditya Gupta. +* #614: Fix issues when view source. + Thanks to Daniel Hahler. +* #625: Fix issues when running scripts with non-ASCII characters. +* #639: Fix compatibility issues with pdb++. + Thanks to Daniel Hahler. + +Support for Python 2.6 has been dropped. + +0.15 +---- + +This release contains new features and plenty of bug fixes. + +New features: + +* #425: Added curtsies 0.2.x support. +* #528: Hide private attribute from initial autocompletion suggestions. + Thanks to Jeppe Toustrup. +* #538: Multi-line banners are allowed. +* #229: inspect.getsource works on interactively defined functions. + Thanks to Michael Mulley. +* Attribute completion works on literals and some expressions containing + builtin objects. +* Ctrl-e can be used to autocomplete current fish-style suggestion. + Thanks to Amjith Ramanujam. + +Fixes: + +* #484: Switch `bpython.embed` to the curtsies frontend. +* #548 Fix transpose character bug. + Thanks to Wes E. Vial. +* #527 -q disables version banner. +* #544 Fix Jedi completion error. +* #536 Fix completion on old-style classes with custom __getattr__. +* #480 Fix old-style class autocompletion. + Thanks to Joe Jevnik. +* #506 In python -i mod.py sys.modules[__name__] refers to module dict. +* #590 Fix "None" not being displayed. +* #546 Paste detection uses events instead of bytes returned in a single + os.read call. +* Exceptions in autocompletion are now logged instead of crashing bpython. +* Fix reload in Python 3. + Thanks to sharow. +* Fix keyword argument parameter name completion. + +Changes to dependencies: + +* requests[security] has been changed to pyOpenSSL, pyasn1, and ndg-httpsclient. + These dependencies are required before Python 2.7.7. + +0.14.2 +------ + +Fixes: + +* #498: Fixed is_callable +* #509: Fixed fcntl usage. +* #523, #524: Fix conditional dependencies for SNI support again. +* Fix binary name of bpdb. + +0.14.1 +------ + +Fixes: + +* #483: Fixed jedi exceptions handling. +* #486: Fixed Python 3.3 compatibility. +* #489: Create history file with mode 0600. +* #491: Fix issues with file name completion. +* #494: Fix six version requirement. +* Fix conditional dependencies for SNI support in Python versions before 2.7.7. + +0.14 +---- + +This release contains major changes to the frontends: + +* curtsies is the new default frontend. +* The old curses frontend is available as bpython-curses. +* The GTK+ frontend has been removed. + +New features: + +* #194: Syntax-highlighted tracebacks. Thanks to Miriam Lauter. +* #234: Copy to system clipboard. +* #285: Re-evaluate session and reimport modules. +* #313: Warn when undo may take cause extended delay, and prompt to undo + multiple lines. +* #322: Watch imported modules for changes and re-evaluate on changes. +* #328: bpython history not re-evaluated to edit a previous line of a multiline + statement. +* #334: readline command Meta-. for yank last argument. Thanks to Susan + Steinman and Steph Samson. +* #338: bpython help with F1. +* #354: Edit config file from within bpython. +* #382: Partial support for pasting in text with blank lines. +* #410: Startup banner that shows Python and bpython version +* #426: Experimental multiline autocompletion. +* fish style last history completion with Arrow Right. Thanks to Nicholas + Sweeting. +* fish style automatic reverse history search with Arrow Up. + Thanks to Nicholas Sweeting. +* Incremental forward and reverse search. +* All readline keys which kill/cut text correctly copy text for paste + with Ctrl-y or Meta-y. +* French translation. +* Removal links for bpaste pastebins are now displayed. +* More informative error messages when source cannot be found for an object. + Thanks to Liudmila Nikolaeva and Miriam Lauter. +* Message displayed if history in scrollback buffer is inconsistent with + output from last re-evaluation of bpython session. Thanks to Susan Steinman. +* Adjust logging level with -L or -LL. +* String literal attribute completion. + +Fixes: + +* #254: Use ASCII characters if Unicode box characters are not supported by the + terminal. +* #284: __file__ is in scope after module run with bpython -i. Thanks to + Lindsey Raymond. +* #347: Fixed crash on unsafe autocompletion. +* #349: Fixed writing newlines to stderr. +* #363: Fixed banner crashing bpython-urwid. Thanks to Luca Barbato. +* #366, #367: Fixed help() support in curtsies. +* #369: Interactive sessions inherit compiler directives from files run with -i + interactive flag. +* #370, #401, #440, #448, #468, #472: Fixed various display issues in curtsies. +* #391: Fixed crash when using Meta-backspace. Thanks to Tony Wang. +* #438, #450: bpython-curtsies startup behavior fixed. Errors + during startup are reported instead of crashing. +* #447: Fixed behavior of duplicate keybindings. Thanks to Keyan Pishdadian. +* #458: Fixed dictionary key completion crash in Python 2.6. Thanks to Mary + Mokuolu. +* Documentation fixes from Lindsey Raymond. +* Fixed filename completion. +* Fixed various Unicode issues in curtsies. +* Fixed and re-enabled dictionary key completion in curtsies. + +The commandline option --type / -t has been renamed to --paste / -p. + +Python 2.6, 2.7, 3.3 and newer are supported. Support for 2.5 has been dropped. +Furthermore, it is no longer necessary to run 2to3 on the source code. + +This release brings a lot more code coverage, a new contributing guide, +and most of the code now conforms to PEP-8. + +Changes to dependencies: + +* greenlet and curtsies are no longer optional. +* six is a new dependency. +* jedi is a new optional dependency required for multiline completion. +* watchdog is a new optional dependency required for watching changes in + imported modules. + +0.13.2 +------- + +A bugfix release. The fixed bugs are: + +* #424: Use new JSON API at bpaste.net. +* #430: Fixed SNI issues with new pastebin service on Mac OS X. +* #432: Fixed crash in bpython-curtsies in special circumstances if history file + is empty. Thanks to Lisa van Gelder. + +Changes to dependencies: + +* requests is a new dependency. +* PyOpenSSL, ndg-httpsclient and pyasn1 are new dependencies on Mac OS X. + +0.13.1 ------- A bugfix release. The fixed bugs are: @@ -110,8 +600,8 @@ without twisted installed. * Fix ungetch issues with Python 3.3. See issues #230, #231. -v0.11 ------ +0.11 +---- A bugfix/cleanup release .The fixed bugs are: @@ -123,16 +613,16 @@ frontend, the urwid frontend. I'd like to specifically thank Amjith Ramanujam for his work on history search which was further implemented and is in working order right now. -v0.10.1 -------- +0.10.1 +------ A bugfix release. The fixed bugs are: * #197: find_modules crashes on non-readable directories * #198: Source tarball lacks .po files -v0.10 ------ +0.10 +---- As a highlight of the release, Michele Orrù added i18n support to bpython. Some issues have been resolved as well: @@ -150,16 +640,16 @@ Some issues have been resolved as well: * The short command-line option "-c config" was dropped as it conflicts with vanilla Python's "-c command" option. See issue #186. -v0.9.7.1 --------- +0.9.7.1 +------- A bugfix release. The fixed bugs are: * #128: bpython-gtk is broken * #134: crash when using pastebin and no active internet connection -v0.9.7 ------- +0.9.7 +----- Well guys. It's been some time since the latest release, six months have passed We have added a whole slew of new features, and closed a number of bugs as well. @@ -205,33 +695,33 @@ As always, please submit any bugs you might find to our bugtracker. * #87: Add a closed attribute to Repl to fix mercurial.ui.ui expecting stderr to have this attribute. -* #108: Unicode characters in docsrting crash bpython +* #108: Unicode characters in docstring crash bpython * #118: Load_theme is not defined. * #99: Configurable font now documented. * #123: Pastebin can't handle 'ESC' key * #124: Unwanted input when using / keys in the statusbar prompt. -v0.9.6.2 --------- +0.9.6.2 +------- Unfortunately another bugfix release as I (Bob) broke py3 support. * #84: bpython doesn't work with Python 3 Thanks very much to Henry Prêcheur for both the bug report and the patch. -v0.9.6.1 --------- +0.9.6.1 +------- A quick bugfix release (this should not become a habit). * #82: Crash on saving file. -v0.9.6 +0.9.6 ------ A bugfix/feature release (and a start at gtk). Happy Christmas everyone! * #67: Make pastebin URL really configurable. -* #68: Set a__main__ module and set interpreter's namespace to that module. +* #68: Set a __main__ module and set interpreter's namespace to that module. * #70: Implement backward completion on backward tab. * #62: Hide matches starting with a _ unless explicitly typed. * #72: Auto dedentation @@ -250,8 +740,8 @@ A bugfix/feature release (and a start at gtk). Happy Christmas everyone! - Remove globals for configuration. - rl_history now stays the same, also after undo. -v0.9.5.2 --------- +0.9.5.2 +------- A bugfix release. Fixed issues: @@ -265,14 +755,14 @@ Other fixes without opened issues: * future imports in startup scripts can influence interpreter's behaviour now * Show the correct docstring for types without a own __init__ method -v0.9.5.1 +0.9.5.1 -------- Added missing data files to the tarball. -v0.9.5 ------- +0.9.5 +----- Fixed issues: * #25 Problems with DEL, Backspace and C-u over multiple lines @@ -297,8 +787,8 @@ bpaste.net Argument names are now shown as completion suggestions and one can tab through the completion list. -v0.9.4 ------- +0.9.4 +----- Bugfix release (mostly) * when typing a float literal bpython autocompletes int methods (#36) @@ -310,7 +800,7 @@ Bugfix release (mostly) * numerous fixes and improvements to parentheses highlighting * made *all* keys configurable (except for arrow keys/pgup/pgdown) -v0.9.3 +0.9.3 ------ This release was a true whopper! @@ -321,8 +811,8 @@ This release was a true whopper! * Parentheses matching * Argument highlighting -v0.9.2 ------- +0.9.2 +----- * help() now uses an external pager if available. * Fix for highlighting prefixed strings. * Fix to reset string highlighting after a SyntaxError. @@ -330,14 +820,14 @@ v0.9.2 * Configuration files are no longer passed by the first command line argument but by the -c command line switch. * Fix for problem related to editing lines in the history: http://bitbucket.org/bobf/bpython/issue/10/odd-behaviour-when-editing-commands-in-the-history -v0.9.1 ------- +0.9.1 +----- * Fixed a small but annoying bug with sys.argv ini file passing * Fix for Python 2.6 to monkeypatch they way it detects callables in rlcompleter * Config file conversion fix -v0.9.0 ------- +0.9.0 +----- * Module import completion added. * Changed to paste.pocoo.org due to rafb.net no longer offering a pastebin service. * Switched to .ini file format for config file. @@ -347,16 +837,16 @@ v0.9.0 Probably some other things, but I hate changelogs. :) -v0.8.0 +0.8.0 ------ -It's been a long while since the last release and there've been numerous little +It's been a long while since the last release and there have been numerous little bugfixes and extras here and there so I'm putting this out as 0.8.0. Check the hg commit history if you want more info: http://bitbucket.org/bobf/bpython/ -v0.7.2 ------- +0.7.2 +----- Menno sent me some patches to fix some stuff: * Socket error handled when submitting to a pastebin. @@ -371,16 +861,16 @@ Other stuff: * Bohdan Vlasyuk sent me a patch that fixes a problem with the above patch from Mark if sys.__stdout__.encoding didn't exist. * Save to file now outputs executable code (i.e. without the >>> and ... and with "# OUT: " prepended to all output lines). I never used this feature much but someone asked for this behaviour. -v0.7.1 ------- +0.7.1 +----- * Added support for a history file, defaults to ~/.pythonhist and 100 lines but is configurable from the rc file (see sample-rc). * Charles Duffy has added a yank/put thing - C-k and C-y. He also ran the code through some PEP-8 checker thing and fixed up a few old habits I manage to break but didn't manage to fix the code to reflect this - thank you! * Jørgen Tjernø has fixed up the autoindentation issues we encountered when bringing soft tabs in. * SyntaxError, ValueError and OverflowError are now caught properly (code.InteractiveInterpreter treats these as different to other exceptions as it doesn't print the whole traceback, so a different handler is called). This was discovered as I was trying to stop autoindentation from occurring on a SyntaxError, which has also been fixed. * '.' now in sys.path on startup. -v0.7.0 ------- +0.7.0 +----- C-d behaviour changed so it no longer exits if the current line isn't empty. Extra linebreak added to end of stdout flush. @@ -396,16 +886,16 @@ raw_input() and all its friends now work fine. PYTHONSTARTUP handled without blowing up on stupid errors (it now parses the file at once instead of feeding it to the repl line-by-line). -v0.6.4 ------- +0.6.4 +----- KeyboardInterrupt handler clears the list window properly now. -v0.6.3 ------- +0.6.3 +----- Forgot to switch rpartition to split for 2.4 compat. -v0.6.2 ------- +0.6.2 +----- The help() now works (as far as I can see) exactly the same as the vanilla help() in the regular interpreter. I copied some code from pydoc.py to make it handle the special cases, e.g. @@ -413,16 +903,16 @@ help('keywords') help('modules') etc. -v0.6.1 ------- +0.6.1 +----- Somehow it escaped my attention that the list window was never fully using the rightmost column, except for the first row. This is because me and numbers don't have the best relationship. I think stability is really improving with the latest spat of bugfixes, keep me informed of any bugs. -v0.6.0 ------- +0.6.0 +----- No noticeable changes except that bpython should now work with Python 2.4. Personally I think it's silly to make a development tool work with an out of date version of Python but some people @@ -430,24 +920,24 @@ seem to disagree. The only real downside is that I had to do a horrible version of all() using reduce(), otherwise there's no real differences in the code. -v0.5.3 ------- +0.5.3 +----- Now you can configure a ~/.bpythonrc file (or pass a rc file at the command line (bpython /foo/bar). See README for details. -v0.5.2 ------- +0.5.2 +----- help() actually displays the full help page, and I fixed up the ghetto pager a little. -v0.5.1 ------- +0.5.1 +----- Now you can hit tab to display the autocomplete list, rather than have it pop up automatically as you type which, apparently, annoys Brendogg. -v0.5.0 ------- +0.5.0 +----- A few people have commented that the help() built-in function doesn't work so well with bpython, since Python will try to output the help string to PAGER (usually "less") which obviously makes @@ -457,11 +947,11 @@ into the interpreter when it initialises in an attempt to rectify this. As such, it's pretty untested but it seems to be working okay for me. Suggestions/bug reports/patches are welcome regarding this. -v0.4.2 ------- +0.4.2 +----- Well, hopefully we're one step closer to making the list sizing stuff work. I really hate doing code for that kind of thing as I -never get it quite right, but with perseverence it should end up +never get it quite right, but with perseverance it should end up being completely stable; it's not the hardest thing in the world. Various cosmetic fixes have been put in at the request of a bunch @@ -471,38 +961,38 @@ experiences. PYTHONSTARTUP is now dealt with and used properly, as per the vanilla interpreter. -v0.4.1 ------- +0.4.1 +----- It looks like the last release was actually pretty bug-free, aside from one tiny bug that NEVER ACTUALLY HAPPENS but someone was bugging me about it anyway, oh well. -v0.4.0 ------- +0.4.0 +----- It's been quite a long time since the last update, due to several uninteresting and invalid excuses, but I finally reworked the list drawing procedures so the crashing seems to have been taken care of to an extent. If it still crashes, the way I've written it will hopefully allow a much more robust way of fixing it, one that might actually work. -v0.3.2 ------- +0.3.2 +----- Thanks to Aaron Gallagher for pointing out a case where the hugely inefficient list generation routines were actually making a significant issue; they're much more efficient now and should hopefully not cause any more problems. -v0.3.1 ------- +0.3.1 +----- Thanks to Klaus Alexander Seis for the expanduser() patch. Auto indent works on multiple levels now. -v0.3.0 ------- +0.3.0 +----- Now with auto-indent. Let me know if it's annoying. -v0.2.4 ------- +0.2.4 +----- Thanks a lot to Angus Gibson for submitting a patch to fix a problem I was having with initialising the keyboard stuff in curses properly. @@ -520,18 +1010,18 @@ least that makes sense to me). In so doing I also cleaned up a lot of the reevaluating and resizing code so that a lot of the strange output seen on Rewind/resize seems to be gone. -v0.2.3 ------- +0.2.3 +----- The fix for the last bug broke the positioning of the autocomplete box, whoops. -v0.2.2 ------- +0.2.2 +----- That pesky bug keeps coming up. I think it's finally nailed but it's just a matter of testing and hoping. I hate numbers. -v0.2.1 ------- +0.2.1 +----- I'm having a bit of trouble with some integer division that's causing trouble when a certain set of circumstances arise, and I think I've taken care of that little bug, since it's @@ -539,8 +1029,8 @@ a real pain in the ass and only creeps up when I'm actually doing something useful, so I'll test it for a bit and release it as hopefully a bug fixed version. -v0.2.0 ------- +0.2.0 +----- A little late in the day to start a changelog, but here goes... This version fixed another annoying little bug that was causing crashes given certain exact circumstances. I always find it's the diff --git a/LICENSE b/LICENSE index 57ce59d81..46f642f27 100644 --- a/LICENSE +++ b/LICENSE @@ -20,3 +20,83 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +One function in bpython/simpleeval.py is licensed under the +Python Software Foundation License version 2: simple_eval + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python +alone or in any derivative version, provided, however, that PSF's +License Agreement and PSF's notice of copyright, i.e., "Copyright (c) +2001, 2002, 2003, 2004 Python Software Foundation; All Rights Reserved" +are retained in Python alone or in any derivative version prepared +by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BuildDoc in setup.py is licensed under the BSD-2 license: + +Copyright 2007-2021 Sebastian Wiesner + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in index 73b6ab574..eb5d7f2fb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,18 +1,16 @@ include .pycheckrc -include AUTHORS -include CHANGELOG +include AUTHORS.rst +include CHANGELOG.rst include LICENSE -include data/bpython -include data/bpython.desktop -include doc/sphinx/source/conf.py +include data/bpython.png +include data/org.bpython-interpreter.bpython.desktop +include data/org.bpython-interpreter.bpython.appdata.xml +include doc/sphinx/source/*.py include doc/sphinx/source/*.rst include doc/sphinx/source/logo.png -include sample-config -include *.theme -include bpython/logo.png -include ROADMAP -include TODO include bpython/test/*.py include bpython/test/*.theme include bpython/translations/*/LC_MESSAGES/bpython.po include bpython/translations/*/LC_MESSAGES/bpython.mo +include bpython/sample-config +include theme/*.theme diff --git a/README.rst b/README.rst index 951f55c1b..dd307d33b 100644 --- a/README.rst +++ b/README.rst @@ -1,28 +1,105 @@ -|ImageLink|_ +.. image:: https://img.shields.io/pypi/v/bpython + :target: https://pypi.org/project/bpython -.. |ImageLink| image:: https://travis-ci.org/bpython/bpython.svg?branch=master -.. _ImageLink: https://travis-ci.org/bpython/bpython -bpython - A fancy curses interface to the Python interactive interpreter -======================================================================== +.. image:: https://readthedocs.org/projects/bpython/badge/?version=latest + :target: https://docs.bpython-interpreter.org/en/latest/ -Dependencies -============ +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/ambv/black -* Pygments (apt-get install python-pygments) -* Sphinx != 1.1.2 (for the documentation only) (apt-get install python-sphinx) -* mock (for the testsuite only) -* babel (optional, for internationalization) -bpython-curtsies ----------------- -``bpython-curtsies`` requires the following additional packages: +**************************************************************** +bpython: A fancy interface to the Python interactive interpreter +**************************************************************** -* curtsies >= 0.1.0 -* greenlet +`bpython`_ is a lightweight Python interpreter that adds several features common +to IDEs. These features include **syntax highlighting**, **expected parameter +list**, **auto-indentation**, and **autocompletion**. (See below for example +usage). + +.. image:: https://bpython-interpreter.org/images/math.gif + :alt: bpython + :width: 566 + :height: 348 + :align: center + +bpython does **not** aim to be a complete IDE - the focus is on implementing a +few ideas in a practical, useful, and lightweight manner. + +bpython is a great replacement to any occasion where you would normally use the +vanilla Python interpreter - testing out solutions to people's problems on IRC, +quickly testing a method of doing something without creating a temporary file, +etc. + +You can find more about bpython - including `full documentation`_ - at our +`homepage`_. + +========================== +Installation & Basic Usage +========================== + +Installation using Pip +---------------------- + +If you have `pip`_ installed, you can simply run: + +.. code-block:: bash + + $ pip install bpython + +Start bpython by typing ``bpython`` in your terminal. You can exit bpython by +using the ``exit()`` command or by pressing control-D like regular interactive +Python. + +=================== +Features & Examples +=================== +* Readline-like autocomplete, with suggestions displayed as you type. + +* In-line syntax highlighting. This uses Pygments for lexing the code as you + type, and colours appropriately. + +* Expected parameter list. As in a lot of modern IDEs, bpython will attempt to + display a list of parameters for any function you call. The inspect module (which + works with any Python function) is tried first, and then pydoc if that fails. + +* Rewind. This isn't called "Undo" because it would be misleading, but "Rewind" + is probably as bad. The idea is that the code entered is kept in memory and + when the Rewind function is called, the last line is popped and the entire + session is re-evaluated. Use to rewind. + +* Edit the current line or your entire session in an editor. F7 opens the current + session in a text editor, and if modifications are made, the session is rerun + with these changes. + +* Pastebin code/write to file. Use the key to upload the screen's contents + to pastebin, with a URL returned. + +* Reload imported Python modules. Use to clear sys.modules and rerun your + session to test changes to code in a module you're working on. + +============= +Configuration +============= +See the sample-config file for a list of available options. You should save +your config file as **~/.config/bpython/config** (i.e. +``$XDG_CONFIG_HOME/bpython/config``) or specify at the command line:: -and optionally (for monitoring imported modules for changes) + bpython --config /path/to/bpython/config -* watchdog +============ +Dependencies +============ +* Pygments +* curtsies >= 0.4.0 +* greenlet +* pyxdg +* requests +* Sphinx >= 1.5 (optional, for the documentation) +* babel (optional, for internationalization) +* jedi (optional, for experimental multiline completion) +* watchdog (optional, for monitoring imported modules for changes) +* pyperclip (optional, for copying to the clipboard) bpython-urwid ------------- @@ -30,133 +107,89 @@ bpython-urwid * urwid -Introduction -============ -A few people asked for stuff like syntax highlighting and autocomplete for the -Python interactive interpreter. IPython seems to offer this (plus you can get -readline behaviour in the vanilla interpreter) but I tried IPython a couple of -times. Perhaps I didn't really get it, but I get the feeling that the ideas -behind IPython are pretty different to bpython. I didn't want to create a whole -development environment; I simply wanted to provide a couple of neat features -that already exist and turn them into something a little more interactive. - -The idea is to provide the user with all the features in-line, much like modern -IDEs, but in a simple, lightweight package that can be run in a terminal -window, so curses seemed like the best choice. Sorry if you use Windows. - -bpython doesn't attempt to create anything new or groundbreaking, it simply -brings together a few neat ideas and focuses on practicality and usefulness. -For this reason, the "Rewind" function should be taken with a pinch of salt, -but personally I have found it to be very useful. I use bpython now whenever I -would normally use the vanilla interpreter, e.g. for testing out solutions to -people's problems on IRC, quickly testing a method of doing something without -creating a temporary file, etc.. - -I hope you find it useful and please feel free to submit any bugs/patches (yeah -right)/suggestions to: -robertanthonyfarrell@gmail.com -or place them at the github issue page for this project at: -http://github.com/bpython/bpython/issues/ -For any other ways of communicating with bpython users and devs you can find us -at the community page on the projects homepage: -http://bpython-interpreter.org/community +=================================== +Installation via OS Package Manager +=================================== -Or in the documentation at http://docs.bpython-interpreter.org/community.html. +The majority of desktop computer operating systems come with package management +systems. If you use one of these OSes, you can install ``bpython`` using the +package manager. -Hope to see you there! +Ubuntu/Debian +------------- +Ubuntu/Debian family Linux users can install ``bpython`` using the ``apt`` +package manager, using the command with ``sudo`` privileges: -Features -======== - -* In-line syntax highlighting. - This uses Pygments for lexing the code as you type, and colours - appropriately. Pygments does a great job of doing all of the tricky stuff - and really leaving me with very little to do except format the tokens in - all my favourite colours. - -* Readline-like autocomplete with suggestions displayed as you type. - Thanks to Python's readline interface to libreadline and a ready-made class - for using a Python interpreter's scope as the dataset, the only work here - was displaying the readline matches as you type in a separate curses window - below/above the cursor. - -* Expected parameter list. - As in a lot of modern IDEs, bpython will attempt to display a list of - parameters for any function you call. The inspect module is tried first, - which works with any Python function, and then pydoc if that fails, which - seems to be pretty adequate, but obviously in some cases it's simply not - possible. I used pyparsing to cure my nested parentheses woes; again, it - was nice and easy. - -* Rewind. - I didn't call this "Undo" because I thought that would be misleading, but - "Rewind" is probably as bad. The idea is that the code entered is kept in - memory and when the Rewind function is called, the last line is popped and - the entire code is re-evaluated. As you can imagine, this has a lot of - potential problems, but for defining classes and functions, I've found it - to be nothing but useful. - -* Pastebin code/write to file. - I don't really use the save thing much, but the pastebin thing's great. Hit - a key and what you see on the screen will be sent to a pastebin and a URL - is returned for you to do what you like with. I've hardcoded - paste.pocoo.org in for now, that needs to be fixed so it's configurable. - Next release, I promise. - -* Flush curses screen to stdout. - A featurette, perhaps, but I thought it was worth noting. I can't - personally recall a curses app that does this, perhaps it's often not - useful, but when you quit bpython, the screen data will be flushed to - stdout, so it basically looks the same as if you had quit the vanilla - interpreter. +.. code-block:: bash -Configuration -============= -See the sample-config file for a list of available options. You should save -your config file as ~/.config/bpython/config (i.e -$XDG_CONFIG_HOME/bpython/config) or specify at the command line:: + $ apt install bpython - bpython --config /path/to/bpython/config +In case you are using an older version, run -Known Bugs -========== -For known bugs please see bpython's issue tracker at github: +.. code-block:: bash -http://github.com/bpython/bpython/issues/ + $ apt-get install bpython -CLI Windows Support -=================== +Arch Linux +---------- +Arch Linux uses ``pacman`` as the default package manager; you can use it to install ``bpython``: -Dependencies ------------- -Curses - Use the appropriate version compiled by Christoph Gohlke - http://www.lfd.uci.edu/~gohlke/pythonlibs/ +.. code-block:: bash + + $ pacman -S bpython + +Fedora +------ +Fedora users can install ``bpython`` directly from the command line using ``dnf``. + +.. code-block:: bash + + $ dnf install bpython + +GNU Guix +---------- +Guix users can install ``bpython`` on any GNU/Linux distribution directly from the command line: + +.. code-block:: bash -pyreadline - Use the version in the cheeseshop - http://pypi.python.org/pypi/pyreadline/ + $ guix install bpython -Recommended ------------ -Obtain the less program from GnuUtils. This makes the pager work as intended. -It can be obtained from cygwin or GnuWin32 or msys +macOS +----- +macOS does not include a package manager by default. If you have installed any +third-party package manager like MacPorts, you can install it via -Current version is tested with ------------------------------- - * Curses 2.2 - * pyreadline 1.7 +.. code-block:: bash -Curses Notes ------------- -The curses used has a bug where the colours are displayed incorrectly: - * red is swapped with blue - * cyan is swapped with yellow + $ sudo port install py-bpython -To correct this I have provided my windows.theme file. -This curses implementation has 16 colors (dark and light versions of the -colours) +========== +Known Bugs +========== +For known bugs please see bpython's `known issues and FAQ`_ page. + +====================== +Contact & Contributing +====================== +I hope you find it useful and please feel free to submit any bugs/patches +suggestions to `Robert`_ or place them on the GitHub +`issues tracker`_. +For any other ways of communicating with bpython users and devs you can find us +at the community page on the `project homepage`_, or in the `community`_. + +Hope to see you there! +.. _homepage: http://www.bpython-interpreter.org +.. _full documentation: http://docs.bpython-interpreter.org/ +.. _issues tracker: http://github.com/bpython/bpython/issues/ +.. _pip: https://pip.pypa.io/en/latest/index.html +.. _project homepage: http://bpython-interpreter.org +.. _community: http://docs.bpython-interpreter.org/community.html +.. _Robert: robertanthonyfarrell@gmail.com +.. _bpython: http://www.bpython-interpreter.org/ +.. _Curses: http://www.lfd.uci.edu/~gohlke/pythonlibs/ +.. _pyreadline: http://pypi.python.org/pypi/pyreadline/ +.. _known issues and FAQ: http://bpython-interpreter.org/known-issues-and-faq.html diff --git a/bpdb/__init__.py b/bpdb/__init__.py index ac088796d..9ce932d38 100644 --- a/bpdb/__init__.py +++ b/bpdb/__init__.py @@ -1,7 +1,7 @@ # The MIT License # # Copyright (c) 2008 Bob Farrell -# Copyright (c) 2013 Sebastian Ramacher +# Copyright (c) 2013-2020 Sebastian Ramacher # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -26,21 +26,27 @@ import traceback import bpython -from bpdb.debugger import BPdb +from bpython.args import version_banner, copyright_banner +from .debugger import BPdb from optparse import OptionParser from pdb import Restart +__author__ = bpython.__author__ +__copyright__ = bpython.__copyright__ +__license__ = bpython.__license__ __version__ = bpython.__version__ def set_trace(): - """ Just like pdb.set_trace(), a helper function that creates - a debugger instance and sets the trace. """ + """Just like pdb.set_trace(), a helper function that creates + a debugger instance and sets the trace.""" debugger = BPdb() debugger.set_trace(sys._getframe().f_back) + # Adopted verbatim from pdb for completeness: + def post_mortem(t=None): # handling the default if t is None: @@ -48,33 +54,38 @@ def post_mortem(t=None): # being handled, otherwise it returns None t = sys.exc_info()[2] if t is None: - raise ValueError("A valid traceback must be passed if no " - "exception is being handled") + raise ValueError( + "A valid traceback must be passed if no exception is being handled." + ) p = BPdb() p.reset() p.interaction(None, t) + def pm(): post_mortem(getattr(sys, "last_traceback", None)) + def main(): - parser = OptionParser( - usage='Usage: %prog [options] [file [args]]') - parser.add_option('--version', '-V', action='store_true', - help='Print version and exit.') + parser = OptionParser(usage="Usage: %prog [options] [file [args]]") + parser.add_option( + "--version", "-V", action="store_true", help="Print version and exit." + ) options, args = parser.parse_args(sys.argv) if options.version: - print 'bpdb on top of bpython version', __version__, - print 'on top of Python', sys.version.split()[0] - print ('(C) 2008-2013 Bob Farrell, Andreas Stuehrk et al. ' - 'See AUTHORS for detail.') + print(version_banner(base="bpdb")) + print(copyright_banner()) return 0 - # The following code is baed on Python's pdb.py. + if len(args) < 2: + print("usage: bpdb scriptfile [arg] ...") + return 2 + + # The following code is based on Python's pdb.py. mainpyfile = args[1] if not os.path.exists(mainpyfile): - print 'Error:', mainpyfile, 'does not exist' + print(f"Error: {mainpyfile} does not exist.") return 1 # Hide bpdb from argument list. @@ -89,20 +100,22 @@ def main(): pdb._runscript(mainpyfile) if pdb._user_requested_quit: break - print "The program finished and will be restarted" + print("The program finished and will be restarted.") except Restart: - print "Restarting", mainpyfile, "with arguments:" - print "\t" + " ".join(sys.argv[1:]) + print(f"Restarting {mainpyfile} with arguments:") + print("\t" + " ".join(sys.argv[1:])) except SystemExit: # In most cases SystemExit does not warrant a post-mortem session. - print "The program exited via sys.exit(). Exit status: ", - print sys.exc_info()[1] + print( + "The program exited via sys.exit(). Exit status: ", + ) + print(sys.exc_info()[1]) except: traceback.print_exc() - print "Uncaught exception. Entering post mortem debugging" - print "Running 'cont' or 'step' will restart the program" + print("Uncaught exception. Entering post mortem debugging.") + print("Running 'cont' or 'step' will restart the program.") t = sys.exc_info()[2] pdb.interaction(None, t) - print "Post mortem debugger finished. The " + mainpyfile + \ - " will be restarted" - + print( + f"Post mortem debugger finished. The {mainpyfile} will be restarted." + ) diff --git a/bpdb/__main__.py b/bpdb/__main__.py index b85accb45..5cbd26503 100644 --- a/bpdb/__main__.py +++ b/bpdb/__main__.py @@ -22,6 +22,7 @@ import sys -if __name__ == '__main__': - from bpdb import main +if __name__ == "__main__": + from . import main + sys.exit(main()) diff --git a/bpdb/debugger.py b/bpdb/debugger.py index fc3908bb7..38469541a 100644 --- a/bpdb/debugger.py +++ b/bpdb/debugger.py @@ -20,38 +20,38 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. - import pdb import bpython + class BPdb(pdb.Pdb): - """ PDB with BPython support. """ + """PDB with BPython support.""" - def __init__(self): - pdb.Pdb.__init__(self) - self.rcLines = [] - self.prompt = '(BPdb) ' + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.prompt = "(BPdb) " self.intro = 'Use "B" to enter bpython, Ctrl-d to exit it.' - def postloop(self): + def postloop(self) -> None: # We only want to show the intro message once. self.intro = None - pdb.Pdb.postloop(self) - - ### cmd.Cmd commands - - - def do_Bpython(self, arg): - bpython.embed(self.curframe.f_locals, ['-i']) + super().postloop() + # cmd.Cmd commands - def help_Bpython(self): - print "B(python)" - print - print ("Invoke the bpython interpreter for this stack frame. To exit " - "bpython and return to a standard pdb press Ctrl-d") + def do_Bpython(self, arg: str) -> None: + locals_ = self.curframe.f_globals.copy() + locals_.update(self.curframe.f_locals) + bpython.embed(locals_, ["-i"]) + def help_Bpython(self) -> None: + print("B(python)") + print("") + print( + "Invoke the bpython interpreter for this stack frame. To exit " + "bpython and return to a standard pdb press Ctrl-d" + ) - ### shortcuts + # shortcuts do_B = do_Bpython help_B = help_Bpython diff --git a/bpython/__init__.py b/bpython/__init__.py index b93f04297..7d7bd28e0 100644 --- a/bpython/__init__.py +++ b/bpython/__init__.py @@ -21,11 +21,26 @@ # THE SOFTWARE. import os.path +from typing import Any -__version__ = 'mercurial' +try: + from ._version import __version__ as version # type: ignore +except ImportError: + version = "unknown" + +__author__ = ( + "Bob Farrell, Andreas Stuehrk, Sebastian Ramacher, Thomas Ballinger, et al." +) +__copyright__ = f"(C) 2008-2025 {__author__}" +__license__ = "MIT" +__version__ = version package_dir = os.path.abspath(os.path.dirname(__file__)) -def embed(locals_=None, args=['-i', '-q'], banner=None): - from bpython.cli import main +def embed(locals_=None, args=None, banner=None) -> Any: + if args is None: + args = ["-i", "-q"] + + from .curtsies import main + return main(args, locals_, banner) diff --git a/bpython/_py3compat.py b/bpython/__main__.py similarity index 69% rename from bpython/_py3compat.py rename to bpython/__main__.py index dd3c489fd..9693dadf7 100644 --- a/bpython/_py3compat.py +++ b/bpython/__main__.py @@ -1,8 +1,6 @@ -# encoding: utf-8 - # The MIT License # -# Copyright (c) 2012 the bpython authors. +# Copyright (c) 2015 Sebastian Ramacher # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -21,24 +19,11 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -# - - -""" - Helper module for Python 3 compatibility. - - Defines the following attributes: - - PythonLexer: Pygment's Python lexer matching the hosting runtime's - Python version. - - py3: True if the hosting Python runtime is of Python version 3 or later -""" import sys -py3 = (sys.version_info[0] == 3) +if __name__ == "__main__": + from .curtsies import main -if py3: - from pygments.lexers import Python3Lexer as PythonLexer -else: - from pygments.lexers import PythonLexer + sys.exit(main()) diff --git a/bpython/_internal.py b/bpython/_internal.py index 225133ead..bfcfce46f 100644 --- a/bpython/_internal.py +++ b/bpython/_internal.py @@ -1,28 +1,30 @@ import pydoc import sys -from bpython.pager import page +from .pager import page # Ugly monkeypatching -pydoc.pager = page +pydoc.pager = page # type: ignore -class _Helper(object): - - def __init__(self): +class _Helper: + def __init__(self) -> None: if hasattr(pydoc.Helper, "output"): # See issue #228 self.helper = pydoc.Helper(sys.stdin, None) else: self.helper = pydoc.Helper(sys.stdin, sys.stdout) - def __repr__(self): - return ("Type help() for interactive help, " - "or help(object) for help about object.") + def __repr__(self) -> str: + return ( + "Type help() for interactive help, " + "or help(object) for help about object." + ) - def __call__(self, *args, **kwargs): + def __call__(self, *args, **kwargs) -> None: self.helper(*args, **kwargs) + _help = _Helper() diff --git a/bpython/args.py b/bpython/args.py index dcf3c7b57..ac78267a9 100644 --- a/bpython/args.py +++ b/bpython/args.py @@ -1,111 +1,282 @@ +# The MIT License +# +# Copyright (c) 2008 Bob Farrell +# Copyright (c) 2012-2025 Sebastian Ramacher +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# To gradually migrate to mypy we aren't setting these globally yet +# mypy: disallow_untyped_defs=True +# mypy: disallow_untyped_calls=True + """ Module to handle command line argument parsing, for all front-ends. """ -from __future__ import with_statement +import argparse +import code +import importlib.util +import logging import os import sys -import code -from optparse import OptionParser, OptionGroup +from pathlib import Path +from collections.abc import Callable +from types import ModuleType +from typing import Never + +from . import __version__, __copyright__ +from .config import default_config_path, Config +from .translations import _ -from bpython import __version__ -from bpython.config import default_config_path, loadini, Struct -from bpython.translations import _ +logger = logging.getLogger(__name__) -class OptionParserFailed(ValueError): +class ArgumentParserFailed(ValueError): """Raised by the RaisingOptionParser for a bogus commandline.""" -class RaisingOptionParser(OptionParser): - def error(self, msg): - raise OptionParserFailed() +class RaisingArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> Never: + raise ArgumentParserFailed() + + +def version_banner(base: str = "bpython") -> str: + return _("{} version {} on top of Python {} {}").format( + base, + __version__, + sys.version.split()[0], + sys.executable, + ) + + +def copyright_banner() -> str: + return _("{} See AUTHORS.rst for details.").format(__copyright__) -def parse(args, extras=None, ignore_stdin=False): +def log_version(module: ModuleType, name: str) -> None: + logger.info("%s: %s", name, module.__version__ if hasattr(module, "__version__") else "unknown version") # type: ignore + + +Options = tuple[str, str, Callable[[argparse._ArgumentGroup], None]] + + +def parse( + args: list[str] | None, + extras: Options | None = None, + ignore_stdin: bool = False, +) -> tuple[Config, argparse.Namespace, list[str]]: """Receive an argument list - if None, use sys.argv - parse all args and - take appropriate action. Also receive optional extra options: this should - be a tuple of (title, description, options) - title: The title for the option group - description: A full description of the option group - options: A list of optparse.Option objects to be added to the - group + take appropriate action. Also receive optional extra argument: this should + be a tuple of (title, description, callback) + title: The title for the argument group + description: A full description of the argument group + callback: A callback that adds argument to the argument group e.g.: - parse(['-i', '-m', 'foo.py'], - ('Front end-specific options', - 'A full description of what these options are for', - [optparse.Option('-f', action='store_true', dest='f', help='Explode'), - optparse.Option('-l', action='store_true', dest='l', help='Love')])) + def callback(group): + group.add_argument('-f', action='store_true', dest='f', help='Explode') + group.add_argument('-l', action='store_true', dest='l', help='Love') + + parse( + ['-i', '-m', 'foo.py'], + ( + 'Front end-specific options', + 'A full description of what these options are for', + callback + ), + ) Return a tuple of (config, options, exec_args) wherein "config" is the config object either parsed from a default/specified config file or default config options, "options" is the parsed options from - OptionParser.parse_args, and "exec_args" are the args (if any) to be parsed + ArgumentParser.parse_args, and "exec_args" are the args (if any) to be parsed to the executed file (if any). """ if args is None: args = sys.argv[1:] - parser = RaisingOptionParser( - usage=_('Usage: %prog [options] [file [args]]\n' - 'NOTE: If bpython sees an argument it does ' - 'not know, execution falls back to the ' - 'regular Python interpreter.')) - # This is not sufficient if bpython gains its own -m support - # (instead of falling back to Python itself for that). - # That's probably fixable though, for example by having that - # option swallow all remaining arguments in a callback. - parser.disable_interspersed_args() - parser.add_option('--config', default=default_config_path(), - help=_('Use CONFIG instead of default config file.')) - parser.add_option('--interactive', '-i', action='store_true', - help=_('Drop to bpython shell after running file ' - 'instead of exiting.')) - parser.add_option('--quiet', '-q', action='store_true', - help=_("Don't flush the output to stdout.")) - parser.add_option('--version', '-V', action='store_true', - help=_('Print version and exit.')) + parser = RaisingArgumentParser( + usage=_( + "Usage: %(prog)s [options] [file [args]]\n" + "NOTE: If bpython sees an argument it does " + "not know, execution falls back to the " + "regular Python interpreter." + ) + ) + parser.add_argument( + "--config", + default=default_config_path(), + type=Path, + help=_("Use CONFIG instead of default config file."), + ) + parser.add_argument( + "--interactive", + "-i", + action="store_true", + help=_("Drop to bpython shell after running file instead of exiting."), + ) + parser.add_argument( + "--quiet", + "-q", + action="store_true", + help=_("Don't print version banner."), + ) + parser.add_argument( + "--version", + "-V", + action="store_true", + help=_("Print version and exit."), + ) + parser.add_argument( + "--log-level", + "-l", + choices=("debug", "info", "warning", "error", "critical"), + default="error", + help=_("Set log level for logging"), + ) + parser.add_argument( + "--log-output", + "-L", + help=_("Log output file"), + ) if extras is not None: - extras_group = OptionGroup(parser, extras[0], extras[1]) - for option in extras[2]: - extras_group.add_option(option) - parser.add_option_group(extras_group) + extras_group = parser.add_argument_group(extras[0], extras[1]) + extras[2](extras_group) + + # collect all the remaining arguments into a list + parser.add_argument( + "args", + nargs=argparse.REMAINDER, + help=_( + "File to execute and additional arguments passed on to the executed script." + ), + ) try: - options, args = parser.parse_args(args) - except OptionParserFailed: + options = parser.parse_args(args) + except ArgumentParserFailed: # Just let Python handle this os.execv(sys.executable, [sys.executable] + args) if options.version: - print 'bpython version', __version__, - print 'on top of Python', sys.version.split()[0] - print ('(C) 2008-2014 Bob Farrell, Andreas Stuehrk et al. ' - 'See AUTHORS for detail.') + print(version_banner()) + print(copyright_banner()) raise SystemExit if not ignore_stdin and not (sys.stdin.isatty() and sys.stdout.isatty()): - interpreter = code.InteractiveInterpreter() - interpreter.runsource(sys.stdin.read()) - raise SystemExit + # Just let Python handle this + os.execv(sys.executable, [sys.executable] + args) + + # Configure logging handler + bpython_logger = logging.getLogger("bpython") + curtsies_logger = logging.getLogger("curtsies") + bpython_logger.setLevel(options.log_level.upper()) + curtsies_logger.setLevel(options.log_level.upper()) + if options.log_output: + handler = logging.FileHandler(filename=options.log_output) + handler.setFormatter( + logging.Formatter( + "%(asctime)s: %(name)s: %(levelname)s: %(message)s" + ) + ) + bpython_logger.addHandler(handler) + curtsies_logger.addHandler(handler) + bpython_logger.propagate = curtsies_logger.propagate = False + else: + bpython_logger.addHandler(logging.NullHandler()) + curtsies_logger.addHandler(logging.NullHandler()) + + import cwcwidth + import greenlet + import pygments + import requests + import xdg + + logger.info("Starting bpython %s", __version__) + logger.info("Python %s: %s", sys.executable, sys.version_info) + # versions of required dependencies + try: + import curtsies + + log_version(curtsies, "curtsies") + except ImportError: + # may happen on Windows + logger.info("curtsies: not available") + log_version(cwcwidth, "cwcwidth") + log_version(greenlet, "greenlet") + log_version(pygments, "pygments") + log_version(xdg, "pyxdg") + log_version(requests, "requests") + + # versions of optional dependencies + try: + import pyperclip - config = Struct() - loadini(config, options.config) + log_version(pyperclip, "pyperclip") + except ImportError: + logger.info("pyperclip: not available") + try: + import jedi + + log_version(jedi, "jedi") + except ImportError: + logger.info("jedi: not available") + try: + import watchdog + + logger.info("watchdog: available") + except ImportError: + logger.info("watchdog: not available") - return config, options, args + logger.info("environment:") + for key, value in sorted(os.environ.items()): + if key.startswith("LC") or key.startswith("LANG") or key == "TERM": + logger.info("%s: %s", key, value) -def exec_code(interpreter, args): + return Config(options.config), options, options.args + + +def exec_code( + interpreter: code.InteractiveInterpreter, args: list[str] +) -> None: """ - Helper to execute code in a given interpreter. args should be a [faked] - sys.argv + Helper to execute code in a given interpreter, e.g. to implement the behavior of python3 [-i] file.py + + args should be a [faked] sys.argv. """ - with open(args[0], 'r') as sourcefile: - code_obj = compile(sourcefile.read(), args[0], 'exec') + try: + with open(args[0]) as sourcefile: + source = sourcefile.read() + except OSError as e: + # print an error and exit (if -i is specified the calling code will continue) + print(f"bpython: can't open file '{args[0]}: {e}", file=sys.stderr) + raise SystemExit(e.errno) old_argv, sys.argv = sys.argv, args sys.path.insert(0, os.path.abspath(os.path.dirname(args[0]))) - interpreter.runcode(code_obj) + spec = importlib.util.spec_from_loader("__main__", loader=None) + assert spec + mod = importlib.util.module_from_spec(spec) + sys.modules["__main__"] = mod + interpreter.locals.update(mod.__dict__) # type: ignore # TODO use a more specific type that has a .locals attribute + interpreter.locals["__file__"] = args[0] # type: ignore # TODO use a more specific type that has a .locals attribute + interpreter.runsource(source, args[0], "exec") sys.argv = old_argv diff --git a/bpython/autocomplete.py b/bpython/autocomplete.py index 110c0d762..77887ef4b 100644 --- a/bpython/autocomplete.py +++ b/bpython/autocomplete.py @@ -1,6 +1,7 @@ # The MIT License # -# Copyright (c) 2009-2012 the bpython authors. +# Copyright (c) 2009-2015 the bpython authors. +# Copyright (c) 2015-2020 Sebastian Ramacher # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -19,389 +20,791 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -# -from __future__ import with_statement -import __builtin__ +# To gradually migrate to mypy we aren't setting these globally yet +# mypy: disallow_untyped_defs=True +# mypy: disallow_untyped_calls=True + import __main__ -import rlcompleter -import line as lineparts -import re +import abc +import glob +import itertools +import keyword +import logging import os -from glob import glob -from bpython import inspection -from bpython import importcompletion -from bpython._py3compat import py3 - -# Needed for special handling of __abstractmethods__ -# abc only exists since 2.6, so check both that it exists and that it's -# the one we're expecting -try: - import abc - abc.ABCMeta - has_abc = True -except (ImportError, AttributeError): - has_abc = False +import re +import rlcompleter +import builtins -# Autocomplete modes -SIMPLE = 'simple' -SUBSTRING = 'substring' -FUZZY = 'fuzzy' +from enum import Enum +from typing import ( + Any, + Optional, +) +from collections.abc import Iterator, Sequence + +from . import inspection +from . import line as lineparts +from .line import LinePart +from .lazyre import LazyReCompile +from .simpleeval import safe_eval, evaluate_current_expression, EvaluationError +from .importcompletion import ModuleGatherer -MAGIC_METHODS = ["__%s__" % s for s in [ - "init", "repr", "str", "lt", "le", "eq", "ne", "gt", "ge", "cmp", "hash", - "nonzero", "unicode", "getattr", "setattr", "get", "set","call", "len", - "getitem", "setitem", "iter", "reversed", "contains", "add", "sub", "mul", - "floordiv", "mod", "divmod", "pow", "lshift", "rshift", "and", "xor", "or", - "div", "truediv", "neg", "pos", "abs", "invert", "complex", "int", "float", - "oct", "hex", "index", "coerce", "enter", "exit"]] +logger = logging.getLogger(__name__) -def get_completer(cursor_offset, current_line, locals_, argspec, full_code, mode, complete_magic_methods): - """Returns a list of matches and a class for what kind of completion is happening - If no completion type is relevant, returns None, None +# Autocomplete modes +class AutocompleteModes(Enum): + NONE = "none" + SIMPLE = "simple" + SUBSTRING = "substring" + FUZZY = "fuzzy" - argspec is an output of inspect.getargspec + @classmethod + def from_string(cls, value: str) -> Optional["AutocompleteModes"]: + if value.upper() in cls.__members__: + return cls.__members__[value.upper()] + return None + + +MAGIC_METHODS = tuple( + f"__{s}__" + for s in ( + "new", + "init", + "del", + "repr", + "str", + "bytes", + "format", + "lt", + "le", + "eq", + "ne", + "gt", + "ge", + "hash", + "bool", + "getattr", + "getattribute", + "setattr", + "delattr", + "dir", + "get", + "set", + "delete", + "set_name", + "init_subclass", + "instancecheck", + "subclasscheck", + "class_getitem", + "call", + "len", + "length_hint", + "getitem", + "setitem", + "delitem", + "missing", + "iter", + "reversed", + "contains", + "add", + "sub", + "mul", + "matmul", + "truediv", + "floordiv", + "mod", + "divmod", + "pow", + "lshift", + "rshift", + "and", + "xor", + "or", + "radd", + "rsub", + "rmul", + "rmatmul", + "rtruediv", + "rfloordiv", + "rmod", + "rdivmod", + "rpow", + "rlshift", + "rrshift", + "rand", + "rxor", + "ror", + "iadd", + "isub", + "imul", + "imatmul", + "itruediv", + "ifloordiv", + "imod", + "ipow", + "ilshift", + "irshift", + "iand", + "ixor", + "ixor", + "neg", + "pos", + "abs", + "invert", + "complex", + "int", + "float", + "index", + "round", + "trunc", + "floor", + "ceil", + "enter", + "exit", + "await", + "aiter", + "anext", + "aenter", + "aexit", + ) +) + +KEYWORDS = frozenset(keyword.kwlist) + + +def _after_last_dot(name: str) -> str: + return name.rstrip(".").rsplit(".")[-1] + + +def _few_enough_underscores(current: str, match: str) -> bool: + """Returns whether match should be shown based on current + + if current is _, True if match starts with 0 or 1 underscore + if current is __, True regardless of match + otherwise True if match does not start with any underscore """ + if current.startswith("__"): + return True + elif current.startswith("_") and not match.startswith("__"): + return True + return not match.startswith("_") - kwargs = {'locals_':locals_, 'argspec':argspec, 'full_code':full_code, - 'mode':mode, 'complete_magic_methods':complete_magic_methods} - # mutually exclusive if matches: If one of these returns [], try the next one - for completer in [DictKeyCompletion]: - matches = completer.matches(cursor_offset, current_line, **kwargs) - if matches: - return sorted(set(matches)), completer +def _method_match_none(word: str, size: int, text: str) -> bool: + return False - # mutually exclusive matchers: if one returns [], don't go on - for completer in [StringLiteralAttrCompletion, ImportCompletion, - FilenameCompletion, MagicMethodCompletion, GlobalCompletion]: - matches = completer.matches(cursor_offset, current_line, **kwargs) - if matches is not None: - return sorted(set(matches)), completer - matches = AttrCompletion.matches(cursor_offset, current_line, **kwargs) +def _method_match_simple(word: str, size: int, text: str) -> bool: + return word[:size] == text - # cumulative completions - try them all - # They all use current_word replacement and formatting - current_word_matches = [] - for completer in [AttrCompletion, ParameterNameCompletion]: - matches = completer.matches(cursor_offset, current_line, **kwargs) - if matches is not None: - current_word_matches.extend(matches) - if len(current_word_matches) == 0: - return None, None - return sorted(set(current_word_matches)), AttrCompletion +def _method_match_substring(word: str, size: int, text: str) -> bool: + return text in word -class BaseCompletionType(object): + +def _method_match_fuzzy(word: str, size: int, text: str) -> bool: + s = r".*{}.*".format(".*".join(c for c in text)) + return re.search(s, word) is not None + + +_MODES_MAP = { + AutocompleteModes.NONE: _method_match_none, + AutocompleteModes.SIMPLE: _method_match_simple, + AutocompleteModes.SUBSTRING: _method_match_substring, + AutocompleteModes.FUZZY: _method_match_fuzzy, +} + + +class BaseCompletionType: """Describes different completion types""" - def matches(cls, cursor_offset, line, **kwargs): + + def __init__( + self, + shown_before_tab: bool = True, + mode: AutocompleteModes = AutocompleteModes.SIMPLE, + ) -> None: + self._shown_before_tab = shown_before_tab + self.method_match = _MODES_MAP[mode] + + @abc.abstractmethod + def matches( + self, cursor_offset: int, line: str, **kwargs: Any + ) -> set[str] | None: """Returns a list of possible matches given a line and cursor, or None if this completion type isn't applicable. ie, import completion doesn't make sense if there cursor isn't after - an import or from statement + an import or from statement, so it ought to return None. Completion types are used to: - * `locate(cur, line)` their target word to replace given a line and cursor + * `locate(cur, line)` their initial target word to replace given a + line and cursor * find `matches(cur, line)` that might replace that word * `format(match)` matches to be displayed to the user * determine whether suggestions should be `shown_before_tab` - * `substitute(cur, line, match)` in a match for what's found with `target` - """ + * `substitute(cur, line, match)` in a match for what's found with + `target` + """ raise NotImplementedError - def locate(cls, cursor_offset, line): - """Returns a start, stop, and word given a line and cursor, or None - if no target for this type of completion is found under the cursor""" + + @abc.abstractmethod + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + """Returns a Linepart namedtuple instance or None given cursor and line + + A Linepart namedtuple contains a start, stop, and word. None is + returned if no target for this type of completion is found under + the cursor.""" raise NotImplementedError - @classmethod - def format(cls, word): + + def format(self, word: str) -> str: return word - shown_before_tab = True # whether suggestions should be shown before the - # user hits tab, or only once that has happened - def substitute(cls, cursor_offset, line, match): + + def substitute( + self, cursor_offset: int, line: str, match: str + ) -> tuple[int, str]: """Returns a cursor offset and line with match swapped in""" - start, end, word = cls.locate(cursor_offset, line) - result = start + len(match), line[:start] + match + line[end:] - return result + lpart = self.locate(cursor_offset, line) + assert lpart + offset = lpart.start + len(match) + changed_line = line[: lpart.start] + match + line[lpart.stop :] + return offset, changed_line + + @property + def shown_before_tab(self) -> bool: + """Whether suggestions should be shown before the user hits tab, or only + once that has happened.""" + return self._shown_before_tab + + +class CumulativeCompleter(BaseCompletionType): + """Returns combined matches from several completers""" + + def __init__( + self, + completers: Sequence[BaseCompletionType], + mode: AutocompleteModes = AutocompleteModes.SIMPLE, + ) -> None: + if not completers: + raise ValueError( + "CumulativeCompleter requires at least one completer" + ) + self._completers: Sequence[BaseCompletionType] = completers + + super().__init__(True, mode) + + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + for completer in self._completers: + return_value = completer.locate(cursor_offset, line) + if return_value is not None: + return return_value + return None + + def format(self, word: str) -> str: + return self._completers[0].format(word) + + def matches( + self, cursor_offset: int, line: str, **kwargs: Any + ) -> set[str] | None: + return_value = None + all_matches = set() + for completer in self._completers: + matches = completer.matches( + cursor_offset=cursor_offset, line=line, **kwargs + ) + if matches is not None: + all_matches.update(matches) + return_value = all_matches + + return return_value + class ImportCompletion(BaseCompletionType): - @classmethod - def matches(cls, cursor_offset, current_line, **kwargs): - return importcompletion.complete(cursor_offset, current_line) - locate = staticmethod(lineparts.current_word) - @classmethod - def format(cls, name): - return name.rstrip('.').rsplit('.')[-1] + def __init__( + self, + module_gatherer: ModuleGatherer, + mode: AutocompleteModes = AutocompleteModes.SIMPLE, + ): + super().__init__(False, mode) + self.module_gatherer = module_gatherer + + def matches( + self, cursor_offset: int, line: str, **kwargs: Any + ) -> set[str] | None: + return self.module_gatherer.complete(cursor_offset, line) + + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return lineparts.current_word(cursor_offset, line) + + def format(self, word: str) -> str: + return _after_last_dot(word) + + +def _safe_glob(pathname: str) -> Iterator[str]: + return glob.iglob(glob.escape(pathname) + "*") + class FilenameCompletion(BaseCompletionType): - shown_before_tab = False - @classmethod - def matches(cls, cursor_offset, current_line, **kwargs): - cs = lineparts.current_string(cursor_offset, current_line) + def __init__(self, mode: AutocompleteModes = AutocompleteModes.SIMPLE): + super().__init__(False, mode) + + def matches( + self, cursor_offset: int, line: str, **kwargs: Any + ) -> set[str] | None: + cs = lineparts.current_string(cursor_offset, line) if cs is None: return None - start, end, text = cs - matches = [] - username = text.split(os.path.sep, 1)[0] + matches = set() + username = cs.word.split(os.path.sep, 1)[0] user_dir = os.path.expanduser(username) - for filename in glob(os.path.expanduser(text + '*')): + for filename in _safe_glob(os.path.expanduser(cs.word)): if os.path.isdir(filename): filename += os.path.sep - if text.startswith('~'): - filename = username + filename[len(user_dir):] - matches.append(filename) + if cs.word.startswith("~"): + filename = username + filename[len(user_dir) :] + matches.add(filename) return matches - locate = staticmethod(lineparts.current_string) - @classmethod - def format(cls, filename): - filename.rstrip(os.sep).rsplit(os.sep)[-1] + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return lineparts.current_string(cursor_offset, line) + + def format(self, filename: str) -> str: if os.sep in filename[:-1]: - return filename[filename.rindex(os.sep, 0, -1)+1:] + return filename[filename.rindex(os.sep, 0, -1) + 1 :] else: return filename + class AttrCompletion(BaseCompletionType): - @classmethod - def matches(cls, cursor_offset, line, locals_, mode, **kwargs): - r = cls.locate(cursor_offset, line) + attr_matches_re = LazyReCompile(r"(\w+(\.\w+)*)\.(\w*)") + + def matches( + self, + cursor_offset: int, + line: str, + *, + locals_: dict[str, Any] | None = None, + **kwargs: Any, + ) -> set[str] | None: + r = self.locate(cursor_offset, line) if r is None: return None - text = r[2] - if locals_ is None: + if locals_ is None: # TODO add a note about why locals_ = __main__.__dict__ - assert '.' in text - - for i in range(1, len(text) + 1): - if text[-i] == '[': - i -= 1 - break - methodtext = text[-i:] - matches = [''.join([text[:-i], m]) for m in - attr_matches(methodtext, locals_, mode)] - - #TODO add open paren for methods via _callable_prefix (or decide not to) - # unless the first character is a _ filter out all attributes starting with a _ - if not text.split('.')[-1].startswith('_'): - matches = [match for match in matches - if not match.split('.')[-1].startswith('_')] - return matches + assert "." in r.word + + i = r.word.rfind("[") + 1 + methodtext = r.word[i:] + matches = { + "".join([r.word[:i], m]) + for m in self.attr_matches(methodtext, locals_) + } + + return { + m + for m in matches + if _few_enough_underscores(r.word.split(".")[-1], m.split(".")[-1]) + } + + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return lineparts.current_dotted_attribute(cursor_offset, line) + + def format(self, word: str) -> str: + return _after_last_dot(word) + + def attr_matches( + self, text: str, namespace: dict[str, Any] + ) -> Iterator[str]: + """Taken from rlcompleter.py and bent to my will.""" + + m = self.attr_matches_re.match(text) + if not m: + return (_ for _ in ()) + + expr, attr = m.group(1, 3) + if expr.isdigit(): + # Special case: float literal, using attrs here will result in + # a SyntaxError + return (_ for _ in ()) + try: + obj = safe_eval(expr, namespace) + except EvaluationError: + return (_ for _ in ()) + return self.attr_lookup(obj, expr, attr) + + def attr_lookup(self, obj: Any, expr: str, attr: str) -> Iterator[str]: + """Second half of attr_matches.""" + words = self.list_attributes(obj) + if inspection.hasattr_safe(obj, "__class__"): + words.append("__class__") + klass = inspection.getattr_safe(obj, "__class__") + words = words + rlcompleter.get_class_members(klass) + if not isinstance(klass, abc.ABCMeta): + try: + words.remove("__abstractmethods__") + except ValueError: + pass + + n = len(attr) + return ( + f"{expr}.{word}" + for word in words + if self.method_match(word, n, attr) and word != "__builtins__" + ) + + def list_attributes(self, obj: Any) -> list[str]: + # TODO: re-implement dir without AttrCleaner here + # + # Note: accessing `obj.__dir__` via `getattr_static` is not side-effect free. + with inspection.AttrCleaner(obj): + return dir(obj) - locate = staticmethod(lineparts.current_dotted_attribute) - @classmethod - def format(cls, name): - return name.rstrip('.').rsplit('.')[-1] class DictKeyCompletion(BaseCompletionType): - locate = staticmethod(lineparts.current_dict_key) - @classmethod - def matches(cls, cursor_offset, line, locals_, **kwargs): - r = cls.locate(cursor_offset, line) - if r is None: + def matches( + self, + cursor_offset: int, + line: str, + *, + locals_: dict[str, Any] | None = None, + **kwargs: Any, + ) -> set[str] | None: + if locals_ is None: return None - start, end, orig = r - _, _, dexpr = lineparts.current_dict(cursor_offset, line) - obj = safe_eval(dexpr, locals_) - if obj is SafeEvalFailed: - return [] - if obj and isinstance(obj, type({})) and obj.keys(): - return ["{!r}]".format(k) for k in obj.keys() if repr(k).startswith(orig)] - else: - return [] - @classmethod - def format(cls, match): - return match[:-1] -class MagicMethodCompletion(BaseCompletionType): - locate = staticmethod(lineparts.current_method_definition_name) - @classmethod - def matches(cls, cursor_offset, line, full_code, **kwargs): - r = cls.locate(cursor_offset, line) + r = self.locate(cursor_offset, line) if r is None: return None - if 'class' not in full_code: + current_dict_parts = lineparts.current_dict(cursor_offset, line) + if current_dict_parts is None: return None - start, end, word = r - return [name for name in MAGIC_METHODS if name.startswith(word)] -class GlobalCompletion(BaseCompletionType): - @classmethod - def matches(cls, cursor_offset, line, locals_, mode, **kwargs): - r = cls.locate(cursor_offset, line) - if r is None: + dexpr = current_dict_parts.word + try: + obj = safe_eval(dexpr, locals_) + except EvaluationError: return None - start, end, word = r - return global_matches(word, locals_, mode) - locate = staticmethod(lineparts.current_single_word) - -class ParameterNameCompletion(BaseCompletionType): - @classmethod - def matches(cls, cursor_offset, line, argspec, **kwargs): - if not argspec: - return None - r = cls.locate(cursor_offset, line) - if r is None: + if isinstance(obj, dict) and obj.keys(): + matches = { + f"{k!r}]" for k in obj.keys() if repr(k).startswith(r.word) + } + return matches if matches else None + else: return None - start, end, word = r - if argspec: - matches = [name + '=' for name in argspec[1][0] - if isinstance(name, basestring) and name.startswith(word)] - if py3: - matches.extend(name + '=' for name in argspec[1][4] - if name.startswith(word)) - return matches - locate = staticmethod(lineparts.current_word) + + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return lineparts.current_dict_key(cursor_offset, line) + + def format(self, match: str) -> str: + return match[:-1] + class MagicMethodCompletion(BaseCompletionType): - locate = staticmethod(lineparts.current_method_definition_name) - @classmethod - def matches(cls, cursor_offset, line, full_code, **kwargs): - r = cls.locate(cursor_offset, line) + def matches( + self, + cursor_offset: int, + line: str, + *, + current_block: str | None = None, + complete_magic_methods: bool | None = None, + **kwargs: Any, + ) -> set[str] | None: + if ( + current_block is None + or complete_magic_methods is None + or not complete_magic_methods + ): + return None + + r = self.locate(cursor_offset, line) if r is None: return None - if 'class' not in full_code: + if "class" not in current_block: return None - start, end, word = r - return [name for name in MAGIC_METHODS if name.startswith(word)] + return {name for name in MAGIC_METHODS if name.startswith(r.word)} + + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return lineparts.current_method_definition_name(cursor_offset, line) + class GlobalCompletion(BaseCompletionType): - @classmethod - def matches(cls, cursor_offset, line, locals_, mode, **kwargs): + def matches( + self, + cursor_offset: int, + line: str, + *, + locals_: dict[str, Any] | None = None, + **kwargs: Any, + ) -> set[str] | None: """Compute matches when text is a simple name. Return a list of all keywords, built-in functions and names currently defined in self.namespace that match. """ - r = cls.locate(cursor_offset, line) + if locals_ is None: + return None + + r = self.locate(cursor_offset, line) if r is None: return None - start, end, text = r - - hash = {} - n = len(text) - import keyword - for word in keyword.kwlist: - if method_match(word, n, text, mode): - hash[word] = 1 - for nspace in [__builtin__.__dict__, locals_]: + + n = len(r.word) + matches = { + word for word in KEYWORDS if self.method_match(word, n, r.word) + } + for nspace in (builtins.__dict__, locals_): for word, val in nspace.items(): - if method_match(word, len(text), text, mode) and word != "__builtins__": - hash[_callable_postfix(val, word)] = 1 - matches = hash.keys() - matches.sort() - return matches + # if identifier isn't ascii, don't complete (syntax error) + if word is None: + continue + if ( + self.method_match(word, n, r.word) + and word != "__builtins__" + ): + matches.add(_callable_postfix(val, word)) + return matches if matches else None + + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return lineparts.current_single_word(cursor_offset, line) - locate = staticmethod(lineparts.current_single_word) class ParameterNameCompletion(BaseCompletionType): - @classmethod - def matches(cls, cursor_offset, line, argspec, **kwargs): - if not argspec: + def matches( + self, + cursor_offset: int, + line: str, + *, + funcprops: inspection.FuncProps | None = None, + **kwargs: Any, + ) -> set[str] | None: + if funcprops is None: return None - r = cls.locate(cursor_offset, line) + + r = self.locate(cursor_offset, line) if r is None: return None - start, end, word = r - if argspec: - matches = [name + '=' for name in argspec[1][0] - if isinstance(name, basestring) and name.startswith(word)] - if py3: - matches.extend(name + '=' for name in argspec[1][4] - if name.startswith(word)) - return matches - locate = staticmethod(lineparts.current_word) -class StringLiteralAttrCompletion(BaseCompletionType): - locate = staticmethod(lineparts.current_string_literal_attr) - @classmethod - def matches(cls, cursor_offset, line, **kwargs): - r = cls.locate(cursor_offset, line) - if r is None: + matches = { + f"{name}=" + for name in funcprops.argspec.args + if isinstance(name, str) and name.startswith(r.word) + } + matches.update( + f"{name}=" + for name in funcprops.argspec.kwonly + if name.startswith(r.word) + ) + return matches if matches else None + + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + r = lineparts.current_word(cursor_offset, line) + if r and r.word[-1] == "(": + # if the word ends with a (, it's the parent word with an empty + # param. Return an empty word + return lineparts.LinePart(r.stop, r.stop, "") + return r + + +class ExpressionAttributeCompletion(AttrCompletion): + # could replace attr completion as a more general case with some work + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return lineparts.current_expression_attribute(cursor_offset, line) + + def matches( + self, + cursor_offset: int, + line: str, + *, + locals_: dict[str, Any] | None = None, + **kwargs: Any, + ) -> set[str] | None: + if locals_ is None: + locals_ = __main__.__dict__ + + attr = self.locate(cursor_offset, line) + assert attr, "locate was already truthy for the same call" + + try: + obj = evaluate_current_expression(cursor_offset, line, locals_) + except EvaluationError: + return set() + + # strips leading dot + matches = (m[1:] for m in self.attr_lookup(obj, "", attr.word)) + return {m for m in matches if _few_enough_underscores(attr.word, m)} + + +try: + import jedi +except ImportError: + + class MultilineJediCompletion(BaseCompletionType): # type: ignore [no-redef] + def matches( + self, cursor_offset: int, line: str, **kwargs: Any + ) -> set[str] | None: return None - start, end, word = r - attrs = dir('') - matches = [att for att in attrs if att.startswith(word)] - if not word.startswith('_'): - return [match for match in matches if not match.startswith('_')] - return matches -class SafeEvalFailed(Exception): - """If this object is returned, safe_eval failed""" - # Because every normal Python value is a possible return value of safe_eval - -def safe_eval(expr, namespace): - """Not all that safe, just catches some errors""" - if expr.isdigit(): - # Special case: float literal, using attrs here will result in - # a SyntaxError - return SafeEvalFailed - try: - obj = eval(expr, namespace) - return obj - except (NameError, AttributeError, SyntaxError) as e: - # If debugging safe_eval, raise this! - # raise e - return SafeEvalFailed - -def attr_matches(text, namespace, autocomplete_mode): - """Taken from rlcompleter.py and bent to my will. - """ + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return None - # Gna, Py 2.6's rlcompleter searches for __call__ inside the - # instance instead of the type, so we monkeypatch to prevent - # side-effects (__getattr__/__getattribute__) - m = re.match(r"(\w+(\.\w+)*)\.(\w*)", text) - if not m: - return [] - - expr, attr = m.group(1, 3) - obj = safe_eval(expr, namespace) - if obj is SafeEvalFailed: - return [] - with inspection.AttrCleaner(obj): - matches = attr_lookup(obj, expr, attr, autocomplete_mode) - return matches - -def attr_lookup(obj, expr, attr, autocomplete_mode): - """Second half of original attr_matches method factored out so it can - be wrapped in a safe try/finally block in case anything bad happens to - restore the original __getattribute__ method.""" - words = dir(obj) - if hasattr(obj, '__class__'): - words.append('__class__') - words = words + rlcompleter.get_class_members(obj.__class__) - if has_abc and not isinstance(obj.__class__, abc.ABCMeta): +else: + + class MultilineJediCompletion(BaseCompletionType): # type: ignore [no-redef] + _orig_start: int | None + + def matches( + self, + cursor_offset: int, + line: str, + *, + current_block: str | None = None, + history: list[str] | None = None, + **kwargs: Any, + ) -> set[str] | None: + if ( + current_block is None + or history is None + or "\n" not in current_block + or not lineparts.current_word(cursor_offset, line) + ): + return None + + assert cursor_offset <= len(line), "{!r} {!r}".format( + cursor_offset, + line, + ) + + combined_history = "\n".join(itertools.chain(history, (line,))) try: - words.remove('__abstractmethods__') - except ValueError: - pass - - matches = [] - n = len(attr) - for word in words: - if method_match(word, n, attr, autocomplete_mode) and word != "__builtins__": - matches.append("%s.%s" % (expr, word)) - return matches - -def _callable_postfix(value, word): + script = jedi.Script(combined_history, path="fake.py") + completions = script.complete( + combined_history.count("\n") + 1, cursor_offset + ) + except (jedi.NotFoundError, IndexError, KeyError): + # IndexError for #483 + # KeyError for #544 + self._orig_start = None + return None + + if completions: + diff = len(completions[0].name) - len(completions[0].complete) + self._orig_start = cursor_offset - diff + else: + self._orig_start = None + return None + assert isinstance(self._orig_start, int) + + matches = [c.name for c in completions] + if any( + not m.lower().startswith(matches[0][0].lower()) for m in matches + ): + # Too general - giving completions starting with multiple + # letters + return None + else: + # case-sensitive matches only + first_letter = line[self._orig_start] + return {m for m in matches if m.startswith(first_letter)} + + def locate(self, cursor_offset: int, line: str) -> LinePart: + assert self._orig_start is not None + start = self._orig_start + end = cursor_offset + return LinePart(start, end, line[start:end]) + + +def get_completer( + completers: Sequence[BaseCompletionType], + cursor_offset: int, + line: str, + *, + locals_: dict[str, Any] | None = None, + argspec: inspection.FuncProps | None = None, + history: list[str] | None = None, + current_block: str | None = None, + complete_magic_methods: bool | None = None, +) -> tuple[list[str], BaseCompletionType | None]: + """Returns a list of matches and an applicable completer + + If no matches available, returns a tuple of an empty list and None + + cursor_offset is the current cursor column + line is a string of the current line + kwargs (all optional): + locals_ is a dictionary of the environment + argspec is an inspection.FuncProps instance for the current function where + the cursor is + current_block is the possibly multiline not-yet-evaluated block of + code which the current line is part of + complete_magic_methods is a bool of whether we ought to complete + double underscore methods like __len__ in method signatures + """ + + def _cmpl_sort(x: str) -> tuple[bool, str]: + """ + Function used to sort the matches. + """ + # put parameters above everything in completion + return ( + x[-1] != "=", + x, + ) + + for completer in completers: + try: + matches = completer.matches( + cursor_offset, + line, + locals_=locals_, + funcprops=argspec, + history=history, + current_block=current_block, + complete_magic_methods=complete_magic_methods, + ) + except Exception as e: + # Instead of crashing the UI, log exceptions from autocompleters. + logger.debug( + "Completer %r failed with unhandled exception: %s", completer, e + ) + continue + if matches is not None: + return sorted(matches, key=_cmpl_sort), ( + completer if matches else None + ) + + return [], None + + +def get_default_completer( + mode: AutocompleteModes, module_gatherer: ModuleGatherer +) -> tuple[BaseCompletionType, ...]: + return ( + ( + DictKeyCompletion(mode=mode), + ImportCompletion(module_gatherer, mode=mode), + FilenameCompletion(mode=mode), + MagicMethodCompletion(mode=mode), + MultilineJediCompletion(mode=mode), + CumulativeCompleter( + ( + GlobalCompletion(mode=mode), + ParameterNameCompletion(mode=mode), + ), + mode=mode, + ), + AttrCompletion(mode=mode), + ExpressionAttributeCompletion(mode=mode), + ) + if mode != AutocompleteModes.NONE + else tuple() + ) + + +def _callable_postfix(value: Any, word: str) -> str: """rlcompleter's _callable_postfix done right.""" - with inspection.AttrCleaner(value): - if inspection.is_callable(value): - word += '(' + if callable(value): + word += "(" return word - -#TODO use method_match everywhere instead of startswith to implement other completion modes -# will also need to rewrite checking mode so cseq replace doesn't happen in frontends -def method_match(word, size, text, autocomplete_mode): - if autocomplete_mode == SIMPLE: - return word[:size] == text - elif autocomplete_mode == SUBSTRING: - s = r'.*%s.*' % text - return re.search(s, word) - else: - s = r'.*%s.*' % '.*'.join(list(text)) - return re.search(s, word) diff --git a/bpython/cli.py b/bpython/cli.py deleted file mode 100644 index ebaebe809..000000000 --- a/bpython/cli.py +++ /dev/null @@ -1,1960 +0,0 @@ -# The MIT License -# -# Copyright (c) 2008 Bob Farrell -# Copyright (c) bpython authors -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# - -# Modified by Brandon Navra -# Notes for Windows -# Prerequsites -# - Curses -# - pyreadline -# -# Added -# -# - Support for running on windows command prompt -# - input from numpad keys -# -# Issues -# -# - Suspend doesn't work nor does detection of resizing of screen -# - Instead the suspend key exits the program -# - View source doesn't work on windows unless you install the less program (From GnuUtils or Cygwin) - -from __future__ import division, with_statement - -import platform -import os -import sys -import curses -import math -import re -import time -import functools - -import struct -if platform.system() != 'Windows': - import signal #Windows does not have job control - import termios #Windows uses curses - import fcntl #Windows uses curses -import unicodedata -import errno - -import locale -from types import ModuleType - -# These are used for syntax highlighting -from pygments import format -from pygments.formatters import TerminalFormatter -from pygments.lexers import PythonLexer -from pygments.token import Token -from bpython.formatter import BPythonFormatter - -# This for completion -from bpython import importcompletion - -# This for config -from bpython.config import Struct - -# This for keys -from bpython.keys import cli_key_dispatch as key_dispatch - -# This for i18n -from bpython import translations -from bpython.translations import _ - -from bpython import repl -from bpython._py3compat import py3 -from bpython.pager import page -from bpython import autocomplete -import bpython.args - -if not py3: - import inspect - - -# --- module globals --- -stdscr = None -colors = None - -DO_RESIZE = False -# --- - - -def getpreferredencoding(): - return locale.getpreferredencoding() or sys.getdefaultencoding() - -def calculate_screen_lines(tokens, width, cursor=0): - """Given a stream of tokens and a screen width plus an optional - initial cursor position, return the amount of needed lines on the - screen.""" - lines = 1 - pos = cursor - for (token, value) in tokens: - if token is Token.Text and value == '\n': - lines += 1 - else: - pos += len(value) - lines += pos // width - pos %= width - return lines - -def forward_if_not_current(func): - @functools.wraps(func) - def newfunc(self, *args, **kwargs): - dest = self.get_dest() - if self is dest: - return func(self, *args, **kwargs) - else: - return getattr(self.get_dest(), newfunc.__name__)(*args, **kwargs) - return newfunc - - -class FakeStream(object): - """Provide a fake file object which calls functions on the interface - provided.""" - - def __init__(self, interface, get_dest): - self.encoding = getpreferredencoding() - self.interface = interface - self.get_dest = get_dest - - @forward_if_not_current - def write(self, s): - self.interface.write(s) - - @forward_if_not_current - def writelines(self, l): - for s in l: - self.write(s) - - def isatty(self): - # some third party (amongst them mercurial) depend on this - return True - - def flush(self): - self.interface.flush() - - -class FakeStdin(object): - """Provide a fake stdin type for things like raw_input() etc.""" - - def __init__(self, interface): - """Take the curses Repl on init and assume it provides a get_key method - which, fortunately, it does.""" - - self.encoding = getpreferredencoding() - self.interface = interface - self.buffer = list() - - def __iter__(self): - return iter(self.readlines()) - - def flush(self): - """Flush the internal buffer. This is a no-op. Flushing stdin - doesn't make any sense anyway.""" - - def write(self, value): - # XXX IPython expects sys.stdin.write to exist, there will no doubt be - # others, so here's a hack to keep them happy - raise IOError(errno.EBADF, "sys.stdin is read-only") - - def isatty(self): - return True - - def readline(self, size=-1): - """I can't think of any reason why anything other than readline would - be useful in the context of an interactive interpreter so this is the - only one I've done anything with. The others are just there in case - someone does something weird to stop it from blowing up.""" - - if not size: - return '' - elif self.buffer: - buffer = self.buffer.pop(0) - else: - buffer = '' - - curses.raw(True) - try: - while not buffer.endswith(('\n', '\r')): - key = self.interface.get_key() - if key in [curses.erasechar(), 'KEY_BACKSPACE']: - y, x = self.interface.scr.getyx() - if buffer: - self.interface.scr.delch(y, x - 1) - buffer = buffer[:-1] - continue - elif key == chr(4) and not buffer: - # C-d - return '' - elif (key not in ('\n', '\r') and - (len(key) > 1 or unicodedata.category(key) == 'Cc')): - continue - sys.stdout.write(key) - # Include the \n in the buffer - raw_input() seems to deal with trailing - # linebreaks and will break if it gets an empty string. - buffer += key - finally: - curses.raw(False) - - if size > 0: - rest = buffer[size:] - if rest: - self.buffer.append(rest) - buffer = buffer[:size] - - if py3: - return buffer - else: - return buffer.encode(getpreferredencoding()) - - def read(self, size=None): - if size == 0: - return '' - - data = list() - while size is None or size > 0: - line = self.readline(size or -1) - if not line: - break - if size is not None: - size -= len(line) - data.append(line) - - return ''.join(data) - - def readlines(self, size=-1): - return list(iter(self.readline, '')) - -# TODO: -# -# Tab completion does not work if not at the end of the line. -# -# Numerous optimisations can be made but it seems to do all the lookup stuff -# fast enough on even my crappy server so I'm not too bothered about that -# at the moment. -# -# The popup window that displays the argspecs and completion suggestions -# needs to be an instance of a ListWin class or something so I can wrap -# the addstr stuff to a higher level. -# - - -def get_color(config, name): - global colors - return colors[config.color_scheme[name].lower()] - - -def get_colpair(config, name): - return curses.color_pair(get_color(config, name) + 1) - - -def make_colors(config): - """Init all the colours in curses and bang them into a dictionary""" - - # blacK, Red, Green, Yellow, Blue, Magenta, Cyan, White, Default: - c = { - 'k': 0, - 'r': 1, - 'g': 2, - 'y': 3, - 'b': 4, - 'm': 5, - 'c': 6, - 'w': 7, - 'd': -1, - } - - if platform.system() == 'Windows': - c = dict(c.items() + - [ - ('K', 8), - ('R', 9), - ('G', 10), - ('Y', 11), - ('B', 12), - ('M', 13), - ('C', 14), - ('W', 15), - ] - ) - - for i in range(63): - if i > 7: - j = i // 8 - else: - j = c[config.color_scheme['background']] - curses.init_pair(i + 1, i % 8, j) - - return c - - -class CLIInteraction(repl.Interaction): - def __init__(self, config, statusbar=None): - repl.Interaction.__init__(self, config, statusbar) - - def confirm(self, q): - """Ask for yes or no and return boolean""" - try: - reply = self.statusbar.prompt(q) - except ValueError: - return False - - return reply.lower() in (_('y'), _('yes')) - - - def notify(self, s, n=10): - return self.statusbar.message(s, n) - - def file_prompt(self, s): - return self.statusbar.prompt(s) - - -class CLIRepl(repl.Repl): - - def __init__(self, scr, interp, statusbar, config, idle=None): - repl.Repl.__init__(self, interp, config) - self.interp.writetb = self.writetb - self.scr = scr - self.stdout_hist = '' - self.list_win = newwin(get_colpair(config, 'background'), 1, 1, 1, 1) - self.cpos = 0 - self.do_exit = False - self.exit_value = () - self.f_string = '' - self.idle = idle - self.in_hist = False - self.paste_mode = False - self.last_key_press = time.time() - self.s = '' - self.statusbar = statusbar - self.formatter = BPythonFormatter(config.color_scheme) - self.interact = CLIInteraction(self.config, statusbar=self.statusbar) - - if config.cli_suggestion_width <= 0 or config.cli_suggestion_width > 1: - config.cli_suggestion_width = 0.8 - - def _get_cursor_offset(self): - return len(self.s) - self.cpos - def _set_cursor_offset(self, offset): - self.cpos = len(self.s) - offset - cursor_offset = property(_get_cursor_offset, _set_cursor_offset, None, - "The cursor offset from the beginning of the line") - - def addstr(self, s): - """Add a string to the current input line and figure out - where it should go, depending on the cursor position.""" - self.rl_history.reset() - if not self.cpos: - self.s += s - else: - l = len(self.s) - self.s = self.s[:l - self.cpos] + s + self.s[l - self.cpos:] - - self.complete() - - def atbol(self): - """Return True or False accordingly if the cursor is at the beginning - of the line (whitespace is ignored). This exists so that p_key() knows - how to handle the tab key being pressed - if there is nothing but white - space before the cursor then process it as a normal tab otherwise - attempt tab completion.""" - - return not self.s.lstrip() - - def bs(self, delete_tabs=True): - """Process a backspace""" - - self.rl_history.reset() - y, x = self.scr.getyx() - - if not self.s: - return - - if x == self.ix and y == self.iy: - return - - n = 1 - - self.clear_wrapped_lines() - - if not self.cpos: - # I know the nested if blocks look nasty. :( - if self.atbol() and delete_tabs: - n = len(self.s) % self.config.tab_length - if not n: - n = self.config.tab_length - - self.s = self.s[:-n] - else: - self.s = self.s[:-self.cpos - 1] + self.s[-self.cpos:] - - self.print_line(self.s, clr=True) - - return n - - def bs_word(self): - self.rl_history.reset() - pos = len(self.s) - self.cpos - 1 - deleted = [] - # First we delete any space to the left of the cursor. - while pos >= 0 and self.s[pos] == ' ': - deleted.append(self.s[pos]) - pos -= self.bs() - # Then we delete a full word. - while pos >= 0 and self.s[pos] != ' ': - deleted.append(self.s[pos]) - pos -= self.bs() - - return ''.join(reversed(deleted)) - - def check(self): - """Check if paste mode should still be active and, if not, deactivate - it and force syntax highlighting.""" - - if (self.paste_mode - and time.time() - self.last_key_press > self.config.paste_time): - self.paste_mode = False - self.print_line(self.s) - - def clear_current_line(self): - """Called when a SyntaxError occured in the interpreter. It is - used to prevent autoindentation from occuring after a - traceback.""" - repl.Repl.clear_current_line(self) - self.s = '' - - def clear_wrapped_lines(self): - """Clear the wrapped lines of the current input.""" - # curses does not handle this on its own. Sad. - height, width = self.scr.getmaxyx() - max_y = min(self.iy + (self.ix + len(self.s)) // width + 1, height) - for y in xrange(self.iy + 1, max_y): - self.scr.move(y, 0) - self.scr.clrtoeol() - - def complete(self, tab=False): - """Get Autcomplete list and window. - - Called whenever these should be updated, and called - with tab - """ - if self.paste_mode: - self.scr.touchwin() #TODO necessary? - return - - list_win_visible = repl.Repl.complete(self, tab) - if list_win_visible: - try: - self.show_list(self.matches_iter.matches, topline=self.argspec, formatter=self.matches_iter.completer.format) - except curses.error: - # XXX: This is a massive hack, it will go away when I get - # cusswords into a good enough state that we can start - # using it. - self.list_win.border() - self.list_win.refresh() - list_win_visible = False - if not list_win_visible: - self.scr.redrawwin() - self.scr.refresh() - - def clrtobol(self): - """Clear from cursor to beginning of line; usual C-u behaviour""" - self.clear_wrapped_lines() - - if not self.cpos: - self.s = '' - else: - self.s = self.s[-self.cpos:] - - self.print_line(self.s, clr=True) - self.scr.redrawwin() - self.scr.refresh() - - def _get_current_line(self): - return self.s - def _set_current_line(self, line): - self.s = line - current_line = property(_get_current_line, _set_current_line, None, - "The characters of the current line") - - def cut_to_buffer(self): - """Clear from cursor to end of line, placing into cut buffer""" - self.cut_buffer = self.s[-self.cpos:] - self.s = self.s[:-self.cpos] - self.cpos = 0 - self.print_line(self.s, clr=True) - self.scr.redrawwin() - self.scr.refresh() - - def delete(self): - """Process a del""" - if not self.s: - return - - if self.mvc(-1): - self.bs(False) - - def echo(self, s, redraw=True): - """Parse and echo a formatted string with appropriate attributes. It - uses the formatting method as defined in formatter.py to parse the - srings. It won't update the screen if it's reevaluating the code (as it - does with undo).""" - if not py3 and isinstance(s, unicode): - s = s.encode(getpreferredencoding()) - - a = get_colpair(self.config, 'output') - if '\x01' in s: - rx = re.search('\x01([A-Za-z])([A-Za-z]?)', s) - if rx: - fg = rx.groups()[0] - bg = rx.groups()[1] - col_num = self._C[fg.lower()] - if bg and bg != 'I': - col_num *= self._C[bg.lower()] - - a = curses.color_pair(int(col_num) + 1) - if bg == 'I': - a = a | curses.A_REVERSE - s = re.sub('\x01[A-Za-z][A-Za-z]?', '', s) - if fg.isupper(): - a = a | curses.A_BOLD - s = s.replace('\x03', '') - s = s.replace('\x01', '') - - # Replace NUL bytes, as addstr raises an exception otherwise - s = s.replace('\0', '') - # Replace \r\n bytes, as addstr remove the current line otherwise - s = s.replace('\r\n', '\n') - - self.scr.addstr(s, a) - - if redraw and not self.evaluating: - self.scr.refresh() - - def end(self, refresh=True): - self.cpos = 0 - h, w = gethw() - y, x = divmod(len(self.s) + self.ix, w) - y += self.iy - self.scr.move(y, x) - if refresh: - self.scr.refresh() - - return True - - def hbegin(self): - """Replace the active line with first line in history and - increment the index to keep track""" - self.cpos = 0 - self.clear_wrapped_lines() - self.rl_history.enter(self.s) - self.s = self.rl_history.first() - self.print_line(self.s, clr=True) - - def hend(self): - """Same as hbegin() but, well, forward""" - self.cpos = 0 - self.clear_wrapped_lines() - self.rl_history.enter(self.s) - self.s = self.rl_history.last() - self.print_line(self.s, clr=True) - - def back(self): - """Replace the active line with previous line in history and - increment the index to keep track""" - - self.cpos = 0 - self.clear_wrapped_lines() - self.rl_history.enter(self.s) - self.s = self.rl_history.back() - self.print_line(self.s, clr=True) - - def fwd(self): - """Same as back() but, well, forward""" - - self.cpos = 0 - self.clear_wrapped_lines() - self.rl_history.enter(self.s) - self.s = self.rl_history.forward() - self.print_line(self.s, clr=True) - - def search(self): - """Search with the partial matches from the history object.""" - - self.cpo = 0 - self.clear_wrapped_lines() - self.rl_history.enter(self.s) - self.s = self.rl_history.back(start=False, search=True) - self.print_line(self.s, clr=True) - - def get_key(self): - key = '' - while True: - try: - key += self.scr.getkey() - if py3: - # Seems like we get a in the locale's encoding - # encoded string in Python 3 as well, but of - # type str instead of bytes, hence convert it to - # bytes first and decode then - key = key.encode('latin-1').decode(getpreferredencoding()) - else: - key = key.decode(getpreferredencoding()) - self.scr.nodelay(False) - except UnicodeDecodeError: - # Yes, that actually kind of sucks, but I don't see another way to get - # input right - self.scr.nodelay(True) - except curses.error: - # I'm quite annoyed with the ambiguity of this exception handler. I previously - # caught "curses.error, x" and accessed x.message and checked that it was "no - # input", which seemed a crappy way of doing it. But then I ran it on a - # different computer and the exception seems to have entirely different - # attributes. So let's hope getkey() doesn't raise any other crazy curses - # exceptions. :) - self.scr.nodelay(False) - # XXX What to do here? Raise an exception? - if key: - return key - else: - if key != '\x00': - t = time.time() - self.paste_mode = ( - t - self.last_key_press <= self.config.paste_time - ) - self.last_key_press = t - return key - else: - key = '' - finally: - if self.idle: - self.idle(self) - - def get_line(self): - """Get a line of text and return it - This function initialises an empty string and gets the - curses cursor position on the screen and stores it - for the echo() function to use later (I think). - Then it waits for key presses and passes them to p_key(), - which returns None if Enter is pressed (that means "Return", - idiot).""" - - self.s = '' - self.rl_history.reset() - self.iy, self.ix = self.scr.getyx() - - if not self.paste_mode: - for _ in xrange(self.next_indentation()): - self.p_key('\t') - - self.cpos = 0 - - while True: - key = self.get_key() - if self.p_key(key) is None: - if self.config.cli_trim_prompts and self.s.startswith(">>> "): - self.s = self.s[4:] - return self.s - - def home(self, refresh=True): - self.scr.move(self.iy, self.ix) - self.cpos = len(self.s) - if refresh: - self.scr.refresh() - return True - - def lf(self): - """Process a linefeed character; it only needs to check the - cursor position and move appropriately so it doesn't clear - the current line after the cursor.""" - if self.cpos: - for _ in range(self.cpos): - self.mvc(-1) - - # Reprint the line (as there was maybe a highlighted paren in it) - self.print_line(self.s, newline=True) - self.echo("\n") - - def mkargspec(self, topline, down): - """This figures out what to do with the argspec and puts it nicely into - the list window. It returns the number of lines used to display the - argspec. It's also kind of messy due to it having to call so many - addstr() to get the colouring right, but it seems to be pretty - sturdy.""" - - r = 3 - fn = topline[0] - args = topline[1][0] - kwargs = topline[1][3] - _args = topline[1][1] - _kwargs = topline[1][2] - is_bound_method = topline[2] - in_arg = topline[3] - if py3: - kwonly = topline[1][4] - kwonly_defaults = topline[1][5] or dict() - max_w = int(self.scr.getmaxyx()[1] * 0.6) - self.list_win.erase() - self.list_win.resize(3, max_w) - h, w = self.list_win.getmaxyx() - - self.list_win.addstr('\n ') - self.list_win.addstr(fn, - get_colpair(self.config, 'name') | curses.A_BOLD) - self.list_win.addstr(': (', get_colpair(self.config, 'name')) - maxh = self.scr.getmaxyx()[0] - - if is_bound_method and isinstance(in_arg, int): - in_arg += 1 - - punctuation_colpair = get_colpair(self.config, 'punctuation') - - for k, i in enumerate(args): - y, x = self.list_win.getyx() - ln = len(str(i)) - kw = None - if kwargs and k + 1 > len(args) - len(kwargs): - kw = repr(kwargs[k - (len(args) - len(kwargs))]) - ln += len(kw) + 1 - - if ln + x >= w: - ty = self.list_win.getbegyx()[0] - if not down and ty > 0: - h += 1 - self.list_win.mvwin(ty - 1, 1) - self.list_win.resize(h, w) - elif down and h + r < maxh - ty: - h += 1 - self.list_win.resize(h, w) - else: - break - r += 1 - self.list_win.addstr('\n\t') - - if str(i) == 'self' and k == 0: - color = get_colpair(self.config, 'name') - else: - color = get_colpair(self.config, 'token') - - if k == in_arg or i == in_arg: - color |= curses.A_BOLD - - if not py3: - # See issue #138: We need to format tuple unpacking correctly - # We use the undocumented function inspection.strseq() for - # that. Fortunately, that madness is gone in Python 3. - self.list_win.addstr(inspect.strseq(i, str), color) - else: - self.list_win.addstr(str(i), color) - if kw is not None: - self.list_win.addstr('=', punctuation_colpair) - self.list_win.addstr(kw, get_colpair(self.config, 'token')) - if k != len(args) -1: - self.list_win.addstr(', ', punctuation_colpair) - - if _args: - if args: - self.list_win.addstr(', ', punctuation_colpair) - self.list_win.addstr('*%s' % (_args, ), - get_colpair(self.config, 'token')) - - if py3 and kwonly: - if not _args: - if args: - self.list_win.addstr(', ', punctuation_colpair) - self.list_win.addstr('*', punctuation_colpair) - marker = object() - for arg in kwonly: - self.list_win.addstr(', ', punctuation_colpair) - color = get_colpair(self.config, 'token') - if arg == in_arg: - color |= curses.A_BOLD - self.list_win.addstr(arg, color) - default = kwonly_defaults.get(arg, marker) - if default is not marker: - self.list_win.addstr('=', punctuation_colpair) - self.list_win.addstr(repr(default), - get_colpair(self.config, 'token')) - - if _kwargs: - if args or _args or (py3 and kwonly): - self.list_win.addstr(', ', punctuation_colpair) - self.list_win.addstr('**%s' % (_kwargs, ), - get_colpair(self.config, 'token')) - self.list_win.addstr(')', punctuation_colpair) - - return r - - def mvc(self, i, refresh=True): - """This method moves the cursor relatively from the current - position, where: - 0 == (right) end of current line - length of current line len(self.s) == beginning of current line - and: - current cursor position + i - for positive values of i the cursor will move towards the beginning - of the line, negative values the opposite.""" - y, x = self.scr.getyx() - - if self.cpos == 0 and i < 0: - return False - - if x == self.ix and y == self.iy and i >= 1: - return False - - h, w = gethw() - if x - i < 0: - y -= 1 - x = w - - if x - i >= w: - y += 1 - x = 0 + i - - self.cpos += i - self.scr.move(y, x - i) - if refresh: - self.scr.refresh() - - return True - - def p_key(self, key): - """Process a keypress""" - - if key is None: - return '' - - config = self.config - - if platform.system() == 'Windows': - C_BACK = chr(127) - BACKSP = chr(8) - else: - C_BACK = chr(8) - BACKSP = chr(127) - - if key == C_BACK: # C-Backspace (on my computer anyway!) - self.clrtobol() - key = '\n' - # Don't return; let it get handled - - if key == chr(27): #Escape Key - return '' - - if key in (BACKSP, 'KEY_BACKSPACE'): - self.bs() - self.complete() - return '' - - elif key in key_dispatch[config.delete_key] and not self.s: - # Delete on empty line exits - self.do_exit = True - return None - - elif key in ('KEY_DC', ) + key_dispatch[config.delete_key]: - self.delete() - self.complete() - # Redraw (as there might have been highlighted parens) - self.print_line(self.s) - return '' - - elif key in key_dispatch[config.undo_key]: # C-r - self.undo() - return '' - - elif key in key_dispatch[config.search_key]: - self.search() - return '' - - elif key in ('KEY_UP', ) + key_dispatch[config.up_one_line_key]: - # Cursor Up/C-p - self.back() - return '' - - elif key in ('KEY_DOWN', ) + key_dispatch[config.down_one_line_key]: - # Cursor Down/C-n - self.fwd() - return '' - - elif key in ("KEY_LEFT",' ^B', chr(2)): # Cursor Left or ^B - self.mvc(1) - # Redraw (as there might have been highlighted parens) - self.print_line(self.s) - - elif key in ("KEY_RIGHT", '^F', chr(6)): # Cursor Right or ^F - self.mvc(-1) - # Redraw (as there might have been highlighted parens) - self.print_line(self.s) - - elif key in ("KEY_HOME", '^A', chr(1)): # home or ^A - self.home() - # Redraw (as there might have been highlighted parens) - self.print_line(self.s) - - elif key in ("KEY_END", '^E', chr(5)): # end or ^E - self.end() - # Redraw (as there might have been highlighted parens) - self.print_line(self.s) - - elif key in ("KEY_NPAGE", '\T'): # page_down or \T - self.hend() - self.print_line(self.s) - - elif key in ("KEY_PPAGE", '\S'): # page_up or \S - self.hbegin() - self.print_line(self.s) - - elif key in key_dispatch[config.cut_to_buffer_key]: # cut to buffer - self.cut_to_buffer() - return '' - - elif key in key_dispatch[config.yank_from_buffer_key]: - # yank from buffer - self.yank_from_buffer() - return '' - - elif key in key_dispatch[config.clear_word_key]: - self.cut_buffer = self.bs_word() - self.complete() - return '' - - elif key in key_dispatch[config.clear_line_key]: - self.clrtobol() - return '' - - elif key in key_dispatch[config.clear_screen_key]: - self.s_hist = [self.s_hist[-1]] - self.highlighted_paren = None - self.redraw() - return '' - - elif key in key_dispatch[config.exit_key]: - if not self.s: - self.do_exit = True - return None - else: - return '' - - elif key in key_dispatch[config.save_key]: - self.write2file() - return '' - - elif key in key_dispatch[config.pastebin_key]: - self.pastebin() - return '' - - elif key in key_dispatch[config.last_output_key]: - page(self.stdout_hist[self.prev_block_finished:-4]) - return '' - - elif key in key_dispatch[config.show_source_key]: - source = self.get_source_of_current_name() - if source is not None: - if config.highlight_show_source: - source = format(PythonLexer().get_tokens(source), - TerminalFormatter()) - page(source) - else: - self.statusbar.message(_('Cannot show source.')) - return '' - - elif key in ('\n', '\r', 'PADENTER'): - self.lf() - return None - - elif key == '\t': - return self.tab() - - elif key == 'KEY_BTAB': - return self.tab(back=True) - - elif key in key_dispatch[config.suspend_key]: - if platform.system() != 'Windows': - self.suspend() - return '' - else: - self.do_exit = True - return None - - elif key == '\x18': - return self.send_current_line_to_editor() - - elif key[0:3] == 'PAD' and not key in ('PAD0', 'PADSTOP'): - pad_keys = { - 'PADMINUS': '-', - 'PADPLUS': '+', - 'PADSLASH': '/', - 'PADSTAR': '*', - } - try: - self.addstr(pad_keys[key]) - self.print_line(self.s) - except KeyError: - return '' - elif len(key) == 1 and not unicodedata.category(key) == 'Cc': - self.addstr(key) - self.print_line(self.s) - - else: - return '' - - return True - - def print_line(self, s, clr=False, newline=False): - """Chuck a line of text through the highlighter, move the cursor - to the beginning of the line and output it to the screen.""" - - if not s: - clr = True - - if self.highlighted_paren is not None: - # Clear previous highlighted paren - self.reprint_line(*self.highlighted_paren) - self.highlighted_paren = None - - if self.config.syntax and (not self.paste_mode or newline): - o = format(self.tokenize(s, newline), self.formatter) - else: - o = s - - self.f_string = o - self.scr.move(self.iy, self.ix) - - if clr: - self.scr.clrtoeol() - - if clr and not s: - self.scr.refresh() - - if o: - for t in o.split('\x04'): - self.echo(t.rstrip('\n')) - - if self.cpos: - t = self.cpos - for _ in range(self.cpos): - self.mvc(1) - self.cpos = t - - def prompt(self, more): - """Show the appropriate Python prompt""" - if not more: - self.echo("\x01%s\x03%s" % (self.config.color_scheme['prompt'], self.ps1)) - self.stdout_hist += self.ps1 - self.s_hist.append('\x01%s\x03%s\x04' % - (self.config.color_scheme['prompt'], self.ps1)) - else: - prompt_more_color = self.config.color_scheme['prompt_more'] - self.echo("\x01%s\x03%s" % (prompt_more_color, self.ps2)) - self.stdout_hist += self.ps2 - self.s_hist.append('\x01%s\x03%s\x04' % (prompt_more_color, self.ps2)) - - def push(self, s, insert_into_history=True): - # curses.raw(True) prevents C-c from causing a SIGINT - curses.raw(False) - try: - return repl.Repl.push(self, s, insert_into_history) - except SystemExit, e: - # Avoid a traceback on e.g. quit() - self.do_exit = True - self.exit_value = e.args - return False - finally: - curses.raw(True) - - def redraw(self): - """Redraw the screen.""" - self.scr.erase() - for k, s in enumerate(self.s_hist): - if not s: - continue - self.iy, self.ix = self.scr.getyx() - for i in s.split('\x04'): - self.echo(i, redraw=False) - if k < len(self.s_hist) -1: - self.scr.addstr('\n') - self.iy, self.ix = self.scr.getyx() - self.print_line(self.s) - self.scr.refresh() - self.statusbar.refresh() - - def repl(self): - """Initialise the repl and jump into the loop. This method also has to - keep a stack of lines entered for the horrible "undo" feature. It also - tracks everything that would normally go to stdout in the normal Python - interpreter so it can quickly write it to stdout on exit after - curses.endwin(), as well as a history of lines entered for using - up/down to go back and forth (which has to be separate to the - evaluation history, which will be truncated when undoing.""" - - # Use our own helper function because Python's will use real stdin and - # stdout instead of our wrapped - self.push('from bpython._internal import _help as help\n', False) - - self.iy, self.ix = self.scr.getyx() - self.more = False - while not self.do_exit: - self.f_string = '' - self.prompt(self.more) - try: - inp = self.get_line() - except KeyboardInterrupt: - self.statusbar.message('KeyboardInterrupt') - self.scr.addstr('\n') - self.scr.touchwin() - self.scr.refresh() - continue - - self.scr.redrawwin() - if self.do_exit: - return self.exit_value - - self.history.append(inp) - self.s_hist[-1] += self.f_string - if py3: - self.stdout_hist += inp + '\n' - else: - self.stdout_hist += inp.encode(getpreferredencoding()) + '\n' - stdout_position = len(self.stdout_hist) - self.more = self.push(inp) - if not self.more: - self.prev_block_finished = stdout_position - self.s = '' - return self.exit_value - - def reprint_line(self, lineno, tokens): - """Helper function for paren highlighting: Reprint line at offset - `lineno` in current input buffer.""" - if not self.buffer or lineno == len(self.buffer): - return - - real_lineno = self.iy - height, width = self.scr.getmaxyx() - for i in xrange(lineno, len(self.buffer)): - string = self.buffer[i] - # 4 = length of prompt - length = len(string.encode(getpreferredencoding())) + 4 - real_lineno -= int(math.ceil(length / width)) - if real_lineno < 0: - return - - self.scr.move(real_lineno, - len(self.ps1) if lineno == 0 else len(self.ps2)) - line = format(tokens, BPythonFormatter(self.config.color_scheme)) - for string in line.split('\x04'): - self.echo(string) - - def resize(self): - """This method exists simply to keep it straight forward when - initialising a window and resizing it.""" - self.size() - self.scr.erase() - self.scr.resize(self.h, self.w) - self.scr.mvwin(self.y, self.x) - self.statusbar.resize(refresh=False) - self.redraw() - - - def getstdout(self): - """This method returns the 'spoofed' stdout buffer, for writing to a - file or sending to a pastebin or whatever.""" - - return self.stdout_hist + '\n' - - - def reevaluate(self): - """Clear the buffer, redraw the screen and re-evaluate the history""" - - self.evaluating = True - self.stdout_hist = '' - self.f_string = '' - self.buffer = [] - self.scr.erase() - self.s_hist = [] - # Set cursor position to -1 to prevent paren matching - self.cpos = -1 - - self.prompt(False) - - self.iy, self.ix = self.scr.getyx() - for line in self.history: - if py3: - self.stdout_hist += line + '\n' - else: - self.stdout_hist += line.encode(getpreferredencoding()) + '\n' - self.print_line(line) - self.s_hist[-1] += self.f_string - # I decided it was easier to just do this manually - # than to make the print_line and history stuff more flexible. - self.scr.addstr('\n') - self.more = self.push(line) - self.prompt(self.more) - self.iy, self.ix = self.scr.getyx() - - self.cpos = 0 - indent = repl.next_indentation(self.s, self.config.tab_length) - self.s = '' - self.scr.refresh() - - if self.buffer: - for _ in xrange(indent): - self.tab() - - self.evaluating = False - #map(self.push, self.history) - #^-- That's how simple this method was at first :( - - def write(self, s): - """For overriding stdout defaults""" - if '\x04' in s: - for block in s.split('\x04'): - self.write(block) - return - if s.rstrip() and '\x03' in s: - t = s.split('\x03')[1] - else: - t = s - - if not py3 and isinstance(t, unicode): - t = t.encode(getpreferredencoding()) - - if not self.stdout_hist: - self.stdout_hist = t - else: - self.stdout_hist += t - - self.echo(s) - self.s_hist.append(s.rstrip()) - - - def show_list(self, items, topline=None, formatter=None, current_item=None): - - shared = Struct() - shared.cols = 0 - shared.rows = 0 - shared.wl = 0 - y, x = self.scr.getyx() - h, w = self.scr.getmaxyx() - down = (y < h // 2) - if down: - max_h = h - y - else: - max_h = y + 1 - max_w = int(w * self.config.cli_suggestion_width) - self.list_win.erase() - - if items: - items = [formatter(x) for x in items] - if current_item: - current_item = formatter(current_item) - - if topline: - height_offset = self.mkargspec(topline, down) + 1 - else: - height_offset = 0 - - def lsize(): - wl = max(len(i) for i in v_items) + 1 - if not wl: - wl = 1 - cols = ((max_w - 2) // wl) or 1 - rows = len(v_items) // cols - - if cols * rows < len(v_items): - rows += 1 - - if rows + 2 >= max_h: - rows = max_h - 2 - return False - - shared.rows = rows - shared.cols = cols - shared.wl = wl - return True - - if items: - # visible items (we'll append until we can't fit any more in) - v_items = [items[0][:max_w - 3]] - lsize() - else: - v_items = [] - - for i in items[1:]: - v_items.append(i[:max_w - 3]) - if not lsize(): - del v_items[-1] - v_items[-1] = '...' - break - - rows = shared.rows - if rows + height_offset < max_h: - rows += height_offset - display_rows = rows - else: - display_rows = rows + height_offset - - cols = shared.cols - wl = shared.wl - - if topline and not v_items: - w = max_w - elif wl + 3 > max_w: - w = max_w - else: - t = (cols + 1) * wl + 3 - if t > max_w: - t = max_w - w = t - - if height_offset and display_rows + 5 >= max_h: - del v_items[-(cols * (height_offset)):] - - if self.docstring is None: - self.list_win.resize(rows + 2, w) - else: - docstring = self.format_docstring(self.docstring, max_w - 2, - max_h - height_offset) - docstring_string = ''.join(docstring) - rows += len(docstring) - self.list_win.resize(rows, max_w) - - if down: - self.list_win.mvwin(y + 1, 0) - else: - self.list_win.mvwin(y - rows - 2, 0) - - if v_items: - self.list_win.addstr('\n ') - - if not py3: - encoding = getpreferredencoding() - for ix, i in enumerate(v_items): - padding = (wl - len(i)) * ' ' - if i == current_item: - color = get_colpair(self.config, 'operator') - else: - color = get_colpair(self.config, 'main') - if not py3: - i = i.encode(encoding) - self.list_win.addstr(i + padding, color) - if ((cols == 1 or (ix and not (ix + 1) % cols)) - and ix + 1 < len(v_items)): - self.list_win.addstr('\n ') - - if self.docstring is not None: - if not py3 and isinstance(docstring_string, unicode): - docstring_string = docstring_string.encode(encoding, 'ignore') - self.list_win.addstr('\n' + docstring_string, - get_colpair(self.config, 'comment')) - # XXX: After all the trouble I had with sizing the list box (I'm not very good - # at that type of thing) I decided to do this bit of tidying up here just to - # make sure there's no unnececessary blank lines, it makes things look nicer. - - y = self.list_win.getyx()[0] - self.list_win.resize(y + 2, w) - - self.statusbar.win.touchwin() - self.statusbar.win.noutrefresh() - self.list_win.attron(get_colpair(self.config, 'main')) - self.list_win.border() - self.scr.touchwin() - self.scr.cursyncup() - self.scr.noutrefresh() - - # This looks a little odd, but I can't figure a better way to stick the cursor - # back where it belongs (refreshing the window hides the list_win) - - self.scr.move(*self.scr.getyx()) - self.list_win.refresh() - - def size(self): - """Set instance attributes for x and y top left corner coordinates - and width and heigth for the window.""" - global stdscr - h, w = stdscr.getmaxyx() - self.y = 0 - self.w = w - self.h = h - 1 - self.x = 0 - - def suspend(self): - """Suspend the current process for shell job control.""" - if platform.system() != 'Windows': - curses.endwin() - os.kill(os.getpid(), signal.SIGSTOP) - - def tab(self, back=False): - """Process the tab key being hit. - - If there's only whitespace - in the line or the line is blank then process a normal tab, - otherwise attempt to autocomplete to the best match of possible - choices in the match list. - - If `back` is True, walk backwards through the list of suggestions - and don't indent if there are only whitespace in the line. - """ - - # 1. check if we should add a tab character - if self.atbol() and not back: - x_pos = len(self.s) - self.cpos - num_spaces = x_pos % self.config.tab_length - if not num_spaces: - num_spaces = self.config.tab_length - - self.addstr(' ' * num_spaces) - self.print_line(self.s) - return True - - # 2. run complete() if we aren't already iterating through matches - if not self.matches_iter: - self.complete(tab=True) - self.print_line(self.s) - - # 3. check to see if we can expand the current word - if self.matches_iter.is_cseq(): - #TODO resolve this error-prone situation: - # can't assign at same time to self.s and self.cursor_offset - # because for cursor_offset - # property to work correctly, self.s must already be set - temp_cursor_offset, self.s = self.matches_iter.substitute_cseq() - self.cursor_offset = temp_cursor_offset - self.print_line(self.s) - if not self.matches_iter: - self.complete() - - # 4. swap current word for a match list item - elif self.matches_iter.matches: - current_match = back and self.matches_iter.previous() \ - or self.matches_iter.next() - try: - self.show_list(self.matches_iter.matches, topline=self.argspec, - formatter=self.matches_iter.completer.format, - current_item=current_match) - except curses.error: - # XXX: This is a massive hack, it will go away when I get - # cusswords into a good enough state that we can start - # using it. - self.list_win.border() - self.list_win.refresh() - _, self.s = self.matches_iter.cur_line() - self.print_line(self.s, True) - return True - - def undo(self, n=1): - repl.Repl.undo(self, n) - - # This will unhighlight highlighted parens - self.print_line(self.s) - - def writetb(self, lines): - for line in lines: - self.write('\x01%s\x03%s' % (self.config.color_scheme['error'], - line)) - - def yank_from_buffer(self): - """Paste the text from the cut buffer at the current cursor location""" - self.addstr(self.cut_buffer) - self.print_line(self.s, clr=True) - - def send_current_line_to_editor(self): - lines = self.send_to_external_editor(self.s).split('\n') - self.s = '' - self.print_line(self.s) - while lines and not lines[-1]: - lines.pop() - if not lines: - return '' - - self.f_string = '' - self.cpos = -1 # Set cursor position to -1 to prevent paren matching - - self.iy, self.ix = self.scr.getyx() - self.evaluating = True - for line in lines: - if py3: - self.stdout_hist += line + '\n' - else: - self.stdout_hist += line.encode(getpreferredencoding()) + '\n' - self.history.append(line) - self.print_line(line) - self.s_hist[-1] += self.f_string - self.scr.addstr('\n') - self.more = self.push(line) - self.prompt(self.more) - self.iy, self.ix = self.scr.getyx() - self.evaluating = False - - self.cpos = 0 - indent = repl.next_indentation(self.s, self.config.tab_length) - self.s = '' - self.scr.refresh() - - if self.buffer: - for _ in xrange(indent): - self.tab() - - self.print_line(self.s) - self.scr.redrawwin() - return '' - -class Statusbar(object): - """This class provides the status bar at the bottom of the screen. - It has message() and prompt() methods for user interactivity, as - well as settext() and clear() methods for changing its appearance. - - The check() method needs to be called repeatedly if the statusbar is - going to be aware of when it should update its display after a message() - has been called (it'll display for a couple of seconds and then disappear). - - It should be called as: - foo = Statusbar(stdscr, scr, 'Initial text to display') - or, for a blank statusbar: - foo = Statusbar(stdscr, scr) - - It can also receive the argument 'c' which will be an integer referring - to a curses colour pair, e.g.: - foo = Statusbar(stdscr, 'Hello', c=4) - - stdscr should be a curses window object in which to put the status bar. - pwin should be the parent window. To be honest, this is only really here - so the cursor can be returned to the window properly. - - """ - - def __init__(self, scr, pwin, background, config, s=None, c=None): - """Initialise the statusbar and display the initial text (if any)""" - self.size() - self.win = newwin(background, self.h, self.w, self.y, self.x) - - self.config = config - - self.s = s or '' - self._s = self.s - self.c = c - self.timer = 0 - self.pwin = pwin - self.settext(s, c) - - def size(self): - """Set instance attributes for x and y top left corner coordinates - and width and heigth for the window.""" - h, w = gethw() - self.y = h - 1 - self.w = w - self.h = 1 - self.x = 0 - - def resize(self, refresh=True): - """This method exists simply to keep it straight forward when - initialising a window and resizing it.""" - self.size() - self.win.mvwin(self.y, self.x) - self.win.resize(self.h, self.w) - if refresh: - self.refresh() - - def refresh(self): - """This is here to make sure the status bar text is redraw properly - after a resize.""" - self.settext(self._s) - - def check(self): - """This is the method that should be called every half second or so - to see if the status bar needs updating.""" - if not self.timer: - return - - if time.time() < self.timer: - return - - self.settext(self._s) - - def message(self, s, n=3): - """Display a message for a short n seconds on the statusbar and return - it to its original state.""" - self.timer = time.time() + n - self.settext(s) - - def prompt(self, s=''): - """Prompt the user for some input (with the optional prompt 's') and - return the input text, then restore the statusbar to its original - value.""" - - self.settext(s or '? ', p=True) - iy, ix = self.win.getyx() - - def bs(s): - y, x = self.win.getyx() - if x == ix: - return s - s = s[:-1] - self.win.delch(y, x - 1) - self.win.move(y, x - 1) - return s - - o = '' - while True: - c = self.win.getch() - - # '\b' - if c == 127: - o = bs(o) - # '\n' - elif c == 10: - break - # ESC - elif c == 27: - curses.flushinp() - raise ValueError - # literal - elif 0 < c < 127: - c = chr(c) - self.win.addstr(c, get_colpair(self.config, 'prompt')) - o += c - - self.settext(self._s) - return o - - def settext(self, s, c=None, p=False): - """Set the text on the status bar to a new permanent value; this is the - value that will be set after a prompt or message. c is the optional - curses colour pair to use (if not specified the last specified colour - pair will be used). p is True if the cursor is expected to stay in the - status window (e.g. when prompting).""" - - self.win.erase() - if len(s) >= self.w: - s = s[:self.w - 1] - - self.s = s - if c: - self.c = c - - if s: - if not py3 and isinstance(s, unicode): - s = s.encode(getpreferredencoding()) - - if self.c: - self.win.addstr(s, self.c) - else: - self.win.addstr(s) - - if not p: - self.win.noutrefresh() - self.pwin.refresh() - else: - self.win.refresh() - - def clear(self): - """Clear the status bar.""" - self.win.clear() - - -def init_wins(scr, config): - """Initialise the two windows (the main repl interface and the little - status bar at the bottom with some stuff in it)""" - #TODO: Document better what stuff is on the status bar. - - background = get_colpair(config, 'background') - h, w = gethw() - - main_win = newwin(background, h - 1, w, 0, 0) - main_win.scrollok(True) - main_win.keypad(1) - # Thanks to Angus Gibson for pointing out this missing line which was causing - # problems that needed dirty hackery to fix. :) - - statusbar = Statusbar(scr, main_win, background, config, - _(" <%s> Rewind <%s> Save <%s> Pastebin " - " <%s> Pager <%s> Show Source ") % - (config.undo_key, config.save_key, config.pastebin_key, - config.last_output_key, config.show_source_key), - get_colpair(config, 'main')) - - return main_win, statusbar - - -def sigwinch(unused_scr): - global DO_RESIZE - DO_RESIZE = True - -def sigcont(unused_scr): - sigwinch(unused_scr) - # Forces the redraw - curses.ungetch('\x00') - -def gethw(): - """I found this code on a usenet post, and snipped out the bit I needed, - so thanks to whoever wrote that, sorry I forgot your name, I'm sure you're - a great guy. - - It's unfortunately necessary (unless someone has any better ideas) in order - to allow curses and readline to work together. I looked at the code for - libreadline and noticed this comment: - - /* This is the stuff that is hard for me. I never seem to write good - display routines in C. Let's see how I do this time. */ - - So I'm not going to ask any questions. - - """ - - if platform.system() != 'Windows': - h, w = struct.unpack( - "hhhh", - fcntl.ioctl(sys.__stdout__, termios.TIOCGWINSZ, "\000" * 8))[0:2] - else: - from ctypes import windll, create_string_buffer - - # stdin handle is -10 - # stdout handle is -11 - # stderr handle is -12 - - h = windll.kernel32.GetStdHandle(-12) - csbi = create_string_buffer(22) - res = windll.kernel32.GetConsoleScreenBufferInfo(h, csbi) - - if res: - (bufx, bufy, curx, cury, wattr, - left, top, right, bottom, maxx, maxy) = struct.unpack("hhhhHhhhhhh", csbi.raw) - sizex = right - left + 1 - sizey = bottom - top + 1 - else: - sizex, sizey = stdscr.getmaxyx()# can't determine actual size - return default values - - h, w = sizey, sizex - return h, w - - -def idle(caller): - """This is called once every iteration through the getkey() - loop (currently in the Repl class, see the get_line() method). - The statusbar check needs to go here to take care of timed - messages and the resize handlers need to be here to make - sure it happens conveniently.""" - global DO_RESIZE - - if importcompletion.find_coroutine() or caller.paste_mode: - caller.scr.nodelay(True) - key = caller.scr.getch() - caller.scr.nodelay(False) - if key != -1: - curses.ungetch(key) - else: - curses.ungetch('\x00') - caller.statusbar.check() - caller.check() - - if DO_RESIZE: - do_resize(caller) - - -def do_resize(caller): - """This needs to hack around readline and curses not playing - nicely together. See also gethw() above.""" - global DO_RESIZE - h, w = gethw() - if not h: - # Hopefully this shouldn't happen. :) - return - - curses.endwin() - os.environ["LINES"] = str(h) - os.environ["COLUMNS"] = str(w) - curses.doupdate() - DO_RESIZE = False - - try: - caller.resize() - except curses.error: - pass - # The list win resizes itself every time it appears so no need to do it here. - - -class FakeDict(object): - """Very simple dict-alike that returns a constant value for any key - - used as a hacky solution to using a colours dict containing colour codes if - colour initialisation fails.""" - - def __init__(self, val): - self._val = val - - def __getitem__(self, k): - return self._val - - -def newwin(background, *args): - """Wrapper for curses.newwin to automatically set background colour on any - newly created window.""" - win = curses.newwin(*args) - win.bkgd(' ', background) - return win - - -def curses_wrapper(func, *args, **kwargs): - """Like curses.wrapper(), but reuses stdscr when called again.""" - global stdscr - if stdscr is None: - stdscr = curses.initscr() - try: - curses.noecho() - curses.cbreak() - stdscr.keypad(1) - - try: - curses.start_color() - except curses.error: - pass - - return func(stdscr, *args, **kwargs) - finally: - stdscr.keypad(0) - curses.echo() - curses.nocbreak() - curses.endwin() - - -def main_curses(scr, args, config, interactive=True, locals_=None, - banner=None): - """main function for the curses convenience wrapper - - Initialise the two main objects: the interpreter - and the repl. The repl does what a repl does and lots - of other cool stuff like syntax highlighting and stuff. - I've tried to keep it well factored but it needs some - tidying up, especially in separating the curses stuff - from the rest of the repl. - - Returns a tuple (exit value, output), where exit value is a tuple - with arguments passed to SystemExit. - """ - global stdscr - global DO_RESIZE - global colors - DO_RESIZE = False - - if platform.system() != 'Windows': - old_sigwinch_handler = signal.signal(signal.SIGWINCH, - lambda *_: sigwinch(scr)) - # redraw window after being suspended - old_sigcont_handler = signal.signal(signal.SIGCONT, lambda *_: sigcont(scr)) - - stdscr = scr - try: - curses.start_color() - curses.use_default_colors() - cols = make_colors(config) - except curses.error: - cols = FakeDict(-1) - - # FIXME: Gargh, bad design results in using globals without a refactor :( - colors = cols - - scr.timeout(300) - - curses.raw(True) - main_win, statusbar = init_wins(scr, config) - - if locals_ is None: - sys.modules['__main__'] = ModuleType('__main__') - locals_ = sys.modules['__main__'].__dict__ - interpreter = repl.Interpreter(locals_, getpreferredencoding()) - - clirepl = CLIRepl(main_win, interpreter, statusbar, config, idle) - clirepl._C = cols - - sys.stdin = FakeStdin(clirepl) - sys.stdout = FakeStream(clirepl, lambda: sys.stdout) - sys.stderr = FakeStream(clirepl, lambda: sys.stderr) - - if args: - exit_value = () - try: - bpython.args.exec_code(interpreter, args) - except SystemExit, e: - # The documentation of code.InteractiveInterpreter.runcode claims - # that it reraises SystemExit. However, I can't manage to trigger - # that. To be one the safe side let's catch SystemExit here anyway. - exit_value = e.args - if not interactive: - curses.raw(False) - return (exit_value, clirepl.getstdout()) - else: - sys.path.insert(0, '') - clirepl.startup() - - if banner is not None: - clirepl.write(banner) - clirepl.write('\n') - exit_value = clirepl.repl() - if hasattr(sys, 'exitfunc'): - sys.exitfunc() - delattr(sys, 'exitfunc') - - main_win.erase() - main_win.refresh() - statusbar.win.clear() - statusbar.win.refresh() - curses.raw(False) - - # Restore signal handlers - if platform.system() != 'Windows': - signal.signal(signal.SIGWINCH, old_sigwinch_handler) - signal.signal(signal.SIGCONT, old_sigcont_handler) - - return (exit_value, clirepl.getstdout()) - - -def main(args=None, locals_=None, banner=None): - translations.init() - - - config, options, exec_args = bpython.args.parse(args) - - # Save stdin, stdout and stderr for later restoration - orig_stdin = sys.stdin - orig_stdout = sys.stdout - orig_stderr = sys.stderr - - try: - (exit_value, output) = curses_wrapper( - main_curses, exec_args, config, options.interactive, locals_, - banner=banner) - finally: - sys.stdin = orig_stdin - sys.stderr = orig_stderr - sys.stdout = orig_stdout - - # Fake stdout data so everything's still visible after exiting - if config.flush_output and not options.quiet: - sys.stdout.write(output) - if hasattr(sys.stdout, 'flush'): - sys.stdout.flush() - return repl.extract_exit_value(exit_value) - -if __name__ == '__main__': - from bpython.cli import main - sys.exit(main()) - -# vim: sw=4 ts=4 sts=4 ai et diff --git a/bpython/config.py b/bpython/config.py index 7a21becfe..c309403fd 100644 --- a/bpython/config.py +++ b/bpython/config.py @@ -1,212 +1,398 @@ -from __future__ import with_statement +# The MIT License +# +# Copyright (c) 2009-2015 the bpython authors. +# Copyright (c) 2015-2022 Sebastian Ramacher +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# To gradually migrate to mypy we aren't setting these globally yet +# mypy: disallow_untyped_defs=True +# mypy: disallow_untyped_calls=True + import os import sys -from ConfigParser import ConfigParser +import locale +from configparser import ConfigParser from itertools import chain -from bpython.keys import cli_key_dispatch as key_dispatch -from bpython.autocomplete import SIMPLE as default_completion +from pathlib import Path +from typing import Any, Dict +from collections.abc import MutableMapping, Mapping +from xdg import BaseDirectory + +from .autocomplete import AutocompleteModes + +default_completion = AutocompleteModes.SIMPLE +# All supported letters for colors for themes +# +# Instead of importing it from .curtsiesfrontend.parse, we define them here to +# avoid a potential import of fcntl on Windows. +COLOR_LETTERS = tuple("krgybmcwd") + + +class UnknownColorCode(Exception): + def __init__(self, key: str, color: str) -> None: + self.key = key + self.color = color + + +def getpreferredencoding() -> str: + """Get the user's preferred encoding.""" + return locale.getpreferredencoding() or sys.getdefaultencoding() + + +def can_encode(c: str) -> bool: + try: + c.encode(getpreferredencoding()) + return True + except UnicodeEncodeError: + return False -class Struct(object): - """Simple class for instantiating objects we can add arbitrary attributes - to and use for various arbitrary things.""" -def get_config_home(): +def supports_box_chars() -> bool: + """Check if the encoding supports Unicode box characters.""" + return all(map(can_encode, "│─└┘┌┐")) + + +def get_config_home() -> Path: """Returns the base directory for bpython's configuration files.""" - xdg_config_home = os.environ.get('XDG_CONFIG_HOME', '~/.config') - return os.path.join(xdg_config_home, 'bpython') + return Path(BaseDirectory.xdg_config_home) / "bpython" + -def default_config_path(): +def default_config_path() -> Path: """Returns bpython's default configuration file path.""" - return os.path.join(get_config_home(), 'config') + return get_config_home() / "config" + + +def default_editor() -> str: + """Returns the default editor.""" + return os.environ.get("VISUAL", os.environ.get("EDITOR", "vi")) -def fill_config_with_default_values(config, default_values): - for section in default_values.iterkeys(): + +def fill_config_with_default_values( + config: ConfigParser, default_values: Mapping[str, Mapping[str, Any]] +) -> None: + for section in default_values.keys(): if not config.has_section(section): config.add_section(section) - for (opt, val) in default_values[section].iteritems(): + for opt, val in default_values[section].items(): if not config.has_option(section, opt): config.set(section, opt, str(val)) -def loadini(struct, configfile): - """Loads .ini configuration file and stores its values in struct""" - - config_path = os.path.expanduser(configfile) - - config = ConfigParser() - fill_config_with_default_values(config, { - 'general': { - 'arg_spec': True, - 'auto_display_list': True, - 'color_scheme': 'default', - 'complete_magic_methods' : True, - 'autocomplete_mode': default_completion, - 'dedent_after': 1, - 'flush_output': True, - 'highlight_show_source': True, - 'hist_file': '~/.pythonhist', - 'hist_length': 100, - 'hist_duplicates': True, - 'paste_time': 0.02, - 'syntax': True, - 'tab_length': 4, - 'pastebin_confirm': True, - 'pastebin_private': False, - 'pastebin_url': 'http://bpaste.net/xmlrpc/', - 'pastebin_private': True, - 'pastebin_show_url': 'http://bpaste.net/show/$paste_id/', - 'pastebin_helper': '', - 'save_append_py': False, - 'editor': os.environ.get('VISUAL', os.environ.get('EDITOR', 'vi')) +class Config: + default_colors = { + "keyword": "y", + "name": "c", + "comment": "b", + "string": "m", + "error": "r", + "number": "G", + "operator": "Y", + "punctuation": "y", + "token": "C", + "background": "d", + "output": "w", + "main": "c", + "paren": "R", + "prompt": "c", + "prompt_more": "g", + "right_arrow_suggestion": "K", + } + + defaults: dict[str, dict[str, Any]] = { + "general": { + "arg_spec": True, + "auto_display_list": True, + "autocomplete_mode": default_completion, + "color_scheme": "default", + "complete_magic_methods": True, + "dedent_after": 1, + "default_autoreload": False, + "editor": default_editor(), + "flush_output": True, + "import_completion_skiplist": ":".join( + ( + # version tracking + ".git", + ".svn", + ".hg" + # XDG + ".config", + ".local", + ".share", + # nodejs + "node_modules", + # PlayOnLinux + "PlayOnLinux's virtual drives", + # wine + "dosdevices", + # Python byte code cache + "__pycache__", + ) + ), + "highlight_show_source": True, + "hist_duplicates": True, + "hist_file": "~/.pythonhist", + "hist_length": 1000, + "paste_time": 0.02, + "pastebin_confirm": True, + "pastebin_expiry": "1week", + "pastebin_helper": "", + "pastebin_url": "https://bpaste.net", + "save_append_py": False, + "single_undo_time": 1.0, + "syntax": True, + "tab_length": 4, + "unicode_box": True, + "brackets_completion": False, }, - 'keyboard': { - 'clear_line': 'C-u', - 'clear_screen': 'C-l', - 'clear_word': 'C-w', - 'cut_to_buffer': 'C-k', - 'delete': 'C-d', - 'down_one_line': 'C-n', - 'exit': '', - 'external_editor': 'F7', - 'edit_current_block': 'C-x', - 'help': 'F1', - 'last_output': 'F9', - 'pastebin': 'F8', - 'save': 'C-s', - 'show_source': 'F2', - 'suspend': 'C-z', - 'toggle_file_watch': 'F5', - 'undo': 'C-r', - 'reimport': 'F6', - 'search': 'C-o', - 'up_one_line': 'C-p', - 'yank_from_buffer': 'C-y'}, - 'cli': { - 'suggestion_width': 0.8, - 'trim_prompts': False, + "keyboard": { + "backspace": "C-h", + "beginning_of_line": "C-a", + "clear_line": "C-u", + "clear_screen": "C-l", + "clear_word": "C-w", + "copy_clipboard": "F10", + "cut_to_buffer": "C-k", + "delete": "C-d", + "down_one_line": "C-n", + "edit_config": "F3", + "edit_current_block": "C-x", + "end_of_line": "C-e", + "exit": "", + "external_editor": "F7", + "help": "F1", + "incremental_search": "M-s", + "last_output": "F9", + "left": "C-b", + "pastebin": "F8", + "redo": "C-g", + "reimport": "F6", + "reverse_incremental_search": "M-r", + "right": "C-f", + "save": "C-s", + "search": "C-o", + "show_source": "F2", + "suspend": "C-z", + "toggle_file_watch": "F5", + "transpose_chars": "C-t", + "undo": "C-r", + "up_one_line": "C-p", + "yank_from_buffer": "C-y", }, - 'curtsies': { - 'list_above' : False, - 'fill_terminal' : False, - 'right_arrow_completion' : True, - }}) - if not config.read(config_path): - # No config file. If the user has it in the old place then complain - if os.path.isfile(os.path.expanduser('~/.bpython.ini')): - sys.stderr.write("Error: It seems that you have a config file at " - "~/.bpython.ini. Please move your config file to " - "%s\n" % default_config_path()) - sys.exit(1) + "cli": { + "suggestion_width": 0.8, + "trim_prompts": False, + }, + "curtsies": { + "list_above": False, + "right_arrow_completion": True, + }, + } - struct.dedent_after = config.getint('general', 'dedent_after') - struct.tab_length = config.getint('general', 'tab_length') - struct.auto_display_list = config.getboolean('general', - 'auto_display_list') - struct.syntax = config.getboolean('general', 'syntax') - struct.arg_spec = config.getboolean('general', 'arg_spec') - struct.paste_time = config.getfloat('general', 'paste_time') - struct.highlight_show_source = config.getboolean('general', - 'highlight_show_source') - struct.hist_file = config.get('general', 'hist_file') - struct.editor = config.get('general', 'editor') - struct.hist_length = config.getint('general', 'hist_length') - struct.hist_duplicates = config.getboolean('general', 'hist_duplicates') - struct.flush_output = config.getboolean('general', 'flush_output') - struct.pastebin_key = config.get('keyboard', 'pastebin') - struct.save_key = config.get('keyboard', 'save') - struct.search_key = config.get('keyboard', 'search') - struct.show_source_key = config.get('keyboard', 'show_source') - struct.suspend_key = config.get('keyboard', 'suspend') - struct.toggle_file_watch_key = config.get('keyboard', 'toggle_file_watch') - struct.undo_key = config.get('keyboard', 'undo') - struct.reimport_key = config.get('keyboard', 'reimport') - struct.up_one_line_key = config.get('keyboard', 'up_one_line') - struct.down_one_line_key = config.get('keyboard', 'down_one_line') - struct.cut_to_buffer_key = config.get('keyboard', 'cut_to_buffer') - struct.yank_from_buffer_key = config.get('keyboard', 'yank_from_buffer') - struct.clear_word_key = config.get('keyboard', 'clear_word') - struct.clear_line_key = config.get('keyboard', 'clear_line') - struct.clear_screen_key = config.get('keyboard', 'clear_screen') - struct.delete_key = config.get('keyboard', 'delete') - struct.exit_key = config.get('keyboard', 'exit') - struct.last_output_key = config.get('keyboard', 'last_output') - struct.edit_current_block_key = config.get('keyboard', 'edit_current_block') - struct.external_editor_key = config.get('keyboard', 'external_editor') - struct.help_key = config.get('keyboard', 'help') - - struct.pastebin_confirm = config.getboolean('general', 'pastebin_confirm') - struct.pastebin_private = config.getboolean('general', 'pastebin_private') - struct.pastebin_url = config.get('general', 'pastebin_url') - struct.pastebin_private = config.get('general', 'pastebin_private') - struct.pastebin_show_url = config.get('general', 'pastebin_show_url') - struct.pastebin_helper = config.get('general', 'pastebin_helper') - - struct.cli_suggestion_width = config.getfloat('cli', - 'suggestion_width') - struct.cli_trim_prompts = config.getboolean('cli', - 'trim_prompts') - - struct.complete_magic_methods = config.getboolean('general', - 'complete_magic_methods') - struct.autocomplete_mode = config.get('general', 'autocomplete_mode') - struct.save_append_py = config.getboolean('general', 'save_append_py') - - struct.curtsies_list_above = config.getboolean('curtsies', 'list_above') - struct.curtsies_fill_terminal = config.getboolean('curtsies', 'fill_terminal') - struct.curtsies_right_arrow_completion = config.getboolean('curtsies', 'right_arrow_completion') - - color_scheme_name = config.get('general', 'color_scheme') + def __init__(self, config_path: Path | None = None) -> None: + """Loads .ini configuration file and stores its values.""" - default_colors = { - 'keyword': 'y', - 'name': 'c', - 'comment': 'b', - 'string': 'm', - 'error': 'r', - 'number': 'G', - 'operator': 'Y', - 'punctuation': 'y', - 'token': 'C', - 'background': 'd', - 'output': 'w', - 'main': 'c', - 'paren': 'R', - 'prompt': 'c', - 'prompt_more': 'g', + config = ConfigParser() + fill_config_with_default_values(config, self.defaults) + try: + if config_path is not None: + config.read(config_path) + except UnicodeDecodeError as e: + sys.stderr.write( + "Error: Unable to parse config file at '{}' due to an " + "encoding issue ({}). Please make sure to fix the encoding " + "of the file or remove it and then try again.\n".format( + config_path, e + ) + ) + sys.exit(1) + + default_keys_to_commands = { + value: key for (key, value) in self.defaults["keyboard"].items() } - if color_scheme_name == 'default': - struct.color_scheme = default_colors - else: - struct.color_scheme = dict() + def get_key_no_doublebind(command: str) -> str: + default_commands_to_keys = self.defaults["keyboard"] + requested_key = config.get("keyboard", command) - theme_filename = color_scheme_name + '.theme' - path = os.path.expanduser(os.path.join(get_config_home(), - theme_filename)) - try: - load_theme(struct, path, struct.color_scheme, default_colors) - except EnvironmentError: - sys.stderr.write("Could not load theme '%s'.\n" % - (color_scheme_name, )) - sys.exit(1) + try: + default_command = default_keys_to_commands[requested_key] + if default_commands_to_keys[default_command] == config.get( + "keyboard", default_command + ): + setattr(self, f"{default_command}_key", "") + except KeyError: + pass + + return requested_key + + self.config_path = ( + config_path.absolute() if config_path is not None else None + ) + self.hist_file = Path(config.get("general", "hist_file")).expanduser() + + self.dedent_after = config.getint("general", "dedent_after") + self.tab_length = config.getint("general", "tab_length") + self.auto_display_list = config.getboolean( + "general", "auto_display_list" + ) + self.syntax = config.getboolean("general", "syntax") + self.arg_spec = config.getboolean("general", "arg_spec") + self.paste_time = config.getfloat("general", "paste_time") + self.single_undo_time = config.getfloat("general", "single_undo_time") + self.highlight_show_source = config.getboolean( + "general", "highlight_show_source" + ) + self.editor = config.get("general", "editor") + self.hist_length = config.getint("general", "hist_length") + self.hist_duplicates = config.getboolean("general", "hist_duplicates") + self.flush_output = config.getboolean("general", "flush_output") + self.default_autoreload = config.getboolean( + "general", "default_autoreload" + ) + self.import_completion_skiplist = config.get( + "general", "import_completion_skiplist" + ).split(":") + + self.pastebin_key = get_key_no_doublebind("pastebin") + self.copy_clipboard_key = get_key_no_doublebind("copy_clipboard") + self.save_key = get_key_no_doublebind("save") + self.search_key = get_key_no_doublebind("search") + self.show_source_key = get_key_no_doublebind("show_source") + self.suspend_key = get_key_no_doublebind("suspend") + self.toggle_file_watch_key = get_key_no_doublebind("toggle_file_watch") + self.undo_key = get_key_no_doublebind("undo") + self.redo_key = get_key_no_doublebind("redo") + self.reimport_key = get_key_no_doublebind("reimport") + self.reverse_incremental_search_key = get_key_no_doublebind( + "reverse_incremental_search" + ) + self.incremental_search_key = get_key_no_doublebind( + "incremental_search" + ) + self.up_one_line_key = get_key_no_doublebind("up_one_line") + self.down_one_line_key = get_key_no_doublebind("down_one_line") + self.cut_to_buffer_key = get_key_no_doublebind("cut_to_buffer") + self.yank_from_buffer_key = get_key_no_doublebind("yank_from_buffer") + self.clear_word_key = get_key_no_doublebind("clear_word") + self.backspace_key = get_key_no_doublebind("backspace") + self.clear_line_key = get_key_no_doublebind("clear_line") + self.clear_screen_key = get_key_no_doublebind("clear_screen") + self.delete_key = get_key_no_doublebind("delete") + + self.left_key = get_key_no_doublebind("left") + self.right_key = get_key_no_doublebind("right") + self.end_of_line_key = get_key_no_doublebind("end_of_line") + self.beginning_of_line_key = get_key_no_doublebind("beginning_of_line") + self.transpose_chars_key = get_key_no_doublebind("transpose_chars") + self.exit_key = get_key_no_doublebind("exit") + self.last_output_key = get_key_no_doublebind("last_output") + self.edit_config_key = get_key_no_doublebind("edit_config") + self.edit_current_block_key = get_key_no_doublebind( + "edit_current_block" + ) + self.external_editor_key = get_key_no_doublebind("external_editor") + self.help_key = get_key_no_doublebind("help") + + self.pastebin_confirm = config.getboolean("general", "pastebin_confirm") + self.pastebin_url = config.get("general", "pastebin_url") + self.pastebin_expiry = config.get("general", "pastebin_expiry") + self.pastebin_helper = config.get("general", "pastebin_helper") + + self.cli_suggestion_width = config.getfloat("cli", "suggestion_width") + self.cli_trim_prompts = config.getboolean("cli", "trim_prompts") + + self.complete_magic_methods = config.getboolean( + "general", "complete_magic_methods" + ) + self.autocomplete_mode = ( + AutocompleteModes.from_string( + config.get("general", "autocomplete_mode") + ) + or default_completion + ) + self.save_append_py = config.getboolean("general", "save_append_py") + + self.curtsies_list_above = config.getboolean("curtsies", "list_above") + self.curtsies_right_arrow_completion = config.getboolean( + "curtsies", "right_arrow_completion" + ) + self.unicode_box = config.getboolean("general", "unicode_box") + + self.color_scheme = dict() + color_scheme_name = config.get("general", "color_scheme") + if color_scheme_name == "default": + self.color_scheme.update(self.default_colors) + else: + path = get_config_home() / f"{color_scheme_name}.theme" + try: + load_theme(path, self.color_scheme, self.default_colors) + except OSError: + sys.stderr.write( + f"Could not load theme '{color_scheme_name}' from {path}.\n" + ) + sys.exit(1) + except UnknownColorCode as ucc: + sys.stderr.write( + f"Theme '{color_scheme_name}' contains invalid color: {ucc.key} = {ucc.color}.\n" + ) + sys.exit(1) + + # set box drawing characters + ( + self.left_border, + self.right_border, + self.top_border, + self.bottom_border, + self.left_bottom_corner, + self.right_bottom_corner, + self.left_top_corner, + self.right_top_corner, + ) = ( + ("│", "│", "─", "─", "└", "┘", "┌", "┐") + if self.unicode_box and supports_box_chars() + else ("|", "|", "-", "-", "+", "+", "+", "+") + ) + self.brackets_completion = config.getboolean( + "general", "brackets_completion" + ) - # checks for valid key configuration this part still sucks - for key in (struct.pastebin_key, struct.save_key): - key_dispatch[key] -def load_theme(struct, path, colors, default_colors): +def load_theme( + path: Path, + colors: MutableMapping[str, str], + default_colors: Mapping[str, str], +) -> None: theme = ConfigParser() - with open(path, 'r') as f: - theme.readfp(f) - for k, v in chain(theme.items('syntax'), theme.items('interface')): - if theme.has_option('syntax', k): - colors[k] = theme.get('syntax', k) + with open(path) as f: + theme.read_file(f) + for k, v in chain(theme.items("syntax"), theme.items("interface")): + if theme.has_option("syntax", k): + colors[k] = theme.get("syntax", k) else: - colors[k] = theme.get('interface', k) + colors[k] = theme.get("interface", k) + if colors[k].lower() not in COLOR_LETTERS: + raise UnknownColorCode(k, colors[k]) # Check against default theme to see if all values are defined - for k, v in default_colors.iteritems(): + for k, v in default_colors.items(): if k not in colors: colors[k] = v - f.close() diff --git a/bpython/curtsies.py b/bpython/curtsies.py index 9f80cb247..ae48a6007 100644 --- a/bpython/curtsies.py +++ b/bpython/curtsies.py @@ -1,142 +1,294 @@ -from __future__ import absolute_import +# To gradually migrate to mypy we aren't setting these globally yet +# mypy: disallow_untyped_defs=True +# mypy: disallow_untyped_calls=True -import code +import argparse +import collections import logging import sys -import time -from subprocess import Popen, PIPE -from optparse import Option -from itertools import izip -from functools import wraps import curtsies -import curtsies.window -import curtsies.input import curtsies.events +import curtsies.input +import curtsies.window -from bpython.curtsiesfrontend.repl import Repl -from bpython.curtsiesfrontend.coderunner import SystemExitFromCodeGreenlet -from bpython import args as bpargs -from bpython.translations import _ -from bpython.importcompletion import find_iterator - -repl = None # global for `from bpython.curtsies import repl` -#WARNING Will be a problem if more than one repl is ever instantiated this way - -def main(args=None, locals_=None, banner=None): - config, options, exec_args = bpargs.parse(args, ( - 'scroll options', None, [ - Option('--log', '-L', action='store_true', - help=_("log debug messages to bpython.log")), - Option('--type', '-t', action='store_true', - help=_("enter lines of file as though interactively typed")), - ])) - if options.log: - handler = logging.FileHandler(filename='bpython.log') - logging.getLogger('curtsies').setLevel(logging.DEBUG) - logging.getLogger('curtsies').addHandler(handler) - logging.getLogger('curtsies').propagate = False - logging.getLogger('bpython').setLevel(logging.DEBUG) - logging.getLogger('bpython').addHandler(handler) - logging.getLogger('bpython').propagate = False - else: - logging.getLogger('bpython').setLevel(logging.WARNING) +from . import args as bpargs, translations, inspection +from .config import Config +from .curtsiesfrontend import events +from .curtsiesfrontend.coderunner import SystemExitFromCodeRunner +from .curtsiesfrontend.interpreter import Interp +from .curtsiesfrontend.repl import BaseRepl +from .repl import extract_exit_value +from .translations import _ + +from typing import ( + Any, + Dict, + List, + Optional, + Protocol, + Tuple, + Union, +) +from collections.abc import Callable, Generator, Sequence + +logger = logging.getLogger(__name__) + + +class SupportsEventGeneration(Protocol): + def send( + self, timeout: float | None + ) -> str | curtsies.events.Event | None: ... + + def __iter__(self) -> "SupportsEventGeneration": ... + + def __next__(self) -> str | curtsies.events.Event | None: ... + + +class FullCurtsiesRepl(BaseRepl): + def __init__( + self, + config: Config, + locals_: dict[str, Any] | None = None, + banner: str | None = None, + interp: Interp | None = None, + ) -> None: + self.input_generator = curtsies.input.Input( + keynames="curtsies", sigint_event=True, paste_threshold=None + ) + window = curtsies.window.CursorAwareWindow( + sys.stdout, + sys.stdin, + keep_last_line=True, + hide_cursor=False, + extra_bytes_callback=self.input_generator.unget_bytes, + ) + + self._request_refresh_callback: Callable[[], None] = ( + self.input_generator.event_trigger(events.RefreshRequestEvent) + ) + self._schedule_refresh_callback = ( + self.input_generator.scheduled_event_trigger( + events.ScheduledRefreshRequestEvent + ) + ) + self._request_reload_callback = ( + self.input_generator.threadsafe_event_trigger(events.ReloadEvent) + ) + self._interrupting_refresh_callback = ( + self.input_generator.threadsafe_event_trigger(lambda: None) + ) + self._request_undo_callback = self.input_generator.event_trigger( + events.UndoEvent + ) + + with self.input_generator: + pass # temp hack to get .original_stty + + super().__init__( + config, + window, + locals_=locals_, + banner=banner, + interp=interp, + orig_tcattrs=self.input_generator.original_stty, + ) + + def _request_refresh(self) -> None: + return self._request_refresh_callback() + + def _schedule_refresh(self, when: float) -> None: + return self._schedule_refresh_callback(when) + + def _request_reload(self, files_modified: Sequence[str]) -> None: + return self._request_reload_callback(files_modified=files_modified) + + def interrupting_refresh(self) -> None: + return self._interrupting_refresh_callback() + + def request_undo(self, n: int = 1) -> None: + return self._request_undo_callback(n=n) + + def get_term_hw(self) -> tuple[int, int]: + return self.window.get_term_hw() + + def get_cursor_vertical_diff(self) -> int: + return self.window.get_cursor_vertical_diff() + + def get_top_usable_line(self) -> int: + return self.window.top_usable_row + + def on_suspend(self) -> None: + self.window.__exit__(None, None, None) + self.input_generator.__exit__(None, None, None) + + def after_suspend(self) -> None: + self.input_generator.__enter__() + self.window.__enter__() + self.interrupting_refresh() + + def process_event_and_paint( + self, e: str | curtsies.events.Event | None + ) -> None: + """If None is passed in, just paint the screen""" + try: + if e is not None: + self.process_event(e) + except (SystemExitFromCodeRunner, SystemExit) as err: + array, cursor_pos = self.paint( + about_to_exit=True, + user_quit=isinstance(err, SystemExitFromCodeRunner), + ) + scrolled = self.window.render_to_terminal(array, cursor_pos) + self.scroll_offset += scrolled + raise + else: + array, cursor_pos = self.paint() + scrolled = self.window.render_to_terminal(array, cursor_pos) + self.scroll_offset += scrolled + + def mainloop( + self, + interactive: bool = True, + paste: curtsies.events.PasteEvent | None = None, + ) -> None: + if interactive: + # Add custom help command + # TODO: add methods to run the code + self.initialize_interp() + + # run startup file + self.process_event(events.RunStartupFileEvent()) + + # handle paste + if paste: + self.process_event(paste) + + # do a display before waiting for first event + self.process_event_and_paint(None) + inputs = combined_events(self.input_generator) + while self.module_gatherer.find_coroutine(): + e = inputs.send(0) + if e is not None: + self.process_event_and_paint(e) + + for e in inputs: + self.process_event_and_paint(e) + + +def main( + args: list[str] | None = None, + locals_: dict[str, Any] | None = None, + banner: str | None = None, + welcome_message: str | None = None, +) -> Any: + """ + banner is displayed directly after the version information. + welcome_message is passed on to Repl and displayed in the statusbar. + """ + translations.init() + + def curtsies_arguments(parser: argparse._ArgumentGroup) -> None: + parser.add_argument( + "--paste", + "-p", + action="store_true", + help=_("start by pasting lines of a file into session"), + ) + + config, options, exec_args = bpargs.parse( + args, + ( + _("curtsies arguments"), + _("Additional arguments specific to the curtsies-based REPL."), + curtsies_arguments, + ), + ) interp = None paste = None + exit_value: tuple[Any, ...] = () if exec_args: - assert options, "don't pass in exec_args without options" - exit_value = 0 - if options.type: + if not options: + raise ValueError("don't pass in exec_args without options") + if options.paste: paste = curtsies.events.PasteEvent() - sourcecode = open(exec_args[0]).read() + encoding = inspection.get_encoding_file(exec_args[0]) + with open(exec_args[0], encoding=encoding) as f: + sourcecode = f.read() paste.events.extend(sourcecode) else: try: - interp = code.InteractiveInterpreter(locals=locals_) + interp = Interp(locals=locals_) bpargs.exec_code(interp, exec_args) - except SystemExit, e: + except SystemExit as e: exit_value = e.args if not options.interactive: - raise SystemExit(exit_value) + return extract_exit_value(exit_value) else: - sys.path.insert(0, '') # expected for interactive sessions (vanilla python does it) - - - mainloop(config, locals_, banner, interp, paste, interactive=(not exec_args)) - -def mainloop(config, locals_, banner, interp=None, paste=None, interactive=True): - with curtsies.input.Input(keynames='curtsies', sigint_event=True) as input_generator: - with curtsies.window.CursorAwareWindow( - sys.stdout, - sys.stdin, - keep_last_line=True, - hide_cursor=False, - extra_bytes_callback=input_generator.unget_bytes) as window: - - reload_requests = [] - def request_reload(desc): - reload_requests.append(curtsies.events.ReloadEvent([desc])) - refresh_requests = [] - def request_refresh(when='now'): - refresh_requests.append(curtsies.events.RefreshRequestEvent(when=when)) - - def event_or_refresh(timeout=None): - if timeout is None: - timeout = .2 - else: - timeout = min(.2, timeout) - starttime = time.time() - while True: - t = time.time() - refresh_requests.sort(key=lambda r: 0 if r.when == 'now' else r.when) - if refresh_requests and (refresh_requests[0].when == 'now' or refresh_requests[-1].when < t): - yield refresh_requests.pop(0) - elif reload_requests: - e = reload_requests.pop() - yield e - else: - e = input_generator.send(timeout) - if starttime + timeout < time.time() or e is not None: - yield e - - global repl # global for easy introspection `from bpython.curtsies import repl` - with Repl(config=config, - locals_=locals_, - request_refresh=request_refresh, - request_reload=request_reload, - get_term_hw=window.get_term_hw, - get_cursor_vertical_diff=window.get_cursor_vertical_diff, - banner=banner, - interp=interp, - interactive=interactive, - orig_tcattrs=input_generator.original_stty) as repl: - repl.height, repl.width = window.t.height, window.t.width - - def process_event(e): - """If None is passed in, just paint the screen""" - try: - if e is not None: - repl.process_event(e) - except (SystemExitFromCodeGreenlet, SystemExit) as err: - array, cursor_pos = repl.paint(about_to_exit=True, user_quit=isinstance(err, SystemExitFromCodeGreenlet)) - scrolled = window.render_to_terminal(array, cursor_pos) - repl.scroll_offset += scrolled - raise - else: - array, cursor_pos = repl.paint() - scrolled = window.render_to_terminal(array, cursor_pos) - repl.scroll_offset += scrolled - - if paste: - process_event(paste) - - process_event(None) #priming the pump (do a display before waiting for first event) - for _, e in izip(find_iterator, event_or_refresh(0)): - if e is not None: - process_event(e) - for e in event_or_refresh(): - process_event(e) - -if __name__ == '__main__': + # expected for interactive sessions (vanilla python does it) + sys.path.insert(0, "") + + if not options.quiet: + print(bpargs.version_banner()) + if banner is not None: + print(banner) + if welcome_message is None and not options.quiet and config.help_key: + welcome_message = ( + _("Welcome to bpython!") + + " " + + _("Press <%s> for help.") % config.help_key + ) + + repl = FullCurtsiesRepl(config, locals_, welcome_message, interp) + try: + with repl.input_generator: + with repl.window as win: + with repl: + repl.height, repl.width = win.t.height, win.t.width + repl.mainloop(True, paste) + except (SystemExitFromCodeRunner, SystemExit) as e: + exit_value = e.args + return extract_exit_value(exit_value) + + +def _combined_events( + event_provider: SupportsEventGeneration, paste_threshold: int +) -> Generator[str | curtsies.events.Event | None, float | None, None]: + """Combines consecutive keypress events into paste events.""" + timeout = yield "nonsense_event" # so send can be used immediately + queue: collections.deque = collections.deque() + while True: + e = event_provider.send(timeout) + if isinstance(e, curtsies.events.Event): + timeout = yield e + continue + elif e is None: + timeout = yield None + continue + else: + queue.append(e) + e = event_provider.send(0) + while not (e is None or isinstance(e, curtsies.events.Event)): + queue.append(e) + e = event_provider.send(0) + if len(queue) >= paste_threshold: + paste = curtsies.events.PasteEvent() + paste.events.extend(queue) + queue.clear() + timeout = yield paste + else: + while len(queue): + timeout = yield queue.popleft() + + +def combined_events( + event_provider: SupportsEventGeneration, paste_threshold: int = 3 +) -> SupportsEventGeneration: + g = _combined_events(event_provider, paste_threshold) + next(g) + return g + + +if __name__ == "__main__": sys.exit(main()) diff --git a/bpython/curtsiesfrontend/__init__.py b/bpython/curtsiesfrontend/__init__.py index 8b1378917..e69de29bb 100644 --- a/bpython/curtsiesfrontend/__init__.py +++ b/bpython/curtsiesfrontend/__init__.py @@ -1 +0,0 @@ - diff --git a/bpython/curtsiesfrontend/_internal.py b/bpython/curtsiesfrontend/_internal.py new file mode 100644 index 000000000..72572b0b1 --- /dev/null +++ b/bpython/curtsiesfrontend/_internal.py @@ -0,0 +1,66 @@ +# The MIT License +# +# Copyright (c) 2015 the bpython authors. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import pydoc +from types import TracebackType +from typing import Literal + +from .. import _internal + + +class NopPydocPager: + def __enter__(self): + self._orig_pager = pydoc.pager + pydoc.pager = self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> Literal[False]: + pydoc.pager = self._orig_pager + return False + + def __call__(self, text): + return None + + +class _Helper(_internal._Helper): + def __init__(self, repl=None): + self._repl = repl + pydoc.pager = self.pager + + super().__init__() + + def pager(self, output, title=""): + self._repl.pager(output, title) + + def __call__(self, *args, **kwargs): + if self._repl.reevaluating: + with NopPydocPager(): + return super().__call__(*args, **kwargs) + else: + return super().__call__(*args, **kwargs) + + +# vim: sw=4 ts=4 sts=4 ai et diff --git a/bpython/curtsiesfrontend/beeper.py b/bpython/curtsiesfrontend/beeper.py deleted file mode 100644 index 287013c09..000000000 --- a/bpython/curtsiesfrontend/beeper.py +++ /dev/null @@ -1,9 +0,0 @@ -import time -import sys - -if __name__ == '__main__': - while True: - sys.stdout.write('beep\n') - sys.stdout.flush() - time.sleep(5) - diff --git a/bpython/curtsiesfrontend/coderunner.py b/bpython/curtsiesfrontend/coderunner.py index d2706cecc..f059fab88 100644 --- a/bpython/curtsiesfrontend/coderunner.py +++ b/bpython/curtsiesfrontend/coderunner.py @@ -1,58 +1,72 @@ -"""For running Python code that could interrupt itself at any time -in order to, for example, ask for a read on stdin, or a write on stdout +"""For running Python code that could interrupt itself at any time in order to, +for example, ask for a read on stdin, or a write on stdout -The CodeRunner spawns a greenlet to run code in, and that code can suspend -its own execution to ask the main greenlet to refresh the display or get information. +The CodeRunner spawns a greenlet to run code in, and that code can suspend its +own execution to ask the main greenlet to refresh the display or get +information. -Greenlets are basically threads that can explicitly switch control to each other. -You can replace the word "greenlet" with "thread" in these docs if that makes more -sense to you. +Greenlets are basically threads that can explicitly switch control to each +other. You can replace the word "greenlet" with "thread" in these docs if that +makes more sense to you. """ import code -import signal -import sys import greenlet import logging +import signal + +from curtsies.input import is_main_thread logger = logging.getLogger(__name__) -class SigintHappened(object): + +class SigintHappened: """If this class is returned, a SIGINT happened while the main greenlet""" -class SystemExitFromCodeGreenlet(SystemExit): - """If this class is returned, a SystemExit happened while in the code greenlet""" +class SystemExitFromCodeRunner(SystemExit): + """If this class is returned, a SystemExit happened while in the code + greenlet""" -class RequestFromCodeGreenlet(object): - """Message from the code greenlet""" -class Wait(RequestFromCodeGreenlet): +class RequestFromCodeRunner: + """Message from the code runner""" + + +class Wait(RequestFromCodeRunner): """Running code would like the main loop to run for a bit""" -class Refresh(RequestFromCodeGreenlet): + +class Refresh(RequestFromCodeRunner): """Running code would like the main loop to refresh the display""" -class Done(RequestFromCodeGreenlet): + +class Done(RequestFromCodeRunner): """Running code is done running""" -class Unfinished(RequestFromCodeGreenlet): + +class Unfinished(RequestFromCodeRunner): """Source code wasn't executed because it wasn't fully formed""" -class SystemExitRequest(RequestFromCodeGreenlet): + +class SystemExitRequest(RequestFromCodeRunner): """Running code raised a SystemExit""" -class CodeRunner(object): + def __init__(self, *args): + self.args = args + + +class CodeRunner: """Runs user code in an interpreter. Running code requests a refresh by calling - request_from_main_greenlet(force_refresh=True), which + request_from_main_context(force_refresh=True), which suspends execution of the code and switches back to the main greenlet After load_code() is called with the source code to be run, the run_code() method should be called to start running the code. The running code may request screen refreshes and user input - by calling request_from_main_greenlet. + by calling request_from_main_context. When this are called, the running source code cedes control, and the current run_code() method call returns. @@ -68,38 +82,44 @@ class CodeRunner(object): just passes whatever is passed in to run_code(for_code) to the code greenlet """ - def __init__(self, interp=None, stuff_a_refresh_request=lambda:None): + + def __init__(self, interp=None, request_refresh=lambda: None): """ interp is an interpreter object to use. By default a new one is created. - stuff_a_refresh_request is a function that will be called each time - the running code asks for a refresh - to, for example, update the screen. + request_refresh is a function that will be called each time the running + code asks for a refresh - to, for example, update the screen. """ self.interp = interp or code.InteractiveInterpreter() self.source = None - self.main_greenlet = greenlet.getcurrent() - self.code_greenlet = None - self.stuff_a_refresh_request = stuff_a_refresh_request - self.code_is_waiting = False # waiting for response from main thread - self.sigint_happened_in_main_greenlet = False # sigint happened while in main thread + self.main_context = greenlet.getcurrent() + self.code_context = None + self.request_refresh = request_refresh + # waiting for response from main thread + self.code_is_waiting = False + # sigint happened while in main thread + self.sigint_happened_in_main_context = False self.orig_sigint_handler = None @property def running(self): - """Returns greenlet if code has been loaded greenlet has been started""" - return self.source and self.code_greenlet + """Returns greenlet if code has been loaded greenlet has been + started""" + return self.source and self.code_context def load_code(self, source): """Prep code to be run""" - assert self.source is None, "you shouldn't load code when some is already running" + assert self.source is None, ( + "you shouldn't load code when some is " "already running" + ) self.source = source - self.code_greenlet = None + self.code_context = None def _unload_code(self): """Called when done running code""" self.source = None - self.code_greenlet = None + self.code_context = None self.code_is_waiting = False def run_code(self, for_code=None): @@ -109,104 +129,111 @@ def run_code(self, for_code=None): if source code is complete, returns "done" if source code is incomplete, returns "unfinished" """ - if self.code_greenlet is None: + if self.code_context is None: assert self.source is not None - self.code_greenlet = greenlet.greenlet(self._blocking_run_code) - self.orig_sigint_handler = signal.getsignal(signal.SIGINT) - signal.signal(signal.SIGINT, self.sigint_handler) - request = self.code_greenlet.switch() + self.code_context = greenlet.greenlet(self._blocking_run_code) + if is_main_thread(): + self.orig_sigint_handler = signal.getsignal(signal.SIGINT) + signal.signal(signal.SIGINT, self.sigint_handler) + request = self.code_context.switch() else: assert self.code_is_waiting self.code_is_waiting = False - signal.signal(signal.SIGINT, self.sigint_handler) - if self.sigint_happened_in_main_greenlet: - self.sigint_happened_in_main_greenlet = False - request = self.code_greenlet.switch(SigintHappened) + if is_main_thread(): + signal.signal(signal.SIGINT, self.sigint_handler) + if self.sigint_happened_in_main_context: + self.sigint_happened_in_main_context = False + request = self.code_context.switch(SigintHappened) else: - request = self.code_greenlet.switch(for_code) - - if not issubclass(request, RequestFromCodeGreenlet): - raise ValueError("Not a valid value from code greenlet: %r" % request) - if request in [Wait, Refresh]: + request = self.code_context.switch(for_code) + + logger.debug("request received from code was %r", request) + if not isinstance(request, RequestFromCodeRunner): + raise ValueError( + "Not a valid value from code greenlet: %r" % request + ) + if isinstance(request, (Wait, Refresh)): self.code_is_waiting = True - if request == Refresh: - self.stuff_a_refresh_request() + if isinstance(request, Refresh): + self.request_refresh() return False - elif request in [Done, Unfinished]: + elif isinstance(request, (Done, Unfinished)): self._unload_code() - signal.signal(signal.SIGINT, self.orig_sigint_handler) + if is_main_thread(): + signal.signal(signal.SIGINT, self.orig_sigint_handler) self.orig_sigint_handler = None return request - elif request in [SystemExitRequest]: + elif isinstance(request, SystemExitRequest): self._unload_code() - raise SystemExitFromCodeGreenlet() + raise SystemExitFromCodeRunner(request.args) def sigint_handler(self, *args): - """SIGINT handler to use while code is running or request being fufilled""" - if greenlet.getcurrent() is self.code_greenlet: - logger.debug('sigint while running user code!') + """SIGINT handler to use while code is running or request being + fulfilled""" + if greenlet.getcurrent() is self.code_context: + logger.debug("sigint while running user code!") raise KeyboardInterrupt() else: - logger.debug('sigint while fufilling code request sigint handler running!') - self.sigint_happened_in_main_greenlet = True + logger.debug( + "sigint while fulfilling code request sigint handler " + "running!" + ) + self.sigint_happened_in_main_context = True def _blocking_run_code(self): try: unfinished = self.interp.runsource(self.source) - except SystemExit: - return SystemExitRequest - return Unfinished if unfinished else Done + except SystemExit as e: + return SystemExitRequest(*e.args) + return Unfinished() if unfinished else Done() - def request_from_main_greenlet(self, force_refresh=False): + def request_from_main_context(self, force_refresh=False): """Return the argument passed in to .run_code(for_code) Nothing means calls to run_code must be... ??? """ if force_refresh: - value = self.main_greenlet.switch(Refresh) + value = self.main_context.switch(Refresh()) else: - value = self.main_greenlet.switch(Wait) + value = self.main_context.switch(Wait()) if value is SigintHappened: raise KeyboardInterrupt() return value -class FakeOutput(object): - def __init__(self, coderunner, on_write): + +class FakeOutput: + def __init__(self, coderunner, on_write, real_fileobj): + """Fakes sys.stdout or sys.stderr + + on_write should always take unicode + + fileno should be the fileno that on_write will + output to (e.g. 1 for standard output). + """ self.coderunner = coderunner self.on_write = on_write - def write(self, *args, **kwargs): - self.on_write(*args, **kwargs) - return self.coderunner.request_from_main_greenlet(force_refresh=True) + self._real_fileobj = real_fileobj + + def write(self, s, *args, **kwargs): + self.on_write(s, *args, **kwargs) + return self.coderunner.request_from_main_context(force_refresh=True) + + # Some applications which use curses require that sys.stdout + # have a method called fileno. One example is pwntools. This + # is not a widespread issue, but is annoying. + def fileno(self): + return self._real_fileobj.fileno() + def writelines(self, l): for s in l: self.write(s) + def flush(self): pass + def isatty(self): return True -def test_simple(): - orig_stdout = sys.stdout - orig_stderr = sys.stderr - c = CodeRunner(stuff_a_refresh_request=lambda: orig_stdout.flush() or orig_stderr.flush()) - stdout = FakeOutput(c, orig_stdout.write) - sys.stdout = stdout - c.load_code('1 + 1') - c.run_code() - c.run_code() - c.run_code() - -def test_exception(): - orig_stdout = sys.stdout - orig_stderr = sys.stderr - c = CodeRunner(stuff_a_refresh_request=lambda: orig_stdout.flush() or orig_stderr.flush()) - def ctrlc(): - raise KeyboardInterrupt() - stdout = FakeOutput(c, lambda x: ctrlc()) - sys.stdout = stdout - c.load_code('1 + 1') - c.run_code() - -if __name__ == '__main__': - test_simple() - + @property + def encoding(self): + return self._real_fileobj.encoding diff --git a/bpython/curtsiesfrontend/events.py b/bpython/curtsiesfrontend/events.py new file mode 100644 index 000000000..4f9c13e55 --- /dev/null +++ b/bpython/curtsiesfrontend/events.py @@ -0,0 +1,49 @@ +"""Non-keyboard events used in bpython curtsies REPL""" + +import time +from collections.abc import Sequence + +import curtsies.events + + +class ReloadEvent(curtsies.events.Event): + """Request to rerun REPL session ASAP because imported modules changed""" + + def __init__(self, files_modified: Sequence[str] = ("?",)) -> None: + self.files_modified = files_modified + + def __repr__(self) -> str: + return "".format(" & ".join(self.files_modified)) + + +class RefreshRequestEvent(curtsies.events.Event): + """Request to refresh REPL display ASAP""" + + def __repr__(self) -> str: + return "" + + +class ScheduledRefreshRequestEvent(curtsies.events.ScheduledEvent): + """Request to refresh the REPL display at some point in the future + + Used to schedule the disappearance of status bar message that only shows + for a few seconds""" + + def __init__(self, when: float) -> None: + super().__init__(when) + + def __repr__(self) -> str: + return "".format( + self.when - time.time() + ) + + +class RunStartupFileEvent(curtsies.events.Event): + """Request to run the startup file.""" + + +class UndoEvent(curtsies.events.Event): + """Request to undo.""" + + def __init__(self, n: int = 1) -> None: + self.n = n diff --git a/bpython/curtsiesfrontend/filewatch.py b/bpython/curtsiesfrontend/filewatch.py index 73930730e..b9778c97a 100644 --- a/bpython/curtsiesfrontend/filewatch.py +++ b/bpython/curtsiesfrontend/filewatch.py @@ -1,77 +1,87 @@ -import time import os from collections import defaultdict +from collections.abc import Callable, Iterable, Sequence -from bpython import importcompletion +from .. import importcompletion try: from watchdog.observers import Observer - from watchdog.events import FileSystemEventHandler + from watchdog.events import FileSystemEventHandler, FileSystemEvent except ImportError: + def ModuleChangedEventHandler(*args): return None + else: - class ModuleChangedEventHandler(FileSystemEventHandler): - def __init__(self, paths, on_change): - self.dirs = defaultdict(set) + + class ModuleChangedEventHandler(FileSystemEventHandler): # type: ignore [no-redef] + def __init__( + self, + paths: Iterable[str], + on_change: Callable[[Sequence[str]], None], + ) -> None: + self.dirs: dict[str, set[str]] = defaultdict(set) self.on_change = on_change - self.modules_to_add_later = [] + self.modules_to_add_later: list[str] = [] self.observer = Observer() - self.old_dirs = defaultdict(set) self.started = False + self.activated = False for path in paths: - self.add_module(path) + self._add_module(path) + + super().__init__() - def reset(self): - self.dirs = defaultdict(set) - del self.modules_to_add_later[:] - self.old_dirs = defaultdict(set) + def reset(self) -> None: + self.dirs.clear() + self.modules_to_add_later.clear() self.observer.unschedule_all() - def add_module(self, path): - """Add a python module to track changes to""" + def _add_module(self, path: str) -> None: + """Add a python module to track changes""" path = os.path.abspath(path) for suff in importcompletion.SUFFIXES: if path.endswith(suff): - path = path[:-len(suff)] + path = path[: -len(suff)] break dirname = os.path.dirname(path) if dirname not in self.dirs: self.observer.schedule(self, dirname, recursive=False) - self.dirs[os.path.dirname(path)].add(path) + self.dirs[dirname].add(path) - def add_module_later(self, path): + def _add_module_later(self, path: str) -> None: self.modules_to_add_later.append(path) - def activate(self): + def track_module(self, path: str) -> None: + """ + Begins tracking this if activated, or remembers to track later. + """ + if self.activated: + self._add_module(path) + else: + self._add_module_later(path) + + def activate(self) -> None: + if self.activated: + raise ValueError(f"{self!r} is already activated.") if not self.started: self.started = True self.observer.start() - self.dirs = self.old_dirs for dirname in self.dirs: self.observer.schedule(self, dirname, recursive=False) for module in self.modules_to_add_later: - self.add_module(module) - del self.modules_to_add_later[:] + self._add_module(module) + self.modules_to_add_later.clear() + self.activated = True - def deactivate(self): + def deactivate(self) -> None: + if not self.activated: + raise ValueError(f"{self!r} is not activated.") self.observer.unschedule_all() - self.old_dirs = self.dirs - self.dirs = defaultdict(set) + self.activated = False - def on_any_event(self, event): + def on_any_event(self, event: FileSystemEvent) -> None: dirpath = os.path.dirname(event.src_path) - paths = [path + '.py' for path in self.dirs[dirpath]] - if event.src_path in paths: - self.on_change(event.src_path) - -if __name__ == '__main__': - m = ModuleChangedEventHandler([]) - m.add_module('./wdtest.py') - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - m.observer.stop() - m.observer.join() - + if any( + event.src_path == f"{path}.py" for path in self.dirs[dirpath] + ): + self.on_change((event.src_path,)) diff --git a/bpython/curtsiesfrontend/interaction.py b/bpython/curtsiesfrontend/interaction.py index e67667569..17b178de6 100644 --- a/bpython/curtsiesfrontend/interaction.py +++ b/bpython/curtsiesfrontend/interaction.py @@ -1,109 +1,135 @@ import greenlet import time -import curtsies.events as events +from curtsies import events -from bpython.repl import Interaction as BpythonInteraction +from ..translations import _ +from ..repl import Interaction +from ..curtsiesfrontend.events import RefreshRequestEvent +from ..curtsiesfrontend.manual_readline import edit_keys -from bpython.curtsiesfrontend.manual_readline import char_sequences as rl_char_sequences -class StatusBar(BpythonInteraction): +class StatusBar(Interaction): """StatusBar and Interaction for Repl Passing of control back and forth between calls that use interact api - (notify, confirm, file_prompt) like bpython.Repl.write2file and events - on the main thread happens via those calls and self.wait_for_request_or_notify. + (notify, confirm, file_prompt) like bpython.Repl.write2file and events on + the main thread happens via those calls and + self.wait_for_request_or_notify. - Calling one of these three is required for the main thread to regain control! + Calling one of these three is required for the main thread to regain + control! This is probably a terrible idea, and better would be rewriting this functionality in a evented or callback style, but trying to integrate bpython.Repl code. """ - def __init__(self, permanent_text="", refresh_request=lambda: None): - self._current_line = '' + + def __init__( + self, + config, + permanent_text="", + request_refresh=lambda: None, + schedule_refresh=lambda when: None, + ): + self._current_line = "" self.cursor_offset_in_line = 0 self.in_prompt = False self.in_confirm = False self.waiting_for_refresh = False - self.prompt = '' - self._message = '' + self.prompt = "" + self._message = "" self.message_start_time = time.time() - self.message_time = 3 + self.message_time = 3.0 self.permanent_stack = [] if permanent_text: self.permanent_stack.append(permanent_text) - self.main_greenlet = greenlet.getcurrent() - self.request_greenlet = None - self.refresh_request = refresh_request + self.main_context = greenlet.getcurrent() + self.request_context = None + self.request_refresh = request_refresh + self.schedule_refresh = schedule_refresh + + super().__init__(config) def push_permanent_message(self, msg): - self._message = '' + self._message = "" self.permanent_stack.append(msg) def pop_permanent_message(self, msg): if msg in self.permanent_stack: self.permanent_stack.remove(msg) else: - raise ValueError("Messsage %r was not in permanent_stack" % msg) + raise ValueError("Message %r was not in permanent_stack" % msg) @property def has_focus(self): return self.in_prompt or self.in_confirm or self.waiting_for_refresh - def message(self, msg): + def message(self, msg, schedule_refresh=True): + """Sets a temporary message""" self.message_start_time = time.time() self._message = msg - self.refresh_request(time.time() + self.message_time) + if schedule_refresh: + self.schedule_refresh(time.time() + self.message_time) def _check_for_expired_message(self): - if self._message and time.time() > self.message_start_time + self.message_time: - self._message = '' + if ( + self._message + and time.time() > self.message_start_time + self.message_time + ): + self._message = "" - def process_event(self, e): + def process_event(self, e) -> None: """Returns True if shutting down""" assert self.in_prompt or self.in_confirm or self.waiting_for_refresh - if isinstance(e, events.RefreshRequestEvent): + if isinstance(e, RefreshRequestEvent): self.waiting_for_refresh = False - self.request_greenlet.switch() + self.request_context.switch() elif isinstance(e, events.PasteEvent): for ee in e.events: - self.add_normal_character(ee if len(ee) == 1 else ee[-1]) #strip control seq - elif e in rl_char_sequences: - self.cursor_offset_in_line, self._current_line = rl_char_sequences[e](self.cursor_offset_in_line, self._current_line) - elif e == "": + # strip control seq + self.add_normal_character(ee if len(ee) == 1 else ee[-1]) + elif e == "" or isinstance(e, events.SigIntEvent): + self.request_context.switch(False) + self.escape() + elif e in edit_keys: + self.cursor_offset_in_line, self._current_line = edit_keys[e]( + self.cursor_offset_in_line, self._current_line + ) + elif e == "": # TODO can this be removed? raise KeyboardInterrupt() - elif e == "": + elif e == "": # TODO this isn't a very intuitive behavior raise SystemExit() elif self.in_prompt and e in ("\n", "\r", "", "Ctrl-m>"): line = self._current_line self.escape() - self.request_greenlet.switch(line) + self.request_context.switch(line) elif self.in_confirm: - if e in ('y', 'Y'): - self.request_greenlet.switch(True) + if e.lower() == _("y"): + self.request_context.switch(True) else: - self.request_greenlet.switch(False) + self.request_context.switch(False) self.escape() - elif e in ['']: - self.request_greenlet.switch(False) - self.escape() - else: # add normal character + else: # add normal character self.add_normal_character(e) def add_normal_character(self, e): - if e == '': e = ' ' - if len(e) > 1: return - self._current_line = (self._current_line[:self.cursor_offset_in_line] + - e + - self._current_line[self.cursor_offset_in_line:]) + if e == "": + e = " " + if len(e) > 1: + return + self._current_line = ( + self._current_line[: self.cursor_offset_in_line] + + e + + self._current_line[self.cursor_offset_in_line :] + ) self.cursor_offset_in_line += 1 def escape(self): """unfocus from statusbar, clear prompt state, wait for notify call""" self.in_prompt = False self.in_confirm = False - self.prompt = '' - self._current_line = '' + self.prompt = "" + self._current_line = "" @property def current_line(self): @@ -116,32 +142,33 @@ def current_line(self): return self._message if self.permanent_stack: return self.permanent_stack[-1] - return '' + return "" @property def should_show_message(self): return bool(self.current_line) # interaction interface - should be called from other greenlets - def notify(self, msg, n=3): - self.request_greenlet = greenlet.getcurrent() + def notify(self, msg, n=3.0, wait_for_keypress=False): + self.request_context = greenlet.getcurrent() self.message_time = n - self.message(msg) + self.message(msg, schedule_refresh=wait_for_keypress) self.waiting_for_refresh = True - self.refresh_request() - self.main_greenlet.switch(msg) + self.request_refresh() + self.main_context.switch(msg) - # below Really ought to be called from greenlets other than main because they block + # below really ought to be called from greenlets other than main because + # they block def confirm(self, q): """Expected to return True or False, given question prompt q""" - self.request_greenlet = greenlet.getcurrent() + self.request_context = greenlet.getcurrent() self.prompt = q self.in_confirm = True - return self.main_greenlet.switch(q) + return self.main_context.switch(q) + def file_prompt(self, s): - """Expected to return a file name, given """ - self.request_greenlet = greenlet.getcurrent() + """Expected to return a file name, given""" + self.request_context = greenlet.getcurrent() self.prompt = s self.in_prompt = True - result = self.main_greenlet.switch(s) - return result + return self.main_context.switch(s) diff --git a/bpython/curtsiesfrontend/interpreter.py b/bpython/curtsiesfrontend/interpreter.py new file mode 100644 index 000000000..9382db6bc --- /dev/null +++ b/bpython/curtsiesfrontend/interpreter.py @@ -0,0 +1,136 @@ +import sys +from codeop import CommandCompiler +from typing import Any +from collections.abc import Iterable + +from pygments.token import Generic, Token, Keyword, Name, Comment, String +from pygments.token import Error, Literal, Number, Operator, Punctuation +from pygments.token import Whitespace, _TokenType +from pygments.formatter import Formatter +from pygments.lexers import get_lexer_by_name +from curtsies.formatstring import FmtStr + +from ..curtsiesfrontend.parse import parse +from ..repl import Interpreter as ReplInterpreter + + +default_colors = { + Generic.Error: "R", + Keyword: "d", + Name: "c", + Name.Builtin: "g", + Comment: "b", + String: "m", + Error: "r", + Literal: "d", + Number: "M", + Number.Integer: "d", + Operator: "d", + Punctuation: "d", + Token: "d", + Whitespace: "d", + Token.Punctuation.Parenthesis: "R", + Name.Function: "d", + Name.Class: "d", +} + + +class BPythonFormatter(Formatter): + """This is subclassed from the custom formatter for bpython. Its format() + method receives the tokensource and outfile params passed to it from the + Pygments highlight() method and slops them into the appropriate format + string as defined above, then writes to the outfile object the final + formatted string. This does not write real strings. It writes format string + (FmtStr) objects. + + See the Pygments source for more info; it's pretty + straightforward.""" + + def __init__( + self, + color_scheme: dict[_TokenType, str], + **options: str | bool | None, + ) -> None: + self.f_strings = {k: f"\x01{v}" for k, v in color_scheme.items()} + # FIXME: mypy currently fails to handle this properly + super().__init__(**options) # type: ignore + + def format(self, tokensource, outfile): + o = "" + + for token, text in tokensource: + while token not in self.f_strings: + token = token.parent + o += f"{self.f_strings[token]}\x03{text}\x04" + outfile.write(parse(o.rstrip())) + + +class Interp(ReplInterpreter): + def __init__( + self, + locals: dict[str, Any] | None = None, + ) -> None: + """Constructor. + + We include an argument for the outfile to pass to the formatter for it + to write to. + """ + super().__init__(locals) + + # typically changed after being instantiated + # but used when interpreter used corresponding REPL + def write(err_line: str | FmtStr) -> None: + """Default stderr handler for tracebacks + + Accepts FmtStrs so interpreters can output them""" + sys.stderr.write(str(err_line)) + + self.write = write # type: ignore + self.outfile = self + + def writetb(self, lines: Iterable[str]) -> None: + tbtext = "".join(lines) + lexer = get_lexer_by_name("pytb") + self.format(tbtext, lexer) + # TODO for tracebacks get_lexer_by_name("pytb", stripall=True) + + def format(self, tbtext: str, lexer: Any) -> None: + # FIXME: lexer should be "Lexer" + traceback_informative_formatter = BPythonFormatter(default_colors) + traceback_code_formatter = BPythonFormatter({Token: "d"}) + + no_format_mode = False + cur_line = [] + for token, text in lexer.get_tokens(tbtext): + if text.endswith("\n"): + cur_line.append((token, text)) + if no_format_mode: + traceback_code_formatter.format(cur_line, self.outfile) + no_format_mode = False + else: + traceback_informative_formatter.format( + cur_line, self.outfile + ) + cur_line = [] + elif text == " " and len(cur_line) == 0: + no_format_mode = True + cur_line.append((token, text)) + else: + cur_line.append((token, text)) + assert cur_line == [], cur_line + + +def code_finished_will_parse( + s: str, compiler: CommandCompiler +) -> tuple[bool, bool]: + """Returns a tuple of whether the buffer could be complete and whether it + will parse + + True, True means code block is finished and no predicted parse error + True, False means code block is finished because a parse error is predicted + False, True means code block is unfinished + False, False isn't possible - an predicted error makes code block done""" + try: + return bool(compiler(s)), True + except (ValueError, SyntaxError, OverflowError): + return True, False diff --git a/bpython/curtsiesfrontend/manual_readline.py b/bpython/curtsiesfrontend/manual_readline.py index 50ab4f6e4..3d02c024a 100644 --- a/bpython/curtsiesfrontend/manual_readline.py +++ b/bpython/curtsiesfrontend/manual_readline.py @@ -1,147 +1,372 @@ -"""implementations of simple readline control sequences +"""implementations of simple readline edit operations just the ones that fit the model of transforming the current line and the cursor location -in the order of description at http://www.bigsmoke.us/readline/shortcuts""" +based on http://www.bigsmoke.us/readline/shortcuts""" -import re -char_sequences = {} +import inspect + +from ..lazyre import LazyReCompile +from ..line import cursor_on_closing_char_pair INDENT = 4 -#TODO Allow user config of keybindings for these actions +# TODO Allow user config of keybindings for these actions +getargspec = lambda func: inspect.signature(func).parameters + + +class AbstractEdits: + default_kwargs = { + "line": "hello world", + "cursor_offset": 5, + "cut_buffer": "there", + } + + def __init__(self, simple_edits=None, cut_buffer_edits=None): + self.simple_edits = {} if simple_edits is None else simple_edits + self.cut_buffer_edits = ( + {} if cut_buffer_edits is None else cut_buffer_edits + ) + self.awaiting_config = {} + + def add(self, key, func, overwrite=False): + if key in self: + if overwrite: + del self[key] + else: + raise ValueError(f"key {key!r} already has a mapping") + params = getargspec(func) + args = {k: v for k, v in self.default_kwargs.items() if k in params} + r = func(**args) + if len(r) == 2: + if hasattr(func, "kills"): + raise ValueError( + "function %r returns two values, but has a " + "kills attribute" % (func,) + ) + self.simple_edits[key] = func + elif len(r) == 3: + if not hasattr(func, "kills"): + raise ValueError( + "function %r returns three values, but has " + "no kills attribute" % (func,) + ) + self.cut_buffer_edits[key] = func + else: + raise ValueError(f"return type of function {func!r} not recognized") + + def add_config_attr(self, config_attr, func): + if config_attr in self.awaiting_config: + raise ValueError( + f"config attribute {config_attr!r} already has a mapping" + ) + self.awaiting_config[config_attr] = func + + def call(self, key, **kwargs): + func = self[key] + params = getargspec(func) + args = {k: v for k, v in kwargs.items() if k in params} + return func(**args) + + def __contains__(self, key): + return key in self.simple_edits or key in self.cut_buffer_edits + + def __getitem__(self, key): + if key in self.simple_edits: + return self.simple_edits[key] + if key in self.cut_buffer_edits: + return self.cut_buffer_edits[key] + raise KeyError(f"key {key!r} not mapped") + + def __delitem__(self, key): + if key in self.simple_edits: + del self.simple_edits[key] + elif key in self.cut_buffer_edits: + del self.cut_buffer_edits[key] + else: + raise KeyError(f"key {key!r} not mapped") + + +class UnconfiguredEdits(AbstractEdits): + """Maps key to edit functions, and bins them by what parameters they take. + + Only functions with specific signatures can be added: + * func(**kwargs) -> cursor_offset, line + * func(**kwargs) -> cursor_offset, line, cut_buffer + where kwargs are in among the keys of Edits.default_kwargs + These functions will be run to determine their return type, so no side + effects! + + More concrete Edits instances can be created by applying a config with + Edits.mapping_with_config() - this creates a new Edits instance + that uses a config file to assign config_attr bindings. + + Keys can't be added twice, config attributes can't be added twice. + """ + + def mapping_with_config(self, config, key_dispatch): + """Creates a new mapping object by applying a config object""" + return ConfiguredEdits( + self.simple_edits, + self.cut_buffer_edits, + self.awaiting_config, + config, + key_dispatch, + ) + + def on(self, key=None, config=None): + if not ((key is None) ^ (config is None)): + raise ValueError("Must use exactly one of key, config") + if key is not None: + + def add_to_keybinds(func): + self.add(key, func) + return func -def on(seq): - def add_to_char_sequences(func): - char_sequences[seq] = func - return func - return add_to_char_sequences + return add_to_keybinds + else: -@on('') -@on('') + def add_to_config(func): + self.add_config_attr(config, func) + return func + + return add_to_config + + +class ConfiguredEdits(AbstractEdits): + def __init__( + self, + simple_edits, + cut_buffer_edits, + awaiting_config, + config, + key_dispatch, + ): + super().__init__(dict(simple_edits), dict(cut_buffer_edits)) + for attr, func in awaiting_config.items(): + for key in key_dispatch[getattr(config, attr)]: + super().add(key, func, overwrite=True) + + def add_config_attr(self, config_attr, func): + raise NotImplementedError("Config already set on this mapping") + + def add(self, key, func, overwrite=False): + raise NotImplementedError("Config already set on this mapping") + + +edit_keys = UnconfiguredEdits() + +# Because the edits.on decorator runs the functions, functions which depend +# on other functions must be declared after their dependencies + + +def kills_behind(func): + func.kills = "behind" + return func + + +def kills_ahead(func): + func.kills = "ahead" + return func + + +@edit_keys.on(config="left_key") +@edit_keys.on("") def left_arrow(cursor_offset, line): return max(0, cursor_offset - 1), line -@on('') -@on('') + +@edit_keys.on(config="right_key") +@edit_keys.on("") def right_arrow(cursor_offset, line): return min(len(line), cursor_offset + 1), line -@on('') -@on('') + +@edit_keys.on(config="beginning_of_line_key") +@edit_keys.on("") def beginning_of_line(cursor_offset, line): return 0, line -@on('') -@on('') + +@edit_keys.on(config="end_of_line_key") +@edit_keys.on("") def end_of_line(cursor_offset, line): return len(line), line -@on('') -@on('') -@on('') + +forward_word_re = LazyReCompile(r"\S\s") + + +@edit_keys.on("") +@edit_keys.on("") +@edit_keys.on("") def forward_word(cursor_offset, line): - patt = r"\S\s" - match = re.search(patt, line[cursor_offset:]+' ') + match = forward_word_re.search(line[cursor_offset:] + " ") delta = match.end() - 1 if match else 0 return (cursor_offset + delta, line) -@on('') -@on('') -@on('') -def back_word(cursor_offset, line): - return (last_word_pos(line[:cursor_offset]), line) def last_word_pos(string): """returns the start index of the last word of given string""" - patt = r'\S\s' - match = re.search(patt, string[::-1]) + match = forward_word_re.search(string[::-1]) index = match and len(string) - match.end() + 1 return index or 0 -@on('') + +@edit_keys.on("") +@edit_keys.on("") +@edit_keys.on("") +def back_word(cursor_offset, line): + return (last_word_pos(line[:cursor_offset]), line) + + +@edit_keys.on("") def delete(cursor_offset, line): - return (cursor_offset, - line[:cursor_offset] + line[cursor_offset+1:]) + return (cursor_offset, line[:cursor_offset] + line[cursor_offset + 1 :]) -@on('') -@on('') + +@edit_keys.on("") +@edit_keys.on(config="backspace_key") def backspace(cursor_offset, line): if cursor_offset == 0: return cursor_offset, line - if not line[:cursor_offset].strip(): #if just whitespace left of cursor - #front_white = len(line[:cursor_offset]) - len(line[:cursor_offset].lstrip()) + if not line[:cursor_offset].strip(): # if just whitespace left of cursor + # front_white = len(line[:cursor_offset]) - \ + # len(line[:cursor_offset].lstrip()) to_delete = ((cursor_offset - 1) % INDENT) + 1 - return cursor_offset - to_delete, line[:cursor_offset - to_delete] + line[cursor_offset:] - return (cursor_offset - 1, - line[:cursor_offset - 1] + line[cursor_offset:]) + return ( + cursor_offset - to_delete, + line[: cursor_offset - to_delete] + line[cursor_offset:], + ) + # removes opening bracket along with closing bracket + # if there is nothing between them + # TODO: could not get config value here, works even without -B option + on_closing_char, pair_close = cursor_on_closing_char_pair( + cursor_offset, line + ) + if on_closing_char and pair_close: + return ( + cursor_offset - 1, + line[: cursor_offset - 1] + line[cursor_offset + 1 :], + ) + + return (cursor_offset - 1, line[: cursor_offset - 1] + line[cursor_offset:]) + -@on('') +@edit_keys.on(config="clear_line_key") def delete_from_cursor_back(cursor_offset, line): return 0, line[cursor_offset:] -@on('') -def delete_from_cursor_forward(cursor_offset, line): - return cursor_offset, line[:cursor_offset] -@on('') # option-d +delete_rest_of_word_re = LazyReCompile(r"\w\b") + + +@edit_keys.on("") # option-d +@kills_ahead def delete_rest_of_word(cursor_offset, line): - m = re.search(r'\w\b', line[cursor_offset:]) + m = delete_rest_of_word_re.search(line[cursor_offset:]) if not m: - return cursor_offset, line - return cursor_offset, line[:cursor_offset] + line[m.start()+cursor_offset+1:] + return cursor_offset, line, "" + return ( + cursor_offset, + line[:cursor_offset] + line[m.start() + cursor_offset + 1 :], + line[cursor_offset : m.start() + cursor_offset + 1], + ) + + +delete_word_to_cursor_re = LazyReCompile(r"\s\S") -@on('') + +@edit_keys.on(config="clear_word_key") +@kills_behind def delete_word_to_cursor(cursor_offset, line): - matches = list(re.finditer(r'\s\S', line[:cursor_offset])) - start = matches[-1].start()+1 if matches else 0 - return start, line[:start] + line[cursor_offset:] + start = 0 + for match in delete_word_to_cursor_re.finditer(line[:cursor_offset]): + start = match.start() + 1 + return ( + start, + line[:start] + line[cursor_offset:], + line[start:cursor_offset], + ) + + +@edit_keys.on("") +def yank_prev_prev_killed_text(cursor_offset, line, cut_buffer): + # TODO not implemented - just prev + return ( + cursor_offset + len(cut_buffer), + line[:cursor_offset] + cut_buffer + line[cursor_offset:], + ) + + +@edit_keys.on(config="yank_from_buffer_key") +def yank_prev_killed_text(cursor_offset, line, cut_buffer): + return ( + cursor_offset + len(cut_buffer), + line[:cursor_offset] + cut_buffer + line[cursor_offset:], + ) -@on('') -def yank_prev_prev_killed_text(cursor_offset, line): - return cursor_offset, line #TODO Not implemented -@on('') +@edit_keys.on(config="transpose_chars_key") def transpose_character_before_cursor(cursor_offset, line): - return (min(len(line), cursor_offset + 1), - line[:cursor_offset-1] + - (line[cursor_offset] if len(line) > cursor_offset else '') + - line[cursor_offset - 1] + - line[cursor_offset+1:]) + if cursor_offset < 2: + return cursor_offset, line + if cursor_offset == len(line): + return cursor_offset, line[:-2] + line[-1] + line[-2] + return ( + min(len(line), cursor_offset + 1), + line[: cursor_offset - 1] + + (line[cursor_offset] if len(line) > cursor_offset else "") + + line[cursor_offset - 1] + + line[cursor_offset + 1 :], + ) + -@on('') +@edit_keys.on("") def transpose_word_before_cursor(cursor_offset, line): - return cursor_offset, line #TODO Not implemented + return cursor_offset, line # TODO Not implemented + + +# TODO undo all changes to line: meta-r # bonus functions (not part of readline) -@on('') -def delete_line(cursor_offset, line): - return 0, "" -@on('') +@edit_keys.on("") def uppercase_next_word(cursor_offset, line): - return cursor_offset, line #TODO Not implemented + return cursor_offset, line # TODO Not implemented -@on('') + +@edit_keys.on(config="cut_to_buffer_key") +@kills_ahead +def delete_from_cursor_forward(cursor_offset, line): + return cursor_offset, line[:cursor_offset], line[cursor_offset:] + + +@edit_keys.on("") def titlecase_next_word(cursor_offset, line): - return cursor_offset, line #TODO Not implemented + return cursor_offset, line # TODO Not implemented + -@on('') -@on('') +delete_word_from_cursor_back_re = LazyReCompile(r"^|\b\w") + + +@edit_keys.on("") +@edit_keys.on("") +@kills_behind def delete_word_from_cursor_back(cursor_offset, line): """Whatever my option-delete does in bash on my mac""" if not line: - return cursor_offset, line - starts = [m.start() for m in list(re.finditer(r'\b\w', line)) if m.start() < cursor_offset] - if starts: - return starts[-1], line[:starts[-1]] + line[cursor_offset:] - return cursor_offset, line - -def get_updated_char_sequences(key_dispatch, config): - updated_char_sequences = dict(char_sequences) - updated_char_sequences[key_dispatch[config.delete_key]] = backspace - updated_char_sequences[key_dispatch[config.clear_word_key]] = delete_word_to_cursor - updated_char_sequences[key_dispatch[config.clear_line_key]] = delete_from_cursor_back - return updated_char_sequences - + return cursor_offset, line, "" + start = None + for match in delete_word_from_cursor_back_re.finditer(line): + if match.start() < cursor_offset: + start = match.start() + if start is not None: + return ( + start, + line[:start] + line[cursor_offset:], + line[start:cursor_offset], + ) + else: + return cursor_offset, line, "" diff --git a/bpython/curtsiesfrontend/parse.py b/bpython/curtsiesfrontend/parse.py new file mode 100644 index 000000000..122f1ee9f --- /dev/null +++ b/bpython/curtsiesfrontend/parse.py @@ -0,0 +1,109 @@ +import re +from functools import partial +from typing import Any +from collections.abc import Callable + +from curtsies.formatstring import fmtstr, FmtStr +from curtsies.termformatconstants import ( + FG_COLORS, + BG_COLORS, + colors as CURTSIES_COLORS, +) + +from ..config import COLOR_LETTERS +from ..lazyre import LazyReCompile + + +COLORS = CURTSIES_COLORS + ("default",) +CNAMES = dict(zip(COLOR_LETTERS, COLORS)) +# hack for finding the "inverse" +INVERSE_COLORS = { + CURTSIES_COLORS[idx]: CURTSIES_COLORS[ + (idx + (len(CURTSIES_COLORS) // 2)) % len(CURTSIES_COLORS) + ] + for idx in range(len(CURTSIES_COLORS)) +} +INVERSE_COLORS["default"] = INVERSE_COLORS[CURTSIES_COLORS[0]] + + +def func_for_letter( + letter_color_code: str, default: str = "k" +) -> Callable[..., FmtStr]: + """Returns FmtStr constructor for a bpython-style color code""" + if letter_color_code == "d": + letter_color_code = default + elif letter_color_code == "D": + letter_color_code = default.upper() + return partial( + fmtstr, + fg=CNAMES[letter_color_code.lower()], + bold=letter_color_code.isupper(), + ) + + +def color_for_letter(letter_color_code: str, default: str = "k") -> str: + if letter_color_code == "d": + letter_color_code = default + return CNAMES[letter_color_code.lower()] + + +def parse(s: str) -> FmtStr: + """Returns a FmtStr object from a bpython-formatted colored string""" + rest = s + stuff = [] + while rest: + start, rest = peel_off_string(rest) + stuff.append(start) + return ( + sum((fs_from_match(d) for d in stuff[1:]), fs_from_match(stuff[0])) + if len(stuff) > 0 + else FmtStr() + ) + + +def fs_from_match(d: dict[str, Any]) -> FmtStr: + atts = {} + color = "default" + if d["fg"]: + # this isn't according to spec as I understand it + if d["fg"].isupper(): + d["bold"] = True + # TODO figure out why boldness isn't based on presence of \x02 + + color = CNAMES[d["fg"].lower()] + if color != "default": + atts["fg"] = FG_COLORS[color] + if d["bg"]: + if d["bg"] == "I": + # hack for finding the "inverse" + color = INVERSE_COLORS[color] + else: + color = CNAMES[d["bg"].lower()] + if color != "default": + atts["bg"] = BG_COLORS[color] + if d["bold"]: + atts["bold"] = True + return fmtstr(d["string"], **atts) + + +peel_off_string_re = LazyReCompile( + r"""(?P\x01 + (?P[krgybmcwdKRGYBMCWD]?) + (?P[krgybmcwdKRGYBMCWDI]?)?) + (?P\x02?) + \x03 + (?P[^\x04]*) + \x04 + (?P.*) + """, + re.VERBOSE | re.DOTALL, +) + + +def peel_off_string(s: str) -> tuple[dict[str, Any], str]: + m = peel_off_string_re.match(s) + assert m, repr(s) + d = m.groupdict() + rest = d["rest"] + del d["rest"] + return d, rest diff --git a/bpython/curtsiesfrontend/preprocess.py b/bpython/curtsiesfrontend/preprocess.py new file mode 100644 index 000000000..f48a79bf7 --- /dev/null +++ b/bpython/curtsiesfrontend/preprocess.py @@ -0,0 +1,53 @@ +"""Tools for preparing code to be run in the REPL (removing blank lines, +etc)""" + +from codeop import CommandCompiler +from re import Match +from itertools import tee, islice, chain + +from ..lazyre import LazyReCompile + +# TODO specifically catch IndentationErrors instead of any syntax errors + +indent_empty_lines_re = LazyReCompile(r"\s*") +tabs_to_spaces_re = LazyReCompile(r"^\t+") + + +def indent_empty_lines(s: str, compiler: CommandCompiler) -> str: + """Indents blank lines that would otherwise cause early compilation + + Only really works if starting on a new line""" + initial_lines = s.split("\n") + ends_with_newline = False + if initial_lines and not initial_lines[-1]: + ends_with_newline = True + initial_lines.pop() + result_lines = [] + + prevs, lines, nexts = tee(initial_lines, 3) + prevs = chain(("",), prevs) + nexts = chain(islice(nexts, 1, None), ("",)) + + for p_line, line, n_line in zip(prevs, lines, nexts): + if len(line) == 0: + # "\s*" always matches + p_indent = indent_empty_lines_re.match(p_line).group() # type: ignore + n_indent = indent_empty_lines_re.match(n_line).group() # type: ignore + result_lines.append(min([p_indent, n_indent], key=len) + line) + else: + result_lines.append(line) + + return "\n".join(result_lines) + ("\n" if ends_with_newline else "") + + +def leading_tabs_to_spaces(s: str) -> str: + def tab_to_space(m: Match[str]) -> str: + return len(m.group()) * 4 * " " + + return "\n".join( + tabs_to_spaces_re.sub(tab_to_space, line) for line in s.split("\n") + ) + + +def preprocess(s: str, compiler: CommandCompiler) -> str: + return indent_empty_lines(leading_tabs_to_spaces(s), compiler) diff --git a/bpython/curtsiesfrontend/repl.py b/bpython/curtsiesfrontend/repl.py index 0bac4f2e3..928be253e 100644 --- a/bpython/curtsiesfrontend/repl.py +++ b/bpython/curtsiesfrontend/repl.py @@ -1,8 +1,6 @@ -import code import contextlib import errno -import functools -import greenlet +import itertools import logging import os import re @@ -10,310 +8,555 @@ import subprocess import sys import tempfile -import threading import time import unicodedata +from enum import Enum +from types import FrameType, TracebackType +from typing import ( + Any, + Literal, +) +from collections.abc import Iterable, Sequence -from pygments import format -from pygments.lexers import PythonLexer +import greenlet +from curtsies import ( + FSArray, + fmtstr, + FmtStr, + Termmode, + fmtfuncs, + events, + __version__ as curtsies_version, +) +from curtsies.configfile_keynames import keymap as key_dispatch +from curtsies.input import is_main_thread +from curtsies.window import CursorAwareWindow +from cwcwidth import wcswidth +from pygments import format as pygformat from pygments.formatters import TerminalFormatter +from pygments.lexers import Python3Lexer + +from . import events as bpythonevents, sitefix, replpainter as paint +from ..config import Config +from .coderunner import ( + CodeRunner, + FakeOutput, +) +from .filewatch import ModuleChangedEventHandler +from .interaction import StatusBar +from .interpreter import ( + Interp, + code_finished_will_parse, +) +from .manual_readline import ( + edit_keys, + cursor_on_closing_char_pair, + AbstractEdits, +) +from .parse import parse as bpythonparse, func_for_letter, color_for_letter +from .preprocess import preprocess +from .. import __version__ +from ..config import getpreferredencoding +from ..formatter import BPythonFormatter +from ..pager import get_pager_command +from ..repl import ( + Repl, + SourceNotFound, +) +from ..translations import _ +from ..line import CHARACTER_PAIR_MAP -import blessings - -import curtsies -from curtsies import FSArray, fmtstr, FmtStr, Termmode -from curtsies.bpythonparse import parse as bpythonparse -from curtsies.bpythonparse import func_for_letter, color_for_letter -from curtsies import fmtfuncs -from curtsies import events - -import bpython -from bpython.repl import Repl as BpythonRepl -from bpython.config import Struct, loadini, default_config_path -from bpython.formatter import BPythonFormatter -from bpython import autocomplete, importcompletion -from bpython import translations; translations.init() -from bpython.translations import _ -from bpython._py3compat import py3 - -from bpython.curtsiesfrontend import replpainter as paint -from bpython.curtsiesfrontend import sitefix; sitefix.monkeypatch_quit() -from bpython.curtsiesfrontend.coderunner import CodeRunner, FakeOutput -from bpython.curtsiesfrontend.filewatch import ModuleChangedEventHandler -from bpython.curtsiesfrontend.interaction import StatusBar -from bpython.curtsiesfrontend.manual_readline import char_sequences as rl_char_sequences -from bpython.curtsiesfrontend.manual_readline import get_updated_char_sequences - -#TODO other autocomplete modes (also fix in other bpython implementations) +logger = logging.getLogger(__name__) -from curtsies.configfile_keynames import keymap as key_dispatch +INCONSISTENT_HISTORY_MSG = "#<---History inconsistent with output shown--->" +CONTIGUITY_BROKEN_MSG = "#<---History contiguity broken by rewind--->" +EXAMPLE_CONFIG_URL = "https://raw.githubusercontent.com/bpython/bpython/master/bpython/sample-config" +EDIT_SESSION_HEADER = """### current bpython session - make changes and save to reevaluate session. +### lines beginning with ### will be ignored. +### To return to bpython without reevaluating make no changes to this file +### or save an empty file. +""" -logger = logging.getLogger(__name__) +# more than this many events will be assumed to be a true paste event, +# i.e. control characters like '' will be stripped +MAX_EVENTS_POSSIBLY_NOT_PASTE = 20 -HELP_MESSAGE = """ -Thanks for using bpython! -See http://bpython-interpreter.org/ for info, http://docs.bpython-interpreter.org/ for docs, and https://github.com/bpython/bpython for source. -Please report issues at https://github.com/bpython/bpython/issues +class SearchMode(Enum): + NO_SEARCH = 0 + INCREMENTAL_SEARCH = 1 + REVERSE_INCREMENTAL_SEARCH = 2 -Try using undo ({config.undo_key})! -Edit the current line ({config.edit_current_block_key}) or the entire session ({config.external_editor_key}) in an external editor! (currently {config.editor}) -Save sessions ({config.save_key}) or post them to pastebins ({config.pastebin_key})! Current pastebin helper: {config.pastebin_helper} -Re-execute the current session and reload all modules ({config.reimport_key}) to test out changes to a module! -Toggle auto-reload mode ({config.toggle_file_watch_key}) to re-execute the current session when a module you've imported is modified! -Use bpython-curtsies -i your_script.py to run a file in interactive mode (interpreter in namespace of script). -Use bpython-curtsies -t your_script.py to paste in the contents of a file, as though you typed them. +class LineType(Enum): + """Used when adding a tuple to all_logical_lines, to get input / output values + having to actually type/know the strings""" -Use a config file at {config_file_location} to customize keys and behavior of bpython. -You can customize which pastebin helper to use and which external editor to use. -See {example_config_url} for an example config file. -""" + INPUT = "input" + OUTPUT = "output" + + +class FakeStdin: + """The stdin object user code will reference -class FakeStdin(object): - """Stdin object user code references so sys.stdin.read() asked user for interactive input""" - def __init__(self, coderunner, repl, updated_rl_char_sequences=None): + In user code, sys.stdin.read() asks the user for interactive input, + so this class returns control to the UI to get that input.""" + + def __init__( + self, + coderunner: CodeRunner, + repl: "BaseRepl", + configured_edit_keys: AbstractEdits | None = None, + ): self.coderunner = coderunner self.repl = repl - self.has_focus = False # whether FakeStdin receives keypress events - self.current_line = '' + self.has_focus = False # whether FakeStdin receives keypress events + self.current_line = "" self.cursor_offset = 0 self.old_num_lines = 0 - self.readline_results = [] - if updated_rl_char_sequences: - self.rl_char_sequences = updated_rl_char_sequences + self.readline_results: list[str] = [] + if configured_edit_keys is not None: + self.rl_char_sequences = configured_edit_keys else: - self.rl_char_sequences = rl_char_sequences + self.rl_char_sequences = edit_keys - def process_event(self, e): + def process_event(self, e: events.Event | str) -> None: assert self.has_focus - logger.debug('fake input processing event %r', e) - if isinstance(e, events.PasteEvent): - for ee in e.events: - if ee not in self.rl_char_sequences: - self.add_input_character(ee) + + logger.debug("fake input processing event %r", e) + if isinstance(e, events.Event): + if isinstance(e, events.PasteEvent): + for ee in e.events: + if ee not in self.rl_char_sequences: + self.add_input_character(ee) + elif isinstance(e, events.SigIntEvent): + self.coderunner.sigint_happened_in_main_context = True + self.has_focus = False + self.current_line = "" + self.cursor_offset = 0 + self.repl.run_code_and_maybe_finish() elif e in self.rl_char_sequences: - self.cursor_offset, self.current_line = self.rl_char_sequences[e](self.cursor_offset, self.current_line) - elif isinstance(e, events.SigIntEvent): - self.coderunner.sigint_happened_in_main_greenlet = True - self.has_focus = False - self.current_line = '' - self.cursor_offset = 0 - self.repl.run_code_and_maybe_finish() - elif e in [""]: - pass - elif e in ['']: - if self.current_line == '': - self.repl.send_to_stdin('\n') + self.cursor_offset, self.current_line = self.rl_char_sequences[e]( + self.cursor_offset, self.current_line + ) + elif e == "": + if not len(self.current_line): + self.repl.send_to_stdin("\n") self.has_focus = False - self.current_line = '' + self.current_line = "" self.cursor_offset = 0 - self.repl.run_code_and_maybe_finish(for_code='') - else: - pass - elif e in ["\n", "\r", "", ""]: - line = self.current_line - self.repl.send_to_stdin(line + '\n') + self.repl.run_code_and_maybe_finish(for_code="") + elif e in ("\n", "\r", "", ""): + line = f"{self.current_line}\n" + self.repl.send_to_stdin(line) self.has_focus = False - self.current_line = '' + self.current_line = "" self.cursor_offset = 0 - self.repl.run_code_and_maybe_finish(for_code=line+'\n') - else: # add normal character + self.repl.run_code_and_maybe_finish(for_code=line) + elif e != "": # add normal character self.add_input_character(e) - if self.current_line.endswith(("\n", "\r")): - pass - else: + if not self.current_line.endswith(("\n", "\r")): self.repl.send_to_stdin(self.current_line) - def add_input_character(self, e): - if e == '': e = ' ' - if e.startswith('<') and e.endswith('>'): return - assert len(e) == 1, 'added multiple characters: %r' % e - logger.debug('adding normal char %r to current line', e) - - c = e if py3 else e.encode('utf8') - self.current_line = (self.current_line[:self.cursor_offset] + - c + - self.current_line[self.cursor_offset:]) + def add_input_character(self, e: str) -> None: + if e == "": + e = " " + if e.startswith("<") and e.endswith(">"): + return + assert len(e) == 1, "added multiple characters: %r" % e + logger.debug("adding normal char %r to current line", e) + + self.current_line = ( + self.current_line[: self.cursor_offset] + + e + + self.current_line[self.cursor_offset :] + ) self.cursor_offset += 1 - def readline(self): + def readline(self, size: int = -1) -> str: + if not isinstance(size, int): + raise TypeError( + f"'{type(size).__name__}' object cannot be interpreted as an integer" + ) + elif size == 0: + return "" self.has_focus = True self.repl.send_to_stdin(self.current_line) - value = self.coderunner.request_from_main_greenlet() + value = self.coderunner.request_from_main_context() + assert isinstance(value, str) self.readline_results.append(value) - return value + return value if size <= -1 else value[:size] + + def readlines(self, size: int | None = -1) -> list[str]: + if size is None: + # the default readlines implementation also accepts None + size = -1 + if not isinstance(size, int): + raise TypeError("argument should be integer or None, not 'str'") + if size <= 0: + # read as much as we can + return list(iter(self.readline, "")) - def readlines(self, size=-1): - return list(iter(self.readline, '')) + lines = [] + while size > 0: + line = self.readline() + lines.append(line) + size -= len(line) + return lines def __iter__(self): return iter(self.readlines()) - def isatty(self): + def isatty(self) -> bool: return True - def flush(self): + def flush(self) -> None: """Flush the internal buffer. This is a no-op. Flushing stdin doesn't make any sense anyway.""" def write(self, value): # XXX IPython expects sys.stdin.write to exist, there will no doubt be # others, so here's a hack to keep them happy - raise IOError(errno.EBADF, "sys.stdin is read-only") + raise OSError(errno.EBADF, "sys.stdin is read-only") + + def close(self) -> None: + # hack to make closing stdin a nop + # This is useful for multiprocessing.Process, which does work + # for the most part, although output from other processes is + # discarded. + pass @property - def encoding(self): - return 'UTF8' + def encoding(self) -> str: + # `encoding` is new in py39 + return sys.__stdin__.encoding # type: ignore + + # TODO write a read() method? - #TODO write a read() method -class ReevaluateFakeStdin(object): - """Stdin mock used during reevaluation (undo) so raw_inputs don't have to be reentered""" - def __init__(self, fakestdin, repl): +class ReevaluateFakeStdin: + """Stdin mock used during reevaluation (undo) so raw_inputs don't have to + be reentered""" + + def __init__(self, fakestdin: FakeStdin, repl: "BaseRepl"): self.fakestdin = fakestdin self.repl = repl self.readline_results = fakestdin.readline_results[:] + def readline(self): if self.readline_results: value = self.readline_results.pop(0) else: - value = 'no saved input available' - self.repl.send_to_stdout(value) + value = "no saved input available" + self.repl.send_to_stdouterr(value) return value -class Repl(BpythonRepl): + +class ImportLoader: + """Wrapper for module loaders to watch their paths with watchdog.""" + + def __init__(self, watcher, loader): + self.watcher = watcher + self.loader = loader + + def __getattr__(self, name): + if name == "create_module" and hasattr(self.loader, name): + return self._create_module + return getattr(self.loader, name) + + def _create_module(self, spec): + module_object = self.loader.create_module(spec) + if ( + getattr(spec, "origin", None) is not None + and spec.origin != "builtin" + ): + self.watcher.track_module(spec.origin) + return module_object + + +class ImportFinder: + """Wrapper for finders in sys.meta_path to wrap all loaders with ImportLoader.""" + + def __init__(self, watcher, finder): + self.watcher = watcher + self.finder = finder + + def __getattr__(self, name): + if name == "find_spec" and hasattr(self.finder, name): + return self._find_spec + return getattr(self.finder, name) + + def _find_spec(self, fullname, path, target=None): + # Attempt to find the spec + spec = self.finder.find_spec(fullname, path, target) + if spec is not None: + if getattr(spec, "loader", None) is not None: + # Patch the loader to enable reloading + spec.loader = ImportLoader(self.watcher, spec.loader) + return spec + + +def _process_ps(ps, default_ps: str): + """Replace ps1/ps2 with the default if the user specified value contains control characters.""" + if not isinstance(ps, str): + return ps + + return ps if wcswidth(ps) >= 0 else default_ps + + +class BaseRepl(Repl): """Python Repl Reacts to events like - -terminal dimensions and change events - -keystrokes + - terminal dimensions and change events + - keystrokes Behavior altered by - -number of scroll downs that were necessary to render array after each display - -initial cursor position + - number of scroll downs that were necessary to render array after each + display + - initial cursor position outputs: - -2D array to be rendered + - 2D array to be rendered + + BaseRepl is mostly view-independent state of Repl - but self.width and + self.height are important for figuring out how to wrap lines for example. + Usually self.width and self.height should be set by receiving a window + resize event, not manually set to anything - as long as the first event + received is a window resize event, this works fine. - Repl is mostly view-independent state of Repl - but self.width and self.height - are important for figuring out how to wrap lines for example. - Usually self.width and self.height should be set by receiving a window resize event, - not manually set to anything - as long as the first event received is a window - resize event, this works fine. + Subclasses are responsible for implementing several methods. """ - ## initialization, cleanup - def __init__(self, - locals_=None, - config=None, - request_refresh=lambda when='now': None, - request_reload=lambda desc: None, get_term_hw=lambda:(50, 10), - get_cursor_vertical_diff=lambda: 0, - banner=None, - interp=None, - interactive=True, - orig_tcattrs=None): + def __init__( + self, + config: Config, + window: CursorAwareWindow, + locals_: dict[str, Any] | None = None, + banner: str | None = None, + interp: Interp | None = None, + orig_tcattrs: list[Any] | None = None, + ): """ locals_ is a mapping of locals to pass into the interpreter config is a bpython config.Struct with config attributes - request_refresh is a function that will be called when the Repl - wants to refresh the display, but wants control returned to it afterwards - Takes as a kwarg when= which is when to fire - get_term_hw is a function that returns the current width and height - of the terminal - get_cursor_vertical_diff is a function that returns how the cursor moved - due to a window size change banner is a string to display briefly in the status bar - interp is an interpreter to use + interp is an interpreter instance to use + original terminal state, useful for shelling out with normal terminal """ logger.debug("starting init") + self.window = window - if config is None: - config = Struct() - loadini(config, default_config_path()) + # If creating a new interpreter on undo would be unsafe because initial + # state was passed in + self.weak_rewind = bool(locals_ or interp) - self.weak_rewind = bool(locals_ or interp) # If creating a new interpreter on undo - # would be unsafe because initial - # state was passed in if interp is None: - interp = code.InteractiveInterpreter(locals=locals_) - if banner is None: - banner = _('Welcome to bpython! Press <%s> for help.') % config.help_key - config.autocomplete_mode = autocomplete.SIMPLE # only one implemented currently + interp = Interp(locals=locals_) + interp.write = self.send_to_stdouterr # type: ignore if config.cli_suggestion_width <= 0 or config.cli_suggestion_width > 1: config.cli_suggestion_width = 1 self.reevaluating = False self.fake_refresh_requested = False - def smarter_request_refresh(when='now'): - if self.reevaluating or self.paste_mode: - self.fake_refresh_requested = True - else: - request_refresh(when=when) - self.request_refresh = smarter_request_refresh - def smarter_request_reload(desc): - if self.watching_files: - request_reload(desc) - else: - pass - self.request_reload = smarter_request_reload - self.get_term_hw = get_term_hw - self.get_cursor_vertical_diff = get_cursor_vertical_diff self.status_bar = StatusBar( - (_(" <%s> Rewind <%s> Save <%s> Pastebin <%s> Editor") - % (config.undo_key, config.save_key, config.pastebin_key, config.external_editor_key) - if config.curtsies_fill_terminal else ''), - refresh_request=self.request_refresh - ) - self.rl_char_sequences = get_updated_char_sequences(key_dispatch, config) + config, + "", + request_refresh=self.request_refresh, + schedule_refresh=self.schedule_refresh, + ) + self.edit_keys = edit_keys.mapping_with_config(config, key_dispatch) logger.debug("starting parent init") - super(Repl, self).__init__(interp, config) - #TODO bring together all interactive stuff - including current directory in path? - if interactive: - self.startup() + # interp is a subclass of repl.Interpreter, so it definitely, + # implements the methods of Interpreter! + super().__init__(interp, config) + self.formatter = BPythonFormatter(config.color_scheme) - self.interact = self.status_bar # overwriting what bpython.Repl put there - # interact is called to interact with the status bar, - # so we're just using the same object - self._current_line = '' # line currently being edited, without ps1 (usually '>>> ') - self.current_stdouterr_line = '' # current line of output - stdout and stdin go here - self.display_lines = [] # lines separated whenever logical line - # length goes over what the terminal width - # was at the time of original output - self.history = [] # this is every line that's been executed; - # it gets smaller on rewind - self.display_buffer = [] # formatted version of lines in the buffer - # kept around so we can unhighlight parens - # using self.reprint_line as called by - # bpython.Repl - self.scroll_offset = 0 # how many times display has been scrolled down - # because there wasn't room to display everything - self._cursor_offset = 0 # from the left, 0 means first char - self.orig_tcattrs = orig_tcattrs # useful for shelling out with normal terminal + + # overwriting what bpython.Repl put there + # interact is called to interact with the status bar, + # so we're just using the same object + self.interact = self.status_bar + + # logical line currently being edited, without ps1 (usually '>>> ') + self._current_line = "" + + # current line of output - stdout and stdin go here + self.current_stdouterr_line: str | FmtStr = "" + + # this is every line that's been displayed (input and output) + # as with formatting applied. Logical lines that exceeded the terminal width + # at the time of output are split across multiple entries in this list. + self.display_lines: list[FmtStr] = [] + + # this is every line that's been executed; it gets smaller on rewind + self.history = [] + + # This is every logical line that's been displayed, both input and output. + # Like self.history, lines are unwrapped, uncolored, and without prompt. + # Entries are tuples, where + # - the first element the line (string, not fmtsr) + # - the second element is one of 2 global constants: "input" or "output" + # (use LineType.INPUT or LineType.OUTPUT to avoid typing these strings) + self.all_logical_lines: list[tuple[str, LineType]] = [] + + # formatted version of lines in the buffer kept around so we can + # unhighlight parens using self.reprint_line as called by bpython.Repl + self.display_buffer: list[FmtStr] = [] + + # how many times display has been scrolled down + # because there wasn't room to display everything + self.scroll_offset = 0 + + # cursor position relative to start of current_line, 0 is first char + self._cursor_offset = 0 + + self.orig_tcattrs: list[Any] | None = orig_tcattrs self.coderunner = CodeRunner(self.interp, self.request_refresh) - self.stdout = FakeOutput(self.coderunner, self.send_to_stdout) - self.stderr = FakeOutput(self.coderunner, self.send_to_stderr) - self.stdin = FakeStdin(self.coderunner, self, self.rl_char_sequences) - self.request_paint_to_clear_screen = False # next paint should clear screen - self.last_events = [None] * 50 + # filenos match the backing device for libs that expect it, + # but writing to them will do weird things to the display + self.stdout = FakeOutput( + self.coderunner, + self.send_to_stdouterr, + real_fileobj=sys.__stdout__, + ) + self.stderr = FakeOutput( + self.coderunner, + self.send_to_stdouterr, + real_fileobj=sys.__stderr__, + ) + self.stdin = FakeStdin(self.coderunner, self, self.edit_keys) + + # next paint should clear screen + self.request_paint_to_clear_screen = False + + self.request_paint_to_pad_bottom = 0 + + # offscreen command yields results different from scrollback buffer + self.inconsistent_history = False + + # history error message has already been displayed + self.history_already_messed_up = False + + # some commands act differently based on the prev event + # this list doesn't include instances of event.Event, + # only keypress-type events (no refresh screen events etc.) + self.last_events: list[str | None] = [None] * 50 + + # displays prev events in a column on the right hand side self.presentation_mode = False + self.paste_mode = False self.current_match = None self.list_win_visible = False - self.watching_files = False + # whether auto reloading active + self.watching_files = config.default_autoreload - self.original_modules = sys.modules.keys() + self.incr_search_mode = SearchMode.NO_SEARCH + self.incr_search_target = "" - self.width = None # will both be set by a window resize event - self.height = None + self.original_modules = set(sys.modules.keys()) + + # as long as the first event received is a window resize event, + # this works fine... + try: + self.width, self.height = os.get_terminal_size() + except OSError: + # this case will trigger during unit tests when stdout is redirected + self.width = -1 + self.height = -1 self.status_bar.message(banner) - self.watcher = ModuleChangedEventHandler([], smarter_request_reload) + self.watcher = ModuleChangedEventHandler([], self.request_reload) + if self.watcher and config.default_autoreload: + self.watcher.activate() + + # The methods below should be overridden, but the default implementations + # below can be used as well. + + def get_cursor_vertical_diff(self) -> int: + """Return how the cursor moved due to a window size change""" + return 0 + + def get_top_usable_line(self) -> int: + """Return the top line of display that can be rewritten""" + return 0 + + def get_term_hw(self) -> tuple[int, int]: + """Returns the current width and height of the display area.""" + return (50, 10) + + def _schedule_refresh(self, when: float): + """Arrange for the bpython display to be refreshed soon. + + This method will be called when the Repl wants the display to be + refreshed at a known point in the future, and as such it should + interrupt a pending request to the user for input. + + Because the worst-case effect of not refreshing + is only having an out of date UI until the user enters input, a + default NOP implementation is provided.""" + + # The methods below must be overridden in subclasses. + + def _request_refresh(self): + """Arrange for the bpython display to be refreshed soon. + + This method will be called when the Repl wants to refresh the display, + but wants control returned to it afterwards. (it is assumed that simply + returning from process_event will cause an event refresh) + + The very next event received by process_event should be a + RefreshRequestEvent.""" + raise NotImplementedError + + def _request_reload(self, files_modified: Sequence[str]) -> None: + """Like request_refresh, but for reload requests events.""" + raise NotImplementedError + + def request_undo(self, n=1): + """Like request_refresh, but for undo request events.""" + raise NotImplementedError + + def on_suspend(self): + """Will be called on sigtstp. + + Do whatever cleanup would allow the user to use other programs.""" + raise NotImplementedError + + def after_suspend(self): + """Will be called when process foregrounded after suspend. + + See to it that process_event is called with None to trigger a refresh + if not in the middle of a process_event call when suspend happened.""" + raise NotImplementedError + + # end methods that should be overridden in subclass + + def request_refresh(self): + """Request that the bpython display to be refreshed soon.""" + if self.reevaluating or self.paste_mode: + self.fake_refresh_requested = True + else: + self._request_refresh() + + def request_reload(self, files_modified: Sequence[str] = ()) -> None: + """Request that a ReloadEvent be passed next into process_event""" + if self.watching_files: + self._request_reload(files_modified) + + def schedule_refresh(self, when: float = 0) -> None: + """Schedule a ScheduledRefreshRequestEvent for when. + + Such a event should interrupt if blocked waiting for keyboard input""" + if self.reevaluating or self.paste_mode: + self.fake_refresh_requested = True + else: + self._schedule_refresh(when=when) def __enter__(self): self.orig_stdout = sys.stdout @@ -323,99 +566,100 @@ def __enter__(self): sys.stderr = self.stderr sys.stdin = self.stdin self.orig_sigwinch_handler = signal.getsignal(signal.SIGWINCH) - signal.signal(signal.SIGWINCH, self.sigwinch_handler) + self.orig_sigtstp_handler = signal.getsignal(signal.SIGTSTP) - self.orig_import = __builtins__['__import__'] + if is_main_thread(): + # This turns off resize detection and ctrl-z suspension. + signal.signal(signal.SIGWINCH, self.sigwinch_handler) + signal.signal(signal.SIGTSTP, self.sigtstp_handler) + + self.orig_meta_path = sys.meta_path if self.watcher: - old_module_locations = {} # for readding modules if they fail to load - @functools.wraps(self.orig_import) - def new_import(name, globals={}, locals={}, fromlist=[], level=-1): - try: - m = self.orig_import(name, globals=globals, locals=locals, fromlist=fromlist) - except: - if name in old_module_locations: - loc = old_module_locations[name] - if self.watching_files: - self.watcher.add_module(old_module_locations[name]) - else: - self.watcher.add_module_later(old_module_locations[name]) - raise - else: - if hasattr(m, "__file__"): - old_module_locations[name] = m.__file__ - if self.watching_files: - self.watcher.add_module(m.__file__) - else: - self.watcher.add_module_later(m.__file__) - return m - __builtins__['__import__'] = new_import + meta_path = [] + for finder in sys.meta_path: + meta_path.append(ImportFinder(self.watcher, finder)) + sys.meta_path = meta_path + sitefix.monkeypatch_quit() return self - def __exit__(self, *args): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> Literal[False]: sys.stdin = self.orig_stdin sys.stdout = self.orig_stdout sys.stderr = self.orig_stderr - signal.signal(signal.SIGWINCH, self.orig_sigwinch_handler) - __builtins__['__import__'] = self.orig_import - def sigwinch_handler(self, signum, frame): + if is_main_thread(): + # This turns off resize detection and ctrl-z suspension. + signal.signal(signal.SIGWINCH, self.orig_sigwinch_handler) + signal.signal(signal.SIGTSTP, self.orig_sigtstp_handler) + + sys.meta_path = self.orig_meta_path + return False + + def sigwinch_handler(self, signum: int, frame: FrameType | None) -> None: old_rows, old_columns = self.height, self.width self.height, self.width = self.get_term_hw() cursor_dy = self.get_cursor_vertical_diff() self.scroll_offset -= cursor_dy - logger.info('sigwinch! Changed from %r to %r', (old_rows, old_columns), (self.height, self.width)) - logger.info('decreasing scroll offset by %d to %d', cursor_dy, self.scroll_offset) - - def startup(self): - """ - Execute PYTHONSTARTUP file if it exits. Call this after front - end-specific initialisation. - """ - filename = os.environ.get('PYTHONSTARTUP') - if filename: - if os.path.isfile(filename): - with open(filename, 'r') as f: - if py3: - #TODO runsource has a new signature in PY3 - self.interp.runsource(f.read(), filename, 'exec') - else: - self.interp.runsource(f.read(), filename, 'exec') - else: - raise IOError("Python startup file (PYTHONSTARTUP) not found at %s" % filename) + logger.info( + "sigwinch! Changed from %r to %r", + (old_rows, old_columns), + (self.height, self.width), + ) + logger.info( + "decreasing scroll offset by %d to %d", + cursor_dy, + self.scroll_offset, + ) + + def sigtstp_handler(self, signum: int, frame: FrameType | None) -> None: + self.scroll_offset = len(self.lines_for_display) + self.__exit__(None, None, None) + self.on_suspend() + os.kill(os.getpid(), signal.SIGTSTP) + self.after_suspend() + self.__enter__() def clean_up_current_line_for_exit(self): """Called when trying to exit to prep for final paint""" - logger.debug('unhighlighting paren for exit') + logger.debug("unhighlighting paren for exit") self.cursor_offset = -1 self.unhighlight_paren() - ## Event handling - def process_event(self, e): + # Event handling + def process_event(self, e: events.Event | str) -> bool | None: """Returns True if shutting down, otherwise returns None. Mostly mutates state of Repl object""" logger.debug("processing event %r", e) if isinstance(e, events.Event): - return self.proccess_control_event(e) + return self.process_control_event(e) else: self.last_events.append(e) self.last_events.pop(0) - return self.process_key_event(e) + self.process_key_event(e) + return None - def proccess_control_event(self, e): + def process_control_event(self, e: events.Event) -> bool | None: + if isinstance(e, bpythonevents.ScheduledRefreshRequestEvent): + # This is a scheduled refresh - it's really just a refresh (so nop) + pass - if isinstance(e, events.RefreshRequestEvent): - if e.when != 'now': - pass # This is a scheduled refresh - it's really just a refresh (so nop) - elif self.status_bar.has_focus: + elif isinstance(e, bpythonevents.RefreshRequestEvent): + logger.info("received ASAP refresh request event") + if self.status_bar.has_focus: self.status_bar.process_event(e) else: assert self.coderunner.code_is_waiting self.run_code_and_maybe_finish() elif self.status_bar.has_focus: - return self.status_bar.process_event(e) + self.status_bar.process_event(e) # handles paste events for both stdin and repl elif isinstance(e, events.PasteEvent): @@ -423,93 +667,290 @@ def proccess_control_event(self, e): if ctrl_char is not None: return self.process_event(ctrl_char) with self.in_paste_mode(): - for ee in e.events: - if self.stdin.has_focus: - self.stdin.process_event(ee) - else: - self.process_simple_keypress(ee) + # Might not really be a paste, UI might just be lagging + if len(e.events) <= MAX_EVENTS_POSSIBLY_NOT_PASTE and any( + not is_simple_event(ee) for ee in e.events + ): + for ee in e.events: + if self.stdin.has_focus: + self.stdin.process_event(ee) + else: + self.process_event(ee) + else: + simple_events = just_simple_events(e.events) + source = preprocess( + "".join(simple_events), self.interp.compile + ) + for ee in source: + if self.stdin.has_focus: + self.stdin.process_event(ee) + else: + self.process_simple_keypress(ee) + + elif isinstance(e, bpythonevents.RunStartupFileEvent): + try: + self.startup() + except OSError as err: + self.status_bar.message( + _("Executing PYTHONSTARTUP failed: %s") % (err,) + ) + + elif isinstance(e, bpythonevents.UndoEvent): + self.undo(n=e.n) elif self.stdin.has_focus: - return self.stdin.process_event(e) + self.stdin.process_event(e) elif isinstance(e, events.SigIntEvent): - logger.debug('received sigint event') + logger.debug("received sigint event") self.keyboard_interrupt() - return - elif isinstance(e, events.ReloadEvent): + elif isinstance(e, bpythonevents.ReloadEvent): if self.watching_files: self.clear_modules_and_reevaluate() - self.status_bar.message('Reloaded at ' + time.strftime('%H:%M:%S') + ' because ' + ' & '.join(e.files_modified) + ' modified') + self.status_bar.message( + _("Reloaded at %s because %s modified.") + % (time.strftime("%X"), " & ".join(e.files_modified)) + ) else: - raise ValueError("don't know how to handle this event type: %r" % e) + raise ValueError("Don't know how to handle event type: %r" % e) + return None - def process_key_event(self, e): - # To find the curtsies name for a keypress, try python -m curtsies.events - if self.status_bar.has_focus: return self.status_bar.process_event(e) - if self.stdin.has_focus: return self.stdin.process_event(e) + def process_key_event(self, e: str) -> None: + # To find the curtsies name for a keypress, try + # python -m curtsies.events + if self.status_bar.has_focus: + return self.status_bar.process_event(e) + if self.stdin.has_focus: + return self.stdin.process_event(e) - if (e in ("", '') and self.config.curtsies_right_arrow_completion - and self.cursor_offset == len(self.current_line)): + if ( + e + in ( + key_dispatch[self.config.right_key] + + key_dispatch[self.config.end_of_line_key] + + ("",) + ) + and self.config.curtsies_right_arrow_completion + and self.cursor_offset == len(self.current_line) + # if at end of current line and user presses RIGHT (to autocomplete) + ): + # then autocomplete self.current_line += self.current_suggestion self.cursor_offset = len(self.current_line) - elif e in ("",) + key_dispatch[self.config.up_one_line_key]: self.up_one_line() elif e in ("",) + key_dispatch[self.config.down_one_line_key]: self.down_one_line() - elif e in self.rl_char_sequences: - self.cursor_offset, self.current_line = self.rl_char_sequences[e](self.cursor_offset, self.current_line) + elif e == "": + self.on_control_d() + elif e == "": + self.operate_and_get_next() + elif e == "": + self.get_last_word() + elif e in key_dispatch[self.config.reverse_incremental_search_key]: + self.incremental_search(reverse=True) + elif e in key_dispatch[self.config.incremental_search_key]: + self.incremental_search() + elif ( + e in (("",) + key_dispatch[self.config.backspace_key]) + and self.incr_search_mode != SearchMode.NO_SEARCH + ): + self.add_to_incremental_search(self, backspace=True) + elif e in self.edit_keys.cut_buffer_edits: + self.readline_kill(e) + elif e in self.edit_keys.simple_edits: + self.cursor_offset, self.current_line = self.edit_keys.call( + e, + cursor_offset=self.cursor_offset, + line=self.current_line, + cut_buffer=self.cut_buffer, + ) + elif e in key_dispatch[self.config.cut_to_buffer_key]: + self.cut_to_buffer() elif e in key_dispatch[self.config.reimport_key]: self.clear_modules_and_reevaluate() elif e in key_dispatch[self.config.toggle_file_watch_key]: - return self.toggle_file_watch() + self.toggle_file_watch() elif e in key_dispatch[self.config.clear_screen_key]: self.request_paint_to_clear_screen = True elif e in key_dispatch[self.config.show_source_key]: self.show_source() elif e in key_dispatch[self.config.help_key]: self.pager(self.help_text()) - elif e in key_dispatch[self.config.suspend_key]: - raise SystemExit() - elif e in ("",): - self.on_control_d() elif e in key_dispatch[self.config.exit_key]: raise SystemExit() elif e in ("\n", "\r", "", "", ""): self.on_enter() - elif e == '': # tab + elif e == "": # tab self.on_tab() - elif e in ("",): + elif e == "": self.on_tab(back=True) - elif e in key_dispatch[self.config.undo_key]: #ctrl-r for undo - self.undo() - elif e in key_dispatch[self.config.save_key]: # ctrl-s for save + elif e in key_dispatch[self.config.undo_key]: # ctrl-r for undo + self.prompt_undo() + elif e in key_dispatch[self.config.redo_key]: # ctrl-g for redo + self.redo() + elif e in key_dispatch[self.config.save_key]: # ctrl-s for save greenlet.greenlet(self.write2file).switch() - elif e in key_dispatch[self.config.pastebin_key]: # F8 for pastebin + elif e in key_dispatch[self.config.pastebin_key]: # F8 for pastebin greenlet.greenlet(self.pastebin).switch() + elif e in key_dispatch[self.config.copy_clipboard_key]: + greenlet.greenlet(self.copy2clipboard).switch() elif e in key_dispatch[self.config.external_editor_key]: self.send_session_to_external_editor() - #TODO add PAD keys hack as in bpython.cli + elif e in key_dispatch[self.config.edit_config_key]: + greenlet.greenlet(self.edit_config).switch() + # TODO add PAD keys hack as in bpython.cli elif e in key_dispatch[self.config.edit_current_block_key]: self.send_current_block_to_external_editor() - elif e in [""]: #ESC - pass - elif e in [""]: - self.add_normal_character(' ') + elif e == "": + self.incr_search_mode = SearchMode.NO_SEARCH + elif e == "": + self.add_normal_character(" ") + elif e in CHARACTER_PAIR_MAP.keys(): + if e in ["'", '"']: + if self.is_closing_quote(e): + self.insert_char_pair_end(e) + else: + self.insert_char_pair_start(e) + else: + self.insert_char_pair_start(e) + elif e in CHARACTER_PAIR_MAP.values(): + self.insert_char_pair_end(e) else: self.add_normal_character(e) - def on_enter(self, insert_into_history=True): - self.cursor_offset = -1 # so the cursor isn't touching a paren - self.unhighlight_paren() # in unhighlight_paren - self.highlighted_paren = None + def is_closing_quote(self, e: str) -> bool: + char_count = self._current_line.count(e) + return ( + char_count % 2 == 0 + and cursor_on_closing_char_pair( + self._cursor_offset, self._current_line, e + )[0] + ) + + def insert_char_pair_start(self, e): + """Accepts character which is a part of CHARACTER_PAIR_MAP + like brackets and quotes, and appends it to the line with + an appropriate character pair ending. Closing character can only be inserted + when the next character is either a closing character or a space + + e.x. if you type "(" (lparen) , this will insert "()" + into the line + """ + self.add_normal_character(e) + if self.config.brackets_completion: + start_of_line = len(self._current_line) == 1 + end_of_line = len(self._current_line) == self._cursor_offset + can_lookup_next = len(self._current_line) > self._cursor_offset + next_char = ( + None + if not can_lookup_next + else self._current_line[self._cursor_offset] + ) + if ( + start_of_line + or end_of_line + or (next_char is not None and next_char in "})] ") + ): + self.add_normal_character( + CHARACTER_PAIR_MAP[e], narrow_search=False + ) + self._cursor_offset -= 1 + + def insert_char_pair_end(self, e): + """Accepts character which is a part of CHARACTER_PAIR_MAP + like brackets and quotes, and checks whether it should be + inserted to the line or overwritten + + e.x. if you type ")" (rparen) , and your cursor is directly + above another ")" (rparen) in the cmd, this will just skip + it and move the cursor. + If there is no same character underneath the cursor, the + character will be printed/appended to the line + """ + if self.config.brackets_completion: + if self.cursor_offset < len(self._current_line): + if self._current_line[self.cursor_offset] == e: + self.cursor_offset += 1 + return + self.add_normal_character(e) + + def get_last_word(self): + previous_word = _last_word(self.rl_history.entry) + word = _last_word(self.rl_history.back()) + line = self.current_line + self._set_current_line( + line[: len(line) - len(previous_word)] + word, + reset_rl_history=False, + ) + self._set_cursor_offset( + self.cursor_offset - len(previous_word) + len(word), + reset_rl_history=False, + ) + + def incremental_search(self, reverse=False, include_current=False): + if self.incr_search_mode == SearchMode.NO_SEARCH: + self.rl_history.enter(self.current_line) + self.incr_search_target = "" + else: + if self.incr_search_target: + line = ( + self.rl_history.back( + False, + search=True, + target=self.incr_search_target, + include_current=include_current, + ) + if reverse + else self.rl_history.forward( + False, + search=True, + target=self.incr_search_target, + include_current=include_current, + ) + ) + self._set_current_line( + line, reset_rl_history=False, clear_special_mode=False + ) + self._set_cursor_offset( + len(self.current_line), + reset_rl_history=False, + clear_special_mode=False, + ) + if reverse: + self.incr_search_mode = SearchMode.REVERSE_INCREMENTAL_SEARCH + else: + self.incr_search_mode = SearchMode.INCREMENTAL_SEARCH + + def readline_kill(self, e): + func = self.edit_keys[e] + self.cursor_offset, self.current_line, cut = func( + self.cursor_offset, self.current_line + ) + if self.last_events[-2] == e: # consecutive kill commands accumulative + if func.kills == "ahead": + self.cut_buffer += cut + elif func.kills == "behind": + self.cut_buffer = cut + self.cut_buffer + else: + raise ValueError("cut value other than 'ahead' or 'behind'") + else: + self.cut_buffer = cut + + def on_enter(self, new_code=True, reset_rl_history=True): + # so the cursor isn't touching a paren TODO: necessary? + if new_code: + self.redo_stack = [] + + self._set_cursor_offset(-1, update_completion=False) + if reset_rl_history: + self.rl_history.reset() - self.rl_history.append(self.current_line) - self.rl_history.last() self.history.append(self.current_line) - self.push(self.current_line, insert_into_history=insert_into_history) + self.all_logical_lines.append((self.current_line, LineType.INPUT)) + self.push(self.current_line, insert_into_history=new_code) def on_tab(self, back=False): """Do something on tab key @@ -517,165 +958,315 @@ def on_tab(self, back=False): Does one of the following: 1) add space to move up to the next %4==0 column - 2) complete the current word with characters common to all completions and + 2) complete the current word with characters common to all completions 3) select the first or last match 4) select the next or previous match if already have a match """ def only_whitespace_left_of_cursor(): - """returns true if all characters on current line before cursor are whitespace""" - return self.current_line[:self.cursor_offset].strip() - - logger.debug('self.matches_iter.matches: %r', self.matches_iter.matches) - if not only_whitespace_left_of_cursor(): - front_white = (len(self.current_line[:self.cursor_offset]) - - len(self.current_line[:self.cursor_offset].lstrip())) - to_add = 4 - (front_white % self.config.tab_length) - for _ in range(to_add): - self.add_normal_character(' ') - return + """returns true if all characters before cursor are whitespace""" + return not self.current_line[: self.cursor_offset].strip() - # run complete() if we aren't already iterating through matches - if not self.matches_iter: + logger.debug("self.matches_iter.matches: %r", self.matches_iter.matches) + if only_whitespace_left_of_cursor(): + front_ws = len(self.current_line[: self.cursor_offset]) - len( + self.current_line[: self.cursor_offset].lstrip() + ) + to_add = 4 - (front_ws % self.config.tab_length) + for unused in range(to_add): + self.add_normal_character(" ") + return + # if cursor on closing character from pair, + # moves cursor behind it on tab + # ? should we leave it here as default? + if self.config.brackets_completion: + on_closing_char, _ = cursor_on_closing_char_pair( + self._cursor_offset, self._current_line + ) + if on_closing_char: + self._cursor_offset += 1 + # run complete() if we don't already have matches + if len(self.matches_iter.matches) == 0: self.list_win_visible = self.complete(tab=True) # 3. check to see if we can expand the current word if self.matches_iter.is_cseq(): - self._cursor_offset, self._current_line = self.matches_iter.substitute_cseq() + cursor_and_line = self.matches_iter.substitute_cseq() + self._cursor_offset, self._current_line = cursor_and_line # using _current_line so we don't trigger a completion reset - if not self.matches_iter: + if not self.matches_iter.matches: self.list_win_visible = self.complete() - elif self.matches_iter.matches: - self.current_match = back and self.matches_iter.previous() \ - or self.matches_iter.next() - self._cursor_offset, self._current_line = self.matches_iter.cur_line() + self.current_match = ( + back and self.matches_iter.previous() or next(self.matches_iter) + ) + cursor_and_line = self.matches_iter.cur_line() + self._cursor_offset, self._current_line = cursor_and_line # using _current_line so we don't trigger a completion reset + self.list_win_visible = True + if self.config.brackets_completion: + # appends closing char pair if completion is a callable + if self.is_completion_callable(self._current_line): + self._current_line = self.append_closing_character( + self._current_line + ) + + def is_completion_callable(self, completion): + """Checks whether given completion is callable (e.x. function)""" + completion_end = completion[-1] + return completion_end in CHARACTER_PAIR_MAP + + def append_closing_character(self, completion): + """Appends closing character/bracket to the completion""" + completion_end = completion[-1] + if completion_end in CHARACTER_PAIR_MAP: + completion = f"{completion}{CHARACTER_PAIR_MAP[completion_end]}" + return completion def on_control_d(self): - if self.current_line == '': + if self.current_line == "": raise SystemExit() else: - self.current_line = self.current_line[:self.cursor_offset] + self.current_line[self.cursor_offset+1:] + self.current_line = ( + self.current_line[: self.cursor_offset] + + self.current_line[(self.cursor_offset + 1) :] + ) + + def cut_to_buffer(self): + self.cut_buffer = self.current_line[self.cursor_offset :] + self.current_line = self.current_line[: self.cursor_offset] + + def yank_from_buffer(self): + pass + + def operate_and_get_next(self): + # If we have not navigated back in history + # ctrl+o will have the same effect as enter + self.on_enter(reset_rl_history=False) def up_one_line(self): self.rl_history.enter(self.current_line) - self._set_current_line(self.rl_history.back(False, search=self.config.curtsies_right_arrow_completion), - reset_rl_history=False) + self._set_current_line( + tabs_to_spaces( + self.rl_history.back( + False, search=self.config.curtsies_right_arrow_completion + ) + ), + update_completion=False, + reset_rl_history=False, + ) self._set_cursor_offset(len(self.current_line), reset_rl_history=False) def down_one_line(self): self.rl_history.enter(self.current_line) - self._set_current_line(self.rl_history.forward(False, search=self.config.curtsies_right_arrow_completion), - reset_rl_history=False) + self._set_current_line( + tabs_to_spaces( + self.rl_history.forward( + False, search=self.config.curtsies_right_arrow_completion + ) + ), + update_completion=False, + reset_rl_history=False, + ) self._set_cursor_offset(len(self.current_line), reset_rl_history=False) - def process_simple_keypress(self, e): - if e in (u"", u"", u""): + def process_simple_keypress(self, e: str): + # '\n' needed for pastes + if e in ("", "", "", "\n", "\r"): self.on_enter() while self.fake_refresh_requested: self.fake_refresh_requested = False - self.process_event(events.RefreshRequestEvent()) + self.process_event(bpythonevents.RefreshRequestEvent()) elif isinstance(e, events.Event): - pass # ignore events - elif e == '': - self.add_normal_character(' ') + pass # ignore events + elif e == "": + self.add_normal_character(" ") else: self.add_normal_character(e) def send_current_block_to_external_editor(self, filename=None): + """ + Sends the current code block to external editor to be edited. Usually bound to C-x. + """ text = self.send_to_external_editor(self.get_current_block()) - lines = [line for line in text.split('\n')] + lines = [line for line in text.split("\n")] while lines and not lines[-1].split(): lines.pop() - events = '\n'.join(lines + ([''] if len(lines) == 1 else ['', ''])) + events = "\n".join(lines + ([""] if len(lines) == 1 else ["", ""])) self.clear_current_block() with self.in_paste_mode(): for e in events: self.process_simple_keypress(e) - self.current_line = '' self.cursor_offset = len(self.current_line) def send_session_to_external_editor(self, filename=None): - for_editor = '### current bpython session - file will be reevaluated, ### lines will not be run\n'.encode('utf8') - for_editor += ('\n'.join(line[len(self.ps1):] if line.startswith(self.ps1) else - (line[len(self.ps2):] if line.startswith(self.ps2) else - '### '+line) - for line in self.getstdout().split('\n')).encode('utf8')) + """ + Sends entire bpython session to external editor to be edited. Usually bound to F7. + """ + for_editor = EDIT_SESSION_HEADER + for_editor += self.get_session_formatted_for_file() + text = self.send_to_external_editor(for_editor) - lines = text.split('\n') - self.history = [line for line in lines if line[:4] != '### '] - self.reevaluate(insert_into_history=True) - self.current_line = lines[-1][4:] + if text == for_editor: + self.status_bar.message( + _("Session not reevaluated because it was not edited") + ) + return + lines = text.split("\n") + if len(lines) and not lines[-1].strip(): + lines.pop() # strip last line if empty + if len(lines) and lines[-1].startswith("### "): + current_line = lines[-1][4:] + else: + current_line = "" + from_editor = [ + line for line in lines if line[:6] != "# OUT:" and line[:3] != "###" + ] + if all(not line.strip() for line in from_editor): + self.status_bar.message( + _("Session not reevaluated because saved file was blank") + ) + return + + source = preprocess("\n".join(from_editor), self.interp.compile) + lines = source.split("\n") + self.history = lines + self.reevaluate(new_code=True) + self.current_line = current_line self.cursor_offset = len(self.current_line) + self.status_bar.message(_("Session edited and reevaluated")) def clear_modules_and_reevaluate(self): - if self.watcher: self.watcher.reset() + if self.watcher: + self.watcher.reset() cursor, line = self.cursor_offset, self.current_line - for modname in sys.modules.keys(): - if modname not in self.original_modules: - del sys.modules[modname] - self.reevaluate(insert_into_history=True) + for modname in set(sys.modules.keys()) - self.original_modules: + del sys.modules[modname] + self.reevaluate(new_code=False) self.cursor_offset, self.current_line = cursor, line - self.status_bar.message('Reloaded at ' + time.strftime('%H:%M:%S') + ' by user') + self.status_bar.message( + _("Reloaded at %s by user.") % (time.strftime("%X"),) + ) def toggle_file_watch(self): if self.watcher: - msg = "Auto-reloading active, watching for file changes..." if self.watching_files: + msg = _("Auto-reloading deactivated.") + self.status_bar.message(msg) self.watcher.deactivate() self.watching_files = False - self.status_bar.pop_permanent_message(msg) else: + msg = _("Auto-reloading active, watching for file changes...") + self.status_bar.message(msg) self.watching_files = True - self.status_bar.push_permanent_message(msg) self.watcher.activate() else: - self.status_bar.message('Autoreloading not available because watchdog not installed') + self.status_bar.message( + _( + "Auto-reloading not available because " + "watchdog not installed." + ) + ) - ## Handler Helpers - def add_normal_character(self, char): + # Handler Helpers + def add_normal_character(self, char, narrow_search=True): if len(char) > 1 or is_nop(char): return - self.current_line = (self.current_line[:self.cursor_offset] + - char + - self.current_line[self.cursor_offset:]) - self.cursor_offset += 1 - if self.config.cli_trim_prompts and self.current_line.startswith(self.ps1): + if self.incr_search_mode != SearchMode.NO_SEARCH: + self.add_to_incremental_search(char) + else: + self._set_current_line( + ( + self.current_line[: self.cursor_offset] + + char + + self.current_line[self.cursor_offset :] + ), + update_completion=False, + reset_rl_history=False, + clear_special_mode=False, + ) + if narrow_search: + self.cursor_offset += 1 + else: + self._cursor_offset += 1 + if self.config.cli_trim_prompts and self.current_line.startswith( + self.ps1 + ): self.current_line = self.current_line[4:] - self.cursor_offset = max(0, self.cursor_offset - 4) + if narrow_search: + self.cursor_offset = max(0, self.cursor_offset - 4) + else: + self._cursor_offset += max(0, self.cursor_offset - 4) + + def add_to_incremental_search(self, char=None, backspace=False): + """Modify the current search term while in incremental search. + + The only operations allowed in incremental search mode are + adding characters and backspacing.""" + if backspace: + self.incr_search_target = self.incr_search_target[:-1] + elif char is not None: + self.incr_search_target += char + else: + raise ValueError("must provide a char or set backspace to True") + if self.incr_search_mode == SearchMode.REVERSE_INCREMENTAL_SEARCH: + self.incremental_search(reverse=True, include_current=True) + elif self.incr_search_mode == SearchMode.INCREMENTAL_SEARCH: + self.incremental_search(include_current=True) + else: + raise ValueError("add_to_incremental_search not in a special mode") def update_completion(self, tab=False): - """Update visible docstring and matches, and possibly hide/show completion box""" - #Update autocomplete info; self.matches_iter and self.argspec - #Should be called whenever the completion box might need to appear / dissapear - #when current line or cursor offset changes, unless via selecting a match + """Update visible docstring and matches and box visibility""" + # Update autocomplete info; self.matches_iter and self.funcprops + # Should be called whenever the completion box might need to appear + # or disappear; whenever current line or cursor offset changes, + # unless this happened via selecting a match self.current_match = None - self.list_win_visible = BpythonRepl.complete(self, tab) + self.list_win_visible = self.complete(tab) + + def predicted_indent(self, line): + # TODO get rid of this! It's repeated code! Combine with Repl. + logger.debug("line is %r", line) + indent = len(re.match(r"[ ]*", line).group()) + if line.endswith(":"): + indent = max(0, indent + self.config.tab_length) + elif line and line.count(" ") == len(line): + indent = max(0, indent - self.config.tab_length) + elif ( + line + and ":" not in line + and line.strip().startswith( + ("return", "pass", "...", "raise", "yield", "break", "continue") + ) + ): + indent = max(0, indent - self.config.tab_length) + logger.debug("indent we found was %s", indent) + return indent - def push(self, line, insert_into_history=True): + def push(self, line, insert_into_history=True) -> bool: """Push a line of code onto the buffer, start running the buffer If the interpreter successfully runs the code, clear the buffer """ + # Note that push() overrides its parent without calling it, unlike + # urwid and cli which implement custom behavior and call repl.Repl.push if self.paste_mode: self.saved_indent = 0 else: - indent = len(re.match(r'[ ]*', line).group()) - if line.endswith(':'): - indent = max(0, indent + self.config.tab_length) - elif line and line.count(' ') == len(line): - indent = max(0, indent - self.config.tab_length) - elif line and ':' not in line and line.strip().startswith(('return', 'pass', 'raise', 'yield')): - indent = max(0, indent - self.config.tab_length) - self.saved_indent = indent - - #current line not added to display buffer if quitting #TODO I don't understand this comment + self.saved_indent = self.predicted_indent(line) + if self.config.syntax: - display_line = bpythonparse(format(self.tokenize(line), self.formatter)) - # careful: self.tokenize requires that the line not be in self.buffer yet! + display_line = bpythonparse( + pygformat(self.tokenize(line), self.formatter) + ) + # self.tokenize requires that the line not be in self.buffer yet - logger.debug('display line being pushed to buffer: %r -> %r', line, display_line) + logger.debug( + "display line being pushed to buffer: %r -> %r", + line, + display_line, + ) self.display_buffer.append(display_line) else: self.display_buffer.append(fmtstr(line)) @@ -684,35 +1275,23 @@ def push(self, line, insert_into_history=True): self.insert_into_history(line) self.buffer.append(line) - code_to_run = '\n'.join(self.buffer) + code_to_run = "\n".join(self.buffer) - logger.debug('running %r in interpreter', self.buffer) - c, code_will_parse = self.buffer_finished_will_parse() + logger.debug("running %r in interpreter", self.buffer) + c, code_will_parse = code_finished_will_parse( + "\n".join(self.buffer), self.interp.compile + ) self.saved_predicted_parse_error = not code_will_parse if c: - logger.debug('finished - buffer cleared') + logger.debug("finished - buffer cleared") + self.cursor_offset = 0 self.display_lines.extend(self.display_buffer_lines) self.display_buffer = [] self.buffer = [] - self.cursor_offset = 0 self.coderunner.load_code(code_to_run) self.run_code_and_maybe_finish() - - def buffer_finished_will_parse(self): - """Returns a tuple of whether the buffer could be complete and whether it will parse - - True, True means code block is finished and no predicted parse error - True, False means code block is finished because a parse error is predicted - False, True means code block is unfinished - False, False isn't possible - an predicted error makes code block done""" - try: - finished = bool(code.compile_command('\n'.join(self.buffer))) - code_will_parse = True - except (ValueError, SyntaxError, OverflowError): - finished = True - code_will_parse = False - return finished, code_will_parse + return not code_will_parse def run_code_and_maybe_finish(self, for_code=None): r = self.coderunner.run_code(for_code=for_code) @@ -726,26 +1305,31 @@ def run_code_and_maybe_finish(self, for_code=None): if err: indent = 0 - #TODO This should be printed ABOVE the error that just happened instead - # or maybe just thrown away and not shown - if self.current_stdouterr_line: - self.display_lines.extend(paint.display_linize(self.current_stdouterr_line, self.width)) - self.current_stdouterr_line = '' - - self.current_line = ' '*indent + if self.rl_history.index == 0: + self._set_current_line(" " * indent, update_completion=True) + else: + self._set_current_line( + self.rl_history.entries[-self.rl_history.index], + reset_rl_history=False, + ) self.cursor_offset = len(self.current_line) def keyboard_interrupt(self): - #TODO factor out the common cleanup from running a line + # TODO factor out the common cleanup from running a line self.cursor_offset = -1 self.unhighlight_paren() self.display_lines.extend(self.display_buffer_lines) - self.display_lines.extend(paint.display_linize(self.current_cursor_line, self.width)) - self.display_lines.extend(paint.display_linize("KeyboardInterrupt", self.width)) + self.display_lines.extend( + paint.display_linize(self.current_cursor_line, self.width) + ) + self.display_lines.extend( + paint.display_linize("KeyboardInterrupt", self.width) + ) self.clear_current_block(remove_from_history=False) def unhighlight_paren(self): - """modify line in self.display_buffer to unhighlight a paren if possible + """Modify line in self.display_buffer to unhighlight a paren if + possible. self.highlighted_paren should be a line in ? """ @@ -755,71 +1339,108 @@ def unhighlight_paren(self): # then this is the current line, so don't worry about it return self.highlighted_paren = None - logger.debug('trying to unhighlight a paren on line %r', lineno) - logger.debug('with these tokens: %r', saved_tokens) - new = bpythonparse(format(saved_tokens, self.formatter)) - self.display_buffer[lineno] = self.display_buffer[lineno].setslice_with_length(0, len(new), new, len(self.display_buffer[lineno])) + logger.debug("trying to unhighlight a paren on line %r", lineno) + logger.debug("with these tokens: %r", saved_tokens) + new = bpythonparse(pygformat(saved_tokens, self.formatter)) + self.display_buffer[lineno] = self.display_buffer[ + lineno + ].setslice_with_length( + 0, len(new), new, len(self.display_buffer[lineno]) + ) def clear_current_block(self, remove_from_history=True): self.display_buffer = [] if remove_from_history: - [self.history.pop() for _ in self.buffer] + del self.history[-len(self.buffer) :] + del self.all_logical_lines[-len(self.buffer) :] self.buffer = [] self.cursor_offset = 0 self.saved_indent = 0 - self.current_line = '' + self.current_line = "" self.cursor_offset = len(self.current_line) def get_current_block(self): - return '\n'.join(self.buffer + [self.current_line]) + """ + Returns the current code block as string (without prompts) + """ + return "\n".join(self.buffer + [self.current_line]) + + def send_to_stdouterr(self, output): + """Send unicode strings or FmtStr to Repl stdout or stderr - def send_to_stdout(self, output): - lines = output.split('\n') - logger.debug('display_lines: %r', self.display_lines) + Must be able to handle FmtStrs because interpreter pass in + tracebacks already formatted.""" + lines = output.split("\n") + logger.debug("display_lines: %r", self.display_lines) self.current_stdouterr_line += lines[0] if len(lines) > 1: - self.display_lines.extend(paint.display_linize(self.current_stdouterr_line, self.width, blank_line=True)) - self.display_lines.extend(sum([paint.display_linize(line, self.width, blank_line=True) for line in lines[1:-1]], [])) - self.current_stdouterr_line = lines[-1] - logger.debug('display_lines: %r', self.display_lines) + self.display_lines.extend( + paint.display_linize( + self.current_stdouterr_line, self.width, blank_line=True + ) + ) + self.display_lines.extend( + sum( + ( + paint.display_linize(line, self.width, blank_line=True) + for line in lines[1:-1] + ), + [], + ) + ) + # These can be FmtStrs, but self.all_logical_lines only wants strings + for line in itertools.chain( + (self.current_stdouterr_line,), lines[1:-1] + ): + if isinstance(line, FmtStr): + self.all_logical_lines.append((line.s, LineType.OUTPUT)) + else: + self.all_logical_lines.append((line, LineType.OUTPUT)) - def send_to_stderr(self, error): - lines = error.split('\n') - if lines[-1]: - self.current_stdouterr_line += lines[-1] - self.display_lines.extend([func_for_letter(self.config.color_scheme['error'])(line) - for line in sum([paint.display_linize(line, self.width, blank_line=True) - for line in lines[:-1]], [])]) + self.current_stdouterr_line = lines[-1] + logger.debug("display_lines: %r", self.display_lines) def send_to_stdin(self, line): - if line.endswith('\n'): - self.display_lines.extend(paint.display_linize(self.current_output_line, self.width)) - self.current_output_line = '' - #self.display_lines = self.display_lines[:len(self.display_lines) - self.stdin.old_num_lines] - #lines = paint.display_linize(line, self.width) - #self.stdin.old_num_lines = len(lines) - #self.display_lines.extend(paint.display_linize(line, self.width)) - pass - + if line.endswith("\n"): + self.display_lines.extend( + paint.display_linize(self.current_output_line, self.width) + ) + self.current_output_line = "" - ## formatting, output + # formatting, output @property def done(self): - """Whether the last block is complete - which prompt to use, ps1 or ps2""" + """Whether the last block is complete - which prompt to use, ps1 or + ps2""" return not self.buffer @property def current_line_formatted(self): """The colored current line (no prompt, not wrapped)""" if self.config.syntax: - fs = bpythonparse(format(self.tokenize(self.current_line), self.formatter)) - if self.rl_history.saved_line in self.current_line: - if self.config.curtsies_right_arrow_completion: - fs = fmtfuncs.on_magenta(self.rl_history.saved_line).join(fs.split(self.rl_history.saved_line)) - logger.debug('Display line %r -> %r', self.current_line, fs) + fs = bpythonparse( + pygformat(self.tokenize(self.current_line), self.formatter) + ) + if self.incr_search_mode != SearchMode.NO_SEARCH: + if self.incr_search_target in self.current_line: + fs = fmtfuncs.on_magenta(self.incr_search_target).join( + fs.split(self.incr_search_target) + ) + elif ( + self.rl_history.saved_line + and self.rl_history.saved_line in self.current_line + ): + if ( + self.config.curtsies_right_arrow_completion + and self.rl_history.index != 0 + ): + fs = fmtfuncs.on_magenta(self.rl_history.saved_line).join( + fs.split(self.rl_history.saved_line) + ) + logger.debug("Display line %r -> %r", self.current_line, fs) else: fs = fmtstr(self.current_line) - if hasattr(self, 'old_fs') and str(fs) != str(self.old_fs): + if hasattr(self, "old_fs") and str(fs) != str(self.old_fs): pass self.old_fs = fs return fs @@ -831,12 +1452,14 @@ def lines_for_display(self): @property def display_buffer_lines(self): - """The display lines (wrapped, colored, with prompts) for the current buffer""" + """The display lines (wrapped, colored, +prompts) of current buffer""" lines = [] for display_line in self.display_buffer: - display_line = (func_for_letter(self.config.color_scheme['prompt_more'])(self.ps2) - if lines else - func_for_letter(self.config.color_scheme['prompt'])(self.ps1)) + display_line + prompt = func_for_letter(self.config.color_scheme["prompt"]) + more = func_for_letter(self.config.color_scheme["prompt_more"]) + display_line = ( + more(self.ps2) if lines else prompt(self.ps1) + ) + display_line for line in paint.display_linize(display_line, self.width): lines.append(line) return lines @@ -844,29 +1467,51 @@ def display_buffer_lines(self): @property def display_line_with_prompt(self): """colored line with prompt""" - return (func_for_letter(self.config.color_scheme['prompt'])(self.ps1) - if self.done else - func_for_letter(self.config.color_scheme['prompt_more'])(self.ps2)) + self.current_line_formatted + prompt = func_for_letter(self.config.color_scheme["prompt"]) + more = func_for_letter(self.config.color_scheme["prompt_more"]) + if self.incr_search_mode == SearchMode.REVERSE_INCREMENTAL_SEARCH: + return ( + prompt(f"(reverse-i-search)`{self.incr_search_target}': ") + + self.current_line_formatted + ) + elif self.incr_search_mode == SearchMode.INCREMENTAL_SEARCH: + return prompt(f"(i-search)`%s': ") + self.current_line_formatted + return ( + prompt(self.ps1) if self.done else more(self.ps2) + ) + self.current_line_formatted @property def current_cursor_line_without_suggestion(self): - """Current line, either output/input or Python prompt + code""" - value = (self.current_output_line + - ('' if self.coderunner.running else self.display_line_with_prompt)) - logger.debug('current cursor line: %r', value) + """ + Current line, either output/input or Python prompt + code + + :returns: FmtStr + """ + value = self.current_output_line + ( + "" if self.coderunner.running else self.display_line_with_prompt + ) + logger.debug("current cursor line: %r", value) return value @property def current_cursor_line(self): if self.config.curtsies_right_arrow_completion: - return self.current_cursor_line_without_suggestion + fmtfuncs.bold(fmtfuncs.dark((self.current_suggestion))) + suggest = func_for_letter( + self.config.color_scheme["right_arrow_suggestion"] + ) + return self.current_cursor_line_without_suggestion + suggest( + self.current_suggestion + ) else: return self.current_cursor_line_without_suggestion @property def current_suggestion(self): - matches = [e for e in self.rl_history.entries if e.startswith(self.current_line) and self.current_line] - return matches[-1][len(self.current_line):] if matches else '' + if self.current_line: + for entry in reversed(self.rl_history.entries): + if entry.startswith(self.current_line): + return entry[len(self.current_line) :] + return "" @property def current_output_line(self): @@ -875,263 +1520,548 @@ def current_output_line(self): @current_output_line.setter def current_output_line(self, value): - self.current_stdouterr_line = '' - self.stdin.current_line = '\n' - - def paint(self, about_to_exit=False, user_quit=False): - """Returns an array of min_height or more rows and width columns, plus cursor position - - Paints the entire screen - ideally the terminal display layer will take a diff and only - write to the screen in portions that have changed, but the idea is that we don't need - to worry about that here, instead every frame is completely redrawn because - less state is cool! + self.current_stdouterr_line = "" + self.stdin.current_line = "\n" + + def number_of_padding_chars_on_current_cursor_line(self): + """To avoid cutting off two-column characters at the end of lines where + there's only one column left, curtsies adds a padding char (u' '). + It's important to know about these for cursor positioning. + + Should return zero unless there are fullwidth characters.""" + full_line = self.current_cursor_line_without_suggestion + line_with_padding_len = sum( + len(line.s) + for line in paint.display_linize( + self.current_cursor_line_without_suggestion.s, self.width + ) + ) + + # the difference in length here is how much padding there is + return line_with_padding_len - len(full_line) + + def paint( + self, + about_to_exit=False, + user_quit=False, + try_preserve_history_height=30, + min_infobox_height=5, + ) -> tuple[FSArray, tuple[int, int]]: + """Returns an array of min_height or more rows and width columns, plus + cursor position + + Paints the entire screen - ideally the terminal display layer will take + a diff and only write to the screen in portions that have changed, but + the idea is that we don't need to worry about that here, instead every + frame is completely redrawn because less state is cool! + + try_preserve_history_height is the the number of rows of content that + must be visible before the suggestion box scrolls the terminal in order + to display more than min_infobox_height rows of suggestions, docs etc. """ - # The hairiest function in the curtsies - a cleanup would be great. - + # The hairiest function in the curtsies if about_to_exit: - self.clean_up_current_line_for_exit() # exception to not changing state! + # exception to not changing state! + self.clean_up_current_line_for_exit() width, min_height = self.width, self.height - show_status_bar = bool(self.status_bar.should_show_message) or (self.config.curtsies_fill_terminal or self.status_bar.has_focus) + show_status_bar = ( + bool(self.status_bar.should_show_message) + or self.status_bar.has_focus + ) and not self.request_paint_to_pad_bottom if show_status_bar: + # because we're going to tack the status bar on at the end, shoot + # for an array one less than the height of the screen min_height -= 1 - current_line_start_row = len(self.lines_for_display) - max(0, self.scroll_offset) - #current_line_start_row = len(self.lines_for_display) - self.scroll_offset - if self.request_paint_to_clear_screen: # or show_status_bar and about_to_exit ? + current_line_start_row = len(self.lines_for_display) - max( + 0, self.scroll_offset + ) + # TODO how is the situation of self.scroll_offset < 0 possible? + # or show_status_bar and about_to_exit ? + if self.request_paint_to_clear_screen: self.request_paint_to_clear_screen = False - if self.config.curtsies_fill_terminal: #TODO clean up this logic - really necessary check? - arr = FSArray(self.height - 1 + current_line_start_row, width) - else: - arr = FSArray(self.height + current_line_start_row, width) + arr = FSArray(min_height + current_line_start_row, width) + elif self.request_paint_to_pad_bottom: + # min_height - 1 for startup banner with python version + height = min(self.request_paint_to_pad_bottom, min_height - 1) + arr = FSArray(height, width) + self.request_paint_to_pad_bottom = 0 else: arr = FSArray(0, width) - #TODO test case of current line filling up the whole screen (there aren't enough rows to show it) + # TODO test case of current line filling up the whole screen (there + # aren't enough rows to show it) - if current_line_start_row < 0: #if current line trying to be drawn off the top of the screen - logger.debug('#<---History contiguity broken by rewind--->') - msg = "#<---History contiguity broken by rewind--->" - arr[0, 0:min(len(msg), width)] = [msg[:width]] + current_line = paint.paint_current_line( + min_height, width, self.current_cursor_line + ) + # needs to happen before we calculate contents of history because + # calculating self.current_cursor_line has the side effect of + # unhighlighting parens in buffer + def move_screen_up(current_line_start_row): # move screen back up a screen minus a line while current_line_start_row < 0: + logger.debug( + "scroll_offset was %s, current_line_start_row " "was %s", + self.scroll_offset, + current_line_start_row, + ) self.scroll_offset = self.scroll_offset - self.height - current_line_start_row = len(self.lines_for_display) - max(-1, self.scroll_offset) + current_line_start_row = len(self.lines_for_display) - max( + -1, self.scroll_offset + ) + logger.debug( + "scroll_offset changed to %s, " + "current_line_start_row changed to %s", + self.scroll_offset, + current_line_start_row, + ) + return current_line_start_row + + if self.inconsistent_history and not self.history_already_messed_up: + logger.debug(INCONSISTENT_HISTORY_MSG) + self.history_already_messed_up = True + msg = INCONSISTENT_HISTORY_MSG + arr[0, 0 : min(len(msg), width)] = [msg[:width]] + current_line_start_row += 1 # for the message + + # to make up for the scroll that will be received after the + # scrolls are rendered down a line + self.scroll_offset -= 1 + + current_line_start_row = move_screen_up(current_line_start_row) + logger.debug("current_line_start_row: %r", current_line_start_row) + + history = paint.paint_history( + max(0, current_line_start_row - 1), + width, + self.lines_for_display, + ) + arr[1 : history.height + 1, : history.width] = history + + if arr.height <= min_height: + # force scroll down to hide broken history message + arr[min_height, 0] = " " + + elif current_line_start_row < 0: + # if current line trying to be drawn off the top of the screen + logger.debug(CONTIGUITY_BROKEN_MSG) + msg = CONTIGUITY_BROKEN_MSG + arr[0, 0 : min(len(msg), width)] = [msg[:width]] - history = paint.paint_history(max(0, current_line_start_row - 1), width, self.lines_for_display) - arr[1:history.height+1,:history.width] = history + current_line_start_row = move_screen_up(current_line_start_row) + + history = paint.paint_history( + max(0, current_line_start_row - 1), + width, + self.lines_for_display, + ) + arr[1 : history.height + 1, : history.width] = history if arr.height <= min_height: - arr[min_height, 0] = ' ' # force scroll down to hide broken history message + # force scroll down to hide broken history message + arr[min_height, 0] = " " + else: - history = paint.paint_history(current_line_start_row, width, self.lines_for_display) - arr[:history.height,:history.width] = history + assert current_line_start_row >= 0 + logger.debug("no history issues. start %i", current_line_start_row) + history = paint.paint_history( + current_line_start_row, width, self.lines_for_display + ) + arr[: history.height, : history.width] = history + + self.inconsistent_history = False - current_line = paint.paint_current_line(min_height, width, self.current_cursor_line) - if user_quit: # quit() or exit() in interp - current_line_start_row = current_line_start_row - current_line.height - logger.debug("---current line row slice %r, %r", current_line_start_row, current_line_start_row + current_line.height) + if user_quit: # quit() or exit() in interp + current_line_start_row = ( + current_line_start_row - current_line.height + ) + logger.debug( + "---current line row slice %r, %r", + current_line_start_row, + current_line_start_row + current_line.height, + ) logger.debug("---current line col slice %r, %r", 0, current_line.width) - arr[current_line_start_row:current_line_start_row + current_line.height, - 0:current_line.width] = current_line + arr[ + current_line_start_row : ( + current_line_start_row + current_line.height + ), + 0 : current_line.width, + ] = current_line if current_line.height > min_height: - return arr, (0, 0) # short circuit, no room for infobox + return arr, (0, 0) # short circuit, no room for infobox - lines = paint.display_linize(self.current_cursor_line+'X', width) - # extra character for space for the cursor + lines = paint.display_linize(self.current_cursor_line + "X", width) + # extra character for space for the cursor current_line_end_row = current_line_start_row + len(lines) - 1 + current_line_height = current_line_end_row - current_line_start_row if self.stdin.has_focus: - cursor_row, cursor_column = divmod(len(self.current_stdouterr_line) + self.stdin.cursor_offset, width) - assert cursor_column >= 0, cursor_column - elif self.coderunner.running: #TODO does this ever happen? - cursor_row, cursor_column = divmod(len(self.current_cursor_line_without_suggestion) + self.cursor_offset, width) - assert cursor_column >= 0, (cursor_column, len(self.current_cursor_line), len(self.current_line), self.cursor_offset) - else: - cursor_row, cursor_column = divmod(len(self.current_cursor_line_without_suggestion) - len(self.current_line) + self.cursor_offset, width) - assert cursor_column >= 0, (cursor_column, len(self.current_cursor_line), len(self.current_line), self.cursor_offset) + logger.debug( + "stdouterr when self.stdin has focus: %r %r", + type(self.current_stdouterr_line), + self.current_stdouterr_line, + ) + # mypy can't do ternary type guards yet + stdouterr = self.current_stdouterr_line + if isinstance(stdouterr, FmtStr): + stdouterr_width = stdouterr.width + else: + stdouterr_width = len(stdouterr) + cursor_row, cursor_column = divmod( + stdouterr_width + + wcswidth( + self.stdin.current_line, max(0, self.stdin.cursor_offset) + ), + width, + ) + assert cursor_row >= 0 and cursor_column >= 0, ( + cursor_row, + cursor_column, + self.current_stdouterr_line, + self.stdin.current_line, + ) + elif self.coderunner.running: # TODO does this ever happen? + cursor_row, cursor_column = divmod( + len(self.current_cursor_line_without_suggestion) + + self.cursor_offset, + width, + ) + assert cursor_row >= 0 and cursor_column >= 0, ( + cursor_row, + cursor_column, + len(self.current_cursor_line), + len(self.current_line), + self.cursor_offset, + ) + else: # Common case for determining cursor position + cursor_row, cursor_column = divmod( + wcswidth(self.current_cursor_line_without_suggestion.s) + - wcswidth(self.current_line) + + wcswidth(self.current_line, max(0, self.cursor_offset)) + + self.number_of_padding_chars_on_current_cursor_line(), + width, + ) + assert cursor_row >= 0 and cursor_column >= 0, ( + cursor_row, + cursor_column, + self.current_cursor_line_without_suggestion.s, + self.current_line, + self.cursor_offset, + ) cursor_row += current_line_start_row - if self.list_win_visible: - logger.debug('infobox display code running') + if self.list_win_visible and not self.coderunner.running: + logger.debug("infobox display code running") visible_space_above = history.height - visible_space_below = min_height - current_line_end_row - 1 - - info_max_rows = max(visible_space_above, visible_space_below) - infobox = paint.paint_infobox(info_max_rows, - int(width * self.config.cli_suggestion_width), - self.matches_iter.matches, - self.argspec, - self.current_match, - self.docstring, - self.config, - self.matches_iter.completer.format if self.matches_iter.completer else None) - - if visible_space_above >= infobox.height and self.config.curtsies_list_above: - arr[current_line_start_row - infobox.height:current_line_start_row, 0:infobox.width] = infobox + potential_space_below = min_height - current_line_end_row - 1 + visible_space_below = ( + potential_space_below - self.get_top_usable_line() + ) + + if self.config.curtsies_list_above: + info_max_rows = max(visible_space_above, visible_space_below) else: - arr[current_line_end_row + 1:current_line_end_row + 1 + infobox.height, 0:infobox.width] = infobox - logger.debug('slamming infobox of shape %r into arr of shape %r', infobox.shape, arr.shape) + # Logic for determining size of completion box + # smallest allowed over-full completion box + preferred_height = max( + # always make infobox at least this height + min_infobox_height, + # use this value if there's so much space that we can + # preserve this try_preserve_history_height rows history + min_height - try_preserve_history_height, + ) + + info_max_rows = min( + max(visible_space_below, preferred_height), + min_height - current_line_height - 1, + ) + infobox = paint.paint_infobox( + info_max_rows, + int(width * self.config.cli_suggestion_width), + self.matches_iter.matches, + self.funcprops, + self.arg_pos, + self.current_match, + self.docstring, + self.config, + ( + self.matches_iter.completer.format + if self.matches_iter.completer + else None + ), + ) - logger.debug('about to exit: %r', about_to_exit) + if ( + visible_space_below >= infobox.height + or not self.config.curtsies_list_above + ): + arr[ + current_line_end_row + + 1 : (current_line_end_row + 1 + infobox.height), + 0 : infobox.width, + ] = infobox + else: + arr[ + current_line_start_row + - infobox.height : current_line_start_row, + 0 : infobox.width, + ] = infobox + logger.debug( + "infobox of shape %r added to arr of shape %r", + infobox.shape, + arr.shape, + ) + + logger.debug("about to exit: %r", about_to_exit) if show_status_bar: - if self.config.curtsies_fill_terminal: - if about_to_exit: - arr[max(arr.height, min_height), :] = FSArray(1, width) - else: - arr[max(arr.height, min_height), :] = paint.paint_statusbar(1, width, self.status_bar.current_line, self.config) - - if self.presentation_mode: - rows = arr.height - columns = arr.width - last_key_box = paint.paint_last_events(rows, columns, [events.pp_event(x) for x in self.last_events if x]) - arr[arr.height-last_key_box.height:arr.height, arr.width-last_key_box.width:arr.width] = last_key_box + statusbar_row = ( + min_height if arr.height == min_height else arr.height + ) + if about_to_exit: + arr[statusbar_row, :] = FSArray(1, width) else: - statusbar_row = min_height + 1 if arr.height == min_height else arr.height - if about_to_exit: - arr[statusbar_row, :] = FSArray(1, width) - else: - arr[statusbar_row, :] = paint.paint_statusbar(1, width, self.status_bar.current_line, self.config) + arr[statusbar_row, :] = paint.paint_statusbar( + 1, width, self.status_bar.current_line, self.config + ) + + if self.presentation_mode: + rows = arr.height + columns = arr.width + last_key_box = paint.paint_last_events( + rows, + columns, + [events.pp_event(x) for x in self.last_events if x], + self.config, + ) + arr[ + arr.height - last_key_box.height : arr.height, + arr.width - last_key_box.width : arr.width, + ] = last_key_box - if self.config.color_scheme['background'] not in ('d', 'D'): + if self.config.color_scheme["background"] not in ("d", "D"): for r in range(arr.height): - arr[r] = fmtstr(arr[r], bg=color_for_letter(self.config.color_scheme['background'])) - logger.debug('returning arr of size %r', arr.shape) - logger.debug('cursor pos: %r', (cursor_row, cursor_column)) + bg = color_for_letter(self.config.color_scheme["background"]) + arr[r] = fmtstr(arr[r], bg=bg) + logger.debug("returning arr of size %r", arr.shape) + logger.debug("cursor pos: %r", (cursor_row, cursor_column)) return arr, (cursor_row, cursor_column) - @contextlib.contextmanager def in_paste_mode(self): orig_value = self.paste_mode self.paste_mode = True yield self.paste_mode = orig_value - - ## Debugging shims, good example of embedding a Repl in other code - def dumb_print_output(self): - arr, cpos = self.paint() - arr[cpos[0]:cpos[0]+1, cpos[1]:cpos[1]+1] = ['~'] - def my_print(msg): - self.orig_stdout.write(str(msg)+'\n') - my_print('X'*(self.width+8)) - my_print(' use "/" for enter '.center(self.width+8, 'X')) - my_print(' use "\\" for rewind '.center(self.width+8, 'X')) - my_print(' use "|" to raise an error '.center(self.width+8, 'X')) - my_print(' use "$" to pastebin '.center(self.width+8, 'X')) - my_print(' "~" is the cursor '.center(self.width+8, 'X')) - my_print('X'*(self.width+8)) - my_print('X``'+('`'*(self.width+2))+'``X') - for line in arr: - my_print('X```'+line.ljust(self.width)+'```X') - logger.debug('line:') - logger.debug(repr(line)) - my_print('X``'+('`'*(self.width+2))+'``X') - my_print('X'*(self.width+8)) - return max(len(arr) - self.height, 0) - - def dumb_input(self, requested_refreshes=[]): - chars = list(self.orig_stdin.readline()[:-1]) - while chars or requested_refreshes: - if requested_refreshes: - requested_refreshes.pop() - self.process_event(events.RefreshRequestEvent()) - continue - c = chars.pop(0) - if c in '/': - c = '\n' - elif c in '\\': - c = '' - elif c in '|': - def r(): raise Exception('errors in other threads should look like this') - t = threading.Thread(target=r) - t.daemon = True - t.start() - elif c in '$': - c = key_dispatch[self.config.pastebin_key][0] - self.process_event(c) + if not self.paste_mode: + self.update_completion() def __repr__(self): - s = '' - s += '""" + + def _get_current_line(self) -> str: + """The current line""" return self._current_line - def _set_current_line(self, line, update_completion=True, reset_rl_history=True): + + def _set_current_line( + self, + line: str, + update_completion=True, + reset_rl_history=True, + clear_special_mode=True, + ): + if self._current_line == line: + return self._current_line = line + if self.paste_mode: + return if update_completion: self.update_completion() if reset_rl_history: self.rl_history.reset() - current_line = property(_get_current_line, _set_current_line, None, - "The current line") - def _get_cursor_offset(self): + if clear_special_mode: + self.special_mode = None + self.unhighlight_paren() + + def _get_cursor_offset(self) -> int: + """The current cursor offset from the front of the "line".""" return self._cursor_offset - def _set_cursor_offset(self, offset, update_completion=True, reset_rl_history=True): - if update_completion: - self.update_completion() + + def _set_cursor_offset( + self, + offset: int, + update_completion=True, + reset_rl_history=False, + clear_special_mode=True, + ): + if self._cursor_offset == offset: + return + if self.paste_mode: + self._cursor_offset = offset + self.unhighlight_paren() + return if reset_rl_history: self.rl_history.reset() + if clear_special_mode: + self.incr_search_mode = SearchMode.NO_SEARCH self._cursor_offset = offset - self.update_completion() - cursor_offset = property(_get_cursor_offset, _set_cursor_offset, None, - "The current cursor offset from the front of the line") + if update_completion: + self.update_completion() + self.unhighlight_paren() + def echo(self, msg, redraw=True): """ Notification that redrawing the current line is necessary (we don't care, since we always redraw the whole screen) - Supposed to parse and echo a formatted string with appropriate attributes. - It's not supposed to update the screen if it's reevaluating the code (as it - does with undo).""" + Supposed to parse and echo a formatted string with appropriate + attributes. It's not supposed to update the screen if it's reevaluating + the code (as it does with undo).""" logger.debug("echo called with %r" % msg) + @property def cpos(self): - "many WATs were had - it's the pos from the end of the line back""" + "many WATs were had - it's the pos from the end of the line back" return len(self.current_line) - self.cursor_offset + def reprint_line(self, lineno, tokens): logger.debug("calling reprint line with %r %r", lineno, tokens) if self.config.syntax: - self.display_buffer[lineno] = bpythonparse(format(tokens, self.formatter)) - def reevaluate(self, insert_into_history=False): + self.display_buffer[lineno] = bpythonparse( + pygformat(tokens, self.formatter) + ) + + def take_back_buffer_line(self): + assert len(self.buffer) > 0 + if len(self.buffer) == 1: + self._cursor_offset = 0 + self.current_line = "" + else: + line = self.buffer[-1] + indent = self.predicted_indent(line) + self._current_line = indent * " " + self.cursor_offset = len(self.current_line) + self.display_buffer.pop() + self.buffer.pop() + self.history.pop() + self.all_logical_lines.pop() + + def take_back_empty_line(self): + assert self.history and not self.history[-1] + self.history.pop() + self.display_lines.pop() + self.all_logical_lines.pop() + + def prompt_undo(self): + if self.buffer: + return self.take_back_buffer_line() + if self.history and not self.history[-1]: + return self.take_back_empty_line() + + def prompt_for_undo(): + n = super(BaseRepl, self).prompt_undo() + if n > 0: + self.request_undo(n=n) + + greenlet.greenlet(prompt_for_undo).switch() + + def redo(self) -> None: + if self.redo_stack: + temp = self.redo_stack.pop() + self.history.append(temp) + self.all_logical_lines.append((temp, LineType.INPUT)) + self.push(temp) + else: + self.status_bar.message("Nothing to redo.") + + def reevaluate(self, new_code=False): """bpython.Repl.undo calls this""" - if self.watcher: self.watcher.reset() + if self.watcher: + self.watcher.reset() old_logical_lines = self.history + old_display_lines = self.display_lines self.history = [] self.display_lines = [] + self.all_logical_lines = [] if not self.weak_rewind: self.interp = self.interp.__class__() + self.interp.write = self.send_to_stdouterr self.coderunner.interp = self.interp + self.initialize_interp() self.buffer = [] self.display_buffer = [] self.highlighted_paren = None + self.process_event(bpythonevents.RunStartupFileEvent()) self.reevaluating = True sys.stdin = ReevaluateFakeStdin(self.stdin, self) for line in old_logical_lines: - self.current_line = line - self.on_enter(insert_into_history=insert_into_history) + self._current_line = line + self.on_enter(new_code=new_code) while self.fake_refresh_requested: self.fake_refresh_requested = False - self.process_event(events.RefreshRequestEvent()) + self.process_event(bpythonevents.RefreshRequestEvent()) sys.stdin = self.stdin self.reevaluating = False - self.cursor_offset = 0 - self.current_line = '' - - def getstdout(self): + num_lines_onscreen = len(self.lines_for_display) - max( + 0, self.scroll_offset + ) + display_lines_offscreen = self.display_lines[ + : len(self.display_lines) - num_lines_onscreen + ] + old_display_lines_offscreen = old_display_lines[ + : (len(self.display_lines) - num_lines_onscreen) + ] + logger.debug( + "old_display_lines_offscreen %s", + "|".join(str(x) for x in old_display_lines_offscreen), + ) + logger.debug( + " display_lines_offscreen %s", + "|".join(str(x) for x in display_lines_offscreen), + ) + if ( + old_display_lines_offscreen[: len(display_lines_offscreen)] + != display_lines_offscreen + ) and not self.history_already_messed_up: + self.inconsistent_history = True + logger.debug( + "after rewind, self.inconsistent_history is %r", + self.inconsistent_history, + ) + + self._cursor_offset = 0 + self.current_line = "" + + def initialize_interp(self) -> None: + self.coderunner.interp.locals["_repl"] = self + self.coderunner.interp.runsource( + "from bpython.curtsiesfrontend._internal import _Helper\n" + ) + self.coderunner.interp.runsource("help = _Helper(_repl)\n") + self.coderunner.interp.runsource("del _Helper\n") + + del self.coderunner.interp.locals["_repl"] + + def getstdout(self) -> str: + """ + Returns a string of the current bpython session, wrapped, WITH prompts. + """ lines = self.lines_for_display + [self.current_line_formatted] - s = '\n'.join([x.s if isinstance(x, FmtStr) else x for x in lines] - ) if lines else '' + s = ( + "\n".join(x.s if isinstance(x, FmtStr) else x for x in lines) + if lines + else "" + ) return s def focus_on_subprocess(self, args): @@ -1139,93 +2069,178 @@ def focus_on_subprocess(self, args): try: signal.signal(signal.SIGWINCH, self.orig_sigwinch_handler) with Termmode(self.orig_stdin, self.orig_tcattrs): - terminal = blessings.Terminal(stream=sys.__stdout__) + terminal = self.window.t with terminal.fullscreen(): sys.__stdout__.write(terminal.save) sys.__stdout__.write(terminal.move(0, 0)) sys.__stdout__.flush() - p = subprocess.Popen(args, - stdin=self.orig_stdin, - stderr=sys.__stderr__, - stdout=sys.__stdout__) + p = subprocess.Popen( + args, + stdin=self.orig_stdin, + stderr=sys.__stderr__, + stdout=sys.__stdout__, + ) p.wait() sys.__stdout__.write(terminal.restore) sys.__stdout__.flush() finally: signal.signal(signal.SIGWINCH, prev_sigwinch_handler) - def pager(self, text): - command = os.environ.get('PAGER', 'less -rf').split() + def pager(self, text: str, title: str = "") -> None: + """Runs an external pager on text""" + + # TODO: make less handle title + command = get_pager_command() with tempfile.NamedTemporaryFile() as tmp: - tmp.write(text) + tmp.write(text.encode(getpreferredencoding())) tmp.flush() self.focus_on_subprocess(command + [tmp.name]) - def show_source(self): - source = self.get_source_of_current_name() - if source is None: - self.status_bar.message(_('Cannot show source.')) + def show_source(self) -> None: + try: + source = self.get_source_of_current_name() + except SourceNotFound as e: + self.status_bar.message(f"{e}") else: if self.config.highlight_show_source: - source = format(PythonLexer().get_tokens(source), TerminalFormatter()) + source = pygformat( + Python3Lexer().get_tokens(source), TerminalFormatter() + ) self.pager(source) - def help_text(self): - return self.version_help_text() + '\n' + self.key_help_text() - - def version_help_text(self): - return (('bpython-curtsies version %s' % bpython.__version__) + ' ' + - ('using curtsies version %s' % curtsies.__version__) + '\n' + - HELP_MESSAGE.format(config_file_location=default_config_path(), - example_config_url='https://raw.githubusercontent.com/bpython/bpython/master/sample-config', - config=self.config) - ) - - def key_help_text(self): - NOT_IMPLEMENTED = ['suspend', 'cut to buffer', 'search', 'last output', 'yank from buffer', 'cut to buffer'] - pairs = [] - pairs.append(['complete history suggestion', 'right arrow at end of line']) - pairs.append(['previous match with current line', 'up arrow']) - for functionality, key in [(attr[:-4].replace('_', ' '), getattr(self.config, attr)) - for attr in self.config.__dict__ - if attr.endswith('key')]: - if functionality in NOT_IMPLEMENTED: key = "Not Implemented" - if key == '': key = 'Disabled' + def help_text(self) -> str: + return self.version_help_text() + "\n" + self.key_help_text() + + def version_help_text(self) -> str: + help_message = _( + """ +Thanks for using bpython! + +See http://bpython-interpreter.org/ for more information and http://docs.bpython-interpreter.org/ for docs. +Please report issues at https://github.com/bpython/bpython/issues + +Features: +Try using undo ({config.undo_key})! +Edit the current line ({config.edit_current_block_key}) or the entire session ({config.external_editor_key}) in an external editor. (currently {config.editor}) +Save sessions ({config.save_key}) or post them to pastebins ({config.pastebin_key})! Current pastebin helper: {config.pastebin_helper} +Reload all modules and rerun session ({config.reimport_key}) to test out changes to a module. +Toggle auto-reload mode ({config.toggle_file_watch_key}) to re-execute the current session when a module you've imported is modified. + +bpython -i your_script.py runs a file in interactive mode +bpython -t your_script.py pastes the contents of a file into the session + +A config file at {config.config_path} customizes keys and behavior of bpython. +You can also set which pastebin helper and which external editor to use. +See {example_config_url} for an example config file. +Press {config.edit_config_key} to edit this config file. +""" + ).format(example_config_url=EXAMPLE_CONFIG_URL, config=self.config) + + return f"bpython-curtsies version {__version__} using curtsies version {curtsies_version}\n{help_message}" + + def key_help_text(self) -> str: + NOT_IMPLEMENTED = ( + "suspend", + "cut to buffer", + "search", + "last output", + "yank from buffer", + "cut to buffer", + ) + pairs = [ + ["complete history suggestion", "right arrow at end of line"], + ["previous match with current line", "up arrow"], + ] + for functionality, key in ( + (attr[:-4].replace("_", " "), getattr(self.config, attr)) + for attr in self.config.__dict__ + if attr.endswith("key") + ): + if functionality in NOT_IMPLEMENTED: + key = "Not Implemented" + if key == "": + key = "Disabled" pairs.append([functionality, key]) max_func = max(len(func) for func, key in pairs) - return '\n'.join('%s : %s' % (func.rjust(max_func), key) for func, key in pairs) + return "\n".join( + f"{func.rjust(max_func)} : {key}" for func, key in pairs + ) + + def get_session_formatted_for_file(self) -> str: + def process(): + for line, lineType in self.all_logical_lines: + if lineType == LineType.INPUT: + yield line + elif line.rstrip(): + yield "# OUT: %s" % line + yield "### %s" % self.current_line + + return "\n".join(process()) + + @property + def ps1(self): + return _process_ps(super().ps1, ">>> ") + + @property + def ps2(self): + return _process_ps(super().ps2, "... ") + + +def is_nop(char: str) -> bool: + return unicodedata.category(char) == "Cc" + + +def tabs_to_spaces(line: str) -> str: + return line.replace("\t", " ") + + +def _last_word(line: str) -> str: + split_line = line.split() + return split_line.pop() if split_line else "" -def is_nop(char): - return unicodedata.category(unicode(char)) == 'Cc' def compress_paste_event(paste_event): - """If all events in a paste event are identical and not simple characters, returns one of them + """If all events in a paste event are identical and not simple characters, + returns one of them - Useful for when the UI is running so slowly that repeated keypresses end up in a paste event. - If we value not getting delayed and assume the user is holding down a key to produce such frequent - key events, it makes sense to drop some of the events. + Useful for when the UI is running so slowly that repeated keypresses end up + in a paste event. If we value not getting delayed and assume the user is + holding down a key to produce such frequent key events, it makes sense to + drop some of the events. """ if not all(paste_event.events[0] == e for e in paste_event.events): return None event = paste_event.events[0] - if len(event) > 1:# basically "is there a special curtsies names for this key?" + # basically "is there a special curtsies names for this key?" + if len(event) > 1: return event else: return None -def simple_repl(): - refreshes = [] - def request_refresh(): - refreshes.append(1) - with Repl(request_refresh=request_refresh) as r: - r.width = 50 - r.height = 10 - while True: - [_ for _ in importcompletion.find_iterator] - r.dumb_print_output() - r.dumb_input(refreshes) - -if __name__ == '__main__': - simple_repl() + +def just_simple_events(event_list: Iterable[str | events.Event]) -> list[str]: + simple_events = [] + for e in event_list: + if isinstance(e, events.Event): + continue # ignore events + # '\n' necessary for pastes + elif e in ("", "", "", "\n", "\r"): + simple_events.append("\n") + elif e == "": + simple_events.append(" ") + elif len(e) > 1: + continue # get rid of etc. + else: + simple_events.append(e) + return simple_events + + +def is_simple_event(e: str | events.Event) -> bool: + if isinstance(e, events.Event): + return False + return ( + e in ("", "", "", "\n", "\r", "") + or len(e) <= 1 + ) diff --git a/bpython/curtsiesfrontend/replpainter.py b/bpython/curtsiesfrontend/replpainter.py index 56d418e1c..3b63ca4c9 100644 --- a/bpython/curtsiesfrontend/replpainter.py +++ b/bpython/curtsiesfrontend/replpainter.py @@ -1,15 +1,11 @@ -# -*- coding: utf-8 -*- import logging -import os +import itertools -from curtsies import fsarray, fmtstr -from curtsies.bpythonparse import func_for_letter +from curtsies import fsarray, fmtstr, FSArray from curtsies.formatstring import linesplit from curtsies.fmtfuncs import bold -from bpython._py3compat import py3 -if not py3: - import inspect +from .parse import func_for_letter logger = logging.getLogger(__name__) @@ -17,161 +13,268 @@ # * return an array of the width they were asked for # * return an array not taller than the height they were asked for + def display_linize(msg, columns, blank_line=False): """Returns lines obtained by splitting msg over multiple lines. Warning: if msg is empty, returns an empty list of lines""" - display_lines = ([msg[start:end] - for start, end in zip( - range(0, len(msg), columns), - range(columns, len(msg)+columns, columns))] - if msg else ([''] if blank_line else [])) + if not msg: + return [""] if blank_line else [] + msg = fmtstr(msg) + try: + display_lines = list(msg.width_aware_splitlines(columns)) + # use old method if wcwidth can't determine width of msg + except ValueError: + display_lines = [ + msg[start:end] + for start, end in zip( + range(0, len(msg), columns), + range(columns, len(msg) + columns, columns), + ) + ] return display_lines + def paint_history(rows, columns, display_lines): lines = [] for r, line in zip(range(rows), display_lines[-rows:]): lines.append(fmtstr(line[:columns])) r = fsarray(lines, width=columns) - assert r.shape[0] <= rows, repr(r.shape)+' '+repr(rows) - assert r.shape[1] <= columns, repr(r.shape)+' '+repr(columns) + assert r.shape[0] <= rows, repr(r.shape) + " " + repr(rows) + assert r.shape[1] <= columns, repr(r.shape) + " " + repr(columns) return r + def paint_current_line(rows, columns, current_display_line): lines = display_linize(current_display_line, columns, True) return fsarray(lines, width=columns) -def matches_lines(rows, columns, matches, current, config, format): - highlight_color = func_for_letter(config.color_scheme['operator'].lower()) + +def paginate(rows, matches, current, words_wide): + if current not in matches: + current = matches[0] + per_page = rows * words_wide + current_page = matches.index(current) // per_page + return matches[per_page * current_page : per_page * (current_page + 1)] + + +def matches_lines(rows, columns, matches, current, config, match_format): + highlight_color = func_for_letter(config.color_scheme["operator"].lower()) if not matches: return [] - color = func_for_letter(config.color_scheme['main']) + color = func_for_letter(config.color_scheme["main"]) max_match_width = max(len(m) for m in matches) words_wide = max(1, (columns - 1) // (max_match_width + 1)) - matches = [format(m) for m in matches] + matches = [match_format(m) for m in matches] if current: - current = format(current) + current = match_format(current) - matches_lines = [fmtstr(' ').join(color(m.ljust(max_match_width)) - if m != current - else highlight_color(m.ljust(max_match_width)) - for m in matches[i:i+words_wide]) - for i in range(0, len(matches), words_wide)] + matches = paginate(rows, matches, current, words_wide) - logger.debug('match: %r' % current) - logger.debug('matches_lines: %r' % matches_lines) - return matches_lines + result = [ + fmtstr(" ").join( + ( + color(m.ljust(max_match_width)) + if m != current + else highlight_color(m.ljust(max_match_width)) + ) + for m in matches[i : i + words_wide] + ) + for i in range(0, len(matches), words_wide) + ] -def formatted_argspec(argspec, columns, config): + logger.debug("match: %r" % current) + logger.debug("matches_lines: %r" % result) + return result + + +def formatted_argspec(funcprops, arg_pos, columns, config): # Pretty directly taken from bpython.cli - is_bound_method = argspec[2] - func = argspec[0] - args = argspec[1][0] - kwargs = argspec[1][3] - _args = argspec[1][1] #*args - _kwargs = argspec[1][2] #**kwargs - is_bound_method = argspec[2] - in_arg = argspec[3] - if py3: - kwonly = argspec[1][4] - kwonly_defaults = argspec[1][5] or dict() - - arg_color = func_for_letter(config.color_scheme['name']) - func_color = func_for_letter(config.color_scheme['name'].swapcase()) - punctuation_color = func_for_letter(config.color_scheme['punctuation']) - token_color = func_for_letter(config.color_scheme['token']) - bolds = {token_color: lambda x: bold(token_color(x)), - arg_color: lambda x: bold(arg_color(x))} - - s = func_color(func) + arg_color(': (') - - if is_bound_method and isinstance(in_arg, int): #TODO what values could this have? - in_arg += 1 + func = funcprops.func + args = funcprops.argspec.args + kwargs = funcprops.argspec.defaults + _args = funcprops.argspec.varargs + _kwargs = funcprops.argspec.varkwargs + is_bound_method = funcprops.is_bound_method + kwonly = funcprops.argspec.kwonly + kwonly_defaults = funcprops.argspec.kwonly_defaults or dict() + + arg_color = func_for_letter(config.color_scheme["name"]) + func_color = func_for_letter(config.color_scheme["name"].swapcase()) + punctuation_color = func_for_letter(config.color_scheme["punctuation"]) + token_color = func_for_letter(config.color_scheme["token"]) + bolds = { + token_color: lambda x: bold(token_color(x)), + arg_color: lambda x: bold(arg_color(x)), + } + + s = func_color(func) + arg_color(": (") + + if is_bound_method and isinstance(arg_pos, int): + # TODO what values could this have? + arg_pos += 1 for i, arg in enumerate(args): kw = None if kwargs and i >= len(args) - len(kwargs): kw = str(kwargs[i - (len(args) - len(kwargs))]) - color = token_color if in_arg in (i, arg) else arg_color - if i == in_arg or arg == in_arg: + color = token_color if arg_pos in (i, arg) else arg_color + if i == arg_pos or arg == arg_pos: color = bolds[color] - if not py3: - s += color(inspect.strseq(arg, str)) - else: - s += color(arg) + s += color(arg) if kw is not None: - s += punctuation_color('=') + s += punctuation_color("=") s += token_color(kw) if i != len(args) - 1: - s += punctuation_color(', ') + s += punctuation_color(", ") if _args: if args: - s += punctuation_color(', ') - s += token_color('*%s' % (_args,)) + s += punctuation_color(", ") + s += token_color(f"*{_args}") - if py3 and kwonly: + if kwonly: if not _args: if args: - s += punctuation_color(', ') - s += punctuation_color('*') + s += punctuation_color(", ") + s += punctuation_color("*") marker = object() for arg in kwonly: - s += punctuation_color(', ') + s += punctuation_color(", ") color = token_color - if in_arg: + if arg_pos: color = bolds[color] s += color(arg) default = kwonly_defaults.get(arg, marker) if default is not marker: - s += punctuation_color('=') + s += punctuation_color("=") s += token_color(repr(default)) if _kwargs: - if args or _args or (py3 and kwonly): - s += punctuation_color(', ') - s += token_color('**%s' % (_kwargs,)) - s += punctuation_color(')') + if args or _args or kwonly: + s += punctuation_color(", ") + s += token_color(f"**{_kwargs}") + s += punctuation_color(")") return linesplit(s, columns) + def formatted_docstring(docstring, columns, config): - color = func_for_letter(config.color_scheme['comment']) - return sum(([color(x) for x in (display_linize(line, columns) if line else fmtstr(''))] - for line in docstring.split('\n')), []) + if isinstance(docstring, bytes): + docstring = docstring.decode("utf8") + elif isinstance(docstring, str): + pass + else: + # TODO: fail properly here and catch possible exceptions in callers. + return [] + color = func_for_letter(config.color_scheme["comment"]) + return sum( + ( + [ + color(x) + for x in (display_linize(line, columns) if line else fmtstr("")) + ] + for line in docstring.split("\n") + ), + [], + ) -def paint_infobox(rows, columns, matches, argspec, match, docstring, config, format): - """Returns painted completions, argspec, match, docstring etc.""" + +def paint_infobox( + rows, + columns, + matches, + funcprops, + arg_pos, + match, + docstring, + config, + match_format, +): + """Returns painted completions, funcprops, match, docstring etc.""" if not (rows and columns): - return fsarray(0, 0) + return FSArray(0, 0) width = columns - 4 - lines = ((formatted_argspec(argspec, width, config) if argspec else []) + - (matches_lines(rows, width, matches, match, config, format) if matches else []) + - (formatted_docstring(docstring, width, config) if docstring else [])) + from_argspec = ( + formatted_argspec(funcprops, arg_pos, width, config) + if funcprops + else [] + ) + from_doc = ( + formatted_docstring(docstring, width, config) if docstring else [] + ) + from_matches = ( + matches_lines( + max(1, rows - len(from_argspec) - 2), + width, + matches, + match, + config, + match_format, + ) + if matches + else [] + ) - output_lines = [] - border_color = func_for_letter(config.color_scheme['main']) - output_lines.append(border_color(u'┌─'+u'─'*width+u'─┐')) - for line in lines: - output_lines.append(border_color(u'│ ')+((line+' '*(width - len(line)))[:width])+border_color(u' │')) - output_lines.append(border_color(u'└─'+u'─'*width+u'─┘')) - r = fsarray(output_lines[:min(rows-1, len(output_lines)-1)] + output_lines[-1:]) + lines = from_argspec + from_matches + from_doc + + def add_border(line): + """Add colored borders left and right to a line.""" + new_line = border_color(config.left_border + " ") + new_line += line.ljust(width)[:width] + new_line += border_color(" " + config.right_border) + return new_line + + border_color = func_for_letter(config.color_scheme["main"]) + + top_line = border_color( + config.left_top_corner + + config.top_border * (width + 2) + + config.right_top_corner + ) + bottom_line = border_color( + config.left_bottom_corner + + config.bottom_border * (width + 2) + + config.right_bottom_corner + ) + + output_lines = list( + itertools.chain((top_line,), map(add_border, lines), (bottom_line,)) + ) + r = fsarray( + output_lines[: min(rows - 1, len(output_lines) - 1)] + output_lines[-1:] + ) return r -def paint_last_events(rows, columns, names): + +def paint_last_events(rows, columns, names, config): if not names: return fsarray([]) - width = min(max(len(name) for name in names), columns-2) + width = min(max(len(name) for name in names), columns - 2) output_lines = [] - output_lines.append(u'┌'+u'─'*width+u'┐') - for name in names[-(rows-2):]: - output_lines.append(u'│'+name[:width].center(width)+u'│') - output_lines.append(u'└'+u'─'*width+u'┘') + output_lines.append( + config.left_top_corner + + config.top_border * width + + config.right_top_corner + ) + for name in reversed(names[max(0, len(names) - (rows - 2)) :]): + output_lines.append( + config.left_border + + name[:width].center(width) + + config.right_border + ) + output_lines.append( + config.left_bottom_corner + + config.bottom_border * width + + config.right_bottom_corner + ) return fsarray(output_lines) + def paint_statusbar(rows, columns, msg, config): - return fsarray([func_for_letter(config.color_scheme['main'])(msg.ljust(columns))[:columns]]) + func = func_for_letter(config.color_scheme["main"]) + return fsarray([func(msg.ljust(columns))[:columns]]) diff --git a/bpython/curtsiesfrontend/sitefix.py b/bpython/curtsiesfrontend/sitefix.py index f584fc36d..96626c16c 100644 --- a/bpython/curtsiesfrontend/sitefix.py +++ b/bpython/curtsiesfrontend/sitefix.py @@ -1,17 +1,17 @@ -"""""" import sys +import builtins -from bpython._py3compat import py3 def resetquit(builtins): - """Redefine builtins 'quit' and 'exit' not so close stdin + """Redefine builtins 'quit' and 'exit' not so close stdin""" - """ def __call__(self, code=None): raise SystemExit(code) - __call__.__name__ = 'FakeQuitCall' + + __call__.__name__ = "FakeQuitCall" builtins.quit.__class__.__call__ = __call__ + def monkeypatch_quit(): - if 'site' in sys.modules: - resetquit(sys.modules['builtins' if py3 else '__builtin__']) + if "site" in sys.modules: + resetquit(builtins) diff --git a/bpython/filelock.py b/bpython/filelock.py new file mode 100644 index 000000000..c106c4155 --- /dev/null +++ b/bpython/filelock.py @@ -0,0 +1,136 @@ +# The MIT License +# +# Copyright (c) 2015-2021 Sebastian Ramacher +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from typing import IO, Literal +from types import TracebackType + + +class BaseLock: + """Base class for file locking""" + + def __init__(self) -> None: + self.locked = False + + def acquire(self) -> None: + pass + + def release(self) -> None: + pass + + def __enter__(self) -> "BaseLock": + self.acquire() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> Literal[False]: + if self.locked: + self.release() + return False + + def __del__(self) -> None: + if self.locked: + self.release() + + +try: + import fcntl + import errno + + class UnixFileLock(BaseLock): + """Simple file locking for Unix using fcntl""" + + def __init__(self, fileobj, mode: int = 0) -> None: + super().__init__() + self.fileobj = fileobj + self.mode = mode | fcntl.LOCK_EX + + def acquire(self) -> None: + try: + fcntl.flock(self.fileobj, self.mode) + self.locked = True + except OSError as e: + if e.errno != errno.ENOLCK: + raise e + + def release(self) -> None: + self.locked = False + fcntl.flock(self.fileobj, fcntl.LOCK_UN) + + has_fcntl = True +except ImportError: + has_fcntl = False + + +try: + import msvcrt + import os + + class WindowsFileLock(BaseLock): + """Simple file locking for Windows using msvcrt""" + + def __init__(self, filename: str) -> None: + super().__init__() + self.filename = f"{filename}.lock" + self.fileobj = -1 + + def acquire(self) -> None: + # create a lock file and lock it + self.fileobj = os.open( + self.filename, os.O_RDWR | os.O_CREAT | os.O_TRUNC + ) + msvcrt.locking(self.fileobj, msvcrt.LK_NBLCK, 1) + + self.locked = True + + def release(self) -> None: + self.locked = False + + # unlock lock file and remove it + msvcrt.locking(self.fileobj, msvcrt.LK_UNLCK, 1) + os.close(self.fileobj) + self.fileobj = -1 + + try: + os.remove(self.filename) + except OSError: + pass + + has_msvcrt = True +except ImportError: + has_msvcrt = False + + +def FileLock( + fileobj: IO, mode: int = 0, filename: str | None = None +) -> BaseLock: + if has_fcntl: + return UnixFileLock(fileobj, mode) + elif has_msvcrt and filename is not None: + return WindowsFileLock(filename) + return BaseLock() + + +# vim: sw=4 ts=4 sts=4 ai et diff --git a/bpython/formatter.py b/bpython/formatter.py index 8586102aa..8e74ac2c2 100644 --- a/bpython/formatter.py +++ b/bpython/formatter.py @@ -24,13 +24,31 @@ # Pygments really kicks ass, it made it really easy to # get the exact behaviour I wanted, thanks Pygments.:) +# mypy: disallow_untyped_defs=True +# mypy: disallow_untyped_calls=True + + +from typing import Any, TextIO +from collections.abc import MutableMapping, Iterable from pygments.formatter import Formatter -from pygments.token import Keyword, Name, Comment, String, Error, \ - Number, Operator, Token, Whitespace, Literal, Punctuation +from pygments.token import ( + _TokenType, + Keyword, + Name, + Comment, + String, + Error, + Number, + Operator, + Token, + Whitespace, + Literal, + Punctuation, +) """These format strings are pretty ugly. \x01 represents a colour marker, which - can be proceded by one or two of + can be preceded by one or two of the following letters: k, r, g, y, b, m, c, w, d Which represent: @@ -48,27 +66,28 @@ \x04 represents the end of the string; this is necessary because the strings are all joined together at the end so the parser needs them - as delimeters + as delimiters """ Parenthesis = Token.Punctuation.Parenthesis theme_map = { - Keyword: 'keyword', - Name: 'name', - Comment: 'comment', - String: 'string', - Literal: 'string', - Error: 'error', - Number: 'number', - Token.Literal.Number.Float: 'number', - Operator: 'operator', - Punctuation: 'punctuation', - Token: 'token', - Whitespace: 'background', - Parenthesis: 'paren', - Parenthesis.UnderCursor: 'operator'} + Keyword: "keyword", + Name: "name", + Comment: "comment", + String: "string", + Literal: "string", + Error: "error", + Number: "number", + Token.Literal.Number.Float: "number", + Operator: "operator", + Punctuation: "punctuation", + Token: "token", + Whitespace: "background", + Parenthesis: "paren", + Parenthesis.UnderCursor: "operator", +} class BPythonFormatter(Formatter): @@ -83,25 +102,35 @@ class BPythonFormatter(Formatter): See the Pygments source for more info; it's pretty straightforward.""" - def __init__(self, color_scheme, **options): + def __init__( + self, color_scheme: MutableMapping[str, str], **options: Any + ) -> None: self.f_strings = {} - for k, v in theme_map.iteritems(): - self.f_strings[k] = '\x01%s' % (color_scheme[v],) + for k, v in theme_map.items(): + self.f_strings[k] = f"\x01{color_scheme[v]}" if k is Parenthesis: # FIXME: Find a way to make this the inverse of the current # background colour - self.f_strings[k] += 'I' - Formatter.__init__(self, **options) - - def format(self, tokensource, outfile): - o = '' + self.f_strings[k] += "I" + super().__init__(**options) + + def format( + self, + tokensource: Iterable[MutableMapping[_TokenType, str]], + outfile: TextIO, + ) -> None: + o: str = "" for token, text in tokensource: - if text == '\n': + if text == "\n": continue while token not in self.f_strings: - token = token.parent - o += "%s\x03%s\x04" % (self.f_strings[token], text) + if token.parent is None: + break + else: + token = token.parent + o += f"{self.f_strings[token]}\x03{text}\x04" outfile.write(o.rstrip()) + # vim: sw=4 ts=4 sts=4 ai et diff --git a/bpython/history.py b/bpython/history.py new file mode 100644 index 000000000..27852e837 --- /dev/null +++ b/bpython/history.py @@ -0,0 +1,259 @@ +# The MIT License +# +# Copyright (c) 2009 the bpython authors. +# Copyright (c) 2012-2021 Sebastian Ramacher +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import os +from pathlib import Path +import stat +from itertools import islice, chain +from typing import TextIO +from collections.abc import Iterable + +from .translations import _ +from .filelock import FileLock + + +class History: + """Stores readline-style history and current place in it""" + + def __init__( + self, + entries: Iterable[str] | None = None, + duplicates: bool = True, + hist_size: int = 100, + ) -> None: + if entries is None: + self.entries = [""] + else: + self.entries = list(entries) + # how many lines back in history is currently selected where 0 is the + # saved typed line, 1 the prev entered line + self.index = 0 + # what was on the prompt before using history + self.saved_line = "" + self.duplicates = duplicates + self.hist_size = hist_size + + def append(self, line: str) -> None: + self.append_to(self.entries, line) + + def append_to(self, entries: list[str], line: str) -> None: + line = line.rstrip("\n") + if line: + if not self.duplicates: + # remove duplicates + try: + while True: + entries.remove(line) + except ValueError: + pass + entries.append(line) + + def first(self) -> str: + """Move back to the beginning of the history.""" + if not self.is_at_end: + self.index = len(self.entries) + return self.entries[-self.index] + + def back( + self, + start: bool = True, + search: bool = False, + target: str | None = None, + include_current: bool = False, + ) -> str: + """Move one step back in the history.""" + if target is None: + target = self.saved_line + if not self.is_at_end: + if search: + self.index += self.find_partial_match_backward( + target, include_current + ) + elif start: + self.index += self.find_match_backward(target, include_current) + else: + self.index += 1 + return self.entry + + @property + def entry(self) -> str: + """The current entry, which may be the saved line""" + return self.entries[-self.index] if self.index else self.saved_line + + @property + def entries_by_index(self) -> list[str]: + return list(chain((self.saved_line,), reversed(self.entries))) + + def find_match_backward( + self, search_term: str, include_current: bool = False + ) -> int: + add = 0 if include_current else 1 + start = self.index + add + for idx, val in enumerate(islice(self.entries_by_index, start, None)): + if val.startswith(search_term): + return idx + add + return 0 + + def find_partial_match_backward( + self, search_term: str, include_current: bool = False + ) -> int: + add = 0 if include_current else 1 + start = self.index + add + for idx, val in enumerate(islice(self.entries_by_index, start, None)): + if search_term in val: + return idx + add + return 0 + + def forward( + self, + start: bool = True, + search: bool = False, + target: str | None = None, + include_current: bool = False, + ) -> str: + """Move one step forward in the history.""" + if target is None: + target = self.saved_line + if self.index > 1: + if search: + self.index -= self.find_partial_match_forward( + target, include_current + ) + elif start: + self.index -= self.find_match_forward(target, include_current) + else: + self.index -= 1 + return self.entry + else: + self.index = 0 + return self.saved_line + + def find_match_forward( + self, search_term: str, include_current: bool = False + ) -> int: + add = 0 if include_current else 1 + end = max(0, self.index - (1 - add)) + for idx in range(end): + val = self.entries_by_index[end - 1 - idx] + if val.startswith(search_term): + return idx + (0 if include_current else 1) + return self.index + + def find_partial_match_forward( + self, search_term: str, include_current: bool = False + ) -> int: + add = 0 if include_current else 1 + end = max(0, self.index - (1 - add)) + for idx in range(end): + val = self.entries_by_index[end - 1 - idx] + if search_term in val: + return idx + add + return self.index + + def last(self) -> str: + """Move forward to the end of the history.""" + if not self.is_at_start: + self.index = 0 + return self.entries[0] + + @property + def is_at_end(self) -> bool: + return self.index >= len(self.entries) or self.index == -1 + + @property + def is_at_start(self) -> bool: + return self.index == 0 + + def enter(self, line: str) -> None: + if self.index == 0: + self.saved_line = line + + def reset(self) -> None: + self.index = 0 + self.saved_line = "" + + def load(self, filename: Path, encoding: str) -> None: + with open(filename, encoding=encoding, errors="ignore") as hfile: + with FileLock(hfile, filename=str(filename)): + self.entries = self.load_from(hfile) + + def load_from(self, fd: TextIO) -> list[str]: + entries: list[str] = [] + for line in fd: + self.append_to(entries, line) + return entries if len(entries) else [""] + + def save(self, filename: Path, encoding: str, lines: int = 0) -> None: + fd = os.open( + filename, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + stat.S_IRUSR | stat.S_IWUSR, + ) + with open(fd, "w", encoding=encoding, errors="ignore") as hfile: + with FileLock(hfile, filename=str(filename)): + self.save_to(hfile, self.entries, lines) + + def save_to( + self, fd: TextIO, entries: list[str] | None = None, lines: int = 0 + ) -> None: + if entries is None: + entries = self.entries + for line in entries[-lines:]: + fd.write(line) + fd.write("\n") + + def append_reload_and_write( + self, s: str, filename: Path, encoding: str + ) -> None: + if not self.hist_size: + return self.append(s) + + try: + fd = os.open( + filename, + os.O_APPEND | os.O_RDWR | os.O_CREAT, + stat.S_IRUSR | stat.S_IWUSR, + ) + with open(fd, "a+", encoding=encoding, errors="ignore") as hfile: + with FileLock(hfile, filename=str(filename)): + # read entries + hfile.seek(0, os.SEEK_SET) + entries = self.load_from(hfile) + self.append_to(entries, s) + + # write new entries + hfile.seek(0, os.SEEK_SET) + hfile.truncate() + self.save_to(hfile, entries, self.hist_size) + + self.entries = entries + except OSError as err: + raise RuntimeError( + _("Error occurred while writing to file %s (%s)") + % (filename, err.strerror) + ) + else: + if len(self.entries) == 0: + # Make sure that entries contains at least one element. If the + # file and s are empty, this can occur. + self.entries = [""] diff --git a/bpython/importcompletion.py b/bpython/importcompletion.py index da08704be..e22b61f62 100644 --- a/bpython/importcompletion.py +++ b/bpython/importcompletion.py @@ -1,6 +1,7 @@ # The MIT License # # Copyright (c) 2009-2011 Andreas Stuehrk +# Copyright (c) 2020-2021 Sebastian Ramacher # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -20,189 +21,238 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import with_statement - -from bpython import line as lineparts -import imp -import os +import fnmatch +import importlib.machinery import sys import warnings +from dataclasses import dataclass +from pathlib import Path +from collections.abc import Generator, Sequence, Iterable -if sys.version_info[0] == 3 and sys.version_info[1] >= 3: - import importlib.machinery - SUFFIXES = importlib.machinery.all_suffixes() -else: - SUFFIXES = [suffix for suffix, mode, type in imp.get_suffixes()] - -try: - from warnings import catch_warnings -except ImportError: - import contextlib - @contextlib.contextmanager - def catch_warnings(): - """Stripped-down version of `warnings.catch_warnings()` - (available in Py >= 2.6).""" - filters = warnings.filters - warnings.filters = list(filters) - try: - yield - finally: - warnings.filters = filters - -from bpython._py3compat import py3 - -# The cached list of all known modules -modules = set() -fully_loaded = False - - -def module_matches(cw, prefix=''): - """Modules names to replace cw with""" - full = '%s.%s' % (prefix, cw) if prefix else cw - matches = [name for name in modules - if (name.startswith(full) and - name.find('.', len(full)) == -1)] - if prefix: - return [match[len(prefix)+1:] for match in matches] - else: - return matches +from .line import ( + current_word, + current_import, + current_from_import_from, + current_from_import_import, +) + +SUFFIXES = importlib.machinery.all_suffixes() +LOADERS = ( + ( + importlib.machinery.ExtensionFileLoader, + importlib.machinery.EXTENSION_SUFFIXES, + ), + ( + importlib.machinery.SourceFileLoader, + importlib.machinery.SOURCE_SUFFIXES, + ), +) + + +@dataclass(frozen=True, slots=True) +class _LoadedInode: + dev: int + inode: int + + +class ModuleGatherer: + def __init__( + self, + paths: Iterable[str | Path] | None = None, + skiplist: Sequence[str] | None = None, + ) -> None: + """Initialize module gatherer with all modules in `paths`, which should be a list of + directory names. If `paths` is not given, `sys.path` will be used.""" + + # Cached list of all known modules + self.modules: set[str] = set() + # Set of (st_dev, st_ino) to compare against so that paths are not repeated + self.paths: set[_LoadedInode] = set() + # Patterns to skip + self.skiplist: Sequence[str] = ( + skiplist if skiplist is not None else tuple() + ) + self.fully_loaded = False -def attr_matches(cw, prefix='', only_modules=False): - """Attributes to replace name with""" - full = '%s.%s' % (prefix, cw) if prefix else cw - module_name, _, name_after_dot = full.rpartition('.') - if module_name not in sys.modules: - return [] - module = sys.modules[module_name] - if only_modules: - matches = [name for name in dir(module) - if name.startswith(name_after_dot) and - '%s.%s' % (module_name, name) in sys.modules] - else: - matches = [name for name in dir(module) if name.startswith(name_after_dot)] - module_part, _, _ = cw.rpartition('.') - if module_part: - return ['%s.%s' % (module_part, m) for m in matches] - return matches - -def module_attr_matches(name): - """Only attributes which are modules to replace name with""" - return attr_matches(name, prefix='', only_modules=True) - -def complete(cursor_offset, line): - """Construct a full list of possibly completions for imports.""" - tokens = line.split() - if 'from' not in tokens and 'import' not in tokens: - return None - - result = lineparts.current_word(cursor_offset, line) - if result is None: - return None - - if lineparts.current_from_import_from(cursor_offset, line) is not None: - if lineparts.current_from_import_import(cursor_offset, line) is not None: - # `from a import ` completion - return (module_matches(lineparts.current_from_import_import(cursor_offset, line)[2], - lineparts.current_from_import_from(cursor_offset, line)[2]) + - attr_matches(lineparts.current_from_import_import(cursor_offset, line)[2], - lineparts.current_from_import_from(cursor_offset, line)[2])) + if paths is None: + self.modules.update(sys.builtin_module_names) + paths = sys.path + + self.find_iterator = self.find_all_modules( + Path(p).resolve() if p else Path.cwd() for p in paths + ) + + def module_matches(self, cw: str, prefix: str = "") -> set[str]: + """Modules names to replace cw with""" + + full = f"{prefix}.{cw}" if prefix else cw + matches = ( + name + for name in self.modules + if (name.startswith(full) and name.find(".", len(full)) == -1) + ) + if prefix: + return {match[len(prefix) + 1 :] for match in matches} else: - # `from ` completion - return (module_attr_matches(lineparts.current_from_import_from(cursor_offset, line)[2]) + - module_matches(lineparts.current_from_import_from(cursor_offset, line)[2])) - elif lineparts.current_import(cursor_offset, line): - # `import ` completion - return (module_matches(lineparts.current_import(cursor_offset, line)[2]) + - module_attr_matches(lineparts.current_import(cursor_offset, line)[2])) - else: - return None - -def find_modules(path): - """Find all modules (and packages) for a given directory.""" - if not os.path.isdir(path): - # Perhaps a zip file - return - - try: - filenames = os.listdir(path) - except EnvironmentError: - filenames = [] - for name in filenames: - if not any(name.endswith(suffix) for suffix in SUFFIXES): - # Possibly a package - if '.' in name: - continue - elif os.path.isdir(os.path.join(path, name)): - # Unfortunately, CPython just crashes if there is a directory - # which ends with a python extension, so work around. - continue - for suffix in SUFFIXES: - if name.endswith(suffix): - name = name[:-len(suffix)] - break - if py3 and name == "badsyntax_pep3120": - # Workaround for issue #166 - continue - try: - with catch_warnings(): - warnings.simplefilter("ignore", ImportWarning) - fo, pathname, _ = imp.find_module(name, [path]) - except (ImportError, IOError, SyntaxError): - continue - except UnicodeEncodeError: - # Happens with Python 3 when there is a filename in some - # invalid encoding - continue + return set(matches) + + def attr_matches( + self, cw: str, prefix: str = "", only_modules: bool = False + ) -> set[str]: + """Attributes to replace name with""" + full = f"{prefix}.{cw}" if prefix else cw + module_name, _, name_after_dot = full.rpartition(".") + if module_name not in sys.modules: + return set() + module = sys.modules[module_name] + if only_modules: + matches = { + name + for name in dir(module) + if name.startswith(name_after_dot) + and f"{module_name}.{name}" in sys.modules + } else: - if fo is not None: - fo.close() + matches = { + name for name in dir(module) if name.startswith(name_after_dot) + } + module_part = cw.rpartition(".")[0] + if module_part: + matches = {f"{module_part}.{m}" for m in matches} + + return matches + + def module_attr_matches(self, name: str) -> set[str]: + """Only attributes which are modules to replace name with""" + return self.attr_matches(name, only_modules=True) + + def complete(self, cursor_offset: int, line: str) -> set[str] | None: + """Construct a full list of possibly completions for imports.""" + tokens = line.split() + if "from" not in tokens and "import" not in tokens: + return None + + result = current_word(cursor_offset, line) + if result is None: + return None + + from_import_from = current_from_import_from(cursor_offset, line) + if from_import_from is not None: + import_import = current_from_import_import(cursor_offset, line) + if import_import is not None: + # `from a import ` completion + matches = self.module_matches( + import_import.word, from_import_from.word + ) + matches.update( + self.attr_matches(import_import.word, from_import_from.word) + ) else: - # Yay, package - for subname in find_modules(pathname): - if subname != '__init__': - yield '%s.%s' % (name, subname) - yield name - - -def find_all_modules(path=None): - """Return a list with all modules in `path`, which should be a list of - directory names. If path is not given, sys.path will be used.""" - if path is None: - modules.update(sys.builtin_module_names) - path = sys.path - - for p in path: - if not p: - p = os.curdir - for module in find_modules(p): - if not py3 and not isinstance(module, unicode): - try: - module = module.decode(sys.getfilesystemencoding()) - except UnicodeDecodeError: - # Not importable anyway, ignore it - continue - modules.add(module) - yield + # `from ` completion + matches = self.module_attr_matches(from_import_from.word) + matches.update(self.module_matches(from_import_from.word)) + return matches + + cur_import = current_import(cursor_offset, line) + if cur_import is not None: + # `import ` completion + matches = self.module_matches(cur_import.word) + matches.update(self.module_attr_matches(cur_import.word)) + return matches + else: + return None + + def find_modules(self, path: Path) -> Generator[str | None, None, None]: + """Find all modules (and packages) for a given directory.""" + if not path.is_dir(): + # Perhaps a zip file + return + if any(fnmatch.fnmatch(path.name, entry) for entry in self.skiplist): + # Path is on skiplist + return + finder = importlib.machinery.FileFinder(str(path), *LOADERS) # type: ignore + try: + for p in path.iterdir(): + if p.name.startswith(".") or p.name == "__pycache__": + # Impossible to import from names starting with . and we can skip __pycache__ + continue + elif any( + fnmatch.fnmatch(p.name, entry) for entry in self.skiplist + ): + # Path is on skiplist + continue + elif not any(p.name.endswith(suffix) for suffix in SUFFIXES): + # Possibly a package + if "." in p.name: + continue + elif p.is_dir(): + # Unfortunately, CPython just crashes if there is a directory + # which ends with a python extension, so work around. + continue + name = p.name + for suffix in SUFFIXES: + if name.endswith(suffix): + name = name[: -len(suffix)] + break + if name == "badsyntax_pep3120": + # Workaround for issue #166 + continue -def find_coroutine(): - global fully_loaded + package_pathname = None + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ImportWarning) + spec = finder.find_spec(name) + if spec is None: + continue + if spec.submodule_search_locations is not None: + package_pathname = spec.submodule_search_locations[ + 0 + ] + except (ImportError, OSError, SyntaxError, UnicodeEncodeError): + # UnicodeEncodeError happens with Python 3 when there is a filename in some invalid encoding + continue - if fully_loaded: - return None + if package_pathname is not None: + path_real = Path(package_pathname).resolve() + try: + stat = path_real.stat() + except OSError: + continue + loaded_inode = _LoadedInode(stat.st_dev, stat.st_ino) + if loaded_inode not in self.paths: + self.paths.add(loaded_inode) + for subname in self.find_modules(path_real): + if subname is None: + yield None # take a break to avoid unresponsiveness + elif subname != "__init__": + yield f"{name}.{subname}" + yield name + except OSError: + # Path is not readable + return + yield None # take a break to avoid unresponsiveness - try: - find_iterator.next() - except StopIteration: - fully_loaded = True + def find_all_modules( + self, paths: Iterable[Path] + ) -> Generator[None, None, None]: + """Return a list with all modules in `path`, which should be a list of + directory names. If path is not given, sys.path will be used.""" - return True + for p in paths: + for module in self.find_modules(p): + if module is not None: + self.modules.add(module) + yield + def find_coroutine(self) -> bool: + if self.fully_loaded: + return False -def reload(): - """Refresh the list of known modules.""" - modules.clear() - for _ in find_all_modules(): - pass + try: + next(self.find_iterator) + except StopIteration: + self.fully_loaded = True -find_iterator = find_all_modules() + return True diff --git a/bpython/inspection.py b/bpython/inspection.py index 0c7f14cc9..d3e2d5e56 100644 --- a/bpython/inspection.py +++ b/bpython/inspection.py @@ -1,6 +1,7 @@ # The MIT License # # Copyright (c) 2009-2011 the bpython authors. +# Copyright (c) 2015 Sebastian Ramacher # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -19,48 +20,73 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -# -from __future__ import with_statement -import collections import inspect import keyword import pydoc import re -import types +from dataclasses import dataclass +from typing import ( + Any, + ContextManager, + Literal, +) +from collections.abc import Callable +from types import MemberDescriptorType, TracebackType from pygments.token import Token +from pygments.lexers import Python3Lexer + +from .lazyre import LazyReCompile + + +class _Repr: + """ + Helper for `ArgSpec`: Returns the given value in `__repr__()`. + """ -from bpython._py3compat import PythonLexer, py3 + __slots__ = ("value",) -try: - collections.Callable - has_collections_callable = True -except AttributeError: - has_collections_callable = False -try: - types.InstanceType - has_instance_type = True -except AttributeError: - has_instance_type = False + def __init__(self, value: str) -> None: + self.value = value -if not py3: - _name = re.compile(r'[a-zA-Z_]\w*$') + def __repr__(self) -> str: + return self.value + + __str__ = __repr__ -class AttrCleaner(object): +@dataclass +class ArgSpec: + args: list[str] + varargs: str | None + varkwargs: str | None + defaults: list[_Repr] | None + kwonly: list[str] + kwonly_defaults: dict[str, _Repr] | None + annotations: dict[str, Any] | None + + +@dataclass +class FuncProps: + func: str + argspec: ArgSpec + is_bound_method: bool + + +class AttrCleaner(ContextManager[None]): """A context manager that tries to make an object not exhibit side-effects - on attribute lookup.""" + on attribute lookup. + + Unless explicitly required, prefer `getattr_safe`.""" - def __init__(self, obj): - self.obj = obj + def __init__(self, obj: Any) -> None: + self._obj = obj - def __enter__(self): + def __enter__(self) -> None: """Try to make an object not exhibit side-effects on attribute lookup.""" - type_ = type(self.obj) - __getattribute__ = None - __getattr__ = None + type_ = type(self._obj) # Dark magic: # If __getattribute__ doesn't exist on the class and __getattr__ does # then __getattr__ will be called when doing @@ -70,203 +96,306 @@ def __enter__(self): # original methods. :-( # The upshot being that introspecting on an object to display its # attributes will avoid unwanted side-effects. - if py3 or type_ != types.InstanceType: - __getattr__ = getattr(type_, '__getattr__', None) - if __getattr__ is not None: - try: - setattr(type_, '__getattr__', (lambda *_, **__: None)) - except TypeError: - __getattr__ = None - __getattribute__ = getattr(type_, '__getattribute__', None) - if __getattribute__ is not None: - try: - setattr(type_, '__getattribute__', object.__getattribute__) - except TypeError: - # XXX: This happens for e.g. built-in types - __getattribute__ = None - self.attribs = (__getattribute__, __getattr__) + __getattr__ = getattr(type_, "__getattr__", None) + if __getattr__ is not None: + try: + setattr(type_, "__getattr__", (lambda *_, **__: None)) + except (TypeError, AttributeError): + __getattr__ = None + __getattribute__ = getattr(type_, "__getattribute__", None) + if __getattribute__ is not None: + try: + setattr(type_, "__getattribute__", object.__getattribute__) + except (TypeError, AttributeError): + # XXX: This happens for e.g. built-in types + __getattribute__ = None + self._attribs = (__getattribute__, __getattr__) # /Dark magic - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> Literal[False]: """Restore an object's magic methods.""" - type_ = type(self.obj) - __getattribute__, __getattr__ = self.attribs + type_ = type(self._obj) + __getattribute__, __getattr__ = self._attribs # Dark magic: if __getattribute__ is not None: - setattr(type_, '__getattribute__', __getattribute__) + setattr(type_, "__getattribute__", __getattribute__) if __getattr__ is not None: - setattr(type_, '__getattr__', __getattr__) + setattr(type_, "__getattr__", __getattr__) # /Dark magic + return False -class _Repr(object): - """ - Helper for `fixlongargs()`: Returns the given value in `__repr__()`. - """ - - def __init__(self, value): - self.value = value - - def __repr__(self): - return self.value - - __str__ = __repr__ -def parsekeywordpairs(signature): - tokens = PythonLexer().get_tokens(signature) +def parsekeywordpairs(signature: str) -> dict[str, str]: preamble = True stack = [] - substack = [] + substack: list[str] = [] parendepth = 0 - for token, value in tokens: + annotation = False + for token, value in Python3Lexer().get_tokens(signature): if preamble: - if token is Token.Punctuation and value == u"(": + if token is Token.Punctuation and value == "(": + # First "(" starts the list of arguments preamble = False continue if token is Token.Punctuation: - if value in [u'(', u'{', u'[']: + if value in "({[": parendepth += 1 - elif value in [u')', u'}', u']']: + elif value in ")}]": parendepth -= 1 - elif value == ':' and parendepth == -1: - # End of signature reached - break - if ((value == ',' and parendepth == 0) or - (value == ')' and parendepth == -1)): + elif value == ":": + if parendepth == -1: + # End of signature reached + break + elif parendepth == 0: + # Start of type annotation + annotation = True + + if (value, parendepth) in ((",", 0), (")", -1)): + # End of current argument stack.append(substack) substack = [] + # If type annotation didn't end before, it does now. + annotation = False continue + elif token is Token.Operator and value == "=" and parendepth == 0: + # End of type annotation + annotation = False - if value and (parendepth > 0 or value.strip()): + if value and not annotation and (parendepth > 0 or value.strip()): substack.append(value) - d = {} - for item in stack: - if len(item) >= 3: - d[item[0]] = ''.join(item[2:]) - return d + return {item[0]: "".join(item[2:]) for item in stack if len(item) >= 3} -def fixlongargs(f, argspec): +def _fix_default_values(f: Callable, argspec: ArgSpec) -> ArgSpec: """Functions taking default arguments that are references to other objects - whose str() is too big will cause breakage, so we swap out the object - itself with the name it was referenced with in the source by parsing the - source itself !""" - if argspec[3] is None: + will cause breakage, so we swap out the object itself with the name it was + referenced with in the source by parsing the source itself!""" + + if argspec.defaults is None and argspec.kwonly_defaults is None: # No keyword args, no need to do anything - return - values = list(argspec[3]) - if not values: - return - keys = argspec[0][-len(values):] + return argspec + try: - src = inspect.getsourcelines(f) - except (IOError, IndexError): + src, _ = inspect.getsourcelines(f) + except (OSError, IndexError): # IndexError is raised in inspect.findsource(), can happen in # some situations. See issue #94. - return - signature = ''.join(src[0]) - kwparsed = parsekeywordpairs(signature) - - for i, (key, value) in enumerate(zip(keys, values)): - if len(repr(value)) != len(kwparsed[key]): + return argspec + except TypeError: + # No source code is available, so replace the default values with what we have. + if argspec.defaults is not None: + argspec.defaults = [_Repr(str(value)) for value in argspec.defaults] + if argspec.kwonly_defaults is not None: + argspec.kwonly_defaults = { + key: _Repr(str(value)) + for key, value in argspec.kwonly_defaults.items() + } + return argspec + + kwparsed = parsekeywordpairs("".join(src)) + + if argspec.defaults is not None: + values = list(argspec.defaults) + keys = argspec.args[-len(values) :] + for i, key in enumerate(keys): values[i] = _Repr(kwparsed[key]) - argspec[3] = values + argspec.defaults = values + if argspec.kwonly_defaults is not None: + for key in argspec.kwonly_defaults.keys(): + argspec.kwonly_defaults[key] = _Repr(kwparsed[key]) + return argspec -def getpydocspec(f, func): + +_getpydocspec_re = LazyReCompile( + r"([a-zA-Z_][a-zA-Z0-9_]*?)\((.*?)\)", re.DOTALL +) + + +def _getpydocspec(f: Callable) -> ArgSpec | None: try: argspec = pydoc.getdoc(f) except NameError: return None - rx = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*?)\((.*?)\)') - s = rx.search(argspec) + s = _getpydocspec_re.search(argspec) if s is None: return None - if not hasattr(f, '__name__') or s.groups()[0] != f.__name__: + if not hasattr_safe(f, "__name__") or s.groups()[0] != f.__name__: return None - args = list() - defaults = list() + args = [] + defaults = [] varargs = varkwargs = None - kwonly_args = list() - kwonly_defaults = dict() - for arg in s.group(2).split(','): + kwonly_args = [] + kwonly_defaults = {} + for arg in s.group(2).split(","): arg = arg.strip() - if arg.startswith('**'): + if arg.startswith("**"): varkwargs = arg[2:] - elif arg.startswith('*'): + elif arg.startswith("*"): varargs = arg[1:] + elif arg == "...": + # At least print denotes "..." as separator between varargs and kwonly args. + varargs = "" else: - arg, _, default = arg.partition('=') + arg, _, default = arg.partition("=") if varargs is not None: kwonly_args.append(arg) if default: - kwonly_defaults[arg] = default + kwonly_defaults[arg] = _Repr(default) else: args.append(arg) if default: - defaults.append(default) + defaults.append(_Repr(default)) - return [func, (args, varargs, varkwargs, defaults, - kwonly_args, kwonly_defaults)] + return ArgSpec( + args, varargs, varkwargs, defaults, kwonly_args, kwonly_defaults, None + ) -def getargspec(func, f): +def getfuncprops(func: str, f: Callable) -> FuncProps | None: # Check if it's a real bound method or if it's implicitly calling __init__ # (i.e. FooClass(...) and not FooClass.__init__(...) -- the former would # not take 'self', the latter would: try: - func_name = getattr(f, '__name__', None) + func_name = getattr(f, "__name__", None) except: # if calling foo.__name__ would result in an error func_name = None try: - is_bound_method = ((inspect.ismethod(f) and f.im_self is not None) - or (func_name == '__init__' and not - func.endswith('.__init__'))) + is_bound_method = ( + (inspect.ismethod(f) and f.__self__ is not None) + or (func_name == "__init__" and not func.endswith(".__init__")) + or (func_name == "__new__" and not func.endswith(".__new__")) + ) except: # if f is a method from a xmlrpclib.Server instance, func_name == # '__init__' throws xmlrpclib.Fault (see #202) return None try: - if py3: - argspec = inspect.getfullargspec(f) - else: - argspec = inspect.getargspec(f) - - argspec = list(argspec) - fixlongargs(f, argspec) - argspec = [func, argspec, is_bound_method] - except (TypeError, KeyError): - with AttrCleaner(f): - argspec = getpydocspec(f, func) - if argspec is None: + argspec = _get_argspec_from_signature(f) + try: + argspec = _fix_default_values(f, argspec) + except KeyError as ex: + # Parsing of the source failed. If f has a __signature__, we trust it. + if not hasattr(f, "__signature__"): + raise ex + fprops = FuncProps(func, argspec, is_bound_method) + except (TypeError, KeyError, ValueError): + argspec_pydoc = _getpydocspec(f) + if argspec_pydoc is None: return None if inspect.ismethoddescriptor(f): - argspec[1][0].insert(0, 'obj') - argspec.append(is_bound_method) - return argspec + argspec_pydoc.args.insert(0, "obj") + fprops = FuncProps(func, argspec_pydoc, is_bound_method) + return fprops -def is_eval_safe_name(string): - if py3: - return all(part.isidentifier() and not keyword.iskeyword(part) - for part in string.split('.')) - else: - return all(_name.match(part) and not keyword.iskeyword(part) - for part in string.split('.')) +def is_eval_safe_name(string: str) -> bool: + return all( + part.isidentifier() and not keyword.iskeyword(part) + for part in string.split(".") + ) -def is_callable(obj): - if has_instance_type and isinstance(obj, types.InstanceType): - # Work around a CPython bug, see CPython issue #7624 - return callable(obj) - elif has_collections_callable: - return isinstance(obj, collections.Callable) - else: - return callable(obj) +def _get_argspec_from_signature(f: Callable) -> ArgSpec: + """Get callable signature from inspect.signature in argspec format. + + inspect.signature is a Python 3 only function that returns the signature of + a function. Its advantage over inspect.getfullargspec is that it returns + the signature of a decorated function, if the wrapper function itself is + decorated with functools.wraps. + + """ + args = [] + varargs = None + varkwargs = None + defaults = [] + kwonly = [] + kwonly_defaults = {} + annotations = {} + + # We use signature here instead of getfullargspec as the latter also returns + # self and cls (for class methods). + signature = inspect.signature(f) + for parameter in signature.parameters.values(): + if parameter.annotation is not parameter.empty: + annotations[parameter.name] = parameter.annotation + + if parameter.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + args.append(parameter.name) + if parameter.default is not parameter.empty: + defaults.append(parameter.default) + elif parameter.kind == inspect.Parameter.POSITIONAL_ONLY: + args.append(parameter.name) + elif parameter.kind == inspect.Parameter.VAR_POSITIONAL: + varargs = parameter.name + elif parameter.kind == inspect.Parameter.KEYWORD_ONLY: + kwonly.append(parameter.name) + kwonly_defaults[parameter.name] = parameter.default + elif parameter.kind == inspect.Parameter.VAR_KEYWORD: + varkwargs = parameter.name + + return ArgSpec( + args, + varargs, + varkwargs, + defaults if defaults else None, + kwonly, + kwonly_defaults if kwonly_defaults else None, + annotations if annotations else None, + ) + + +_get_encoding_line_re = LazyReCompile(r"^.*coding[:=]\s*([-\w.]+).*$") + + +def get_encoding(obj) -> str: + """Try to obtain encoding information of the source of an object.""" + for line in inspect.findsource(obj)[0][:2]: + m = _get_encoding_line_re.search(line) + if m: + return m.group(1) + return "utf8" + + +def get_encoding_file(fname: str) -> str: + """Try to obtain encoding information from a Python source file.""" + with open(fname, encoding="ascii", errors="ignore") as f: + for _ in range(2): + line = f.readline() + match = _get_encoding_line_re.search(line) + if match: + return match.group(1) + return "utf8" + + +def getattr_safe(obj: Any, name: str) -> Any: + """Side effect free getattr (calls getattr_static).""" + result = inspect.getattr_static(obj, name) + # Slots are a MemberDescriptorType + if isinstance(result, MemberDescriptorType): + result = getattr(obj, name) + # classmethods are safe to access (see #966) + if isinstance(result, (classmethod, staticmethod)): + result = result.__get__(obj, obj) + return result + + +def hasattr_safe(obj: Any, name: str) -> bool: + try: + getattr_safe(obj, name) + return True + except AttributeError: + return False diff --git a/bpython/keys.py b/bpython/keys.py index d401c5d6e..51f4c0117 100644 --- a/bpython/keys.py +++ b/bpython/keys.py @@ -19,55 +19,60 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -# import string +from typing import TypeVar, Generic +T = TypeVar("T") -class KeyMap: - def __init__(self, default=''): - self.map = {} +class KeyMap(Generic[T]): + def __init__(self, default: T) -> None: + self.map: dict[str, T] = {} self.default = default - def __getitem__(self, key): + def __getitem__(self, key: str) -> T: if not key: # Unbound key return self.default elif key in self.map: return self.map[key] else: - raise KeyError('Configured keymap (%s)' % key + - ' does not exist in bpython.keys') + raise KeyError( + f"Configured keymap ({key}) does not exist in bpython.keys" + ) - def __delitem__(self, key): + def __delitem__(self, key: str) -> None: del self.map[key] - def __setitem__(self, key, value): + def __setitem__(self, key: str, value: T) -> None: self.map[key] = value -cli_key_dispatch = KeyMap(tuple()) -urwid_key_dispatch = KeyMap('') + +cli_key_dispatch: KeyMap[tuple[str, ...]] = KeyMap(tuple()) +urwid_key_dispatch = KeyMap("") # fill dispatch with letters for c in string.ascii_lowercase: - cli_key_dispatch['C-%s' % c] = (chr(string.ascii_lowercase.index(c) + 1), - '^%s' % c.upper()) + cli_key_dispatch[f"C-{c}"] = ( + chr(string.ascii_lowercase.index(c) + 1), + f"^{c.upper()}", + ) for c in string.ascii_lowercase: - urwid_key_dispatch['C-%s' % c] = 'ctrl %s' % c - urwid_key_dispatch['M-%s' % c] = 'meta %s' % c + urwid_key_dispatch[f"C-{c}"] = f"ctrl {c}" + urwid_key_dispatch[f"M-{c}"] = f"meta {c}" # fill dispatch with cool characters -cli_key_dispatch['C-['] = (chr(27), '^[') -cli_key_dispatch['C-\\'] = (chr(28), '^\\') -cli_key_dispatch['C-]'] = (chr(29), '^]') -cli_key_dispatch['C-^'] = (chr(30), '^^') -cli_key_dispatch['C-_'] = (chr(31), '^_') +cli_key_dispatch["C-["] = (chr(27), "^[") +cli_key_dispatch["C-\\"] = (chr(28), "^\\") +cli_key_dispatch["C-]"] = (chr(29), "^]") +cli_key_dispatch["C-^"] = (chr(30), "^^") +cli_key_dispatch["C-_"] = (chr(31), "^_") # fill dispatch with function keys -for x in xrange(1, 13): - cli_key_dispatch['F%s' % str(x)] = ('KEY_F(%s)' % str(x),) +for x in range(1, 13): + cli_key_dispatch[f"F{x}"] = (f"KEY_F({x})",) -for x in xrange(1, 13): - urwid_key_dispatch['F%s' % str(x)] = 'f%s' % str(x) +for x in range(1, 13): + urwid_key_dispatch[f"F{x}"] = f"f{x}" diff --git a/bpython/lazyre.py b/bpython/lazyre.py new file mode 100644 index 000000000..3d1bd372f --- /dev/null +++ b/bpython/lazyre.py @@ -0,0 +1,53 @@ +# The MIT License +# +# Copyright (c) 2015-2021 Sebastian Ramacher +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import re +from collections.abc import Iterator +from functools import cached_property +from re import Pattern, Match + + +class LazyReCompile: + """Compile regular expressions on first use + + This class allows one to store regular expressions and compiles them on + first use.""" + + def __init__(self, regex: str, flags: int = 0) -> None: + self.regex = regex + self.flags = flags + + @cached_property + def compiled(self) -> Pattern[str]: + return re.compile(self.regex, self.flags) + + def finditer(self, *args, **kwargs) -> Iterator[Match[str]]: + return self.compiled.finditer(*args, **kwargs) + + def search(self, *args, **kwargs) -> Match[str] | None: + return self.compiled.search(*args, **kwargs) + + def match(self, *args, **kwargs) -> Match[str] | None: + return self.compiled.match(*args, **kwargs) + + def sub(self, *args, **kwargs) -> str: + return self.compiled.sub(*args, **kwargs) diff --git a/bpython/line.py b/bpython/line.py index ee7114670..83a75f09e 100644 --- a/bpython/line.py +++ b/bpython/line.py @@ -1,159 +1,306 @@ """Extracting and changing portions of the current line -All functions take cursor offset from the beginning of the line and the line of python code, -and return None, or a tuple of the start index, end index, and the word""" +All functions take cursor offset from the beginning of the line and the line of +Python code, and return None, or a tuple of the start index, end index, and the +word.""" import re -def current_word(cursor_offset, line): +from dataclasses import dataclass +from itertools import chain + +from .lazyre import LazyReCompile + + +@dataclass +class LinePart: + start: int + stop: int + word: str + + +_current_word_re = LazyReCompile(r"(? LinePart | None: """the object.attribute.attribute just before or under the cursor""" - pos = cursor_offset - matches = list(re.finditer(r'[\w_][\w0-9._]*[(]?', line)) - start = pos - end = pos + start = cursor_offset + end = cursor_offset word = None - for m in matches: - if m.start() < pos and m.end() >= pos: - start = m.start() - end = m.end() - word = m.group() + for m in _current_word_re.finditer(line): + if m.start(1) < cursor_offset <= m.end(1): + start = m.start(1) + end = m.end(1) + word = m.group(1) if word is None: return None - return (start, end, word) + return LinePart(start, end, word) -def current_dict_key(cursor_offset, line): + +# pieces of regex to match repr() of several hashable built-in types +_match_all_dict_keys = r"""[^\]]*""" + +# https://docs.python.org/3/reference/lexical_analysis.html#string-and-bytes-literals +_match_single_quote_str_bytes = r""" + # bytes repr() begins with `b` character; bytes and str begin with `'` + b?' + # match escape sequence; this handles `\'` in the string repr() + (?:\\['"nabfrtvxuU\\]| + # or match any non-`\` and non-single-quote character (most of the string) + [^'\\])* + # matches hanging `\` or ending `'` if one is present + [\\']? +""" + +# bytes and str repr() only uses double quotes if the string contains 1 or more +# `'` character and exactly 0 `"` characters +_match_double_quote_str_bytes = r""" + # bytes repr() begins with `b` character + b?" + # string continues until a `"` character is reached + [^"]* + # end matching at closing double-quote if one is present + "?""" + +# match valid identifier name followed by `[` character +_match_dict_before_key = r"""[\w_][\w0-9._]*\[""" + +_current_dict_key_re = LazyReCompile( + f"{_match_dict_before_key}((?:" + f"{_match_single_quote_str_bytes}|" + f"{_match_double_quote_str_bytes}|" + f"{_match_all_dict_keys}|)*)", + re.VERBOSE, +) + + +def current_dict_key(cursor_offset: int, line: str) -> LinePart | None: """If in dictionary completion, return the current key""" - matches = list(re.finditer(r'''[\w_][\w0-9._]*\[([\w0-9._(), '"]*)''', line)) - for m in matches: - if m.start(1) <= cursor_offset and m.end(1) >= cursor_offset: - return (m.start(1), m.end(1), m.group(1)) + for m in _current_dict_key_re.finditer(line): + if m.start(1) <= cursor_offset <= m.end(1): + return LinePart(m.start(1), m.end(1), m.group(1)) return None -def current_dict(cursor_offset, line): + +# capture valid identifier name if followed by `[` character +_capture_dict_name = r"""([\w_][\w0-9._]*)\[""" + +_current_dict_re = LazyReCompile( + f"{_capture_dict_name}((?:" + f"{_match_single_quote_str_bytes}|" + f"{_match_double_quote_str_bytes}|" + f"{_match_all_dict_keys}|)*)", + re.VERBOSE, +) + + +def current_dict(cursor_offset: int, line: str) -> LinePart | None: """If in dictionary completion, return the dict that should be used""" - matches = list(re.finditer(r'''([\w_][\w0-9._]*)\[([\w0-9._(), '"]*)''', line)) - for m in matches: - if m.start(2) <= cursor_offset and m.end(2) >= cursor_offset: - return (m.start(1), m.end(1), m.group(1)) + for m in _current_dict_re.finditer(line): + if m.start(2) <= cursor_offset <= m.end(2): + return LinePart(m.start(1), m.end(1), m.group(1)) return None -def current_string(cursor_offset, line): - """If inside a string of nonzero length, return the string (excluding quotes) - Weaker than bpython.Repl's current_string, because that checks that a string is a string - based on previous lines in the buffer""" - for m in re.finditer('''(?P(?:""")|"|(?:''\')|')(?:((?P.+?)(?P=open))|(?P.+))''', line): +_current_string_re = LazyReCompile( + '''(?P(?:""")|"|(?:''\')|')(?:((?P.+?)(?P=open))|''' + """(?P.+))""" +) + + +def current_string(cursor_offset: int, line: str) -> LinePart | None: + """If inside a string of nonzero length, return the string (excluding + quotes) + + Weaker than bpython.Repl's current_string, because that checks that a + string is a string based on previous lines in the buffer.""" + for m in _current_string_re.finditer(line): i = 3 if m.group(3) else 4 - if m.start(i) <= cursor_offset and m.end(i) >= cursor_offset: - return m.start(i), m.end(i), m.group(i) + if m.start(i) <= cursor_offset <= m.end(i): + return LinePart(m.start(i), m.end(i), m.group(i)) return None -def current_object(cursor_offset, line): - """If in attribute completion, the object on which attribute should be looked up""" + +_current_object_re = LazyReCompile(r"([\w_][\w0-9_]*)[.]") + + +def current_object(cursor_offset: int, line: str) -> LinePart | None: + """If in attribute completion, the object on which attribute should be + looked up.""" match = current_word(cursor_offset, line) - if match is None: return None - start, end, word = match - matches = list(re.finditer(r'([\w_][\w0-9_]*)[.]', word)) - s = '' - for m in matches: - if m.end(1) + start < cursor_offset: - if s: - s += '.' - s += m.group(1) + if match is None: + return None + s = ".".join( + m.group(1) + for m in _current_object_re.finditer(match.word) + if m.end(1) + match.start < cursor_offset + ) if not s: return None - return start, start+len(s), s + return LinePart(match.start, match.start + len(s), s) + + +_current_object_attribute_re = LazyReCompile(r"([\w_][\w0-9_]*)[.]?") + -def current_object_attribute(cursor_offset, line): +def current_object_attribute(cursor_offset: int, line: str) -> LinePart | None: """If in attribute completion, the attribute being completed""" + # TODO replace with more general current_expression_attribute match = current_word(cursor_offset, line) - if match is None: return None - start, end, word = match - matches = list(re.finditer(r'([\w_][\w0-9_]*)[.]?', word)) - for m in matches[1:]: - if m.start(1) + start <= cursor_offset and m.end(1) + start >= cursor_offset: - return m.start(1) + start, m.end(1) + start, m.group(1) + if match is None: + return None + matches = _current_object_attribute_re.finditer(match.word) + next(matches) + for m in matches: + if m.start(1) + match.start <= cursor_offset <= m.end(1) + match.start: + return LinePart( + m.start(1) + match.start, m.end(1) + match.start, m.group(1) + ) return None -def current_from_import_from(cursor_offset, line): + +_current_from_import_from_re = LazyReCompile( + r"from +([\w0-9_.]*)(?:\s+import\s+([\w0-9_]+[,]?\s*)+)*" +) + + +def current_from_import_from(cursor_offset: int, line: str) -> LinePart | None: """If in from import completion, the word after from - returns None if cursor not in or just after one of the two interesting parts - of an import: from (module) import (name1, name2) + returns None if cursor not in or just after one of the two interesting + parts of an import: from (module) import (name1, name2) """ - #TODO allow for as's - tokens = line.split() - if not ('from' in tokens or 'import' in tokens): - return None - matches = list(re.finditer(r'from ([\w0-9_.]*)(?:\s+import\s+([\w0-9_]+[,]?\s*)+)*', line)) - for m in matches: - if ((m.start(1) < cursor_offset and m.end(1) >= cursor_offset) or - (m.start(2) < cursor_offset and m.end(2) >= cursor_offset)): - return m.start(1), m.end(1), m.group(1) + # TODO allow for as's + for m in _current_from_import_from_re.finditer(line): + if (m.start(1) < cursor_offset <= m.end(1)) or ( + m.start(2) < cursor_offset <= m.end(2) + ): + return LinePart(m.start(1), m.end(1), m.group(1)) return None -def current_from_import_import(cursor_offset, line): + +_current_from_import_import_re_1 = LazyReCompile( + r"from\s+([\w0-9_.]*)\s+import" +) +_current_from_import_import_re_2 = LazyReCompile(r"([\w0-9_]+)") +_current_from_import_import_re_3 = LazyReCompile(r", *([\w0-9_]*)") + + +def current_from_import_import( + cursor_offset: int, line: str +) -> LinePart | None: """If in from import completion, the word after import being completed returns None if cursor not in or just after one of these words """ - baseline = re.search(r'from\s([\w0-9_.]*)\s+import', line) + baseline = _current_from_import_import_re_1.search(line) if baseline is None: return None - match1 = re.search(r'([\w0-9_]+)', line[baseline.end():]) + match1 = _current_from_import_import_re_2.search(line[baseline.end() :]) if match1 is None: return None - matches = list(re.finditer(r'[,][ ]([\w0-9_]*)', line[baseline.end():])) - for m in [match1] + matches: + for m in chain( + (match1,), + _current_from_import_import_re_3.finditer(line[baseline.end() :]), + ): start = baseline.end() + m.start(1) end = baseline.end() + m.end(1) - if start < cursor_offset and end >= cursor_offset: - return start, end, m.group(1) + if start < cursor_offset <= end: + return LinePart(start, end, m.group(1)) return None -def current_import(cursor_offset, line): - #TODO allow for multiple as's - baseline = re.search(r'import', line) + +_current_import_re_1 = LazyReCompile(r"import") +_current_import_re_2 = LazyReCompile(r"([\w0-9_.]+)") +_current_import_re_3 = LazyReCompile(r"[,][ ]*([\w0-9_.]*)") + + +def current_import(cursor_offset: int, line: str) -> LinePart | None: + # TODO allow for multiple as's + baseline = _current_import_re_1.search(line) if baseline is None: return None - match1 = re.search(r'([\w0-9_.]+)', line[baseline.end():]) + match1 = _current_import_re_2.search(line[baseline.end() :]) if match1 is None: return None - matches = list(re.finditer(r'[,][ ]([\w0-9_.]*)', line[baseline.end():])) - for m in [match1] + matches: + for m in chain( + (match1,), _current_import_re_3.finditer(line[baseline.end() :]) + ): start = baseline.end() + m.start(1) end = baseline.end() + m.end(1) - if start < cursor_offset and end >= cursor_offset: - return start, end, m.group(1) + if start < cursor_offset <= end: + return LinePart(start, end, m.group(1)) + return None + + +_current_method_definition_name_re = LazyReCompile(r"def\s+([a-zA-Z_][\w]*)") -def current_method_definition_name(cursor_offset, line): + +def current_method_definition_name( + cursor_offset: int, line: str +) -> LinePart | None: """The name of a method being defined""" - matches = re.finditer("def\s+([a-zA-Z_][\w]*)", line) - for m in matches: - if (m.start(1) <= cursor_offset and m.end(1) >= cursor_offset): - return m.start(1), m.end(1), m.group(1) + for m in _current_method_definition_name_re.finditer(line): + if m.start(1) <= cursor_offset <= m.end(1): + return LinePart(m.start(1), m.end(1), m.group(1)) return None -def current_single_word(cursor_offset, line): + +_current_single_word_re = LazyReCompile(r"(? LinePart | None: """the un-dotted word just before or under the cursor""" - matches = re.finditer(r"(?= cursor_offset): - return m.start(1), m.end(1), m.group(1) + for m in _current_single_word_re.finditer(line): + if m.start(1) <= cursor_offset <= m.end(1): + return LinePart(m.start(1), m.end(1), m.group(1)) return None -def current_dotted_attribute(cursor_offset, line): + +def current_dotted_attribute(cursor_offset: int, line: str) -> LinePart | None: """The dotted attribute-object pair before the cursor""" match = current_word(cursor_offset, line) - if match is None: return None - start, end, word = match - if '.' in word[1:]: - return start, end, word - -def current_string_literal_attr(cursor_offset, line): - """The attribute following a string literal""" - matches = re.finditer("('''" + r'''|"""|'|")((?:(?=([^"'\\]+|\\.|(?!\1)["']))\3)*)\1[.]([a-zA-Z_]?[\w]*)''', line) - for m in matches: - if (m.start(4) <= cursor_offset and m.end(4) >= cursor_offset): - return m.start(4), m.end(4), m.group(4) + if match is not None and "." in match.word[1:]: + return match return None + + +_current_expression_attribute_re = LazyReCompile( + r"[.]\s*((?:[\w_][\w0-9_]*)|(?:))" +) + + +def current_expression_attribute( + cursor_offset: int, line: str +) -> LinePart | None: + """If after a dot, the attribute being completed""" + # TODO replace with more general current_expression_attribute + for m in _current_expression_attribute_re.finditer(line): + if m.start(1) <= cursor_offset <= m.end(1): + return LinePart(m.start(1), m.end(1), m.group(1)) + return None + + +def cursor_on_closing_char_pair( + cursor_offset: int, line: str, ch: str | None = None +) -> tuple[bool, bool]: + """Checks if cursor sits on closing character of a pair + and whether its pair character is directly behind it + """ + on_closing_char, pair_close = False, False + if line is None: + return on_closing_char, pair_close + if cursor_offset < len(line): + cur_char = line[cursor_offset] + if cur_char in CHARACTER_PAIR_MAP.values(): + on_closing_char = True if ch is None else cur_char == ch + if cursor_offset > 0: + prev_char = line[cursor_offset - 1] + if ( + on_closing_char + and prev_char in CHARACTER_PAIR_MAP + and CHARACTER_PAIR_MAP[prev_char] == cur_char + ): + pair_close = True if ch is None else prev_char == ch + return on_closing_char, pair_close diff --git a/bpython/pager.py b/bpython/pager.py index 73cc8f5f5..af9370d6c 100644 --- a/bpython/pager.py +++ b/bpython/pager.py @@ -20,6 +20,8 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +# mypy: disallow_untyped_defs=True +# mypy: disallow_untyped_calls=True import curses import errno @@ -27,22 +29,22 @@ import pydoc import subprocess import sys +import shlex -def get_pager_command(): - command = os.environ.get('PAGER', 'less -r').split() - return command +def get_pager_command(default: str = "less -rf") -> list[str]: + return shlex.split(os.environ.get("PAGER", default)) -def page_internal(data): +def page_internal(data: str) -> None: """A more than dumb pager function.""" - if hasattr(pydoc, 'ttypager'): + if hasattr(pydoc, "ttypager"): pydoc.ttypager(data) else: sys.stdout.write(data) -def page(data, use_internal=False): +def page(data: str, use_internal: bool = False) -> None: command = get_pager_command() if not command or use_internal: page_internal(data) @@ -50,24 +52,27 @@ def page(data, use_internal=False): curses.endwin() try: popen = subprocess.Popen(command, stdin=subprocess.PIPE) - if isinstance(data, unicode): - data = data.encode(sys.__stdout__.encoding, 'replace') - popen.stdin.write(data) + assert popen.stdin is not None + # `encoding` is new in py39 + data_bytes = data.encode(sys.__stdout__.encoding, "replace") # type: ignore + popen.stdin.write(data_bytes) popen.stdin.close() - except OSError, e: + except OSError as e: if e.errno == errno.ENOENT: # pager command not found, fall back to internal pager page_internal(data) return - except IOError, e: if e.errno != errno.EPIPE: raise while True: try: popen.wait() - except OSError, e: + except OSError as e: if e.errno != errno.EINTR: raise else: break curses.doupdate() + + +# vim: sw=4 ts=4 sts=4 ai et diff --git a/bpython/paste.py b/bpython/paste.py new file mode 100644 index 000000000..e43ce2f22 --- /dev/null +++ b/bpython/paste.py @@ -0,0 +1,118 @@ +# The MIT License +# +# Copyright (c) 2014-2022 Sebastian Ramacher +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import errno +import subprocess +from typing import Protocol +from urllib.parse import urljoin, urlparse + +import requests +import unicodedata + +from .config import getpreferredencoding +from .translations import _ + + +class PasteFailed(Exception): + pass + + +class Paster(Protocol): + def paste(self, s: str) -> tuple[str, str | None]: ... + + +class PastePinnwand: + def __init__(self, url: str, expiry: str) -> None: + self.url = url + self.expiry = expiry + + def paste(self, s: str) -> tuple[str, str]: + """Upload to pastebin via json interface.""" + + url = urljoin(self.url, "/api/v1/paste") + payload = { + "expiry": self.expiry, + "files": [{"lexer": "pycon", "content": s}], + } + + try: + response = requests.post(url, json=payload, verify=True) + response.raise_for_status() + except requests.exceptions.RequestException as exc: + raise PasteFailed(str(exc)) + + data = response.json() + + paste_url = data["link"] + removal_url = data["removal"] + + return (paste_url, removal_url) + + +class PasteHelper: + def __init__(self, executable: str) -> None: + self.executable = executable + + def paste(self, s: str) -> tuple[str, None]: + """Call out to helper program for pastebin upload.""" + + try: + helper = subprocess.Popen( + "", + executable=self.executable, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + assert helper.stdin is not None + encoding = getpreferredencoding() + helper.stdin.write(s.encode(encoding)) + output = helper.communicate()[0].decode(encoding) + paste_url = output.split()[0] + except OSError as e: + if e.errno == errno.ENOENT: + raise PasteFailed(_("Helper program not found.")) + else: + raise PasteFailed(_("Helper program could not be run.")) + + if helper.returncode != 0: + raise PasteFailed( + _( + "Helper program returned non-zero exit status %d." + % (helper.returncode,) + ) + ) + + if not paste_url: + raise PasteFailed(_("No output from helper program.")) + + parsed_url = urlparse(paste_url) + if not parsed_url.scheme or any( + unicodedata.category(c) == "Cc" for c in paste_url + ): + raise PasteFailed( + _( + "Failed to recognize the helper " + "program's output as an URL." + ) + ) + + return paste_url, None diff --git a/bpython/patch_linecache.py b/bpython/patch_linecache.py new file mode 100644 index 000000000..fa8e17294 --- /dev/null +++ b/bpython/patch_linecache.py @@ -0,0 +1,81 @@ +import linecache +from typing import Any + + +class BPythonLinecache(dict): + """Replaces the cache dict in the standard-library linecache module, + to also remember (in an unerasable way) bpython console input.""" + + def __init__( + self, + bpython_history: None | (list[tuple[int, None, list[str], str]]) = None, + *args, + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + self.bpython_history = bpython_history or [] + + def is_bpython_filename(self, fname: Any) -> bool: + return isinstance(fname, str) and fname.startswith(" tuple[int, None, list[str], str]: + """Given a filename provided by remember_bpython_input, + returns the associated source string.""" + try: + idx = int(key.split("-")[2][:-1]) + return self.bpython_history[idx] + except (IndexError, ValueError): + raise KeyError + + def remember_bpython_input(self, source: str) -> str: + """Remembers a string of source code, and returns + a fake filename to use to retrieve it later.""" + filename = f"" + self.bpython_history.append( + (len(source), None, source.splitlines(True), filename) + ) + return filename + + def __getitem__(self, key: Any) -> Any: + if self.is_bpython_filename(key): + return self.get_bpython_history(key) + return super().__getitem__(key) + + def __contains__(self, key: Any) -> bool: + if self.is_bpython_filename(key): + try: + self.get_bpython_history(key) + return True + except KeyError: + return False + return super().__contains__(key) + + def __delitem__(self, key: Any) -> None: + if not self.is_bpython_filename(key): + super().__delitem__(key) + + +def _bpython_clear_linecache() -> None: + if isinstance(linecache.cache, BPythonLinecache): + bpython_history = linecache.cache.bpython_history + else: + bpython_history = None + linecache.cache = BPythonLinecache(bpython_history) + + +# Monkey-patch the linecache module so that we are able +# to hold our command history there and have it persist +linecache.cache = BPythonLinecache(None, linecache.cache) # type: ignore +linecache.clearcache = _bpython_clear_linecache + + +def filename_for_console_input(code_string: str) -> str: + """Remembers a string of source code, and returns + a fake filename to use to retrieve it later.""" + if isinstance(linecache.cache, BPythonLinecache): + return linecache.cache.remember_bpython_input(code_string) + else: + # If someone else has patched linecache.cache, better for code to + # simply be unavailable to inspect.getsource() than to raise + # an exception. + return "" diff --git a/bpython/repl.py b/bpython/repl.py index 64b14566a..2ced5b7a8 100644 --- a/bpython/repl.py +++ b/bpython/repl.py @@ -1,6 +1,7 @@ # The MIT License # # Copyright (c) 2009-2011 the bpython authors. +# Copyright (c) 2012-2013,2015 Sebastian Ramacher # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -19,93 +20,159 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -# -from __future__ import with_statement +import abc import code -import codecs -import errno import inspect -import logging import os +import pkgutil import pydoc import shlex import subprocess import sys import tempfile import textwrap +import time import traceback -import unicodedata +from abc import abstractmethod +from dataclasses import dataclass from itertools import takewhile -from locale import getpreferredencoding -from socket import error as SocketError -from string import Template -from urllib import quote as urlquote -from urlparse import urlparse -from xmlrpclib import ServerProxy, Error as XMLRPCError +from pathlib import Path +from types import ModuleType, TracebackType +from typing import ( + Any, + Dict, + List, + Literal, + Optional, + TYPE_CHECKING, + Tuple, + Type, + Union, + cast, +) +from collections.abc import Callable, Iterable + +from pygments.lexers import Python3Lexer +from pygments.token import Token, _TokenType + +have_pyperclip = True +try: + import pyperclip +except ImportError: + have_pyperclip = False + +from . import autocomplete, inspection, simpleeval +from .config import getpreferredencoding, Config +from .formatter import Parenthesis +from .history import History +from .lazyre import LazyReCompile +from .paste import PasteHelper, PastePinnwand, PasteFailed +from .patch_linecache import filename_for_console_input +from .translations import _, ngettext +from .importcompletion import ModuleGatherer + + +class RuntimeTimer: + """Calculate running time""" + + def __init__(self) -> None: + self.reset_timer() + + def __enter__(self) -> None: + self.start = time.monotonic() + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> Literal[False]: + self.last_command = time.monotonic() - self.start + self.running_time += self.last_command + return False -from pygments.token import Token + def reset_timer(self) -> None: + self.running_time = 0.0 + self.last_command = 0.0 -from bpython import inspection -from bpython._py3compat import PythonLexer, py3 -from bpython.formatter import Parenthesis -from bpython.translations import _ -import bpython.autocomplete as autocomplete + def estimate(self) -> float: + return self.running_time - self.last_command class Interpreter(code.InteractiveInterpreter): + """Source code interpreter for use in bpython.""" + + bpython_input_re = LazyReCompile(r"") - def __init__(self, locals=None, encoding=None): - """The syntaxerror callback can be set at any time and will be called + def __init__( + self, + locals: dict[str, Any] | None = None, + ) -> None: + """Constructor. + + The optional 'locals' argument specifies the dictionary in which code + will be executed; it defaults to a newly created dictionary with key + "__name__" set to "__main__". + + The syntaxerror callback can be set at any time and will be called on a caught syntax error. The purpose for this in bpython is so that the repl can be instantiated after the interpreter (which it necessarily must be with the current factoring) and then an exception - callback can be added to the Interpeter instance afterwards - more + callback can be added to the Interpreter instance afterwards - more specifically, this is so that autoindentation does not occur after a - traceback.""" + traceback. + """ - self.encoding = encoding or sys.getdefaultencoding() - self.syntaxerror_callback = None - # Unfortunately code.InteractiveInterpreter is a classic class, so no super() - code.InteractiveInterpreter.__init__(self, locals) + self.syntaxerror_callback: Callable | None = None - if not py3: + if locals is None: + # instead of messing with sys.modules, we should modify sys.modules + # in the interpreter instance + sys.modules["__main__"] = main_mod = ModuleType("__main__") + locals = main_mod.__dict__ - def runsource(self, source, filename='', symbol='single', - encode=True): - if encode: - source = '# coding: %s\n%s' % (self.encoding, - source.encode(self.encoding)) - return code.InteractiveInterpreter.runsource(self, source, - filename, symbol) + super().__init__(locals) + self.timer = RuntimeTimer() - def showsyntaxerror(self, filename=None): + def runsource( + self, + source: str, + filename: str | None = None, + symbol: str = "single", + ) -> bool: + """Execute Python code. + + source, filename and symbol are passed on to + code.InteractiveInterpreter.runsource.""" + + if filename is None: + filename = filename_for_console_input(source) + with self.timer: + return super().runsource(source, filename, symbol) + + def showsyntaxerror(self, filename: str | None = None, **kwargs) -> None: """Override the regular handler, the code's copied and pasted from code.py, as per showtraceback, but with the syntaxerror callback called and the text in a pretty colour.""" if self.syntaxerror_callback is not None: self.syntaxerror_callback() - type, value, sys.last_traceback = sys.exc_info() - sys.last_type = type + exc_type, value, sys.last_traceback = sys.exc_info() + sys.last_type = exc_type sys.last_value = value - if filename and type is SyntaxError: - # Work hard to stuff the correct filename in the exception - try: - msg, (dummy_filename, lineno, offset, line) = value.args - except: - # Not the format we expect; leave it alone - pass - else: - # Stuff in the right filename and right lineno - if not py3: - lineno -= 1 - value = SyntaxError(msg, (filename, lineno, offset, line)) - sys.last_value = value - list = traceback.format_exception_only(type, value) - self.writetb(list) - - def showtraceback(self): + if filename and exc_type is SyntaxError and value is not None: + msg = value.args[0] + args = list(value.args[1]) + # strip linechache line number + if self.bpython_input_re.match(filename): + args[0] = "" + value = SyntaxError(msg, tuple(args)) + sys.last_value = value + exc_formatted = traceback.format_exception_only(exc_type, value) + self.writetb(exc_formatted) + + def showtraceback(self) -> None: """This needs to override the default traceback thing so it can put it into a pretty colour and maybe other stuff, I don't know""" @@ -116,154 +183,29 @@ def showtraceback(self): sys.last_traceback = tb tblist = traceback.extract_tb(tb) del tblist[:1] - # Set the right lineno (encoding header adds an extra line) - if not py3: - for i, (filename, lineno, module, something) in enumerate(tblist): - if filename == '': - tblist[i] = (filename, lineno - 1, module, something) + + for frame in tblist: + if self.bpython_input_re.match(frame.filename): + # strip linecache line number + frame.filename = "" l = traceback.format_list(tblist) if l: l.insert(0, "Traceback (most recent call last):\n") - l[len(l):] = traceback.format_exception_only(t, v) + l[len(l) :] = traceback.format_exception_only(t, v) finally: - tblist = tb = None + pass self.writetb(l) - def writetb(self, lines): + def writetb(self, lines: Iterable[str]) -> None: """This outputs the traceback and should be overridden for anything fancy.""" for line in lines: self.write(line) -class History(object): - - def __init__(self, entries=None, duplicates=False): - if entries is None: - self.entries = [''] - else: - self.entries = list(entries) - self.index = 0 - self.saved_line = '' - self.duplicates = duplicates - - def append(self, line): - line = line.rstrip('\n') - if line: - if not self.duplicates: - # remove duplicates - try: - while True: - self.entries.remove(line) - except ValueError: - pass - self.entries.append(line) - - def first(self): - """Move back to the beginning of the history.""" - if not self.is_at_end: - self.index = len(self.entries) - return self.entries[-self.index] - - def back(self, start=True, search=False): - """Move one step back in the history.""" - if not self.is_at_end: - if search: - self.index += self.find_partial_match_backward(self.saved_line) - elif start: - self.index += self.find_match_backward(self.saved_line) - else: - self.index += 1 - return self.entries[-self.index] if self.index else self.saved_line - - def find_match_backward(self, search_term): - filtered_list_len = len(self.entries) - self.index - for idx, val in enumerate(reversed(self.entries[:filtered_list_len])): - if val.startswith(search_term): - return idx + 1 - return 0 - - def find_partial_match_backward(self, search_term): - filtered_list_len = len(self.entries) - self.index - for idx, val in enumerate(reversed(self.entries[:filtered_list_len])): - if search_term in val: - return idx + 1 - return 0 - - - def forward(self, start=True, search=False): - """Move one step forward in the history.""" - if self.index > 1: - if search: - self.index -= self.find_partial_match_forward(self.saved_line) - elif start: - self.index -= self.find_match_forward(self.saved_line) - else: - self.index -= 1 - return self.entries[-self.index] if self.index else self.saved_line - else: - self.index = 0 - return self.saved_line - - def find_match_forward(self, search_term): - filtered_list_len = len(self.entries) - self.index + 1 - for idx, val in enumerate(self.entries[filtered_list_len:]): - if val.startswith(search_term): - return idx + 1 - return self.index - - def find_partial_match_forward(self, search_term): - filtered_list_len = len(self.entries) - self.index + 1 - for idx, val in enumerate(self.entries[filtered_list_len:]): - if search_term in val: - return idx + 1 - return self.index - - - - def last(self): - """Move forward to the end of the history.""" - if not self.is_at_start: - self.index = 0 - return self.entries[0] - - @property - def is_at_end(self): - return self.index >= len(self.entries) or self.index == -1 - - @property - def is_at_start(self): - return self.index == 0 - - def enter(self, line): - if self.index == 0: - self.saved_line = line - - @classmethod - def from_filename(cls, filename): - history = cls() - history.load(filename) - return history - - def load(self, filename, encoding): - with codecs.open(filename, 'r', encoding, 'ignore') as hfile: - for line in hfile: - self.append(line) - - def reset(self): - self.index = 0 - self.saved_line = '' - - def save(self, filename, encoding, lines=0): - with codecs.open(filename, 'w', encoding, 'ignore') as hfile: - for line in self.entries[-lines:]: - hfile.write(line) - hfile.write('\n') - - -class MatchesIterator(object): +class MatchesIterator: """Stores a list of matches and which one is currently selected if any. Also responsible for doing the actual replacement of the original line with @@ -272,102 +214,179 @@ class MatchesIterator(object): A MatchesIterator can be `clear`ed to reset match iteration, and `update`ed to set what matches will be iterated over.""" - def __init__(self): - self.current_word = '' # word being replaced in the original line of text - self.matches = None # possible replacements for current_word - self.index = -1 # which word is currently replacing the current word - self.orig_cursor_offset = None # cursor position in the original line - self.orig_line = None # original line (before match replacements) - self.completer = None # class describing the current type of completion - - def __nonzero__(self): + def __init__(self) -> None: + # word being replaced in the original line of text + self.current_word = "" + # possible replacements for current_word + self.matches: list[str] = [] + # which word is currently replacing the current word + self.index = -1 + # cursor position in the original line + self.orig_cursor_offset = -1 + # original line (before match replacements) + self.orig_line = "" + # class describing the current type of completion + self.completer: autocomplete.BaseCompletionType | None = None + self.start: int | None = None + self.end: int | None = None + + def __nonzero__(self) -> bool: """MatchesIterator is False when word hasn't been replaced yet""" return self.index != -1 - def __iter__(self): + def __bool__(self) -> bool: + return self.index != -1 + + @property + def candidate_selected(self) -> bool: + """True when word selected/replaced, False when word hasn't been + replaced yet""" + return bool(self) + + def __iter__(self) -> "MatchesIterator": return self - def current(self): + def current(self) -> str: if self.index == -1: - raise ValueError('No current match.') + raise ValueError("No current match.") return self.matches[self.index] - def next(self): + def __next__(self) -> str: self.index = (self.index + 1) % len(self.matches) return self.matches[self.index] - def previous(self): + def previous(self) -> str: if self.index <= 0: self.index = len(self.matches) self.index -= 1 return self.matches[self.index] - def cur_line(self): - """Returns a cursor offset and line with the current substitution made""" + def cur_line(self) -> tuple[int, str]: + """Returns a cursor offset and line with the current substitution + made""" return self.substitute(self.current()) - def substitute(self, match): + def substitute(self, match: str) -> tuple[int, str]: """Returns a cursor offset and line with match substituted in""" - start, end, word = self.completer.locate(self.orig_cursor_offset, self.orig_line) - result = start + len(match), self.orig_line[:start] + match + self.orig_line[end:] - return result + assert self.completer is not None + + lp = self.completer.locate(self.orig_cursor_offset, self.orig_line) + assert lp is not None + return ( + lp.start + len(match), + self.orig_line[: lp.start] + match + self.orig_line[lp.stop :], + ) - def is_cseq(self): - return bool(os.path.commonprefix(self.matches)[len(self.current_word):]) + def is_cseq(self) -> bool: + return bool( + os.path.commonprefix(self.matches)[len(self.current_word) :] + ) + + def substitute_cseq(self) -> tuple[int, str]: + """Returns a new line by substituting a common sequence in, and update + matches""" + assert self.completer is not None - def substitute_cseq(self): - """Returns a new line by substituting a common sequence in, and update matches""" cseq = os.path.commonprefix(self.matches) new_cursor_offset, new_line = self.substitute(cseq) if len(self.matches) == 1: self.clear() else: - self.update(new_cursor_offset, new_line, self.matches, self.completer) + self.update( + new_cursor_offset, new_line, self.matches, self.completer + ) if len(self.matches) == 1: self.clear() return new_cursor_offset, new_line - def update(self, cursor_offset, current_line, matches, completer): + def update( + self, + cursor_offset: int, + current_line: str, + matches: list[str], + completer: autocomplete.BaseCompletionType, + ) -> None: """Called to reset the match index and update the word being replaced - Should only be called if there's a target to update - otherwise, call clear""" + Should only be called if there's a target to update - otherwise, call + clear""" + + if matches is None: + raise ValueError("Matches may not be None.") + self.orig_cursor_offset = cursor_offset self.orig_line = current_line - assert matches is not None self.matches = matches self.completer = completer - #assert self.completer.locate(self.orig_cursor_offset, self.orig_line) is not None, (self.completer.locate, self.orig_cursor_offset, self.orig_line) self.index = -1 - self.start, self.end, self.current_word = self.completer.locate(self.orig_cursor_offset, self.orig_line) + lp = self.completer.locate(self.orig_cursor_offset, self.orig_line) + assert lp is not None + self.start = lp.start + self.end = lp.stop + self.current_word = lp.word - def clear(self): + def clear(self) -> None: self.matches = [] - self.cursor_offset = -1 - self.current_line = '' - self.current_word = '' + self.orig_cursor_offset = -1 + self.orig_line = "" + self.current_word = "" self.start = None self.end = None self.index = -1 -class Interaction(object): - def __init__(self, config, statusbar=None): + +class Interaction(metaclass=abc.ABCMeta): + def __init__(self, config: Config): self.config = config - if statusbar: - self.statusbar = statusbar + @abc.abstractmethod + def confirm(self, s: str) -> bool: + pass + + @abc.abstractmethod + def notify( + self, s: str, n: float = 10.0, wait_for_keypress: bool = False + ) -> None: + pass + + @abc.abstractmethod + def file_prompt(self, s: str) -> str | None: + pass - def confirm(self, s): - raise NotImplementedError - def notify(self, s, n=10): - raise NotImplementedError +class NoInteraction(Interaction): + def __init__(self, config: Config): + super().__init__(config) + + def confirm(self, s: str) -> bool: + return False + + def notify( + self, s: str, n: float = 10.0, wait_for_keypress: bool = False + ) -> None: + pass + + def file_prompt(self, s: str) -> str | None: + return None - def file_prompt(self, s): - raise NotImplementedError +class SourceNotFound(Exception): + """Exception raised when the requested source could not be found.""" -class Repl(object): + +@dataclass +class _FuncExpr: + """Stack element in Repl._funcname_and_argnum""" + + full_expr: str + function_expr: str + arg_number: int + opening: str + keyword: str | None = None + + +class Repl(metaclass=abc.ABCMeta): """Implements the necessary guff for a Python-repl-alike interface The execution of the code entered and all that stuff was taken from the @@ -400,82 +419,159 @@ class Repl(object): XXX Subclasses should implement echo, current_line, cw """ - def __init__(self, interp, config): + @abc.abstractmethod + def reevaluate(self): + pass + + @abc.abstractmethod + def reprint_line( + self, lineno: int, tokens: list[tuple[_TokenType, str]] + ) -> None: + pass + + @abc.abstractmethod + def _get_current_line(self) -> str: + pass + + @abc.abstractmethod + def _set_current_line(self, val: str) -> None: + pass + + @property + def current_line(self) -> str: + """The current line""" + return self._get_current_line() + + @current_line.setter + def current_line(self, value: str) -> None: + self._set_current_line(value) + + @abc.abstractmethod + def _get_cursor_offset(self) -> int: + pass + + @abc.abstractmethod + def _set_cursor_offset(self, val: int) -> None: + pass + + @property + def cursor_offset(self) -> int: + """The current cursor offset from the front of the "line".""" + return self._get_cursor_offset() + + @cursor_offset.setter + def cursor_offset(self, value: int) -> None: + self._set_cursor_offset(value) + + if TYPE_CHECKING: + # not actually defined, subclasses must define + cpos: int + + def __init__(self, interp: Interpreter, config: Config): """Initialise the repl. interp is a Python code.InteractiveInterpreter instance config is a populated bpython.config.Struct. """ - self.config = config - self.cut_buffer = '' - self.buffer = [] + self.cut_buffer = "" + self.buffer: list[str] = [] self.interp = interp self.interp.syntaxerror_callback = self.clear_current_line self.match = False - self.rl_history = History(duplicates=config.hist_duplicates) - self.s_hist = [] - self.history = [] + self.rl_history = History( + duplicates=config.hist_duplicates, hist_size=config.hist_length + ) + # all input and output, stored as old style format strings + # (\x01, \x02, ...) for cli.py + self.screen_hist: list[str] = [] + # commands executed since beginning of session + self.history: list[str] = [] + self.redo_stack: list[str] = [] self.evaluating = False self.matches_iter = MatchesIterator() - self.argspec = None + self.funcprops = None + self.arg_pos: str | int | None = None self.current_func = None - self.highlighted_paren = None - self._C = {} - self.prev_block_finished = 0 - self.interact = Interaction(self.config) + self.highlighted_paren: None | ( + tuple[Any, list[tuple[_TokenType, str]]] + ) = None + self._C: dict[str, int] = {} + self.prev_block_finished: int = 0 + self.interact: Interaction = NoInteraction(self.config) # previous pastebin content to prevent duplicate pastes, filled on call # to repl.pastebin - self.prev_pastebin_content = '' - self.prev_pastebin_url = '' + self.prev_pastebin_content = "" + self.prev_pastebin_url = "" + self.prev_removal_url = "" # Necessary to fix mercurial.ui.ui expecting sys.stderr to have this # attribute self.closed = False + self.paster: PasteHelper | PastePinnwand - pythonhist = os.path.expanduser(self.config.hist_file) - if os.path.exists(pythonhist): - self.rl_history.load(pythonhist, - getpreferredencoding() or "ascii") + if self.config.hist_file.exists(): + try: + self.rl_history.load( + self.config.hist_file, + getpreferredencoding() or "ascii", + ) + except OSError: + pass + + self.module_gatherer = ModuleGatherer( + skiplist=self.config.import_completion_skiplist + ) + self.completers = autocomplete.get_default_completer( + config.autocomplete_mode, self.module_gatherer + ) + if self.config.pastebin_helper: + self.paster = PasteHelper(self.config.pastebin_helper) + else: + self.paster = PastePinnwand( + self.config.pastebin_url, + self.config.pastebin_expiry, + ) @property - def ps1(self): - try: + def ps1(self) -> str: + if hasattr(sys, "ps1"): + # noop in most cases, but at least vscode injects a non-str ps1 + # see #1041 return str(sys.ps1) - except AttributeError: - return '>>> ' + return ">>> " @property - def ps2(self): - try: + def ps2(self) -> str: + if hasattr(sys, "ps2"): return str(sys.ps2) - except AttributeError: - return '... ' + return "... " - def startup(self): + def startup(self) -> None: """ Execute PYTHONSTARTUP file if it exits. Call this after front end-specific initialisation. """ - filename = os.environ.get('PYTHONSTARTUP') - if filename and os.path.isfile(filename): - with open(filename, 'r') as f: - if py3: - self.interp.runsource(f.read(), filename, 'exec') - else: - self.interp.runsource(f.read(), filename, 'exec', encode=False) + filename = os.environ.get("PYTHONSTARTUP") + if filename: + encoding = inspection.get_encoding_file(filename) + with open(filename, encoding=encoding) as f: + source = f.read() + self.interp.runsource(source, filename, "exec") def current_string(self, concatenate=False): """If the line ends in a string get it, otherwise return ''""" tokens = self.tokenize(self.current_line) - string_tokens = list(takewhile(token_is_any_of([Token.String, - Token.Text]), - reversed(tokens))) + string_tokens = list( + takewhile( + token_is_any_of([Token.String, Token.Text]), reversed(tokens) + ) + ) if not string_tokens: - return '' + return "" opening = string_tokens.pop()[1] string = list() - for (token, value) in reversed(string_tokens): + for token, value in reversed(string_tokens): if token is Token.Text: continue elif opening is None: @@ -491,69 +587,119 @@ def current_string(self, concatenate=False): string.append(value) if opening is None: - return '' - return ''.join(string) + return "" + return "".join(string) - def get_object(self, name): - attributes = name.split('.') - obj = eval(attributes.pop(0), self.interp.locals) + def get_object(self, name: str) -> Any: + attributes = name.split(".") + obj = eval(attributes.pop(0), cast(dict[str, Any], self.interp.locals)) while attributes: - with inspection.AttrCleaner(obj): - obj = getattr(obj, attributes.pop(0)) + obj = inspection.getattr_safe(obj, attributes.pop(0)) return obj + @classmethod + def _funcname_and_argnum( + cls, line: str + ) -> tuple[str | None, str | int | None]: + """Parse out the current function name and arg from a line of code.""" + # each element in stack is a _FuncExpr instance + # if keyword is not None, we've encountered a keyword and so we're done counting + stack = [_FuncExpr("", "", 0, "")] + try: + for token, value in Python3Lexer().get_tokens(line): + if token is Token.Punctuation: + if value in "([{": + stack.append(_FuncExpr("", "", 0, value)) + elif value in ")]}": + element = stack.pop() + expr = element.opening + element.full_expr + value + stack[-1].function_expr += expr + stack[-1].full_expr += expr + elif value == ",": + if stack[-1].keyword is None: + stack[-1].arg_number += 1 + else: + stack[-1].keyword = "" + stack[-1].function_expr = "" + stack[-1].full_expr += value + elif value == ":" and stack[-1].opening == "lambda": + expr = stack.pop().full_expr + ":" + stack[-1].function_expr += expr + stack[-1].full_expr += expr + else: + stack[-1].function_expr = "" + stack[-1].full_expr += value + elif ( + token is Token.Number + or token in Token.Number.subtypes + or token is Token.Name + or token in Token.Name.subtypes + or token is Token.Operator + and value == "." + ): + stack[-1].function_expr += value + stack[-1].full_expr += value + elif token is Token.Operator and value == "=": + stack[-1].keyword = stack[-1].function_expr + stack[-1].function_expr = "" + stack[-1].full_expr += value + elif token is Token.Number or token in Token.Number.subtypes: + stack[-1].function_expr = value + stack[-1].full_expr += value + elif token is Token.Keyword and value == "lambda": + stack.append(_FuncExpr(value, "", 0, value)) + else: + stack[-1].function_expr = "" + stack[-1].full_expr += value + while stack[-1].opening in "[{": + stack.pop() + elem1 = stack.pop() + elem2 = stack.pop() + return elem2.function_expr, elem1.keyword or elem1.arg_number + except IndexError: + return None, None + def get_args(self): """Check if an unclosed parenthesis exists, then attempt to get the - argspec() for it. On success, update self.argspec and return True, - otherwise set self.argspec to None and return False""" + argspec() for it. On success, update self.funcprops,self.arg_pos and + return True, otherwise set self.funcprops to None and return False""" self.current_func = None if not self.config.arg_spec: return False - # Get the name of the current function and where we are in - # the arguments - stack = [['', 0, '']] - try: - for (token, value) in PythonLexer().get_tokens( - self.current_line): - if token is Token.Punctuation: - if value in '([{': - stack.append(['', 0, value]) - elif value in ')]}': - stack.pop() - elif value == ',': - try: - stack[-1][1] += 1 - except TypeError: - stack[-1][1] = '' - stack[-1][0] = '' - elif value == ':' and stack[-1][2] == 'lambda': - stack.pop() - else: - stack[-1][0] = '' - elif (token is Token.Name or token in Token.Name.subtypes or - token is Token.Operator and value == '.'): - stack[-1][0] += value - elif token is Token.Operator and value == '=': - stack[-1][1] = stack[-1][0] - stack[-1][0] = '' - elif token is Token.Keyword and value == 'lambda': - stack.append(['', 0, value]) - else: - stack[-1][0] = '' - while stack[-1][2] in '[{': - stack.pop() - _, arg_number, _ = stack.pop() - func, _, _ = stack.pop() - except IndexError: - return False + func, arg_number = self._funcname_and_argnum(self.current_line) if not func: return False try: - f = self.get_object(func) + if inspection.is_eval_safe_name(func): + f = self.get_object(func) + else: + try: + fake_cursor = self.current_line.index(func) + len(func) + f = simpleeval.evaluate_current_attribute( + fake_cursor, self.current_line, self.interp.locals + ) + except simpleeval.EvaluationError: + return False + + if inspect.isclass(f): + class_f = None + + if ( + (not class_f or not inspection.getfuncprops(func, class_f)) + and hasattr(f, "__new__") + and f.__new__ is not object.__new__ + and + # py3 + f.__new__.__class__ is not object.__new__.__class__ + ): + class_f = f.__new__ + + if class_f: + f = class_f except Exception: # another case of needing to catch every kind of error # since user code is run in the case of descriptors @@ -561,43 +707,45 @@ def get_args(self): # stuff ! return False - if inspect.isclass(f): - try: - if f.__init__ is not object.__init__: - f = f.__init__ - except AttributeError: - return None self.current_func = f - - self.argspec = inspection.getargspec(func, f) - if self.argspec: - self.argspec.append(arg_number) + self.funcprops = inspection.getfuncprops(func, f) + if self.funcprops: + self.arg_pos = arg_number return True + self.arg_pos = None return False - def get_source_of_current_name(self): - """Return the source code of the object which is bound to the - current name in the current input line. Return `None` if the + def get_source_of_current_name(self) -> str: + """Return the unicode source code of the object which is bound to the + current name in the current input line. Throw `SourceNotFound` if the source cannot be found.""" + + obj: Callable | None = self.current_func try: - obj = self.current_func if obj is None: line = self.current_line + if not line.strip(): + raise SourceNotFound(_("Nothing to get source of")) if inspection.is_eval_safe_name(line): obj = self.get_object(line) - if obj is None: - return None - source = inspect.getsource(obj) - except (AttributeError, IOError, NameError, TypeError): - return None - else: - return source + # Ignoring the next mypy error because we want this to fail if obj is None + return inspect.getsource(obj) # type:ignore[arg-type] + except (AttributeError, NameError) as e: + msg = _("Cannot get source: %s") % (e,) + except OSError as e: + msg = f"{e}" + except TypeError as e: + if "built-in" in f"{e}": + msg = _("Cannot access source of %r") % (obj,) + else: + msg = _("No source code found for %s") % (self.current_line,) + raise SourceNotFound(msg) - def set_docstring(self): + def set_docstring(self) -> None: self.docstring = None if not self.get_args(): - self.argspec = None - elif self.current_func is not None: + self.funcprops = None + if self.current_func is not None: try: self.docstring = pydoc.getdoc(self.current_func) except IndexError: @@ -608,8 +756,18 @@ def set_docstring(self): if not self.docstring: self.docstring = None - def complete(self, tab=False): - """Construct a full list of possible completions and construct and + # What complete() does: + # Should we show the completion box? (are there matches, or is there a + # docstring to show?) + # Some completions should always be shown, other only if tab=True + # set the current docstring to the "current function's" docstring + # Populate the matches_iter object with new matches from the current state + # if none, clear the matches iterator + # If exactly one match that is equal to current line, clear matches + # If example one match and tab=True, then choose that and clear matches + + def complete(self, tab: bool = False) -> bool | None: + """Construct a full list of possible completions and display them in a window. Also check if there's an available argspec (via the inspect module) and bang that on top of the completions too. The return value is whether the list_win is visible or not. @@ -624,52 +782,59 @@ def complete(self, tab=False): self.set_docstring() matches, completer = autocomplete.get_completer( - self.cursor_offset, - self.current_line, - self.interp.locals, - self.argspec, - '\n'.join(self.buffer + [self.current_line]), - self.config.autocomplete_mode if hasattr(self.config, 'autocomplete_mode') else autocomplete.SIMPLE, - self.config.complete_magic_methods) - #TODO implement completer.shown_before_tab == False (filenames shouldn't fill screen) - - if (matches is None # no completion is relevant - or len(matches) == 0): # a target for completion was found - # but no matches were found + self.completers, + cursor_offset=self.cursor_offset, + line=self.current_line, + locals_=cast(dict[str, Any], self.interp.locals), + argspec=self.funcprops, + current_block="\n".join(self.buffer + [self.current_line]), + complete_magic_methods=self.config.complete_magic_methods, + history=self.history, + ) + + if len(matches) == 0: self.matches_iter.clear() - return bool(self.argspec) - - self.matches_iter.update(self.cursor_offset, - self.current_line, matches, completer) - - if len(matches) == 1: - self.matches_iter.next() - if tab: # if this complete is being run for a tab key press, tab() to do the swap - - self.cursor_offset, self.current_line = self.matches_iter.substitute_cseq() - return Repl.complete(self) + return bool(self.funcprops) + + if completer: + self.matches_iter.update( + self.cursor_offset, self.current_line, matches, completer + ) + + if len(matches) == 1: + if tab: + # if this complete is being run for a tab key press, substitute + # common sequence + ( + self._cursor_offset, + self._current_line, + ) = self.matches_iter.substitute_cseq() + return Repl.complete(self) # again for elif self.matches_iter.current_word == matches[0]: self.matches_iter.clear() return False return completer.shown_before_tab + else: + return tab or completer.shown_before_tab else: - assert len(matches) > 1 - return tab or completer.shown_before_tab + return False - def format_docstring(self, docstring, width, height): + def format_docstring( + self, docstring: str, width: int, height: int + ) -> list[str]: """Take a string and try to format it into a sane list of strings to be put into the suggestion box.""" - lines = docstring.split('\n') + lines = docstring.split("\n") out = [] i = 0 for line in lines: i += 1 if not line.strip(): - out.append('\n') + out.append("\n") for block in textwrap.wrap(line, width): - out.append(' ' + block + '\n') + out.append(" " + block + "\n") if i >= height: return out i += 1 @@ -677,14 +842,18 @@ def format_docstring(self, docstring, width, height): out[-1] = out[-1].rstrip() return out - def next_indentation(self): + def next_indentation(self) -> int: """Return the indentation of the next line based on the current input buffer.""" if self.buffer: - indentation = next_indentation(self.buffer[-1], - self.config.tab_length) + indentation = next_indentation( + self.buffer[-1], self.config.tab_length + ) if indentation and self.config.dedent_after > 0: - line_is_empty = lambda line: not line.strip() + + def line_is_empty(line): + return not line.strip() + empty_lines = takewhile(line_is_empty, reversed(self.buffer)) if sum(1 for _ in empty_lines) >= self.config.dedent_after: indentation -= 1 @@ -692,210 +861,220 @@ def next_indentation(self): indentation = 0 return indentation - def formatforfile(self, s): + @abstractmethod + def getstdout(self) -> str: + raise NotImplementedError() + + def get_session_formatted_for_file(self) -> str: """Format the stdout buffer to something suitable for writing to disk, i.e. without >>> and ... at input lines and with "# OUT: " prepended to - output lines.""" + output lines and "### " prepended to current line""" + + session_output = self.getstdout() def process(): - for line in s.split('\n'): + for line in session_output.split("\n"): if line.startswith(self.ps1): - yield line[len(self.ps1):] + yield line[len(self.ps1) :] elif line.startswith(self.ps2): - yield line[len(self.ps2):] + yield line[len(self.ps2) :] elif line.rstrip(): - yield "# OUT: %s" % (line,) + yield f"# OUT: {line}" + return "\n".join(process()) - def write2file(self): + def write2file(self) -> None: """Prompt for a filename and write the current contents of the stdout buffer to disk.""" try: - fn = self.interact.file_prompt('Save to file (Esc to cancel): ') + fn = self.interact.file_prompt(_("Save to file (Esc to cancel): ")) if not fn: - self.interact.notify("Save cancelled.") + self.interact.notify(_("Save cancelled.")) return except ValueError: - self.interact.notify("Save cancelled.") + self.interact.notify(_("Save cancelled.")) return - if fn.startswith('~'): - fn = os.path.expanduser(fn) - if not fn.endswith('.py') and self.config.save_append_py: - fn = fn + '.py' - - mode = 'w' - if os.path.exists(fn): - mode = self.interact.file_prompt('%s already exists. Do you want ' - 'to (c)ancel, (o)verwrite or ' - '(a)ppend? ' % (fn, )) - if mode in ('o', 'overwrite'): - mode = 'w' - elif mode in ('a', 'append'): - mode = 'a' + path = Path(fn).expanduser() + if path.suffix != ".py" and self.config.save_append_py: + # fn.with_suffix(".py") does not append if fn has a non-empty suffix + path = Path(f"{path}.py") + + mode = "w" + if path.exists(): + new_mode = self.interact.file_prompt( + _( + "%s already exists. Do you want to (c)ancel, (o)verwrite or (a)ppend? " + ) + % (path,) + ) + if new_mode in ("o", "overwrite", _("overwrite")): + mode = "w" + elif new_mode in ("a", "append", _("append")): + mode = "a" else: - self.interact.notify('Save cancelled.') + self.interact.notify(_("Save cancelled.")) return - s = self.formatforfile(self.getstdout()) + stdout_text = self.get_session_formatted_for_file() try: - f = open(fn, mode) - f.write(s) - f.close() - except IOError: - self.interact.notify("Disk write error for file '%s'." % (fn, )) + with open(path, mode) as f: + f.write(stdout_text) + except OSError as e: + self.interact.notify(_("Error writing file '%s': %s") % (path, e)) else: - self.interact.notify('Saved to %s.' % (fn, )) + self.interact.notify(_("Saved to %s.") % (path,)) - def pastebin(self, s=None): + def copy2clipboard(self) -> None: + """Copy current content to clipboard.""" + + if not have_pyperclip: + self.interact.notify(_("No clipboard available.")) + return + + content = self.get_session_formatted_for_file() + try: + pyperclip.copy(content) + except pyperclip.PyperclipException: + self.interact.notify(_("Could not copy to clipboard.")) + else: + self.interact.notify(_("Copied content to clipboard.")) + + def pastebin(self, s=None) -> str | None: """Upload to a pastebin and display the URL in the status bar.""" if s is None: s = self.getstdout() - if (self.config.pastebin_confirm and - not self.interact.confirm(_("Pastebin buffer? (y/N) "))): - self.interact.notify(_("Pastebin aborted")) - return - return self.do_pastebin(s) + if self.config.pastebin_confirm and not self.interact.confirm( + _("Pastebin buffer? (y/N) ") + ): + self.interact.notify(_("Pastebin aborted.")) + return None + else: + return self.do_pastebin(s) - def do_pastebin(self, s): + def do_pastebin(self, s) -> str | None: """Actually perform the upload.""" + paste_url: str if s == self.prev_pastebin_content: - self.interact.notify(_('Duplicate pastebin. Previous URL: %s') % - (self.prev_pastebin_url, )) + self.interact.notify( + _("Duplicate pastebin. Previous URL: %s. " "Removal URL: %s") + % (self.prev_pastebin_url, self.prev_removal_url), + 10, + ) return self.prev_pastebin_url - if self.config.pastebin_helper: - return self.do_pastebin_helper(s) - else: - return self.do_pastebin_xmlrpc(s) - - def do_pastebin_xmlrpc(self, s): - """Upload to pastebin via XML-RPC.""" + self.interact.notify(_("Posting data to pastebin...")) try: - pasteservice = ServerProxy(self.config.pastebin_url) - except IOError, e: - self.interact.notify(_("Pastebin error for URL '%s': %s") % - (self.config.pastebin_url, str(e))) - return - - self.interact.notify(_('Posting data to pastebin...')) - try: - paste_id = pasteservice.pastes.newPaste('pycon', s, '', '', '', - self.config.pastebin_private) - except (SocketError, XMLRPCError), e: - self.interact.notify(_('Upload failed: %s') % (str(e), ) ) - return + paste_url, removal_url = self.paster.paste(s) + except PasteFailed as e: + self.interact.notify(_("Upload failed: %s") % e) + return None self.prev_pastebin_content = s - - paste_url_template = Template(self.config.pastebin_show_url) - paste_id = urlquote(paste_id) - paste_url = paste_url_template.safe_substitute(paste_id=paste_id) self.prev_pastebin_url = paste_url - self.interact.notify(_('Pastebin URL: %s') % (paste_url, ), 10) - return paste_url - - def do_pastebin_helper(self, s): - """Call out to helper program for pastebin upload.""" - self.interact.notify(_('Posting data to pastebin...')) - - try: - helper = subprocess.Popen('', - executable=self.config.pastebin_helper, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE) - helper.stdin.write(s.encode(getpreferredencoding())) - output = helper.communicate()[0].decode(getpreferredencoding()) - paste_url = output.split()[0] - except OSError, e: - if e.errno == errno.ENOENT: - self.interact.notify(_('Upload failed: ' - 'Helper program not found.')) - else: - self.interact.notify(_('Upload failed: ' - 'Helper program could not be run.')) - return - - if helper.returncode != 0: - self.interact.notify(_('Upload failed: ' - 'Helper program returned non-zero exit ' - 'status %s.' % (helper.returncode, ))) - return - - if not paste_url: - self.interact.notify(_('Upload failed: ' - 'No output from helper program.')) - return + self.prev_removal_url = removal_url if removal_url is not None else "" + + if removal_url is not None: + self.interact.notify( + _("Pastebin URL: %s - Removal URL: %s") + % (paste_url, removal_url), + 10, + ) else: - parsed_url = urlparse(paste_url) - if (not parsed_url.scheme - or any(unicodedata.category(c) == 'Cc' for c in paste_url)): - self.interact.notify(_("Upload failed: " - "Failed to recognize the helper " - "program's output as an URL.")) - return + self.interact.notify(_("Pastebin URL: %s") % (paste_url,), 10) - self.prev_pastebin_content = s - self.interact.notify(_('Pastebin URL: %s') % (paste_url, ), 10) return paste_url - def push(self, s, insert_into_history=True): + def push(self, line, insert_into_history=True) -> bool: """Push a line of code onto the buffer so it can process it all at once when a code block ends""" - s = s.rstrip('\n') + # This push method is used by cli and urwid, but not curtsies + s = line.rstrip("\n") self.buffer.append(s) if insert_into_history: self.insert_into_history(s) - more = self.interp.runsource('\n'.join(self.buffer)) + more: bool = self.interp.runsource("\n".join(self.buffer)) if not more: self.buffer = [] return more - def insert_into_history(self, s): - if self.config.hist_length: - histfilename = os.path.expanduser(self.config.hist_file) - oldhistory = self.rl_history.entries - self.rl_history.entries = [] - if os.path.exists(histfilename): - self.rl_history.load(histfilename, getpreferredencoding()) - self.rl_history.append(s) - try: - self.rl_history.save(histfilename, getpreferredencoding(), self.config.hist_length) - except EnvironmentError, err: - self.interact.notify("Error occured while writing to file %s (%s) " % (histfilename, err.strerror)) - self.rl_history.entries = oldhistory - self.rl_history.append(s) - else: - self.rl_history.append(s) + def insert_into_history(self, s: str): + try: + self.rl_history.append_reload_and_write( + s, self.config.hist_file, getpreferredencoding() + ) + except RuntimeError as e: + self.interact.notify(f"{e}") + + def prompt_undo(self) -> int: + """Returns how many lines to undo, 0 means don't undo""" + if ( + self.config.single_undo_time < 0 + or self.interp.timer.estimate() < self.config.single_undo_time + ): + return 1 + est = self.interp.timer.estimate() + m = self.interact.file_prompt( + _("Undo how many lines? (Undo will take up to ~%.1f seconds) [1]") + % (est,) + ) + if m is None: + self.interact.notify(_("Undo canceled"), 0.1) + return 0 - def undo(self, n=1): - """Go back in the undo history n steps and call reeavluate() + try: + if m == "": + m = "1" + n = int(m) + except ValueError: + self.interact.notify(_("Undo canceled"), 0.1) + return 0 + else: + if n == 0: + self.interact.notify(_("Undo canceled"), 0.1) + return 0 + else: + message = ngettext( + "Undoing %d line... (est. %.1f seconds)", + "Undoing %d lines... (est. %.1f seconds)", + n, + ) + self.interact.notify(message % (n, est), 0.1) + return n + + def undo(self, n: int = 1) -> None: + """Go back in the undo history n steps and call reevaluate() Note that in the program this is called "Rewind" because I want it to be clear that this is by no means a true undo implementation, it is merely a convenience bonus.""" if not self.history: return None + self.interp.timer.reset_timer() + if len(self.history) < n: n = len(self.history) entries = list(self.rl_history.entries) + # Most recently undone command + last_entries = self.history[-n:] + last_entries.reverse() + self.redo_stack += last_entries self.history = self.history[:-n] - self.reevaluate() self.rl_history.entries = entries - def flush(self): + def flush(self) -> None: """Olivier Grisel brought it to my attention that the logging module tries to call this method, since it makes assumptions about stdout that may not necessarily be true. The docs for @@ -912,36 +1091,38 @@ def flush(self): def close(self): """See the flush() method docstring.""" - def tokenize(self, s, newline=False): + def tokenize(self, s, newline=False) -> list[tuple[_TokenType, str]]: """Tokenizes a line of code, returning pygments tokens with side effects/impurities: - reads self.cpos to see what parens should be highlighted - reads self.buffer to see what came before the passed in line - - sets self.highlighted_paren to (buffer_lineno, tokens_for_that_line) for buffer line - that should replace that line to unhighlight it - - calls reprint_line with a buffer's line's tokens and the buffer lineno that has changed - iff that line is the not the current line + - sets self.highlighted_paren to (buffer_lineno, tokens_for_that_line) + for buffer line that should replace that line to unhighlight it, + or None if no paren is currently highlighted + - calls reprint_line with a buffer's line's tokens and the buffer + lineno that has changed if line other than the current line changes """ + highlighted_paren = None - source = '\n'.join(self.buffer + [s]) + source = "\n".join(self.buffer + [s]) cursor = len(source) - self.cpos if self.cpos: cursor += 1 - stack = list() - all_tokens = list(PythonLexer().get_tokens(source)) + stack: list[Any] = list() + all_tokens = list(Python3Lexer().get_tokens(source)) # Unfortunately, Pygments adds a trailing newline and strings with # no size, so strip them while not all_tokens[-1][1]: all_tokens.pop() - all_tokens[-1] = (all_tokens[-1][0], all_tokens[-1][1].rstrip('\n')) + all_tokens[-1] = (all_tokens[-1][0], all_tokens[-1][1].rstrip("\n")) line = pos = 0 - parens = dict(zip('{([', '})]')) - line_tokens = list() - saved_tokens = list() + parens = dict(zip("{([", "})]")) + line_tokens: list[tuple[_TokenType, str]] = list() + saved_tokens: list[tuple[_TokenType, str]] = list() search_for_paren = True - for (token, value) in split_lines(all_tokens): + for token, value in split_lines(all_tokens): pos += len(value) - if token is Token.Text and value == '\n': + if token is Token.Text and value == "\n": line += 1 # Remove trailing newline line_tokens = list() @@ -951,7 +1132,7 @@ def tokenize(self, s, newline=False): saved_tokens.append((token, value)) if not search_for_paren: continue - under_cursor = (pos == cursor) + under_cursor = pos == cursor if token is Token.Punctuation: if value in parens: if under_cursor: @@ -959,9 +1140,10 @@ def tokenize(self, s, newline=False): # Push marker on the stack stack.append((Parenthesis, value)) else: - stack.append((line, len(line_tokens) - 1, - line_tokens, value)) - elif value in parens.itervalues(): + stack.append( + (line, len(line_tokens) - 1, line_tokens, value) + ) + elif value in parens.values(): saved_stack = list(stack) try: while True: @@ -992,69 +1174,107 @@ def tokenize(self, s, newline=False): line_tokens[-1] = (Parenthesis, value) (lineno, i, tokens, opening) = opening if lineno == len(self.buffer): - self.highlighted_paren = (lineno, saved_tokens) + highlighted_paren = (lineno, saved_tokens) line_tokens[i] = (Parenthesis, opening) else: - self.highlighted_paren = (lineno, list(tokens)) + highlighted_paren = (lineno, list(tokens)) # We need to redraw a line tokens[i] = (Parenthesis, opening) self.reprint_line(lineno, tokens) search_for_paren = False elif under_cursor: search_for_paren = False + self.highlighted_paren = highlighted_paren if line != len(self.buffer): return list() return line_tokens - def clear_current_line(self): + def clear_current_line(self) -> None: """This is used as the exception callback for the Interpreter instance. - It prevents autoindentation from occuring after a traceback.""" + It prevents autoindentation from occurring after a traceback.""" - def send_to_external_editor(self, text, filename=None): - """Returns modified text from an editor, or the oriignal text if editor exited with non-zero""" + def send_to_external_editor(self, text: str) -> str: + """Returns modified text from an editor, or the original text if editor + exited with non-zero""" + + encoding = getpreferredencoding() editor_args = shlex.split(self.config.editor) - with tempfile.NamedTemporaryFile(suffix='.py') as temp: - temp.write(text) + with tempfile.NamedTemporaryFile(suffix=".py") as temp: + temp.write(text.encode(encoding)) temp.flush() - if subprocess.call(editor_args + [temp.name]) == 0: + + args = editor_args + [temp.name] + if subprocess.call(args) == 0: with open(temp.name) as f: return f.read() else: return text + def open_in_external_editor(self, filename): + editor_args = shlex.split(self.config.editor) + args = editor_args + [filename] + return subprocess.call(args) == 0 + + def edit_config(self): + if self.config.config_path is None: + self.interact.notify(_("No config file specified.")) + return -def next_indentation(line, tab_length): + if not self.config.config_path.is_file(): + if self.interact.confirm( + _("Config file does not exist - create new from default? (y/N)") + ): + try: + default_config = pkgutil.get_data( + "bpython", "sample-config" + ) + # Py3 files need unicode + default_config = default_config.decode("ascii") + containing_dir = self.config.config_path.parent + if not containing_dir.exists(): + containing_dir.mkdir(parents=True) + with open(self.config.config_path, "w") as f: + f.write(default_config) + except OSError as e: + self.interact.notify( + _("Error writing file '%s': %s") + % (self.config.config_path, e) + ) + return False + else: + return False + + try: + if self.open_in_external_editor(self.config.config_path): + self.interact.notify( + _( + "bpython config file edited. Restart bpython for changes to take effect." + ) + ) + except OSError as e: + self.interact.notify(_("Error editing config file: %s") % e) + + +def next_indentation(line, tab_length) -> int: """Given a code line, return the indentation of the next line.""" line = line.expandtabs(tab_length) - indentation = (len(line) - len(line.lstrip(' '))) // tab_length - if line.rstrip().endswith(':'): + indentation: int = (len(line) - len(line.lstrip(" "))) // tab_length + if line.rstrip().endswith(":"): indentation += 1 elif indentation >= 1: - if line.lstrip().startswith(('return', 'pass', 'raise', 'yield')): + if line.lstrip().startswith( + ("return", "pass", "...", "raise", "yield", "break", "continue") + ): indentation -= 1 return indentation -def next_token_inside_string(s, inside_string): - """Given a code string s and an initial state inside_string, return - whether the next token will be inside a string or not.""" - for token, value in PythonLexer().get_tokens(s): - if token is Token.String: - value = value.lstrip('bBrRuU') - if value in ['"""', "'''", '"', "'"]: - if not inside_string: - inside_string = value - elif value == inside_string: - inside_string = False - return inside_string - - def split_lines(tokens): - for (token, value) in tokens: + for token, value in tokens: if not value: continue while value: - head, newline, value = value.partition('\n') + head, newline, value = value.partition("\n") yield (token, head) if newline: yield (Token.Text, newline) @@ -1077,14 +1297,15 @@ def token_is_type(token): def token_is_any_of(token_types): """Return a callable object that returns whether a token is any of the given types `token_types`.""" - is_token_types = map(token_is, token_types) + is_token_types = tuple(map(token_is, token_types)) def token_is_any_of(token): return any(check(token) for check in is_token_types) return token_is_any_of -def extract_exit_value(args): + +def extract_exit_value(args: tuple[Any, ...]) -> Any: """Given the arguments passed to `SystemExit`, return the value that should be passed to `sys.exit`. """ diff --git a/bpython/sample-config b/bpython/sample-config new file mode 100644 index 000000000..03127f1b2 --- /dev/null +++ b/bpython/sample-config @@ -0,0 +1,105 @@ +# This is a standard Python config file. +# Valid values can be True, False, integer numbers, and strings. +# Lines starting with # are treated as comments. +# +# By default bpython will look for $XDG_CONFIG_HOME/bpython/config +# ($XDG_CONFIG_HOME defaults to ~/.config) or you can specify a file with the +# --config option on the command line. +# +# See http://docs.bpython-interpreter.org/configuration.html for all +# configurable options. + +# General section tag +[general] + +# Display the autocomplete list as you type (default: True). +# When this is off, you can hit tab to see the suggestions. +# auto_display_list = True + +# Syntax highlighting as you type (default: True). +# syntax = True + +# Display the arg spec (list of arguments) for callables, +# when possible (default: True). +# arg_spec = True + +# History file (default: ~/.pythonhist): +# hist_file = ~/.pythonhist + +# Number of lines to store in history (set to 0 to disable) (default: 100): +# hist_length = 100 + +# Soft tab size (default: 4, see pep-8): +# tab_length = 4 + +# Color schemes should be put in $XDG_CONFIG_HOME/bpython/ e.g. to use the theme +# $XDG_CONFIG_HOME/bpython/foo.theme set color_scheme = foo. Leave blank or set +# to "default" to use the default theme +# color_scheme = default + +# External editor to use for editing the current line, block, or full history +# Examples: vi (vim) +# code --wait (VS Code) - in VS Code use the command palette to: +# Shell Command: Install 'code' command in PATH +# atom -nw (Atom) +# Default is to try $EDITOR and $VISUAL, then vi - but if you uncomment +# the line below that will take precedence +# editor = vi + +# Whether to append .py to the filename while saving session to a file. +# (default: False) +# save_append_py = False + +# The name of a helper executable that should perform pastebin upload on +# bpython's behalf. If unset, bpython uploads pastes to bpaste.net. (default: ) +#pastebin_helper = gist.py + +# How long an undo must be expected to take before prompting for how +# many lines should be undone. Set to -1 to never prompt, or 0 to +# always prompt. +# single_undo_time = 1.0 + +# Enable autoreload feature by default (default: False). +# default_autoreload = False +# Enable autocompletion of brackets and quotes (default: False) +# brackets_completion = False + +[keyboard] + +# All key bindings are shown commented out with their default binding + +# pastebin = F8 +# last_output = F9 +# reimport = F6 +# help = F1 +# toggle_file_watch = F5 +# save = C-s +# undo = C-r +# redo = C-g +# up_one_line = C-p +# down_one_line = C-n +# cut_to_buffer = C-k +# search = C-o +# yank_from_buffer = C-y +# backspace = C-h +# clear_word = C-w +# clear_line = C-u +# clear_screen = C-l +# show_source = F2 +# exit = C-d +# external_editor = F7 +# edit_config = F3 +# reverse_incremental_search = M-r +# incremental_search = M-s + +[curtsies] + +# Allow the the completion and docstring box above the current line +# (default: False) +# list_above = False + +# Enables two fish (the shell) style features: +# Previous line key will search for the current line (like reverse incremental +# search) and right arrow will complete the current line with the first match +# from history. (default: True) +# right_arrow_completion = True diff --git a/bpython/simpleeval.py b/bpython/simpleeval.py new file mode 100644 index 000000000..6e911590e --- /dev/null +++ b/bpython/simpleeval.py @@ -0,0 +1,258 @@ +# The MIT License +# +# Copyright (c) 2015 the bpython authors. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +"""simple evaluation of side-effect free code + +In order to provide fancy completion, some code can be executed safely. +""" + +import ast +import builtins +from typing import Any + +from . import line as line_properties +from .inspection import getattr_safe + +_numeric_types = (int, float, complex) + + +class EvaluationError(Exception): + """Raised if an exception occurred in safe_eval.""" + + +def safe_eval(expr: str, namespace: dict[str, Any]) -> Any: + """Not all that safe, just catches some errors""" + try: + return eval(expr, namespace) + except (NameError, AttributeError, SyntaxError): + # If debugging safe_eval, raise this! + # raise + raise EvaluationError + + +# This function is under the Python License, Version 2 +# This license requires modifications to the code be reported. +# Based on ast.literal_eval +# Modifications: +# * checks that objects used as operands of + and - are numbers +# instead of checking they are constructed with number literals +# * new docstring describing different functionality +# * looks up names from namespace +# * indexing syntax is allowed +# * evaluates tuple() and list() +def simple_eval(node_or_string, namespace=None): + """ + Safely evaluate an expression node or a string containing a Python + expression without triggering any user code. + + The string or node provided may only consist of: + * the following Python literal structures: strings, numbers, tuples, + lists, dicts, and sets + * variable names causing lookups in the passed in namespace or builtins + * getitem calls using the [] syntax on objects of the types above + + Like Python 3's literal_eval, unary and binary + and - operations are + allowed on all builtin numeric types. + + The optional namespace dict-like ought not to cause side effects on lookup. + """ + if namespace is None: + namespace = {} + if isinstance(node_or_string, str): + node_or_string = ast.parse(node_or_string, mode="eval") + if isinstance(node_or_string, ast.Expression): + node_or_string = node_or_string.body + + def _convert(node): + if isinstance(node, ast.Constant): + return node.value + elif isinstance(node, ast.Tuple): + return tuple(map(_convert, node.elts)) + elif isinstance(node, ast.List): + return list(map(_convert, node.elts)) + elif isinstance(node, ast.Dict): + return { + _convert(k): _convert(v) for k, v in zip(node.keys, node.values) + } + elif isinstance(node, ast.Set): + return set(map(_convert, node.elts)) + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "set" + and node.args == node.keywords == [] + ): + return set() + + # this is a deviation from literal_eval: we evaluate tuple() and list() + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "tuple" + and node.args == node.keywords == [] + ): + return tuple() + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "list" + and node.args == node.keywords == [] + ): + return list() + + # this is a deviation from literal_eval: we allow non-literals + elif isinstance(node, ast.Name): + try: + return namespace[node.id] + except KeyError: + try: + return getattr(builtins, node.id) + except AttributeError: + raise EvaluationError("can't lookup %s" % node.id) + + # unary + and - are allowed on any type + elif isinstance(node, ast.UnaryOp) and isinstance( + node.op, (ast.UAdd, ast.USub) + ): + # ast.literal_eval does ast typechecks here, we use type checks + operand = _convert(node.operand) + if not type(operand) in _numeric_types: + raise ValueError("unary + and - only allowed on builtin nums") + if isinstance(node.op, ast.UAdd): + return +operand + else: + return -operand + elif isinstance(node, ast.BinOp) and isinstance( + node.op, (ast.Add, ast.Sub) + ): + # this is a deviation from literal_eval: ast.literal_eval accepts + # (+/-) int, float and complex literals as left operand, and complex + # as right operation, we evaluate as much as possible + left = _convert(node.left) + right = _convert(node.right) + if not ( + isinstance(left, _numeric_types) + and isinstance(right, _numeric_types) + ): + raise ValueError("binary + and - only allowed on builtin nums") + if isinstance(node.op, ast.Add): + return left + right + else: + return left - right + + # this is a deviation from literal_eval: we allow indexing + elif isinstance(node, ast.Subscript) and isinstance( + node.slice, (ast.Constant, ast.Name) + ): + obj = _convert(node.value) + index = _convert(node.slice) + return safe_getitem(obj, index) + + # this is a deviation from literal_eval: we allow attribute access + if isinstance(node, ast.Attribute): + obj = _convert(node.value) + attr = node.attr + return getattr_safe(obj, attr) + + raise ValueError(f"malformed node or string: {node!r}") + + return _convert(node_or_string) + + +def safe_getitem(obj, index): + """Safely tries to access obj[index]""" + if type(obj) in (list, tuple, dict, bytes, str): + try: + return obj[index] + except (KeyError, IndexError): + raise EvaluationError(f"can't lookup key {index!r} on {obj!r}") + raise ValueError(f"unsafe to lookup on object of type {type(obj)}") + + +def find_attribute_with_name(node, name): + if isinstance(node, ast.Attribute) and node.attr == name: + return node + for item in ast.iter_child_nodes(node): + r = find_attribute_with_name(item, name) + if r: + return r + + +def evaluate_current_expression( + cursor_offset: int, line: str, namespace: dict[str, Any] | None = None +) -> Any: + """ + Return evaluated expression to the right of the dot of current attribute. + + Only evaluates builtin objects, and do any attribute lookup. + """ + # Builds asts from with increasing numbers of characters back from cursor. + # Find the biggest valid ast. + # Once our attribute access is found, return its .value subtree + + # in case attribute is blank, e.g. foo.| -> foo.xxx| + temp_line = line[:cursor_offset] + "xxx" + line[cursor_offset:] + temp_cursor = cursor_offset + 3 + temp_attribute = line_properties.current_expression_attribute( + temp_cursor, temp_line + ) + if temp_attribute is None: + raise EvaluationError("No current attribute") + attr_before_cursor = temp_line[temp_attribute.start : temp_cursor] + + def parse_trees(cursor_offset, line): + for i in range(cursor_offset - 1, -1, -1): + try: + tree = ast.parse(line[i:cursor_offset]) + yield tree + except SyntaxError: + continue + + largest_ast = None + for tree in parse_trees(temp_cursor, temp_line): + attribute_access = find_attribute_with_name(tree, attr_before_cursor) + if attribute_access: + largest_ast = attribute_access.value + + if largest_ast is None: + raise EvaluationError( + "Corresponding ASTs to right of cursor are invalid" + ) + try: + return simple_eval(largest_ast, namespace) + except ValueError: + raise EvaluationError("Could not safely evaluate") + + +def evaluate_current_attribute(cursor_offset, line, namespace=None): + """Safely evaluates the expression having an attributed accessed""" + # this function runs user code in case of custom descriptors, + # so could fail in any way + + obj = evaluate_current_expression(cursor_offset, line, namespace) + attr = line_properties.current_expression_attribute(cursor_offset, line) + if attr is None: + raise EvaluationError("No attribute found to look up") + try: + return getattr(obj, attr.word) + except AttributeError: + raise EvaluationError(f"can't lookup attribute {attr.word} on {obj!r}") diff --git a/bpython/test/__init__.py b/bpython/test/__init__.py index e69de29bb..4618eca4d 100644 --- a/bpython/test/__init__.py +++ b/bpython/test/__init__.py @@ -0,0 +1,19 @@ +import unittest +import unittest.mock +import os +from pathlib import Path + +from bpython.translations import init + + +class FixLanguageTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + init(languages=["en"]) + + +class MagicIterMock(unittest.mock.MagicMock): + __next__ = unittest.mock.Mock(return_value=None) + + +TEST_CONFIG = Path(__file__).parent / "test.config" diff --git a/bpython/test/test_bpython.py b/bpython/test/fodder/__init__.py similarity index 100% rename from bpython/test/test_bpython.py rename to bpython/test/fodder/__init__.py diff --git a/bpython/test/fodder/encoding_ascii.py b/bpython/test/fodder/encoding_ascii.py new file mode 100644 index 000000000..d9caeab01 --- /dev/null +++ b/bpython/test/fodder/encoding_ascii.py @@ -0,0 +1,6 @@ +# -*- coding: ascii -*- + + +def foo(): + """Test""" + pass diff --git a/bpython/test/fodder/encoding_latin1.py b/bpython/test/fodder/encoding_latin1.py new file mode 100644 index 000000000..a74dc3454 --- /dev/null +++ b/bpython/test/fodder/encoding_latin1.py @@ -0,0 +1,6 @@ +# -*- coding: latin1 -*- + + +def foo(): + """Test """ + pass diff --git a/bpython/test/fodder/encoding_utf8.py b/bpython/test/fodder/encoding_utf8.py new file mode 100644 index 000000000..276ef0be0 --- /dev/null +++ b/bpython/test/fodder/encoding_utf8.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + + +def foo(): + """Test äöü""" + pass diff --git a/bpython/test/fodder/original.py b/bpython/test/fodder/original.py new file mode 100644 index 000000000..d644c16dd --- /dev/null +++ b/bpython/test/fodder/original.py @@ -0,0 +1,49 @@ +# careful: whitespace is very important in this file +# also, this code runs - so everything should be a noop + +class BlankLineBetweenMethods: + def method1(self): + pass + + def method2(self): + pass + +def BlankLineInFunction(self): + return 7 + + pass + +#StartTest-blank_lines_in_for_loop +for i in range(2): + pass + + pass +#EndTest + +#StartTest-blank_line_in_try_catch +try: + 1 + +except: + 2 +#EndTest + +#StartTest-blank_line_in_try_catch_else +try: + 1 + +except: + 2 + +else: + 3 +#EndTest + +#StartTest-blank_trailing_line +def foo(): + return 1 + +#EndTest + +def tabs(): + return 1 diff --git a/bpython/test/fodder/processed.py b/bpython/test/fodder/processed.py new file mode 100644 index 000000000..9f944ee66 --- /dev/null +++ b/bpython/test/fodder/processed.py @@ -0,0 +1,48 @@ +#careful! Whitespace is very important in this file + +class BlankLineBetweenMethods: + def method1(self): + pass + + def method2(self): + pass + +def BlankLineInFunction(self): + return 7 + + pass + +#StartTest-blank_lines_in_for_loop +for i in range(2): + pass + + pass +#EndTest + +#StartTest-blank_line_in_try_catch +try: + 1 + +except: + 2 +#EndTest + +#StartTest-blank_line_in_try_catch_else +try: + 1 + +except: + 2 + +else: + 3 +#EndTest + +#StartTest-blank_trailing_line +def foo(): + return 1 + +#EndTest + +def tabs(): + return 1 diff --git a/bpython/test/test.config b/bpython/test/test.config index 334706bab..38a233624 100644 --- a/bpython/test/test.config +++ b/bpython/test/test.config @@ -1,3 +1,4 @@ [general] +hist_length = 0 hist_file = /dev/null -paste_time = 0 \ No newline at end of file +paste_time = 0 diff --git a/bpython/test/test_args.py b/bpython/test/test_args.py index e69de29bb..36f233ebc 100644 --- a/bpython/test/test_args.py +++ b/bpython/test/test_args.py @@ -0,0 +1,122 @@ +import errno +import os +import pty +import re +import select +import subprocess +import sys +import tempfile +import unittest + +from textwrap import dedent +from bpython import args +from bpython.config import getpreferredencoding +from bpython.test import FixLanguageTestCase as TestCase + + +def run_with_tty(command): + # based on https://stackoverflow.com/questions/52954248/capture-output-as-a-tty-in-python + master_stdout, slave_stdout = pty.openpty() + master_stderr, slave_stderr = pty.openpty() + master_stdin, slave_stdin = pty.openpty() + + p = subprocess.Popen( + command, + stdout=slave_stdout, + stderr=slave_stderr, + stdin=slave_stdin, + close_fds=True, + ) + for fd in (slave_stdout, slave_stderr, slave_stdin): + os.close(fd) + + readable = [master_stdout, master_stderr] + result = {master_stdout: b"", master_stderr: b""} + try: + while readable: + ready, _, _ = select.select(readable, [], [], 1) + for fd in ready: + try: + data = os.read(fd, 512) + except OSError as e: + if e.errno != errno.EIO: + raise + # EIO means EOF on some systems + readable.remove(fd) + else: + if not data: # EOF + readable.remove(fd) + result[fd] += data + finally: + for fd in (master_stdout, master_stderr, master_stdin): + os.close(fd) + if p.poll() is None: + p.kill() + p.wait() + + if p.returncode: + raise RuntimeError(f"Subprocess exited with {p.returncode}") + + return ( + result[master_stdout].decode(getpreferredencoding()), + result[master_stderr].decode(getpreferredencoding()), + ) + + +class TestExecArgs(unittest.TestCase): + def test_exec_dunder_file(self): + with tempfile.NamedTemporaryFile(mode="w") as f: + f.write( + dedent( + """\ + import sys + sys.stderr.write(__file__) + sys.stderr.flush()""" + ) + ) + f.flush() + _, stderr = run_with_tty( + [sys.executable] + ["-m", "bpython.curtsies", f.name] + ) + self.assertEqual(stderr.strip(), f.name) + + def test_exec_nonascii_file(self): + with tempfile.NamedTemporaryFile(mode="w") as f: + f.write( + dedent( + """\ + # coding: utf-8 + "你好 # nonascii" + """ + ) + ) + f.flush() + _, stderr = run_with_tty( + [sys.executable, "-m", "bpython.curtsies", f.name], + ) + self.assertEqual(len(stderr), 0) + + def test_exec_nonascii_file_linenums(self): + with tempfile.NamedTemporaryFile(mode="w") as f: + f.write( + dedent( + """\ + 1/0 + """ + ) + ) + f.flush() + _, stderr = run_with_tty( + [sys.executable, "-m", "bpython.curtsies", f.name], + ) + self.assertIn("line 1", clean_colors(stderr)) + + +def clean_colors(s): + return re.sub(r"\x1b[^m]*m", "", s) + + +class TestParse(TestCase): + def test_version(self): + with self.assertRaises(SystemExit): + args.parse(["--version"]) diff --git a/bpython/test/test_autocomplete.py b/bpython/test/test_autocomplete.py index efedb2501..da32fbb8c 100644 --- a/bpython/test/test_autocomplete.py +++ b/bpython/test/test_autocomplete.py @@ -1,67 +1,450 @@ -from bpython import autocomplete -from functools import partial import inspect - +import keyword import unittest +from collections import namedtuple +from unittest import mock + try: - from unittest import skip + import jedi + + has_jedi = True except ImportError: - def skip(f): - return lambda self: None + has_jedi = False +from bpython import autocomplete, inspection +from bpython.line import LinePart -# Parts of autocompletion to test: -# Test that the right matches come back from find_matches (test that priority is correct) -# Test the various complete methods (import, filename) to see if right matches -# Test that MatchesIterator.substitute correctly subs given a match and a completer -""" - def test_cw(self): +glob_function = "glob.iglob" + + +class TestSafeEval(unittest.TestCase): + def test_catches_syntax_error(self): + with self.assertRaises(autocomplete.EvaluationError): + autocomplete.safe_eval("1re", {}) - self.repl.cpos = 2 - self.assertEqual(self.repl.cw(), None) - self.repl.cpos = 0 - self.repl.s = '' - self.assertEqual(self.repl.cw(), None) +class TestFormatters(unittest.TestCase): + def test_filename(self): + completer = autocomplete.FilenameCompletion() + last_part_of_filename = completer.format + self.assertEqual(last_part_of_filename("abc"), "abc") + self.assertEqual(last_part_of_filename("abc/"), "abc/") + self.assertEqual(last_part_of_filename("abc/efg"), "efg") + self.assertEqual(last_part_of_filename("abc/efg/"), "efg/") + self.assertEqual(last_part_of_filename("/abc"), "abc") + self.assertEqual(last_part_of_filename("ab.c/e.f.g/"), "e.f.g/") - self.repl.s = "this.is.a.test\t" - self.assertEqual(self.repl.cw(), None) + def test_attribute(self): + self.assertEqual(autocomplete._after_last_dot("abc.edf"), "edf") - s = "this.is.a.test" - self.repl.s = s - self.assertEqual(self.repl.cw(), s) - s = "\t\tthis.is.a.test" - self.repl.s = s - self.assertEqual(self.repl.cw(), s.lstrip()) +def completer(matches): + mock_completer = autocomplete.BaseCompletionType() + mock_completer.matches = mock.Mock(return_value=matches) + return mock_completer - self.repl.s = "import datetime" - self.assertEqual(self.repl.cw(), 'datetime') -""" -class TestSafeEval(unittest.TestCase): - def test_catches_syntax_error(self): - try: - autocomplete.safe_eval('1re',{}) - except: - self.fail('safe_eval raises an error') +class TestGetCompleter(unittest.TestCase): + def test_no_completers(self): + self.assertTupleEqual(autocomplete.get_completer([], 0, ""), ([], None)) + + def test_one_completer_without_matches_returns_empty_list_and_none(self): + a = completer([]) + self.assertTupleEqual( + autocomplete.get_completer([a], 0, ""), ([], None) + ) + + def test_one_completer_returns_matches_and_completer(self): + a = completer(["a"]) + self.assertTupleEqual( + autocomplete.get_completer([a], 0, ""), (["a"], a) + ) + + def test_two_completers_with_matches_returns_first_matches(self): + a = completer(["a"]) + b = completer(["b"]) + self.assertEqual(autocomplete.get_completer([a, b], 0, ""), (["a"], a)) + + def test_first_non_none_completer_matches_are_returned(self): + a = completer([]) + b = completer(["a"]) + self.assertEqual(autocomplete.get_completer([a, b], 0, ""), ([], None)) + + def test_only_completer_returns_None(self): + a = completer(None) + self.assertEqual(autocomplete.get_completer([a], 0, ""), ([], None)) + + def test_first_completer_returns_None(self): + a = completer(None) + b = completer(["a"]) + self.assertEqual(autocomplete.get_completer([a, b], 0, ""), (["a"], b)) + + +class TestCumulativeCompleter(unittest.TestCase): + def completer(self, matches): + mock_completer = autocomplete.BaseCompletionType() + mock_completer.matches = mock.Mock(return_value=matches) + return mock_completer + + def test_no_completers_fails(self): + with self.assertRaises(ValueError): + autocomplete.CumulativeCompleter([]) + + def test_one_empty_completer_returns_empty(self): + a = self.completer([]) + cumulative = autocomplete.CumulativeCompleter([a]) + self.assertEqual(cumulative.matches(3, "abc"), set()) + + def test_one_none_completer_returns_none(self): + a = self.completer(None) + cumulative = autocomplete.CumulativeCompleter([a]) + self.assertEqual(cumulative.matches(3, "abc"), None) + + def test_two_completers_get_both(self): + a = self.completer(["a"]) + b = self.completer(["b"]) + cumulative = autocomplete.CumulativeCompleter([a, b]) + self.assertEqual(cumulative.matches(3, "abc"), {"a", "b"}) + + def test_order_completer(self): + a = self.completer(["ax", "ab="]) + b = self.completer(["aa"]) + cumulative = autocomplete.CumulativeCompleter([a, b]) + self.assertEqual( + autocomplete.get_completer([cumulative], 1, "a"), + (["ab=", "aa", "ax"], cumulative), + ) + -# make some fake files? Dependency inject? mock? class TestFilenameCompletion(unittest.TestCase): - pass + def setUp(self): + self.completer = autocomplete.FilenameCompletion() + def test_locate_fails_when_not_in_string(self): + self.assertEqual(self.completer.locate(4, "abcd"), None) -class TestFormatters(unittest.TestCase): + def test_locate_succeeds_when_in_string(self): + self.assertEqual( + self.completer.locate(4, "a'bc'd"), LinePart(2, 4, "bc") + ) - @skip('not done yet') - def test_filename(self): - self.assertEqual(autocomplete.last_part_of_filename('abc'), 'abc') - self.assertEqual(autocomplete.last_part_of_filename('abc/'), 'abc/') - self.assertEqual(autocomplete.last_part_of_filename('abc/efg'), 'efg') - self.assertEqual(autocomplete.last_part_of_filename('abc/efg/'), 'efg/') - self.assertEqual(autocomplete.last_part_of_filename('/abc'), 'abc') - self.assertEqual(autocomplete.last_part_of_filename('ab.c/e.f.g/'), 'e.f.g/') - - @skip('not done yet') - def test_attribute(self): - self.assertEqual(autocomplete.after_last_dot('abc.edf'), 'edf') + def test_issue_491(self): + self.assertNotEqual(self.completer.matches(9, '"a[a.l-1]'), None) + + @mock.patch(glob_function, new=lambda text: []) + def test_match_returns_none_if_not_in_string(self): + self.assertEqual(self.completer.matches(2, "abcd"), None) + + @mock.patch(glob_function, new=lambda text: []) + def test_match_returns_empty_list_when_no_files(self): + self.assertEqual(self.completer.matches(2, '"a'), set()) + + @mock.patch(glob_function, new=lambda text: ["abcde", "aaaaa"]) + @mock.patch("os.path.expanduser", new=lambda text: text) + @mock.patch("os.path.isdir", new=lambda text: False) + @mock.patch("os.path.sep", new="/") + def test_match_returns_files_when_files_exist(self): + self.assertEqual( + sorted(self.completer.matches(2, '"x')), ["aaaaa", "abcde"] + ) + + @mock.patch(glob_function, new=lambda text: ["abcde", "aaaaa"]) + @mock.patch("os.path.expanduser", new=lambda text: text) + @mock.patch("os.path.isdir", new=lambda text: True) + @mock.patch("os.path.sep", new="/") + def test_match_returns_dirs_when_dirs_exist(self): + self.assertEqual( + sorted(self.completer.matches(2, '"x')), ["aaaaa/", "abcde/"] + ) + + @mock.patch( + glob_function, new=lambda text: ["/expand/ed/abcde", "/expand/ed/aaaaa"] + ) + @mock.patch( + "os.path.expanduser", new=lambda text: text.replace("~", "/expand/ed") + ) + @mock.patch("os.path.isdir", new=lambda text: False) + @mock.patch("os.path.sep", new="/") + def test_tilde_stays_pretty(self): + self.assertEqual( + sorted(self.completer.matches(4, '"~/a')), ["~/aaaaa", "~/abcde"] + ) + + @mock.patch("os.path.sep", new="/") + def test_formatting_takes_just_last_part(self): + self.assertEqual(self.completer.format("/hello/there/"), "there/") + self.assertEqual(self.completer.format("/hello/there"), "there") + + +class MockNumPy: + """This is a mock numpy object that raises an error when there is an attempt + to convert it to a boolean.""" + + def __nonzero__(self): + raise ValueError( + "The truth value of an array with more than one " + "element is ambiguous. Use a.any() or a.all()" + ) + + +class TestDictKeyCompletion(unittest.TestCase): + def test_set_of_keys_returned_when_matches_found(self): + com = autocomplete.DictKeyCompletion() + local = {"d": {"ab": 1, "cd": 2}} + self.assertSetEqual( + com.matches(2, "d[", locals_=local), {"'ab']", "'cd']"} + ) + + def test_none_returned_when_eval_error(self): + com = autocomplete.DictKeyCompletion() + local = {"e": {"ab": 1, "cd": 2}} + self.assertEqual(com.matches(2, "d[", locals_=local), None) + + def test_none_returned_when_not_dict_type(self): + com = autocomplete.DictKeyCompletion() + local = {"l": ["ab", "cd"]} + self.assertEqual(com.matches(2, "l[", locals_=local), None) + + def test_none_returned_when_no_matches_left(self): + com = autocomplete.DictKeyCompletion() + local = {"d": {"ab": 1, "cd": 2}} + self.assertEqual(com.matches(3, "d[r", locals_=local), None) + + def test_obj_that_does_not_allow_conversion_to_bool(self): + com = autocomplete.DictKeyCompletion() + local = {"mNumPy": MockNumPy()} + self.assertEqual(com.matches(7, "mNumPy[", locals_=local), None) + + +class Foo: + a = 10 + + def __init__(self): + self.b = 20 + + def method(self, x): + pass + + +class Properties(Foo): + @property + def asserts_when_called(self): + raise AssertionError("getter method called") + + +class Slots: + __slots__ = ["a", "b"] + + +class OverriddenGetattribute(Foo): + def __getattribute__(self, name): + raise AssertionError("custom get attribute invoked") + + +class TestAttrCompletion(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.com = autocomplete.AttrCompletion() + + def test_att_matches_found_on_instance(self): + self.assertSetEqual( + self.com.matches(2, "a.", locals_={"a": Foo()}), + {"a.method", "a.a", "a.b"}, + ) + + def test_descriptor_attributes_not_run(self): + com = autocomplete.AttrCompletion() + self.assertSetEqual( + com.matches(2, "a.", locals_={"a": Properties()}), + {"a.b", "a.a", "a.method", "a.asserts_when_called"}, + ) + + def test_custom_get_attribute_not_invoked(self): + com = autocomplete.AttrCompletion() + self.assertSetEqual( + com.matches(2, "a.", locals_={"a": OverriddenGetattribute()}), + {"a.b", "a.a", "a.method"}, + ) + + def test_slots_not_crash(self): + com = autocomplete.AttrCompletion() + self.assertSetEqual( + com.matches(2, "A.", locals_={"A": Slots}), + {"A.b", "A.a"}, + ) + + +class TestExpressionAttributeCompletion(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.com = autocomplete.ExpressionAttributeCompletion() + + def test_att_matches_found_on_instance(self): + self.assertSetEqual( + self.com.matches(5, "a[0].", locals_={"a": [Foo()]}), + {"method", "a", "b"}, + ) + + def test_other_getitem_methods_not_called(self): + class FakeList: + def __getitem__(inner_self, i): + self.fail("possibly side-effecting __getitem_ method called") + + self.com.matches(5, "a[0].", locals_={"a": FakeList()}) + + def test_tuples_complete(self): + self.assertSetEqual( + self.com.matches(5, "a[0].", locals_={"a": (Foo(),)}), + {"method", "a", "b"}, + ) + + @unittest.skip("TODO, subclasses do not complete yet") + def test_list_subclasses_complete(self): + class ListSubclass(list): + pass + + self.assertSetEqual( + self.com.matches(5, "a[0].", locals_={"a": ListSubclass([Foo()])}), + {"method", "a", "b"}, + ) + + def test_getitem_not_called_in_list_subclasses_overriding_getitem(self): + class FakeList(list): + def __getitem__(inner_self, i): + self.fail("possibly side-effecting __getitem_ method called") + + self.com.matches(5, "a[0].", locals_={"a": FakeList()}) + + def test_literals_complete(self): + self.assertSetEqual( + self.com.matches(10, "[a][0][0].", locals_={"a": (Foo(),)}), + {"method", "a", "b"}, + ) + + def test_dictionaries_complete(self): + self.assertSetEqual( + self.com.matches(7, 'a["b"].', locals_={"a": {"b": Foo()}}), + {"method", "a", "b"}, + ) + + +class TestMagicMethodCompletion(unittest.TestCase): + def test_magic_methods_complete_after_double_underscores(self): + com = autocomplete.MagicMethodCompletion() + block = "class Something(object)\n def __" + self.assertSetEqual( + com.matches( + 10, + " def __", + current_block=block, + complete_magic_methods=True, + ), + set(autocomplete.MAGIC_METHODS), + ) + + +Completion = namedtuple("Completion", ["name", "complete"]) + + +@unittest.skipUnless(has_jedi, "jedi required") +class TestMultilineJediCompletion(unittest.TestCase): + def test_returns_none_with_single_line(self): + com = autocomplete.MultilineJediCompletion() + self.assertEqual( + com.matches(2, "Va", current_block="Va", history=[]), None + ) + + def test_returns_non_with_blank_second_line(self): + com = autocomplete.MultilineJediCompletion() + self.assertEqual( + com.matches( + 0, "", current_block="class Foo():\n", history=["class Foo():"] + ), + None, + ) + + def matches_from_completions( + self, cursor, line, block, history, completions + ): + with mock.patch("bpython.autocomplete.jedi.Script") as Script: + script = Script.return_value + script.complete.return_value = completions + com = autocomplete.MultilineJediCompletion() + return com.matches( + cursor, line, current_block=block, history=history + ) + + def test_completions_starting_with_different_letters(self): + matches = self.matches_from_completions( + 2, + " a", + "class Foo:\n a", + ["adsf"], + [Completion("Abc", "bc"), Completion("Cbc", "bc")], + ) + self.assertEqual(matches, None) + + def test_completions_starting_with_different_cases(self): + matches = self.matches_from_completions( + 2, + " a", + "class Foo:\n a", + ["adsf"], + [Completion("Abc", "bc"), Completion("ade", "de")], + ) + self.assertSetEqual(matches, {"ade"}) + + def test_issue_544(self): + com = autocomplete.MultilineJediCompletion() + code = "@asyncio.coroutine\ndef" + history = ("import asyncio", "@asyncio.coroutin") + com.matches(3, "def", current_block=code, history=history) + + +class TestGlobalCompletion(unittest.TestCase): + def setUp(self): + self.com = autocomplete.GlobalCompletion() + + def test_function(self): + def function(): + pass + + self.assertEqual( + self.com.matches(8, "function", locals_={"function": function}), + {"function("}, + ) + + def test_completions_are_unicode(self): + for m in self.com.matches(1, "a", locals_={"abc": 10}): + self.assertIsInstance(m, str) + + def test_mock_kwlist(self): + with mock.patch.object(keyword, "kwlist", new=["abcd"]): + self.assertEqual(self.com.matches(3, "abc", locals_={}), None) + + def test_mock_kwlist_non_ascii(self): + with mock.patch.object(keyword, "kwlist", new=["abcß"]): + self.assertEqual(self.com.matches(3, "abc", locals_={}), None) + + +class TestParameterNameCompletion(unittest.TestCase): + def test_set_of_params_returns_when_matches_found(self): + def func(apple, apricot, banana, carrot): + pass + + argspec = inspection.ArgSpec(*inspect.getfullargspec(func)) + funcspec = inspection.FuncProps("func", argspec, False) + com = autocomplete.ParameterNameCompletion() + self.assertSetEqual( + com.matches(1, "a", funcprops=funcspec), {"apple=", "apricot="} + ) + self.assertSetEqual( + com.matches(2, "ba", funcprops=funcspec), {"banana="} + ) + self.assertSetEqual( + com.matches(3, "car", funcprops=funcspec), {"carrot="} + ) + self.assertSetEqual( + com.matches(5, "func(", funcprops=funcspec), + {"apple=", "apricot=", "banana=", "carrot="}, + ) diff --git a/bpython/test/test_brackets_completion.py b/bpython/test/test_brackets_completion.py new file mode 100644 index 000000000..14169d6a8 --- /dev/null +++ b/bpython/test/test_brackets_completion.py @@ -0,0 +1,154 @@ +import os +from typing import cast + +from bpython.test import FixLanguageTestCase as TestCase, TEST_CONFIG +from bpython.curtsiesfrontend import repl as curtsiesrepl +from bpython import config + +from curtsies.window import CursorAwareWindow + + +def setup_config(conf): + config_struct = config.Config(TEST_CONFIG) + for key, value in conf.items(): + if not hasattr(config_struct, key): + raise ValueError(f"{key!r} is not a valid config attribute") + setattr(config_struct, key, value) + return config_struct + + +def create_repl(brackets_enabled=False, **kwargs): + config = setup_config( + {"editor": "true", "brackets_completion": brackets_enabled} + ) + repl = curtsiesrepl.BaseRepl( + config, cast(CursorAwareWindow, None), **kwargs + ) + os.environ["PAGER"] = "true" + os.environ.pop("PYTHONSTARTUP", None) + repl.width = 50 + repl.height = 20 + return repl + + +class TestBracketCompletionEnabled(TestCase): + def setUp(self): + self.repl = create_repl(brackets_enabled=True) + + def process_multiple_events(self, event_list): + for event in event_list: + self.repl.process_event(event) + + def test_start_line(self): + self.repl.process_event("(") + self.assertEqual(self.repl._current_line, "()") + self.assertEqual(self.repl._cursor_offset, 1) + + def test_nested_brackets(self): + self.process_multiple_events(["(", "[", "{"]) + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 3) + + def test_quotes(self): + self.process_multiple_events(["(", "'", "x", "", ","]) + self.process_multiple_events(["[", '"', "y", "", "", ""]) + self.assertEqual(self.repl._current_line, """('x',["y"])""") + self.assertEqual(self.repl._cursor_offset, 11) + + def test_bracket_overwrite_closing_char(self): + self.process_multiple_events(["(", "[", "{"]) + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 3) + self.process_multiple_events(["}", "]", ")"]) + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 6) + + def test_brackets_move_cursor_on_tab(self): + self.process_multiple_events(["(", "[", "{"]) + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 3) + self.repl.process_event("") + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 4) + self.repl.process_event("") + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 5) + self.repl.process_event("") + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 6) + + def test_brackets_non_whitespace_following_char(self): + self.repl.current_line = "s = s.connect('localhost', 8080)" + self.repl.cursor_offset = 14 + self.repl.process_event("(") + self.assertEqual( + self.repl._current_line, "s = s.connect(('localhost', 8080)" + ) + self.assertEqual(self.repl._cursor_offset, 15) + + def test_brackets_deletion_on_backspace(self): + self.repl.current_line = "def foo()" + self.repl.cursor_offset = 8 + self.repl.process_event("") + self.assertEqual(self.repl._current_line, "def foo") + self.assertEqual(self.repl.cursor_offset, 7) + + def test_brackets_deletion_on_backspace_nested(self): + self.repl.current_line = '([{""}])' + self.repl.cursor_offset = 4 + self.process_multiple_events( + ["", "", ""] + ) + self.assertEqual(self.repl._current_line, "()") + self.assertEqual(self.repl.cursor_offset, 1) + + +class TestBracketCompletionDisabled(TestCase): + def setUp(self): + self.repl = create_repl(brackets_enabled=False) + + def process_multiple_events(self, event_list): + for event in event_list: + self.repl.process_event(event) + + def test_start_line(self): + self.repl.process_event("(") + self.assertEqual(self.repl._current_line, "(") + self.assertEqual(self.repl._cursor_offset, 1) + + def test_nested_brackets(self): + self.process_multiple_events(["(", "[", "{"]) + self.assertEqual(self.repl._current_line, "([{") + self.assertEqual(self.repl._cursor_offset, 3) + + def test_bracket_overwrite_closing_char(self): + self.process_multiple_events(["(", "[", "{"]) + self.assertEqual(self.repl._current_line, """([{""") + self.assertEqual(self.repl._cursor_offset, 3) + self.process_multiple_events(["}", "]", ")"]) + self.assertEqual(self.repl._current_line, """([{}])""") + self.assertEqual(self.repl._cursor_offset, 6) + + def test_brackets_move_cursor_on_tab(self): + self.process_multiple_events(["(", "[", "{"]) + self.assertEqual(self.repl._current_line, """([{""") + self.assertEqual(self.repl._cursor_offset, 3) + self.repl.process_event("") + self.assertEqual(self.repl._current_line, """([{""") + self.assertEqual(self.repl._cursor_offset, 3) + + def test_brackets_deletion_on_backspace(self): + self.repl.current_line = "def foo()" + self.repl.cursor_offset = 8 + self.repl.process_event("") + self.assertEqual(self.repl._current_line, "def foo") + self.assertEqual(self.repl.cursor_offset, 7) + + def test_brackets_deletion_on_backspace_nested(self): + self.repl.current_line = '([{""}])' + self.repl.cursor_offset = 4 + self.process_multiple_events( + ["", "", ""] + ) + self.assertEqual(self.repl._current_line, "()") + self.assertEqual(self.repl.cursor_offset, 1) diff --git a/bpython/test/test_config.py b/bpython/test/test_config.py index d36ffe814..c34f2dac6 100644 --- a/bpython/test/test_config.py +++ b/bpython/test/test_config.py @@ -1,22 +1,105 @@ import os +import tempfile +import textwrap import unittest +from pathlib import Path from bpython import config -TEST_THEME_PATH = os.path.join(os.path.dirname(__file__), "test.theme") +TEST_THEME_PATH = Path(os.path.join(os.path.dirname(__file__), "test.theme")) + class TestConfig(unittest.TestCase): + def load_temp_config(self, content): + """Write config to a temporary file and load it.""" + + with tempfile.NamedTemporaryFile() as f: + f.write(content.encode("utf8")) + f.flush() + + return config.Config(Path(f.name)) + def test_load_theme(self): - struct = config.Struct() - struct.color_scheme = dict() - config.load_theme(struct, TEST_THEME_PATH, struct.color_scheme, dict()) + color_scheme = dict() + config.load_theme(TEST_THEME_PATH, color_scheme, dict()) expected = {"keyword": "y"} - self.assertEquals(struct.color_scheme, expected) + self.assertEqual(color_scheme, expected) defaults = {"name": "c"} expected.update(defaults) - config.load_theme(struct, TEST_THEME_PATH, struct.color_scheme, defaults) - self.assertEquals(struct.color_scheme, expected) + config.load_theme(TEST_THEME_PATH, color_scheme, defaults) + self.assertEqual(color_scheme, expected) + + def test_keybindings_default_contains_no_duplicates(self): + struct = self.load_temp_config("") + + keys = (attr for attr in dir(struct) if attr.endswith("_key")) + mapped_keys = [ + getattr(struct, key) for key in keys if getattr(struct, key) + ] + + mapped_keys_set = set(mapped_keys) + self.assertEqual(len(mapped_keys), len(mapped_keys_set)) + + def test_keybindings_use_default(self): + struct = self.load_temp_config( + textwrap.dedent( + """ + [keyboard] + help = F1 + """ + ) + ) + + self.assertEqual(struct.help_key, "F1") + + def test_keybindings_use_other_default(self): + struct = self.load_temp_config( + textwrap.dedent( + """ + [keyboard] + help = C-h + """ + ) + ) + + self.assertEqual(struct.help_key, "C-h") + self.assertEqual(struct.backspace_key, "") + + def test_keybindings_use_other_default_issue_447(self): + struct = self.load_temp_config( + textwrap.dedent( + """ + [keyboard] + help = F2 + show_source = F9 + """ + ) + ) + + self.assertEqual(struct.help_key, "F2") + self.assertEqual(struct.show_source_key, "F9") + + def test_keybindings_unset(self): + struct = self.load_temp_config( + textwrap.dedent( + """ + [keyboard] + help = + """ + ) + ) + + self.assertFalse(struct.help_key) + + def test_keybindings_unused(self): + struct = self.load_temp_config( + textwrap.dedent( + """ + [keyboard] + help = F4 + """ + ) + ) -if __name__ == '__main__': - unittest.main() + self.assertEqual(struct.help_key, "F4") diff --git a/bpython/test/test_crashers.py b/bpython/test/test_crashers.py index 1b85e6916..051e5b691 100644 --- a/bpython/test/test_crashers.py +++ b/bpython/test/test_crashers.py @@ -7,21 +7,35 @@ import textwrap import unittest +from bpython.test import TEST_CONFIG +from bpython.config import getpreferredencoding + try: from twisted.internet import reactor from twisted.internet.defer import Deferred from twisted.internet.protocol import ProcessProtocol from twisted.trial.unittest import TestCase as TrialTestCase except ImportError: - reactor = None -TEST_CONFIG = os.path.join(os.path.dirname(__file__), "test.config") + class TrialTestCase: # type: ignore [no-redef] + pass + + reactor = None # type: ignore + +try: + import urwid + + have_urwid = True +except ImportError: + have_urwid = False + def set_win_size(fd, rows, columns): - s = struct.pack('HHHH', rows, columns, 0, 0) + s = struct.pack("HHHH", rows, columns, 0, 0) fcntl.ioctl(fd, termios.TIOCSWINSZ, s) -class CrashersTest(object): + +class CrashersTest: backend = "cli" def run_bpython(self, input): @@ -30,21 +44,22 @@ def run_bpython(self, input): enter the given input. Uses a test config that disables the paste detection. - Retuns bpython's output. + Returns bpython's output. """ result = Deferred() + encoding = getpreferredencoding() class Protocol(ProcessProtocol): - STATES = (SEND_INPUT, COLLECT) = xrange(2) + STATES = (SEND_INPUT, COLLECT) = range(2) def __init__(self): self.data = "" self.delayed_call = None self.states = iter(self.STATES) - self.state = self.states.next() + self.state = next(self.states) def outReceived(self, data): - self.data += data + self.data += data.decode(encoding) if self.delayed_call is not None: self.delayed_call.cancel() self.delayed_call = reactor.callLater(0.5, self.next) @@ -54,9 +69,13 @@ def next(self): if self.state == self.SEND_INPUT: index = self.data.find(">>> ") if index >= 0: - self.data = self.data[index + 4:] - self.transport.write(input) - self.state = self.states.next() + self.data = self.data[index + 4 :] + self.transport.write(input.encode(encoding)) + self.state = next(self.states) + elif self.data == "\x1b[6n": + # this is a cursor position query + # respond that cursor is on row 2, column 1 + self.transport.write("\x1b[2;1R".encode(encoding)) else: self.transport.closeStdin() if self.transport.pid is not None: @@ -70,11 +89,23 @@ def processExited(self, reason): (master, slave) = pty.openpty() set_win_size(slave, 25, 80) - reactor.spawnProcess(Protocol(), sys.executable, - (sys.executable, "-m", "bpython." + self.backend, - "--config", TEST_CONFIG), - env=dict(TERM="vt100", LANG=os.environ.get("LANG", "")), - usePTY=(master, slave, os.ttyname(slave))) + reactor.spawnProcess( + Protocol(), + sys.executable, + ( + sys.executable, + "-m", + f"bpython.{self.backend}", + "--config", + str(TEST_CONFIG), + "-q", # prevents version greeting + ), + env={ + "TERM": "vt100", + "LANG": os.environ.get("LANG", "C.UTF-8"), + }, + usePTY=(master, slave, os.ttyname(slave)), + ) return result def test_issue108(self): @@ -83,7 +114,8 @@ def test_issue108(self): def spam(): u"y\\xe4y" \b - spam(""") + spam(""" + ) deferred = self.run_bpython(input) return deferred.addCallback(self.check_no_traceback) @@ -93,19 +125,29 @@ def test_issue133(self): def spam(a, (b, c)): pass \b - spam(1""") + spam(1""" + ) return self.run_bpython(input).addCallback(self.check_no_traceback) def check_no_traceback(self, data): - tb = data[data.find("Traceback"):] - self.assertTrue("Traceback" not in data, tb) + self.assertNotIn("Traceback", data) + + +@unittest.skipIf(reactor is None, "twisted is not available") +class CurtsiesCrashersTest(TrialTestCase, CrashersTest): + backend = "curtsies" + + +@unittest.skipIf(reactor is None, "twisted is not available") +class CursesCrashersTest(TrialTestCase, CrashersTest): + backend = "cli" + -if reactor is not None: - class CursesCrashersTest(TrialTestCase, CrashersTest): - backend = "cli" +@unittest.skipUnless(have_urwid, "urwid is required") +@unittest.skipIf(reactor is None, "twisted is not available") +class UrwidCrashersTest(TrialTestCase, CrashersTest): + backend = "urwid" - class UrwidCrashersTest(TrialTestCase, CrashersTest): - backend = "urwid" if __name__ == "__main__": unittest.main() diff --git a/bpython/test/test_curtsies.py b/bpython/test/test_curtsies.py new file mode 100644 index 000000000..fde2b1037 --- /dev/null +++ b/bpython/test/test_curtsies.py @@ -0,0 +1,89 @@ +import unittest + +from collections import namedtuple +from bpython.curtsies import combined_events +from bpython.test import FixLanguageTestCase as TestCase + +import curtsies.events + + +ScheduledEvent = namedtuple("ScheduledEvent", ["when", "event"]) + + +class EventGenerator: + def __init__(self, initial_events=(), scheduled_events=()): + self._events = [] + self._current_tick = 0 + for e in initial_events: + self.schedule_event(e, 0) + for e, w in scheduled_events: + self.schedule_event(e, w) + + def schedule_event(self, event, when): + self._events.append(ScheduledEvent(when, event)) + self._events.sort() + + def send(self, timeout=None): + if timeout not in [None, 0]: + raise ValueError("timeout value %r not supported" % timeout) + if not self._events: + return None + if self._events[0].when <= self._current_tick: + return self._events.pop(0).event + + if timeout == 0: + return None + elif timeout is None: + e = self._events.pop(0) + self._current_tick = e.when + return e.event + else: + raise ValueError("timeout value %r not supported" % timeout) + + def tick(self, dt=1): + self._current_tick += dt + return self._current_tick + + +class TestCurtsiesPasteDetection(TestCase): + def test_paste_threshold(self): + eg = EventGenerator(list("abc")) + cb = combined_events(eg, paste_threshold=3) + e = next(cb) + self.assertIsInstance(e, curtsies.events.PasteEvent) + self.assertEqual(e.events, list("abc")) + self.assertEqual(next(cb), None) + + eg = EventGenerator(list("abc")) + cb = combined_events(eg, paste_threshold=4) + self.assertEqual(next(cb), "a") + self.assertEqual(next(cb), "b") + self.assertEqual(next(cb), "c") + self.assertEqual(next(cb), None) + + def test_set_timeout(self): + eg = EventGenerator("a", zip("bcdefg", [1, 2, 3, 3, 3, 4])) + eg.schedule_event(curtsies.events.SigIntEvent(), 5) + eg.schedule_event("h", 6) + cb = combined_events(eg, paste_threshold=3) + self.assertEqual(next(cb), "a") + self.assertEqual(cb.send(0), None) + self.assertEqual(next(cb), "b") + self.assertEqual(cb.send(0), None) + eg.tick() + self.assertEqual(cb.send(0), "c") + self.assertEqual(cb.send(0), None) + eg.tick() + self.assertIsInstance(cb.send(0), curtsies.events.PasteEvent) + self.assertEqual(cb.send(0), None) + self.assertEqual(cb.send(None), "g") + self.assertEqual(cb.send(0), None) + eg.tick(1) + self.assertIsInstance(cb.send(0), curtsies.events.SigIntEvent) + self.assertEqual(cb.send(0), None) + self.assertEqual(cb.send(None), "h") + self.assertEqual(cb.send(None), None) + + +if __name__ == "__main__": + unittest.main() diff --git a/bpython/test/test_curtsies_coderunner.py b/bpython/test/test_curtsies_coderunner.py new file mode 100644 index 000000000..bb5cec423 --- /dev/null +++ b/bpython/test/test_curtsies_coderunner.py @@ -0,0 +1,54 @@ +import sys +import unittest + +from unittest import mock +from bpython.curtsiesfrontend.coderunner import CodeRunner, FakeOutput + + +class TestCodeRunner(unittest.TestCase): + def setUp(self): + self.orig_stdout = sys.stdout + self.orig_stderr = sys.stderr + + def tearDown(self): + sys.stdout = self.orig_stdout + sys.stderr = self.orig_stderr + + def test_simple(self): + c = CodeRunner( + request_refresh=lambda: self.orig_stdout.flush() + or self.orig_stderr.flush() + ) + stdout = FakeOutput(c, lambda *args, **kwargs: None, None) + stderr = FakeOutput(c, lambda *args, **kwargs: None, None) + sys.stdout = stdout + sys.stdout = stderr + c.load_code("1 + 1") + c.run_code() + c.run_code() + c.run_code() + + def test_exception(self): + c = CodeRunner( + request_refresh=lambda: self.orig_stdout.flush() + or self.orig_stderr.flush() + ) + + def ctrlc(): + raise KeyboardInterrupt() + + stdout = FakeOutput(c, lambda x: ctrlc(), None) + stderr = FakeOutput(c, lambda *args, **kwargs: None, None) + sys.stdout = stdout + sys.stderr = stderr + c.load_code("1 + 1") + c.run_code() + + +class TestFakeOutput(unittest.TestCase): + def assert_unicode(self, s): + self.assertIsInstance(s, str) + + def test_bytes(self): + out = FakeOutput(mock.Mock(), self.assert_unicode, None) + out.write("native string type") diff --git a/bpython/test/test_curtsies_painting.py b/bpython/test/test_curtsies_painting.py index 3ac7d5d20..fdb9dcad4 100644 --- a/bpython/test/test_curtsies_painting.py +++ b/bpython/test/test_curtsies_painting.py @@ -1,66 +1,883 @@ -# coding: utf8 -import unittest -import sys +import itertools import os +import pydoc +import string +import sys -from curtsies.formatstringarray import FormatStringTest, fsarray +from contextlib import contextmanager +from typing import cast +from curtsies.formatstringarray import ( + fsarray, + assertFSArraysEqual, + assertFSArraysEqualIgnoringFormatting, +) +from curtsies.fmtfuncs import cyan, bold, green, yellow, on_magenta, red +from curtsies.window import CursorAwareWindow +from unittest import mock, skipIf -from curtsies.fmtfuncs import * +from bpython.curtsiesfrontend.events import RefreshRequestEvent +from bpython import config, inspection +from bpython.curtsiesfrontend.repl import BaseRepl +from bpython.curtsiesfrontend import replpainter +from bpython.curtsiesfrontend.repl import ( + INCONSISTENT_HISTORY_MSG, + CONTIGUITY_BROKEN_MSG, +) +from bpython.test import FixLanguageTestCase as TestCase, TEST_CONFIG -from bpython import config -from bpython.curtsiesfrontend.repl import Repl -from bpython.repl import History def setup_config(): - config_struct = config.Struct() - config.loadini(config_struct, os.devnull) + config_struct = config.Config(TEST_CONFIG) + config_struct.cli_suggestion_width = 1 return config_struct -class TestCurtsiesPainting(FormatStringTest): + +class ClearEnviron(TestCase): + @classmethod + def setUpClass(cls): + cls.mock_environ = mock.patch.dict( + "os.environ", + { + "LC_ALL": os.environ.get("LC_ALL", "C.UTF-8"), + "LANG": os.environ.get("LANG", "C.UTF-8"), + }, + clear=True, + ) + cls.mock_environ.start() + TestCase.setUpClass() + + @classmethod + def tearDownClass(cls): + cls.mock_environ.stop() + TestCase.tearDownClass() + + +class CurtsiesPaintingTest(ClearEnviron): def setUp(self): - self.refresh_requests = [] - self.repl = Repl(config=setup_config()) - self.repl.rl_history = History() # clear history + class TestRepl(BaseRepl): + def _request_refresh(inner_self): + pass + + self.repl = TestRepl(setup_config(), cast(CursorAwareWindow, None)) self.repl.height, self.repl.width = (5, 10) + @property + def locals(self): + return self.repl.coderunner.interp.locals + def assert_paint(self, screen, cursor_row_col): array, cursor_pos = self.repl.paint() - self.assertFSArraysEqual(array, screen) + assertFSArraysEqual(array, screen) self.assertEqual(cursor_pos, cursor_row_col) - def assert_paint_ignoring_formatting(self, screen, cursor_row_col): - array, cursor_pos = self.repl.paint() - self.assertFSArraysEqualIgnoringFormatting(array, screen) + def assert_paint_ignoring_formatting( + self, screen, cursor_row_col=None, **paint_kwargs + ): + array, cursor_pos = self.repl.paint(**paint_kwargs) + assertFSArraysEqualIgnoringFormatting(array, screen) + if cursor_row_col is not None: + self.assertEqual(cursor_pos, cursor_row_col) + + def process_box_characters(self, screen): + if not self.repl.config.unicode_box or not config.supports_box_chars(): + return [ + line.replace("┌", "+") + .replace("└", "+") + .replace("┘", "+") + .replace("┐", "+") + .replace("─", "-") + for line in screen + ] + return screen + +class TestCurtsiesPaintingTest(CurtsiesPaintingTest): + def test_history_is_cleared(self): + self.assertEqual(self.repl.rl_history.entries, [""]) + + +class TestCurtsiesPaintingSimple(CurtsiesPaintingTest): def test_startup(self): - screen = fsarray([cyan('>>> '), cyan('Welcome to')]) + screen = fsarray([cyan(">>> ")], width=10) self.assert_paint(screen, (0, 4)) def test_enter_text(self): - [self.repl.add_normal_character(c) for c in '1 + 1'] - screen = fsarray([cyan('>>> ') + bold(green('1')+cyan(' ')+ - yellow('+') + cyan(' ') + green('1')), cyan('Welcome to')]) + [self.repl.add_normal_character(c) for c in "1 + 1"] + screen = fsarray( + [ + cyan(">>> ") + + bold( + green("1") + + cyan(" ") + + yellow("+") + + cyan(" ") + + green("1") + ), + ], + width=10, + ) self.assert_paint(screen, (0, 9)) def test_run_line(self): + orig_stdout = sys.stdout try: - orig_stdout = sys.stdout sys.stdout = self.repl.stdout - [self.repl.add_normal_character(c) for c in '1 + 1'] - self.repl.on_enter() - screen = fsarray([u'>>> 1 + 1', '2', 'Welcome to']) - self.assert_paint_ignoring_formatting(screen, (0, 9)) + [self.repl.add_normal_character(c) for c in "1 + 1"] + self.repl.on_enter(new_code=False) + screen = fsarray([">>> 1 + 1", "2"]) + self.assert_paint_ignoring_formatting(screen, (1, 1)) finally: sys.stdout = orig_stdout def test_completion(self): self.repl.height, self.repl.width = (5, 32) - self.repl.current_line = 'se' + self.repl.current_line = "an" self.cursor_offset = 2 - screen = [u'>>> se', - u'┌───────────────────────┐', - u'│ set( setattr( │', - u'└───────────────────────┘', - u'', - u'Welcome to bpython! Press f'] - self.assert_paint_ignoring_formatting(screen, (0, 9)) + screen = self.process_box_characters( + [ + ">>> an", + "┌──────────────────────────────┐", + "│ and anext( any( │", + "└──────────────────────────────┘", + ] + ) + self.assert_paint_ignoring_formatting(screen, (0, 4)) + + def test_argspec(self): + def foo(x, y, z=10): + "docstring!" + pass + + argspec = inspection.getfuncprops("foo", foo) + array = replpainter.formatted_argspec(argspec, 1, 30, setup_config()) + screen = [ + bold(cyan("foo")) + + cyan(":") + + cyan(" ") + + cyan("(") + + cyan("x") + + yellow(",") + + yellow(" ") + + bold(cyan("y")) + + yellow(",") + + yellow(" ") + + cyan("z") + + yellow("=") + + bold(cyan("10")) + + yellow(")") + ] + assertFSArraysEqual(fsarray(array), fsarray(screen)) + + def test_formatted_docstring(self): + actual = replpainter.formatted_docstring( + "Returns the results\n\n" "Also has side effects", + 40, + config=setup_config(), + ) + expected = fsarray(["Returns the results", "", "Also has side effects"]) + assertFSArraysEqualIgnoringFormatting(actual, expected) + + def test_unicode_docstrings(self): + "A bit of a special case in Python 2" + # issue 653 + + def foo(): + "åß∂ƒ" + + actual = replpainter.formatted_docstring( + foo.__doc__, 40, config=setup_config() + ) + expected = fsarray(["åß∂ƒ"]) + assertFSArraysEqualIgnoringFormatting(actual, expected) + + def test_nonsense_docstrings(self): + for docstring in [ + 123, + {}, + [], + ]: + try: + replpainter.formatted_docstring( + docstring, 40, config=setup_config() + ) + except Exception: + self.fail(f"bad docstring caused crash: {docstring!r}") + + def test_weird_boto_docstrings(self): + # Boto does something like this. + # botocore: botocore/docs/docstring.py + class WeirdDocstring(str): + # a mighty hack. See botocore/docs/docstring.py + def expandtabs(self, tabsize=8): + return "asdfåß∂ƒ".expandtabs(tabsize) + + def foo(): + pass + + foo.__doc__ = WeirdDocstring() + wd = pydoc.getdoc(foo) + actual = replpainter.formatted_docstring(wd, 40, config=setup_config()) + expected = fsarray(["asdfåß∂ƒ"]) + assertFSArraysEqualIgnoringFormatting(actual, expected) + + def test_paint_lasts_events(self): + actual = replpainter.paint_last_events( + 4, 100, ["a", "b", "c"], config=setup_config() + ) + if config.supports_box_chars(): + expected = fsarray(["┌─┐", "│c│", "│b│", "└─┘"]) + else: + expected = fsarray(["+-+", "|c|", "|b|", "+-+"]) + + assertFSArraysEqualIgnoringFormatting(actual, expected) + + +@contextmanager +def output_to_repl(repl): + old_out, old_err = sys.stdout, sys.stderr + try: + sys.stdout, sys.stderr = repl.stdout, repl.stderr + yield + finally: + sys.stdout, sys.stderr = old_out, old_err + + +class HigherLevelCurtsiesPaintingTest(CurtsiesPaintingTest): + def refresh(self): + self.refresh_requests.append(RefreshRequestEvent()) + + def send_refreshes(self): + while self.refresh_requests: + self.repl.process_event(self.refresh_requests.pop()) + _, _ = self.repl.paint() + + def enter(self, line=None): + """Enter a line of text, avoiding autocompletion windows + + autocomplete could still happen if the entered line has + autocompletion that would happen then, but intermediate + stages won't happen""" + if line is not None: + self.repl._set_cursor_offset(len(line), update_completion=False) + self.repl.current_line = line + with output_to_repl(self.repl): + self.repl.on_enter(new_code=False) + self.assertEqual(self.repl.rl_history.entries, [""]) + self.send_refreshes() + + def undo(self): + with output_to_repl(self.repl): + self.repl.undo() + self.send_refreshes() + + def setUp(self): + self.refresh_requests = [] + + class TestRepl(BaseRepl): + def _request_refresh(inner_self): + self.refresh() + + self.repl = TestRepl( + setup_config(), cast(CursorAwareWindow, None), banner="" + ) + self.repl.height, self.repl.width = (5, 32) + + def send_key(self, key): + self.repl.process_event("" if key == " " else key) + self.repl.paint() # has some side effects we need to be wary of + + +class TestWidthAwareness(HigherLevelCurtsiesPaintingTest): + def test_cursor_position_with_fullwidth_char(self): + self.repl.add_normal_character("間") + + cursor_pos = self.repl.paint()[1] + self.assertEqual(cursor_pos, (0, 6)) + + def test_cursor_position_with_padding_char(self): + # odd numbered so fullwidth chars don't wrap evenly + self.repl.width = 11 + [self.repl.add_normal_character(c) for c in "width"] + + cursor_pos = self.repl.paint()[1] + self.assertEqual(cursor_pos, (1, 4)) + + @skipIf( + sys.version_info[:2] >= (3, 11) and sys.version_info[:3] < (3, 11, 1), + "https://github.com/python/cpython/issues/98744", + ) + def test_display_of_padding_chars(self): + self.repl.width = 11 + [self.repl.add_normal_character(c) for c in "width"] + + self.enter() + expected = [">>> wid ", "th"] # <--- note the added trailing space + result = [d.s for d in self.repl.display_lines[0:2]] + self.assertEqual(result, expected) + + +class TestCurtsiesRewindRedraw(HigherLevelCurtsiesPaintingTest): + def test_rewind(self): + self.repl.current_line = "1 + 1" + self.enter() + screen = [">>> 1 + 1", "2", ">>> "] + self.assert_paint_ignoring_formatting(screen, (2, 4)) + self.repl.undo() + screen = [">>> "] + self.assert_paint_ignoring_formatting(screen, (0, 4)) + + def test_rewind_contiguity_loss(self): + self.enter("1 + 1") + self.enter("2 + 2") + self.enter("def foo(x):") + self.repl.current_line = " return x + 1" + screen = [ + ">>> 1 + 1", + "2", + ">>> 2 + 2", + "4", + ">>> def foo(x):", + "... return x + 1", + ] + self.assert_paint_ignoring_formatting(screen, (5, 8)) + self.repl.scroll_offset = 1 + self.assert_paint_ignoring_formatting(screen[1:], (4, 8)) + self.undo() + screen = ["2", ">>> 2 + 2", "4", ">>> "] + self.assert_paint_ignoring_formatting(screen, (3, 4)) + self.undo() + screen = ["2", ">>> "] + self.assert_paint_ignoring_formatting(screen, (1, 4)) + self.undo() + screen = [ + CONTIGUITY_BROKEN_MSG[: self.repl.width], + ">>> ", + "", + "", + "", + " ", + ] # TODO why is that there? Necessary? + self.assert_paint_ignoring_formatting(screen, (1, 4)) + screen = [">>> "] + self.assert_paint_ignoring_formatting(screen, (0, 4)) + + def test_inconsistent_history_doesnt_happen_if_onscreen(self): + self.enter("1 + 1") + screen = [">>> 1 + 1", "2", ">>> "] + self.assert_paint_ignoring_formatting(screen, (2, 4)) + self.enter("2 + 2") + screen = [">>> 1 + 1", "2", ">>> 2 + 2", "4", ">>> "] + self.assert_paint_ignoring_formatting(screen, (4, 4)) + self.repl.display_lines[0] = self.repl.display_lines[0] * 2 + self.undo() + screen = [">>> 1 + 1", "2", ">>> "] + self.assert_paint_ignoring_formatting(screen, (2, 4)) + + def test_rewind_inconsistent_history(self): + self.enter("1 + 1") + self.enter("2 + 2") + self.enter("3 + 3") + screen = [">>> 1 + 1", "2", ">>> 2 + 2", "4", ">>> 3 + 3", "6", ">>> "] + self.assert_paint_ignoring_formatting(screen, (6, 4)) + self.repl.scroll_offset += len(screen) - self.repl.height + self.assert_paint_ignoring_formatting(screen[2:], (4, 4)) + self.repl.display_lines[0] = self.repl.display_lines[0] * 2 + self.undo() + screen = [ + INCONSISTENT_HISTORY_MSG[: self.repl.width], + ">>> 2 + 2", + "4", + ">>> ", + "", + " ", + ] + self.assert_paint_ignoring_formatting(screen, (3, 4)) + self.repl.scroll_offset += len(screen) - self.repl.height + self.assert_paint_ignoring_formatting(screen[1:-2], (2, 4)) + self.assert_paint_ignoring_formatting(screen[1:-2], (2, 4)) + + def test_rewind_inconsistent_history_more_lines_same_screen(self): + self.repl.width = 60 + sys.a = 5 + self.enter("import sys") + self.enter("for i in range(sys.a):") + self.enter(" print(sys.a)") + self.enter("") + self.enter("1 + 1") + self.enter("2 + 2") + screen = [ + ">>> import sys", + ">>> for i in range(sys.a):", + "... print(sys.a)", + "... ", + "5", + "5", + "5", + "5", + "5", + ">>> 1 + 1", + "2", + ">>> 2 + 2", + "4", + ">>> ", + ] + self.assert_paint_ignoring_formatting(screen, (13, 4)) + self.repl.scroll_offset += len(screen) - self.repl.height + self.assert_paint_ignoring_formatting(screen[9:], (4, 4)) + sys.a = 6 + self.undo() + screen = [ + INCONSISTENT_HISTORY_MSG[: self.repl.width], + "6", + # everything will jump down a line - that's perfectly + # reasonable + ">>> 1 + 1", + "2", + ">>> ", + " ", + ] + self.assert_paint_ignoring_formatting(screen, (4, 4)) + self.repl.scroll_offset += len(screen) - self.repl.height + self.assert_paint_ignoring_formatting(screen[1:-1], (3, 4)) + + def test_rewind_inconsistent_history_more_lines_lower_screen(self): + self.repl.width = 60 + sys.a = 5 + self.enter("import sys") + self.enter("for i in range(sys.a):") + self.enter(" print(sys.a)") + self.enter("") + self.enter("1 + 1") + self.enter("2 + 2") + screen = [ + ">>> import sys", + ">>> for i in range(sys.a):", + "... print(sys.a)", + "... ", + "5", + "5", + "5", + "5", + "5", + ">>> 1 + 1", + "2", + ">>> 2 + 2", + "4", + ">>> ", + ] + self.assert_paint_ignoring_formatting(screen, (13, 4)) + self.repl.scroll_offset += len(screen) - self.repl.height + self.assert_paint_ignoring_formatting(screen[9:], (4, 4)) + sys.a = 8 + self.undo() + screen = [ + INCONSISTENT_HISTORY_MSG[: self.repl.width], + "8", + "8", + "8", + ">>> 1 + 1", + "2", + ">>> ", + ] + self.assert_paint_ignoring_formatting(screen) + self.repl.scroll_offset += len(screen) - self.repl.height + self.assert_paint_ignoring_formatting(screen[-5:]) + + def test_rewind_inconsistent_history_more_lines_raise_screen(self): + self.repl.width = 60 + sys.a = 5 + self.enter("import sys") + self.enter("for i in range(sys.a):") + self.enter(" print(sys.a)") + self.enter("") + self.enter("1 + 1") + self.enter("2 + 2") + screen = [ + ">>> import sys", + ">>> for i in range(sys.a):", + "... print(sys.a)", + "... ", + "5", + "5", + "5", + "5", + "5", + ">>> 1 + 1", + "2", + ">>> 2 + 2", + "4", + ">>> ", + ] + self.assert_paint_ignoring_formatting(screen, (13, 4)) + self.repl.scroll_offset += len(screen) - self.repl.height + self.assert_paint_ignoring_formatting(screen[9:], (4, 4)) + sys.a = 1 + self.undo() + screen = [ + INCONSISTENT_HISTORY_MSG[: self.repl.width], + "1", + ">>> 1 + 1", + "2", + ">>> ", + " ", + ] + self.assert_paint_ignoring_formatting(screen) + self.repl.scroll_offset += len(screen) - self.repl.height + self.assert_paint_ignoring_formatting(screen[1:-1]) + + def test_rewind_history_not_quite_inconsistent(self): + self.repl.width = 50 + sys.a = 5 + self.enter("for i in range(__import__('sys').a):") + self.enter(" print(i)") + self.enter("") + self.enter("1 + 1") + self.enter("2 + 2") + screen = [ + ">>> for i in range(__import__('sys').a):", + "... print(i)", + "... ", + "0", + "1", + "2", + "3", + "4", + ">>> 1 + 1", + "2", + ">>> 2 + 2", + "4", + ">>> ", + ] + self.assert_paint_ignoring_formatting(screen, (12, 4)) + self.repl.scroll_offset += len(screen) - self.repl.height + self.assert_paint_ignoring_formatting(screen[8:], (4, 4)) + sys.a = 6 + self.undo() + screen = [ + "5", + # everything will jump down a line - that's perfectly + # reasonable + ">>> 1 + 1", + "2", + ">>> ", + ] + self.assert_paint_ignoring_formatting(screen, (3, 4)) + + def test_rewind_barely_consistent(self): + self.enter("1 + 1") + self.enter("2 + 2") + self.enter("3 + 3") + screen = [">>> 1 + 1", "2", ">>> 2 + 2", "4", ">>> 3 + 3", "6", ">>> "] + self.assert_paint_ignoring_formatting(screen, (6, 4)) + self.repl.scroll_offset += len(screen) - self.repl.height + self.assert_paint_ignoring_formatting(screen[2:], (4, 4)) + self.repl.display_lines[2] = self.repl.display_lines[2] * 2 + self.undo() + screen = [">>> 2 + 2", "4", ">>> "] + self.assert_paint_ignoring_formatting(screen, (2, 4)) + + def test_clear_screen(self): + self.enter("1 + 1") + self.enter("2 + 2") + screen = [">>> 1 + 1", "2", ">>> 2 + 2", "4", ">>> "] + self.assert_paint_ignoring_formatting(screen, (4, 4)) + self.repl.request_paint_to_clear_screen = True + screen = [">>> 1 + 1", "2", ">>> 2 + 2", "4", ">>> ", "", "", "", ""] + self.assert_paint_ignoring_formatting(screen, (4, 4)) + + def test_scroll_down_while_banner_visible(self): + self.repl.status_bar.message("STATUS_BAR") + self.enter("1 + 1") + self.enter("2 + 2") + screen = [ + ">>> 1 + 1", + "2", + ">>> 2 + 2", + "4", + ">>> ", + "STATUS_BAR ", + ] + self.assert_paint_ignoring_formatting(screen, (4, 4)) + self.repl.scroll_offset += len(screen) - self.repl.height + self.assert_paint_ignoring_formatting(screen[1:], (3, 4)) + + def test_clear_screen_while_banner_visible(self): + self.repl.status_bar.message("STATUS_BAR") + self.enter("1 + 1") + self.enter("2 + 2") + screen = [ + ">>> 1 + 1", + "2", + ">>> 2 + 2", + "4", + ">>> ", + "STATUS_BAR ", + ] + self.assert_paint_ignoring_formatting(screen, (4, 4)) + self.repl.scroll_offset += len(screen) - self.repl.height + self.assert_paint_ignoring_formatting(screen[1:], (3, 4)) + + self.repl.request_paint_to_clear_screen = True + screen = [ + "2", + ">>> 2 + 2", + "4", + ">>> ", + "", + "", + "", + "STATUS_BAR ", + ] + self.assert_paint_ignoring_formatting(screen, (3, 4)) + + def test_cursor_stays_at_bottom_of_screen(self): + """infobox showing up during intermediate render was causing this to + fail, #371""" + self.repl.width = 50 + self.repl.current_line = "__import__('random').__name__" + with output_to_repl(self.repl): + self.repl.on_enter(new_code=False) + screen = [">>> __import__('random').__name__", "'random'"] + self.assert_paint_ignoring_formatting(screen) + + with output_to_repl(self.repl): + self.repl.process_event(self.refresh_requests.pop()) + screen = [">>> __import__('random').__name__", "'random'", ""] + self.assert_paint_ignoring_formatting(screen) + + with output_to_repl(self.repl): + self.repl.process_event(self.refresh_requests.pop()) + screen = [">>> __import__('random').__name__", "'random'", ">>> "] + self.assert_paint_ignoring_formatting(screen, (2, 4)) + + def test_unhighlight_paren_bugs(self): + """two previous bugs, parent didn't highlight until next render + and paren didn't unhighlight until enter""" + self.repl.width = 32 + self.assertEqual(self.repl.rl_history.entries, [""]) + self.enter("(") + self.assertEqual(self.repl.rl_history.entries, [""]) + screen = [">>> (", "... "] + self.assertEqual(self.repl.rl_history.entries, [""]) + self.assert_paint_ignoring_formatting(screen) + self.assertEqual(self.repl.rl_history.entries, [""]) + + with output_to_repl(self.repl): + self.assertEqual(self.repl.rl_history.entries, [""]) + self.repl.process_event(")") + self.assertEqual(self.repl.rl_history.entries, [""]) + screen = fsarray( + [ + cyan(">>> ") + on_magenta(bold(red("("))), + green("... ") + on_magenta(bold(red(")"))), + ], + width=32, + ) + self.assert_paint(screen, (1, 5)) + + with output_to_repl(self.repl): + self.repl.process_event(" ") + screen = fsarray( + [ + cyan(">>> ") + yellow("("), + green("... ") + yellow(")") + bold(cyan(" ")), + ], + width=32, + ) + self.assert_paint(screen, (1, 6)) + + def test_472(self): + [self.send_key(c) for c in "(1, 2, 3)"] + with output_to_repl(self.repl): + self.send_key("\n") + self.send_refreshes() + self.send_key("") + self.repl.paint() + [self.send_key("") for _ in range(4)] + self.send_key("") + self.send_key("4") + self.repl.on_enter() + self.send_refreshes() + screen = [ + ">>> (1, 2, 3)", + "(1, 2, 3)", + ">>> (1, 4, 3)", + "(1, 4, 3)", + ">>> ", + ] + self.assert_paint_ignoring_formatting(screen, (4, 4)) + + +def completion_target(num_names, chars_in_first_name=1): + class Class: + pass + + if chars_in_first_name < 1: + raise ValueError("need at least one char in each name") + elif chars_in_first_name == 1 and num_names > len(string.ascii_letters): + raise ValueError("need more chars to make so many names") + + names = gen_names() + if num_names > 0: + setattr(Class, "a" * chars_in_first_name, 1) + next(names) # use the above instead of first name + for _, name in zip(range(num_names - 1), names): + setattr(Class, name, 0) + + return Class() + + +def gen_names(): + for letters in itertools.chain( + itertools.combinations_with_replacement(string.ascii_letters, 1), + itertools.combinations_with_replacement(string.ascii_letters, 2), + ): + yield "".join(letters) + + +class TestCompletionHelpers(TestCase): + def test_gen_names(self): + self.assertEqual( + list(zip([1, 2, 3], gen_names())), [(1, "a"), (2, "b"), (3, "c")] + ) + + def test_completion_target(self): + target = completion_target(14) + self.assertEqual( + len([x for x in dir(target) if not x.startswith("_")]), 14 + ) + + +class TestCurtsiesInfoboxPaint(HigherLevelCurtsiesPaintingTest): + def test_simple(self): + self.repl.width, self.repl.height = (20, 30) + self.locals["abc"] = completion_target(3, 50) + self.repl.current_line = "abc" + self.repl.cursor_offset = 3 + self.repl.process_event(".") + screen = self.process_box_characters( + [ + ">>> abc.", + "┌──────────────────┐", + "│ aaaaaaaaaaaaaaaa │", + "│ b │", + "│ c │", + "└──────────────────┘", + ] + ) + self.assert_paint_ignoring_formatting(screen, (0, 8)) + + def test_fill_screen(self): + self.repl.width, self.repl.height = (20, 15) + self.locals["abc"] = completion_target(20, 100) + self.repl.current_line = "abc" + self.repl.cursor_offset = 3 + self.repl.process_event(".") + screen = self.process_box_characters( + [ + ">>> abc.", + "┌──────────────────┐", + "│ aaaaaaaaaaaaaaaa │", + "│ b │", + "│ c │", + "│ d │", + "│ e │", + "│ f │", + "│ g │", + "│ h │", + "│ i │", + "│ j │", + "│ k │", + "│ l │", + "└──────────────────┘", + ] + ) + self.assert_paint_ignoring_formatting(screen, (0, 8)) + + def test_lower_on_screen(self): + self.repl.get_top_usable_line = lambda: 10 # halfway down terminal + self.repl.width, self.repl.height = (20, 15) + self.locals["abc"] = completion_target(20, 100) + self.repl.current_line = "abc" + self.repl.cursor_offset = 3 + self.repl.process_event(".") + screen = self.process_box_characters( + [ + ">>> abc.", + "┌──────────────────┐", + "│ aaaaaaaaaaaaaaaa │", + "│ b │", + "│ c │", + "│ d │", + "│ e │", + "│ f │", + "│ g │", + "│ h │", + "│ i │", + "│ j │", + "│ k │", + "│ l │", + "└──────────────────┘", + ] + ) + # behavior before issue #466 + self.assert_paint_ignoring_formatting( + screen, try_preserve_history_height=0 + ) + self.assert_paint_ignoring_formatting(screen, min_infobox_height=100) + # behavior after issue #466 + screen = self.process_box_characters( + [ + ">>> abc.", + "┌──────────────────┐", + "│ aaaaaaaaaaaaaaaa │", + "│ b │", + "│ c │", + "└──────────────────┘", + ] + ) + self.assert_paint_ignoring_formatting(screen) + + def test_at_bottom_of_screen(self): + self.repl.get_top_usable_line = lambda: 17 # two lines from bottom + self.repl.width, self.repl.height = (20, 15) + self.locals["abc"] = completion_target(20, 100) + self.repl.current_line = "abc" + self.repl.cursor_offset = 3 + self.repl.process_event(".") + screen = self.process_box_characters( + [ + ">>> abc.", + "┌──────────────────┐", + "│ aaaaaaaaaaaaaaaa │", + "│ b │", + "│ c │", + "│ d │", + "│ e │", + "│ f │", + "│ g │", + "│ h │", + "│ i │", + "│ j │", + "│ k │", + "│ l │", + "└──────────────────┘", + ] + ) + # behavior before issue #466 + self.assert_paint_ignoring_formatting( + screen, try_preserve_history_height=0 + ) + self.assert_paint_ignoring_formatting(screen, min_infobox_height=100) + # behavior after issue #466 + screen = self.process_box_characters( + [ + ">>> abc.", + "┌──────────────────┐", + "│ aaaaaaaaaaaaaaaa │", + "│ b │", + "│ c │", + "└──────────────────┘", + ] + ) + self.assert_paint_ignoring_formatting(screen) diff --git a/bpython/test/test_curtsies_parser.py b/bpython/test/test_curtsies_parser.py new file mode 100644 index 000000000..ede87460d --- /dev/null +++ b/bpython/test/test_curtsies_parser.py @@ -0,0 +1,37 @@ +from bpython.test import unittest +from bpython.curtsiesfrontend import parse +from curtsies.fmtfuncs import yellow, cyan, green, bold + + +class TestExecArgs(unittest.TestCase): + def test_parse(self): + self.assertEqual(parse.parse("\x01y\x03print\x04"), yellow("print")) + + self.assertEqual( + parse.parse( + "\x01y\x03print\x04\x01c\x03 \x04\x01g\x031\x04\x01c" + "\x03 \x04\x01Y\x03+\x04\x01c\x03 \x04\x01g\x032\x04" + ), + yellow("print") + + cyan(" ") + + green("1") + + cyan(" ") + + bold(yellow("+")) + + cyan(" ") + + green("2"), + ) + + def test_peal_off_string(self): + self.assertEqual( + parse.peel_off_string("\x01RI\x03]\x04asdf"), + ( + { + "bg": "I", + "string": "]", + "fg": "R", + "colormarker": "\x01RI", + "bold": "", + }, + "asdf", + ), + ) diff --git a/bpython/test/test_curtsies_repl.py b/bpython/test/test_curtsies_repl.py index c93467741..59102f9e1 100644 --- a/bpython/test/test_curtsies_repl.py +++ b/bpython/test/test_curtsies_repl.py @@ -1,27 +1,488 @@ -import unittest +import code +import os import sys -py3 = (sys.version_info[0] == 3) +import tempfile +import io +from typing import cast +import unittest + +from contextlib import contextmanager +from functools import partial +from unittest import mock + +from bpython.curtsiesfrontend import repl as curtsiesrepl +from bpython.curtsiesfrontend import interpreter +from bpython.curtsiesfrontend import events as bpythonevents +from bpython.curtsiesfrontend.repl import LineType +from bpython import autocomplete +from bpython import config +from bpython import args +from bpython.test import ( + FixLanguageTestCase as TestCase, + MagicIterMock, + TEST_CONFIG, +) + +from curtsies import events +from curtsies.window import CursorAwareWindow +from importlib import invalidate_caches + + +def setup_config(conf): + config_struct = config.Config(TEST_CONFIG) + for key, value in conf.items(): + if not hasattr(config_struct, key): + raise ValueError(f"{key!r} is not a valid config attribute") + setattr(config_struct, key, value) + return config_struct + + +class TestCurtsiesRepl(TestCase): + def setUp(self): + self.repl = create_repl() + + def cfwp(self, source): + return interpreter.code_finished_will_parse( + source, self.repl.interp.compile + ) + + def test_code_finished_will_parse(self): + self.repl.buffer = ["1 + 1"] + self.assertTrue(self.cfwp("\n".join(self.repl.buffer)), (True, True)) + self.repl.buffer = ["def foo(x):"] + self.assertTrue(self.cfwp("\n".join(self.repl.buffer)), (False, True)) + self.repl.buffer = ["def foo(x)"] + self.assertTrue(self.cfwp("\n".join(self.repl.buffer)), (True, False)) + self.repl.buffer = ["def foo(x):", "return 1"] + self.assertTrue(self.cfwp("\n".join(self.repl.buffer)), (True, False)) + self.repl.buffer = ["def foo(x):", " return 1"] + self.assertTrue(self.cfwp("\n".join(self.repl.buffer)), (True, True)) + self.repl.buffer = ["def foo(x):", " return 1", ""] + self.assertTrue(self.cfwp("\n".join(self.repl.buffer)), (True, True)) + + def test_external_communication(self): + self.repl.send_current_block_to_external_editor() + self.repl.send_session_to_external_editor() + + @unittest.skipUnless( + all(map(config.can_encode, "å∂߃")), "Charset can not encode characters" + ) + def test_external_communication_encoding(self): + with captured_output(): + self.repl.display_lines.append('>>> "åß∂ƒ"') + self.repl.history.append('"åß∂ƒ"') + self.repl.all_logical_lines.append(('"åß∂ƒ"', LineType.INPUT)) + self.repl.send_session_to_external_editor() + + def test_get_last_word(self): + self.repl.rl_history.entries = ["1", "2 3", "4 5 6"] + self.repl._set_current_line("abcde") + self.repl.get_last_word() + self.assertEqual(self.repl.current_line, "abcde6") + self.repl.get_last_word() + self.assertEqual(self.repl.current_line, "abcde3") + + def test_last_word(self): + self.assertEqual(curtsiesrepl._last_word(""), "") + self.assertEqual(curtsiesrepl._last_word(" "), "") + self.assertEqual(curtsiesrepl._last_word("a"), "a") + self.assertEqual(curtsiesrepl._last_word("a b"), "b") + + @unittest.skip("this is the behavior of bash - not currently implemented") + def test_get_last_word_with_prev_line(self): + self.repl.rl_history.entries = ["1", "2 3", "4 5 6"] + self.repl._set_current_line("abcde") + self.repl.up_one_line() + self.assertEqual(self.repl.current_line, "4 5 6") + self.repl.get_last_word() + self.assertEqual(self.repl.current_line, "4 5 63") + self.repl.get_last_word() + self.assertEqual(self.repl.current_line, "4 5 64") + self.repl.up_one_line() + self.assertEqual(self.repl.current_line, "2 3") + + +def mock_next(obj, return_value): + obj.__next__.return_value = return_value + + +class TestCurtsiesReplTab(TestCase): + def setUp(self): + self.repl = create_repl() + self.repl.matches_iter = MagicIterMock() + + def add_matches(*args, **kwargs): + self.repl.matches_iter.matches = ["aaa", "aab", "aac"] + + self.repl.complete = mock.Mock( + side_effect=add_matches, return_value=True + ) + + def test_tab_with_no_matches_triggers_completion(self): + self.repl._current_line = " asdf" + self.repl._cursor_offset = 5 + self.repl.matches_iter.matches = [] + self.repl.matches_iter.is_cseq.return_value = False + self.repl.matches_iter.cur_line.return_value = (None, None) + self.repl.on_tab() + self.repl.complete.assert_called_once_with(tab=True) + + def test_tab_after_indentation_adds_space(self): + self.repl._current_line = " " + self.repl._cursor_offset = 4 + self.repl.on_tab() + self.assertEqual(self.repl._current_line, " ") + self.assertEqual(self.repl._cursor_offset, 8) + + def test_tab_at_beginning_of_line_adds_space(self): + self.repl._current_line = "" + self.repl._cursor_offset = 0 + self.repl.on_tab() + self.assertEqual(self.repl._current_line, " ") + self.assertEqual(self.repl._cursor_offset, 4) + + def test_tab_with_no_matches_selects_first(self): + self.repl._current_line = " aa" + self.repl._cursor_offset = 3 + self.repl.matches_iter.matches = [] + self.repl.matches_iter.is_cseq.return_value = False + + mock_next(self.repl.matches_iter, None) + self.repl.matches_iter.cur_line.return_value = (None, None) + self.repl.on_tab() + self.repl.complete.assert_called_once_with(tab=True) + self.repl.matches_iter.cur_line.assert_called_once_with() + + def test_tab_with_matches_selects_next_match(self): + self.repl._current_line = " aa" + self.repl._cursor_offset = 3 + self.repl.complete() + self.repl.matches_iter.is_cseq.return_value = False + mock_next(self.repl.matches_iter, None) + self.repl.matches_iter.cur_line.return_value = (None, None) + self.repl.on_tab() + self.repl.matches_iter.cur_line.assert_called_once_with() + + def test_tab_completes_common_sequence(self): + self.repl._current_line = " a" + self.repl._cursor_offset = 2 + self.repl.matches_iter.matches = ["aaa", "aab", "aac"] + self.repl.matches_iter.is_cseq.return_value = True + self.repl.matches_iter.substitute_cseq.return_value = (None, None) + self.repl.on_tab() + self.repl.matches_iter.substitute_cseq.assert_called_once_with() + + +class TestCurtsiesReplFilenameCompletion(TestCase): + def setUp(self): + self.repl = create_repl() + + def test_list_win_visible_match_selected_on_tab_multiple_options(self): + self.repl._current_line = " './'" + self.repl._cursor_offset = 2 + with mock.patch("bpython.autocomplete.get_completer") as m: + m.return_value = ( + ["./abc", "./abcd", "./bcd"], + autocomplete.FilenameCompletion(), + ) + self.repl.update_completion() + self.assertEqual(self.repl.list_win_visible, False) + self.repl.on_tab() + self.assertEqual(self.repl.current_match, "./abc") + self.assertEqual(self.repl.list_win_visible, True) + + def test_list_win_not_visible_and_cseq_if_cseq(self): + self.repl._current_line = " './a'" + self.repl._cursor_offset = 5 + with mock.patch("bpython.autocomplete.get_completer") as m: + m.return_value = ( + ["./abcd", "./abce"], + autocomplete.FilenameCompletion(), + ) + self.repl.update_completion() + self.assertEqual(self.repl.list_win_visible, False) + self.repl.on_tab() + self.assertEqual(self.repl._current_line, " './abc'") + self.assertEqual(self.repl.current_match, None) + self.assertEqual(self.repl.list_win_visible, False) + + def test_list_win_not_visible_and_match_selected_if_one_option(self): + self.repl._current_line = " './a'" + self.repl._cursor_offset = 5 + with mock.patch("bpython.autocomplete.get_completer") as m: + m.return_value = (["./abcd"], autocomplete.FilenameCompletion()) + self.repl.update_completion() + self.assertEqual(self.repl.list_win_visible, False) + self.repl.on_tab() + self.assertEqual(self.repl._current_line, " './abcd'") + self.assertEqual(self.repl.current_match, None) + self.assertEqual(self.repl.list_win_visible, False) + + +# from http://stackoverflow.com/a/17981937/398212 - thanks @rkennedy +@contextmanager +def captured_output(): + new_out, new_err = io.StringIO(), io.StringIO() + old_out, old_err = sys.stdout, sys.stderr + try: + sys.stdout, sys.stderr = new_out, new_err + yield sys.stdout, sys.stderr + finally: + sys.stdout, sys.stderr = old_out, old_err + + +def create_repl(**kwargs): + config = setup_config({"editor": "true"}) + repl = curtsiesrepl.BaseRepl( + config, cast(CursorAwareWindow, None), **kwargs + ) + os.environ["PAGER"] = "true" + os.environ.pop("PYTHONSTARTUP", None) + repl.width = 50 + repl.height = 20 + return repl + + +class TestFutureImports(TestCase): + def test_repl(self): + repl = create_repl() + with captured_output() as (out, err): + repl.push("1 / 2") + self.assertEqual(out.getvalue(), "0.5\n") -from bpython.curtsiesfrontend import repl + def test_interactive(self): + interp = code.InteractiveInterpreter(locals={}) + with captured_output() as (out, err): + with tempfile.NamedTemporaryFile(mode="w", suffix=".py") as f: + f.write("print(1/2)\n") + f.flush() + args.exec_code(interp, [f.name]) -class TestCurtsiesRepl(unittest.TestCase): + repl = create_repl(interp=interp) + repl.push("1 / 2") + self.assertEqual(out.getvalue(), "0.5\n0.5\n") + + +class TestStdOutErr(TestCase): + def setUp(self): + self.repl = create_repl() + + def test_newline(self): + self.repl.send_to_stdouterr("\n\n") + self.assertEqual(self.repl.display_lines[-2], "") + self.assertEqual(self.repl.display_lines[-1], "") + self.assertEqual(self.repl.current_stdouterr_line, "") + + def test_leading_newline(self): + self.repl.send_to_stdouterr("\nfoo\n") + self.assertEqual(self.repl.display_lines[-2], "") + self.assertEqual(self.repl.display_lines[-1], "foo") + self.assertEqual(self.repl.current_stdouterr_line, "") + + def test_no_trailing_newline(self): + self.repl.send_to_stdouterr("foo") + self.assertEqual(self.repl.current_stdouterr_line, "foo") + + def test_print_without_newline_then_print_with_leading_newline(self): + self.repl.send_to_stdouterr("foo") + self.repl.send_to_stdouterr("\nbar\n") + self.assertEqual(self.repl.display_lines[-2], "foo") + self.assertEqual(self.repl.display_lines[-1], "bar") + self.assertEqual(self.repl.current_stdouterr_line, "") + + +class TestPredictedIndent(TestCase): + def setUp(self): + self.repl = create_repl() + + def test_simple(self): + self.assertEqual(self.repl.predicted_indent(""), 0) + self.assertEqual(self.repl.predicted_indent("class Foo:"), 4) + self.assertEqual(self.repl.predicted_indent("class Foo: pass"), 0) + self.assertEqual(self.repl.predicted_indent("def asdf():"), 4) + self.assertEqual(self.repl.predicted_indent("def asdf(): return 7"), 0) + + @unittest.skip("This would be interesting") + def test_complex(self): + self.assertEqual(self.repl.predicted_indent("[a, "), 1) + self.assertEqual(self.repl.predicted_indent("reduce(asdfasdf, "), 7) + + +class TestCurtsiesReevaluate(TestCase): def setUp(self): - self.repl = repl.Repl() + self.repl = create_repl() + + def test_variable_is_cleared(self): + self.repl._current_line = "b = 10" + self.repl.on_enter() + self.assertIn("b", self.repl.interp.locals) + self.repl.undo() + self.assertNotIn("b", self.repl.interp.locals) + + +class TestCurtsiesReevaluateWithImport(TestCase): + def setUp(self): + self.repl = create_repl() + self.open = partial(io.open, mode="wt", encoding="utf-8") + self.dont_write_bytecode = sys.dont_write_bytecode + sys.dont_write_bytecode = True + self.sys_path = sys.path + sys.path = self.sys_path[:] + + # Because these tests create Python source files at runtime, + # it's possible in Python >=3.3 for the importlib.machinery.FileFinder + # for a directory to have an outdated cache when + # * a module in that directory is imported, + # * then a new module is created in that directory, + # * then that new module is imported. + # Automatic cache invalidation is based on the second-resolution mtime + # of the directory, so we need to manually call invalidate_caches(). + # + # see https://docs.python.org/3/library/importlib.html + # sections #importlib.machinery.FileFinder and + # #importlib.invalidate_caches + invalidate_caches() + + def tearDown(self): + sys.dont_write_bytecode = self.dont_write_bytecode + sys.path = self.sys_path + + def push(self, line): + self.repl._current_line = line + self.repl.on_enter() + + def head(self, path): + self.push("import sys") + self.push('sys.path.append("%s")' % (path)) + + @staticmethod + @contextmanager + def tempfile(): + with tempfile.NamedTemporaryFile(suffix=".py") as temp: + path, name = os.path.split(temp.name) + yield temp.name, path, name.replace(".py", "") + + def test_module_content_changed(self): + with self.tempfile() as (fullpath, path, modname): + print(modname) + with self.open(fullpath) as f: + f.write("a = 0\n") + self.head(path) + self.push("import %s" % (modname)) + self.push("a = %s.a" % (modname)) + self.assertIn("a", self.repl.interp.locals) + self.assertEqual(self.repl.interp.locals["a"], 0) + with self.open(fullpath) as f: + f.write("a = 1\n") + self.repl.clear_modules_and_reevaluate() + self.assertIn("a", self.repl.interp.locals) + self.assertEqual(self.repl.interp.locals["a"], 1) + + def test_import_module_with_rewind(self): + with self.tempfile() as (fullpath, path, modname): + print(modname) + with self.open(fullpath) as f: + f.write("a = 0\n") + self.head(path) + self.push("import %s" % (modname)) + self.assertIn(modname, self.repl.interp.locals) + self.repl.undo() + self.assertNotIn(modname, self.repl.interp.locals) + self.repl.clear_modules_and_reevaluate() + self.assertNotIn(modname, self.repl.interp.locals) + self.push("import %s" % (modname)) + self.push("a = %s.a" % (modname)) + self.assertIn("a", self.repl.interp.locals) + self.assertEqual(self.repl.interp.locals["a"], 0) + with self.open(fullpath) as f: + f.write("a = 1\n") + self.repl.clear_modules_and_reevaluate() + self.assertIn("a", self.repl.interp.locals) + self.assertEqual(self.repl.interp.locals["a"], 1) + + +class TestCurtsiesPagerText(TestCase): + def setUp(self): + self.repl = create_repl() + self.repl.pager = self.assert_pager_gets_unicode + + def assert_pager_gets_unicode(self, text): + self.assertIsInstance(text, str) + + def test_help(self): + self.repl.pager(self.repl.help_text()) + + @unittest.skipUnless( + all(map(config.can_encode, "å∂߃")), "Charset can not encode characters" + ) + def test_show_source_not_formatted(self): + self.repl.config.highlight_show_source = False + self.repl.get_source_of_current_name = lambda: "source code å∂߃åß∂ƒ" + self.repl.show_source() + + @unittest.skipUnless( + all(map(config.can_encode, "å∂߃")), "Charset can not encode characters" + ) + def test_show_source_formatted(self): + self.repl.config.highlight_show_source = True + self.repl.get_source_of_current_name = lambda: "source code å∂߃åß∂ƒ" + self.repl.show_source() + + +class TestCurtsiesStartup(TestCase): + def setUp(self): + self.repl = create_repl() + + def write_startup_file(self, fname, encoding): + with open(fname, mode="w", encoding=encoding) as f: + f.write("# coding: ") + f.write(encoding) + f.write("\n") + f.write('a = "äöü"\n') + + def test_startup_event_utf8(self): + with tempfile.NamedTemporaryFile() as temp: + self.write_startup_file(temp.name, "utf-8") + with mock.patch.dict("os.environ", {"PYTHONSTARTUP": temp.name}): + self.repl.process_event(bpythonevents.RunStartupFileEvent()) + self.assertIn("a", self.repl.interp.locals) + + def test_startup_event_latin1(self): + with tempfile.NamedTemporaryFile() as temp: + self.write_startup_file(temp.name, "latin-1") + with mock.patch.dict("os.environ", {"PYTHONSTARTUP": temp.name}): + self.repl.process_event(bpythonevents.RunStartupFileEvent()) + self.assertIn("a", self.repl.interp.locals) + + +class TestCurtsiesPasteEvents(TestCase): + def setUp(self): + self.repl = create_repl() + + def test_control_events_in_small_paste(self): + self.assertGreaterEqual( + curtsiesrepl.MAX_EVENTS_POSSIBLY_NOT_PASTE, + 6, + "test assumes UI lag could cause 6 events", + ) + p = events.PasteEvent() + p.events = ["a", "b", "c", "d", "", "e"] + self.repl.process_event(p) + self.assertEqual(self.repl.current_line, "eabcd") + + def test_control_events_in_large_paste(self): + """Large paste events should ignore control characters""" + p = events.PasteEvent() + p.events = ["a", ""] + [ + "e" + ] * curtsiesrepl.MAX_EVENTS_POSSIBLY_NOT_PASTE + self.repl.process_event(p) + self.assertEqual( + self.repl.current_line, + "a" + "e" * curtsiesrepl.MAX_EVENTS_POSSIBLY_NOT_PASTE, + ) - def test_buffer_finished_will_parse(self): - self.repl.buffer = ['1 + 1'] - self.assertTrue(self.repl.buffer_finished_will_parse(), (True, True)) - self.repl.buffer = ['def foo(x):'] - self.assertTrue(self.repl.buffer_finished_will_parse(), (False, True)) - self.repl.buffer = ['def foo(x)'] - self.assertTrue(self.repl.buffer_finished_will_parse(), (True, False)) - self.repl.buffer = ['def foo(x):', 'return 1'] - self.assertTrue(self.repl.buffer_finished_will_parse(), (True, False)) - self.repl.buffer = ['def foo(x):', ' return 1'] - self.assertTrue(self.repl.buffer_finished_will_parse(), (True, True)) - self.repl.buffer = ['def foo(x):', ' return 1', ''] - self.assertTrue(self.repl.buffer_finished_will_parse(), (True, True)) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/bpython/test/test_filewatch.py b/bpython/test/test_filewatch.py new file mode 100644 index 000000000..67b29f943 --- /dev/null +++ b/bpython/test/test_filewatch.py @@ -0,0 +1,34 @@ +import os +import unittest + +try: + import watchdog + from bpython.curtsiesfrontend.filewatch import ModuleChangedEventHandler + + has_watchdog = True +except ImportError: + has_watchdog = False + +from unittest import mock + + +@unittest.skipUnless(has_watchdog, "watchdog required") +class TestModuleChangeEventHandler(unittest.TestCase): + def setUp(self): + self.module = ModuleChangedEventHandler([], 1) + self.module.observer = mock.Mock() + + def test_create_module_handler(self): + self.assertIsInstance(self.module, ModuleChangedEventHandler) + + def test_add_module(self): + self.module._add_module("something/test.py") + self.assertIn( + os.path.abspath("something/test"), + self.module.dirs[os.path.abspath("something")], + ) + + def test_activate_throws_error_when_already_activated(self): + self.module.activated = True + with self.assertRaises(ValueError): + self.module.activate() diff --git a/bpython/test/test_history.py b/bpython/test/test_history.py new file mode 100644 index 000000000..d810cf6be --- /dev/null +++ b/bpython/test/test_history.py @@ -0,0 +1,127 @@ +import tempfile +import unittest +from pathlib import Path + +from bpython.config import getpreferredencoding +from bpython.history import History + + +class TestHistory(unittest.TestCase): + def setUp(self): + self.history = History(f"#{x}" for x in range(1000)) + + def test_is_at_start(self): + self.history.first() + + self.assertNotEqual(self.history.index, 0) + self.assertTrue(self.history.is_at_end) + self.history.forward() + self.assertFalse(self.history.is_at_end) + + def test_is_at_end(self): + self.history.last() + + self.assertEqual(self.history.index, 0) + self.assertTrue(self.history.is_at_start) + self.assertFalse(self.history.is_at_end) + + def test_first(self): + self.history.first() + + self.assertFalse(self.history.is_at_start) + self.assertTrue(self.history.is_at_end) + + def test_last(self): + self.history.last() + + self.assertTrue(self.history.is_at_start) + self.assertFalse(self.history.is_at_end) + + def test_back(self): + self.assertEqual(self.history.back(), "#999") + self.assertNotEqual(self.history.back(), "#999") + self.assertEqual(self.history.back(), "#997") + for x in range(997): + self.history.back() + self.assertEqual(self.history.back(), "#0") + + def test_forward(self): + self.history.first() + + self.assertEqual(self.history.forward(), "#1") + self.assertNotEqual(self.history.forward(), "#1") + self.assertEqual(self.history.forward(), "#3") + # 1000 == entries 4 == len(range(1, 3) ===> '#1000' (so +1) + for x in range(1000 - 4 - 1): + self.history.forward() + self.assertEqual(self.history.forward(), "#999") + + def test_append(self): + self.history.append('print "foo\n"\n') + self.history.append("\n") + + self.assertEqual(self.history.back(), 'print "foo\n"') + + def test_enter(self): + self.history.enter("#lastnumber!") + + self.assertEqual(self.history.back(), "#lastnumber!") + self.assertEqual(self.history.forward(), "#lastnumber!") + + def test_enter_2(self): + self.history.enter("#50") + + self.assertEqual(self.history.back(), "#509") + self.assertEqual(self.history.back(), "#508") + self.assertEqual(self.history.forward(), "#509") + + def test_reset(self): + self.history.enter("#lastnumber!") + self.history.reset() + + self.assertEqual(self.history.back(), "#999") + self.assertEqual(self.history.forward(), "") + + +class TestHistoryFileAccess(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.filename = Path(self.tempdir.name) / "history_temp_file" + self.encoding = getpreferredencoding() + + with open( + self.filename, "w", encoding=self.encoding, errors="ignore" + ) as f: + f.write(b"#1\n#2\n".decode()) + + def test_load(self): + history = History() + + history.load(self.filename, self.encoding) + self.assertEqual(history.entries, ["#1", "#2"]) + + def test_append_reload_and_write(self): + history = History() + + history.append_reload_and_write("#3", self.filename, self.encoding) + self.assertEqual(history.entries, ["#1", "#2", "#3"]) + + history.append_reload_and_write("#4", self.filename, self.encoding) + self.assertEqual(history.entries, ["#1", "#2", "#3", "#4"]) + + def test_save(self): + history = History() + for line in ("#1", "#2", "#3", "#4"): + history.append_to(history.entries, line) + + # save only last 2 lines + history.save(self.filename, self.encoding, lines=2) + + # load again from the file + history = History() + history.load(self.filename, self.encoding) + + self.assertEqual(history.entries, ["#3", "#4"]) + + def tearDown(self): + self.tempdir = None diff --git a/bpython/test/test_importcompletion.py b/bpython/test/test_importcompletion.py index 5124455eb..814d3c312 100644 --- a/bpython/test/test_importcompletion.py +++ b/bpython/test/test_importcompletion.py @@ -1,32 +1,223 @@ -from bpython import importcompletion - +import os +import tempfile import unittest +from pathlib import Path +from bpython.importcompletion import ModuleGatherer + + class TestSimpleComplete(unittest.TestCase): def setUp(self): - self.original_modules = importcompletion.modules - importcompletion.modules = ['zzabc', 'zzabd', 'zzefg', 'zzabc.e', 'zzabc.f'] - def tearDown(self): - importcompletion.modules = self.original_modules + self.module_gatherer = ModuleGatherer() + self.module_gatherer.modules = [ + "zzabc", + "zzabd", + "zzefg", + "zzabc.e", + "zzabc.f", + "zzefg.a1", + "zzefg.a2", + ] + def test_simple_completion(self): - self.assertEqual(importcompletion.complete(10, 'import zza'), ['zzabc', 'zzabd']) - def test_package_completion(self): - self.assertEqual(importcompletion.complete(13, 'import zzabc.'), ['zzabc.e', 'zzabc.f', ]) + self.assertSetEqual( + self.module_gatherer.complete(10, "import zza"), {"zzabc", "zzabd"} + ) + self.assertSetEqual( + self.module_gatherer.complete(11, "import zza"), {"zzabc", "zzabd"} + ) + + def test_import_empty(self): + self.assertSetEqual( + self.module_gatherer.complete(13, "import zzabc."), + {"zzabc.e", "zzabc.f"}, + ) + self.assertSetEqual( + self.module_gatherer.complete(14, "import zzabc."), + {"zzabc.e", "zzabc.f"}, + ) + + def test_import(self): + self.assertSetEqual( + self.module_gatherer.complete(14, "import zzefg.a"), + {"zzefg.a1", "zzefg.a2"}, + ) + self.assertSetEqual( + self.module_gatherer.complete(15, "import zzefg.a"), + {"zzefg.a1", "zzefg.a2"}, + ) + + @unittest.expectedFailure + def test_import_blank(self): + self.assertSetEqual( + self.module_gatherer.complete(7, "import "), + {"zzabc", "zzabd", "zzefg"}, + ) + self.assertSetEqual( + self.module_gatherer.complete(8, "import "), + {"zzabc", "zzabd", "zzefg"}, + ) + + @unittest.expectedFailure + def test_from_import_empty(self): + self.assertSetEqual( + self.module_gatherer.complete(5, "from "), + {"zzabc", "zzabd", "zzefg"}, + ) + self.assertSetEqual( + self.module_gatherer.complete(6, "from "), + {"zzabc", "zzabd", "zzefg"}, + ) + + @unittest.expectedFailure + def test_from_module_import_empty(self): + self.assertSetEqual( + self.module_gatherer.complete(18, "from zzabc import "), {"e", "f"} + ) + self.assertSetEqual( + self.module_gatherer.complete(19, "from zzabc import "), {"e", "f"} + ) + self.assertSetEqual( + self.module_gatherer.complete(19, "from zzabc import "), {"e", "f"} + ) + self.assertSetEqual( + self.module_gatherer.complete(19, "from zzabc import "), {"e", "f"} + ) + + def test_from_module_import(self): + self.assertSetEqual( + self.module_gatherer.complete(19, "from zzefg import a"), + {"a1", "a2"}, + ) + self.assertSetEqual( + self.module_gatherer.complete(20, "from zzefg import a"), + {"a1", "a2"}, + ) + self.assertSetEqual( + self.module_gatherer.complete(20, "from zzefg import a"), + {"a1", "a2"}, + ) + self.assertSetEqual( + self.module_gatherer.complete(20, "from zzefg import a"), + {"a1", "a2"}, + ) class TestRealComplete(unittest.TestCase): def setUp(self): - [_ for _ in importcompletion.find_iterator] - __import__('sys') - __import__('os') - def tearDown(self): - importcompletion.find_iterator = importcompletion.find_all_modules() - importcompletion.modules = set() + self.module_gatherer = ModuleGatherer() + while self.module_gatherer.find_coroutine(): + pass + __import__("sys") + __import__("os") + def test_from_attribute(self): - self.assertEqual(importcompletion.complete(19, 'from sys import arg'), ['argv']) + self.assertSetEqual( + self.module_gatherer.complete(19, "from sys import arg"), {"argv"} + ) + def test_from_attr_module(self): - self.assertEqual(importcompletion.complete(9, 'from os.p'), ['os.path']) + self.assertSetEqual( + self.module_gatherer.complete(9, "from os.p"), {"os.path"} + ) + def test_from_package(self): - self.assertEqual(importcompletion.complete(17, 'from xml import d'), ['dom']) + self.assertSetEqual( + self.module_gatherer.complete(17, "from xml import d"), {"dom"} + ) + + +class TestAvoidSymbolicLinks(unittest.TestCase): + def setUp(self): + with tempfile.TemporaryDirectory() as import_test_folder: + base_path = Path(import_test_folder) + (base_path / "Level0" / "Level1" / "Level2").mkdir(parents=True) + (base_path / "Left").mkdir(parents=True) + (base_path / "Right").mkdir(parents=True) + + current_path = base_path / "Level0" + (current_path / "__init__.py").touch() + + current_path = current_path / "Level1" + (current_path / "__init__.py").touch() + + current_path = current_path / "Level2" + (current_path / "__init__.py").touch() + # Level0/Level1/Level2/Level3 -> Level0/Level1 + (current_path / "Level3").symlink_to( + base_path / "Level0" / "Level1", target_is_directory=True + ) + + current_path = base_path / "Right" + (current_path / "__init__.py").touch() + # Right/toLeft -> Left + (current_path / "toLeft").symlink_to( + base_path / "Left", target_is_directory=True + ) + + current_path = base_path / "Left" + (current_path / "__init__.py").touch() + # Left/toRight -> Right + (current_path / "toRight").symlink_to( + base_path / "Right", target_is_directory=True + ) + + self.module_gatherer = ModuleGatherer((base_path.absolute(),)) + while self.module_gatherer.find_coroutine(): + pass + + def test_simple_symbolic_link_loop(self): + filepaths = [ + "Left.toRight.toLeft", + "Left.toRight", + "Left", + "Level0.Level1.Level2.Level3", + "Level0.Level1.Level2", + "Level0.Level1", + "Level0", + "Right", + "Right.toLeft", + "Right.toLeft.toRight", + ] + + for thing in self.module_gatherer.modules: + self.assertIn(thing, filepaths) + if thing == "Left.toRight.toLeft": + filepaths.remove("Right.toLeft") + filepaths.remove("Right.toLeft.toRight") + if thing == "Right.toLeft.toRight": + filepaths.remove("Left.toRight.toLeft") + filepaths.remove("Left.toRight") + filepaths.remove(thing) + self.assertFalse(filepaths) + + +class TestBugReports(unittest.TestCase): + def test_issue_847(self): + with tempfile.TemporaryDirectory() as import_test_folder: + # ./xyzzy + # ./xyzzy/__init__.py + # ./xyzzy/plugh + # ./xyzzy/plugh/__init__.py + # ./xyzzy/plugh/bar.py + # ./xyzzy/plugh/foo.py + + base_path = Path(import_test_folder) + (base_path / "xyzzy" / "plugh").mkdir(parents=True) + (base_path / "xyzzy" / "__init__.py").touch() + (base_path / "xyzzy" / "plugh" / "__init__.py").touch() + (base_path / "xyzzy" / "plugh" / "bar.py").touch() + (base_path / "xyzzy" / "plugh" / "foo.py").touch() + + module_gatherer = ModuleGatherer((base_path.absolute(),)) + while module_gatherer.find_coroutine(): + pass + + self.assertSetEqual( + module_gatherer.complete(17, "from xyzzy.plugh."), + {"xyzzy.plugh.bar", "xyzzy.plugh.foo"}, + ) +if __name__ == "__main__": + unittest.main() diff --git a/bpython/test/test_inspection.py b/bpython/test/test_inspection.py index db4d83620..30e911021 100644 --- a/bpython/test/test_inspection.py +++ b/bpython/test/test_inspection.py @@ -1,62 +1,361 @@ +import inspect +import os +import sys import unittest +from collections.abc import Sequence +from typing import List from bpython import inspection +from bpython.test.fodder import encoding_ascii +from bpython.test.fodder import encoding_latin1 +from bpython.test.fodder import encoding_utf8 -class TestInspection(unittest.TestCase): - def test_is_callable(self): - class OldCallable: - def __call__(self): - pass +pypy = "PyPy" in sys.version - class Callable(object): - def __call__(self): - pass +try: + import numpy +except ImportError: + numpy = None # type: ignore - class OldNoncallable: - pass - class Noncallable(object): - pass +foo_ascii_only = '''def foo(): + """Test""" + pass +''' - def spam(): - pass +foo_non_ascii = '''def foo(): + """Test äöü""" + pass +''' + + +class Callable: + def __call__(self): + pass + + +class Noncallable: + pass + + +def spam(): + pass - self.assertTrue(inspection.is_callable(spam)) - self.assertTrue(inspection.is_callable(Callable)) - self.assertTrue(inspection.is_callable(Callable())) - self.assertTrue(inspection.is_callable(OldCallable)) - self.assertTrue(inspection.is_callable(OldCallable())) - self.assertFalse(inspection.is_callable(Noncallable())) - self.assertFalse(inspection.is_callable(OldNoncallable())) - self.assertFalse(inspection.is_callable(None)) +class CallableMethod: + def method(self): + pass + + +class TestInspection(unittest.TestCase): def test_parsekeywordpairs(self): # See issue #109 - def fails(spam=['-a', '-b']): + def fails(spam=["-a", "-b"]): pass - default_arg_repr = "['-a', '-b']" - self.assertEqual(str(['-a', '-b']), default_arg_repr, - 'This test is broken (repr does not match), fix me.') - - argspec = inspection.getargspec('fails', fails) - defaults = argspec[1][3] - self.assertEqual(str(defaults[0]), default_arg_repr) + argspec = inspection.getfuncprops("fails", fails) + self.assertIsNotNone(argspec) + defaults = argspec.argspec.defaults + self.assertEqual(str(defaults[0]), '["-a", "-b"]') def test_pasekeywordpairs_string(self): def spam(eggs="foo, bar"): pass - defaults = inspection.getargspec("spam", spam)[1][3] - self.assertEqual(repr(defaults[0]), "'foo, bar'") + defaults = inspection.getfuncprops("spam", spam).argspec.defaults + self.assertEqual(repr(defaults[0]), '"foo, bar"') def test_parsekeywordpairs_multiple_keywords(self): def spam(eggs=23, foobar="yay"): pass - defaults = inspection.getargspec("spam", spam)[1][3] + defaults = inspection.getfuncprops("spam", spam).argspec.defaults self.assertEqual(repr(defaults[0]), "23") - self.assertEqual(repr(defaults[1]), "'yay'") + self.assertEqual(repr(defaults[1]), '"yay"') + + def test_pasekeywordpairs_annotation(self): + def spam(eggs: str = "foo, bar"): + pass + + defaults = inspection.getfuncprops("spam", spam).argspec.defaults + self.assertEqual(repr(defaults[0]), '"foo, bar"') + + def test_get_encoding_ascii(self): + self.assertEqual(inspection.get_encoding(encoding_ascii), "ascii") + self.assertEqual(inspection.get_encoding(encoding_ascii.foo), "ascii") + + def test_get_encoding_latin1(self): + self.assertEqual(inspection.get_encoding(encoding_latin1), "latin1") + self.assertEqual(inspection.get_encoding(encoding_latin1.foo), "latin1") + + def test_get_encoding_utf8(self): + self.assertEqual(inspection.get_encoding(encoding_utf8), "utf-8") + self.assertEqual(inspection.get_encoding(encoding_utf8.foo), "utf-8") + + def test_get_source_ascii(self): + self.assertEqual(inspect.getsource(encoding_ascii.foo), foo_ascii_only) + + def test_get_source_utf8(self): + self.assertEqual(inspect.getsource(encoding_utf8.foo), foo_non_ascii) + + def test_get_source_latin1(self): + self.assertEqual(inspect.getsource(encoding_latin1.foo), foo_non_ascii) + + def test_get_source_file(self): + path = os.path.join(os.path.dirname(__file__), "fodder") + + encoding = inspection.get_encoding_file( + os.path.join(path, "encoding_ascii.py") + ) + self.assertEqual(encoding, "ascii") + encoding = inspection.get_encoding_file( + os.path.join(path, "encoding_latin1.py") + ) + self.assertEqual(encoding, "latin1") + encoding = inspection.get_encoding_file( + os.path.join(path, "encoding_utf8.py") + ) + self.assertEqual(encoding, "utf-8") + + @unittest.skipIf(pypy, "pypy builtin signatures aren't complete") + def test_getfuncprops_print(self): + props = inspection.getfuncprops("print", print) + + self.assertEqual(props.func, "print") + self.assertIn("end", props.argspec.kwonly) + self.assertIn("file", props.argspec.kwonly) + self.assertIn("flush", props.argspec.kwonly) + self.assertIn("sep", props.argspec.kwonly) + self.assertEqual(repr(props.argspec.kwonly_defaults["file"]), "None") + self.assertEqual(repr(props.argspec.kwonly_defaults["flush"]), "False") + + @unittest.skipUnless( + numpy is not None and numpy.__version__ >= "1.18", + "requires numpy >= 1.18", + ) + def test_getfuncprops_numpy_array(self): + props = inspection.getfuncprops("array", numpy.array) + + self.assertEqual(props.func, "array") + # This check might need an update in the future, but at least numpy >= 1.18 has + # np.array(object, dtype=None, *, ...). + self.assertEqual(props.argspec.args, ["object", "dtype"]) + + def test_issue_966_freestanding(self): + def fun(number, lst=[]): + """ + Return a list of numbers + + Example: + ======== + C.cmethod(1337, [1, 2]) # => [1, 2, 1337] + """ + return lst + [number] + + def fun_annotations(number: int, lst: list[int] = []) -> list[int]: + """ + Return a list of numbers + + Example: + ======== + C.cmethod(1337, [1, 2]) # => [1, 2, 1337] + """ + return lst + [number] + + props = inspection.getfuncprops("fun", fun) + self.assertEqual(props.func, "fun") + self.assertEqual(props.argspec.args, ["number", "lst"]) + self.assertEqual(repr(props.argspec.defaults[0]), "[]") + + props = inspection.getfuncprops("fun_annotations", fun_annotations) + self.assertEqual(props.func, "fun_annotations") + self.assertEqual(props.argspec.args, ["number", "lst"]) + self.assertEqual(repr(props.argspec.defaults[0]), "[]") + + def test_issue_966_class_method(self): + class Issue966(Sequence): + @classmethod + def cmethod(cls, number: int, lst: list[int] = []): + """ + Return a list of numbers + + Example: + ======== + C.cmethod(1337, [1, 2]) # => [1, 2, 1337] + """ + return lst + [number] + + @classmethod + def bmethod(cls, number, lst): + """ + Return a list of numbers + + Example: + ======== + C.cmethod(1337, [1, 2]) # => [1, 2, 1337] + """ + return lst + [number] + + props = inspection.getfuncprops( + "bmethod", inspection.getattr_safe(Issue966, "bmethod") + ) + self.assertEqual(props.func, "bmethod") + self.assertEqual(props.argspec.args, ["number", "lst"]) + + props = inspection.getfuncprops( + "cmethod", inspection.getattr_safe(Issue966, "cmethod") + ) + self.assertEqual(props.func, "cmethod") + self.assertEqual(props.argspec.args, ["number", "lst"]) + self.assertEqual(repr(props.argspec.defaults[0]), "[]") + + def test_issue_966_static_method(self): + class Issue966(Sequence): + @staticmethod + def cmethod(number: int, lst: list[int] = []): + """ + Return a list of numbers + + Example: + ======== + C.cmethod(1337, [1, 2]) # => [1, 2, 1337] + """ + return lst + [number] + + @staticmethod + def bmethod(number, lst): + """ + Return a list of numbers + + Example: + ======== + C.cmethod(1337, [1, 2]) # => [1, 2, 1337] + """ + return lst + [number] + + props = inspection.getfuncprops( + "bmethod", inspection.getattr_safe(Issue966, "bmethod") + ) + self.assertEqual(props.func, "bmethod") + self.assertEqual(props.argspec.args, ["number", "lst"]) + + props = inspection.getfuncprops( + "cmethod", inspection.getattr_safe(Issue966, "cmethod") + ) + self.assertEqual(props.func, "cmethod") + self.assertEqual(props.argspec.args, ["number", "lst"]) + self.assertEqual(repr(props.argspec.defaults[0]), "[]") + + +class A: + a = "a" + + +class B(A): + b = "b" + + +class Property: + @property + def prop(self): + raise AssertionError("Property __get__ executed") + + +class Slots: + __slots__ = ["s1", "s2", "s3"] + + +class SlotsSubclass(Slots): + @property + def s4(self): + raise AssertionError("Property __get__ executed") + + +class OverriddenGetattr: + def __getattr__(self, attr): + raise AssertionError("custom __getattr__ executed") + + a = 1 + + +class OverriddenGetattribute: + def __getattribute__(self, attr): + raise AssertionError("custom __getattribute__ executed") + + a = 1 + + +class OverriddenMRO: + def __mro__(self): + raise AssertionError("custom mro executed") + + a = 1 + + +member_descriptor = type(Slots.s1) # type: ignore + + +class TestSafeGetAttribute(unittest.TestCase): + def test_lookup_on_object(self): + a = A() + a.x = 1 + self.assertEqual(inspection.getattr_safe(a, "x"), 1) + self.assertEqual(inspection.getattr_safe(a, "a"), "a") + b = B() + b.y = 2 + self.assertEqual(inspection.getattr_safe(b, "y"), 2) + self.assertEqual(inspection.getattr_safe(b, "a"), "a") + self.assertEqual(inspection.getattr_safe(b, "b"), "b") + + self.assertEqual(inspection.hasattr_safe(b, "y"), True) + self.assertEqual(inspection.hasattr_safe(b, "b"), True) + + def test_avoid_running_properties(self): + p = Property() + self.assertEqual(inspection.getattr_safe(p, "prop"), Property.prop) + self.assertEqual(inspection.hasattr_safe(p, "prop"), True) + + def test_lookup_with_slots(self): + s = Slots() + s.s1 = "s1" + self.assertEqual(inspection.getattr_safe(s, "s1"), "s1") + with self.assertRaises(AttributeError): + inspection.getattr_safe(s, "s2") + + self.assertEqual(inspection.hasattr_safe(s, "s1"), True) + self.assertEqual(inspection.hasattr_safe(s, "s2"), False) + + def test_lookup_on_slots_classes(self): + sga = inspection.getattr_safe + s = SlotsSubclass() + self.assertIsInstance(sga(Slots, "s1"), member_descriptor) + self.assertIsInstance(sga(SlotsSubclass, "s1"), member_descriptor) + self.assertIsInstance(sga(SlotsSubclass, "s4"), property) + self.assertIsInstance(sga(s, "s4"), property) + + self.assertEqual(inspection.hasattr_safe(s, "s1"), False) + self.assertEqual(inspection.hasattr_safe(s, "s4"), True) + + def test_lookup_on_overridden_methods(self): + sga = inspection.getattr_safe + self.assertEqual(sga(OverriddenGetattr(), "a"), 1) + self.assertEqual(sga(OverriddenGetattribute(), "a"), 1) + self.assertEqual(sga(OverriddenMRO(), "a"), 1) + with self.assertRaises(AttributeError): + sga(OverriddenGetattr(), "b") + with self.assertRaises(AttributeError): + sga(OverriddenGetattribute(), "b") + with self.assertRaises(AttributeError): + sga(OverriddenMRO(), "b") + + self.assertEqual( + inspection.hasattr_safe(OverriddenGetattr(), "b"), False + ) + self.assertEqual( + inspection.hasattr_safe(OverriddenGetattribute(), "b"), False + ) + self.assertEqual(inspection.hasattr_safe(OverriddenMRO(), "b"), False) + -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/bpython/test/test_interpreter.py b/bpython/test/test_interpreter.py new file mode 100644 index 000000000..e5bc08956 --- /dev/null +++ b/bpython/test/test_interpreter.py @@ -0,0 +1,95 @@ +import sys +import unittest + +from curtsies.fmtfuncs import bold, green, magenta, cyan, red, plain + +from bpython.curtsiesfrontend import interpreter + +pypy = "PyPy" in sys.version + + +class Interpreter(interpreter.Interp): + def __init__(self): + super().__init__() + self.a = [] + self.write = self.a.append + + +class TestInterpreter(unittest.TestCase): + def test_syntaxerror(self): + i = Interpreter() + + i.runsource("1.1.1.1") + + expected = ( + " File " + + green('""') + + ", line " + + bold(magenta("1")) + + "\n 1.1.1.1\n ^^\n" + + bold(red("SyntaxError")) + + ": " + + cyan("invalid syntax") + + "\n" + ) + + a = i.a + self.assertMultiLineEqual(str(plain("").join(a)), str(expected)) + self.assertEqual(plain("").join(a), expected) + + def test_traceback(self): + i = Interpreter() + + def f(): + return 1 / 0 + + def gfunc(): + return f() + + i.runsource("gfunc()") + + global_not_found = "name 'gfunc' is not defined" + + if (3, 13) <= sys.version_info[:2] or pypy: + expected = ( + "Traceback (most recent call last):\n File " + + green('""') + + ", line " + + bold(magenta("1")) + + ", in " + + cyan("") + + "\n gfunc()" + + "\n ^^^^^\n" + + bold(red("NameError")) + + ": " + + cyan(global_not_found) + + "\n" + ) + else: + expected = ( + "Traceback (most recent call last):\n File " + + green('""') + + ", line " + + bold(magenta("1")) + + ", in " + + cyan("") + + "\n gfunc()" + + "\n ^^^^^\n" + + bold(red("NameError")) + + ": " + + cyan(global_not_found) + + "\n" + ) + + a = i.a + self.assertMultiLineEqual(str(expected), str(plain("").join(a))) + self.assertEqual(expected, plain("").join(a)) + + def test_getsource_works_on_interactively_defined_functions(self): + source = "def foo(x):\n return x + 1\n" + i = interpreter.Interp() + i.runsource(source) + import inspect + + inspected_source = inspect.getsource(i.locals["foo"]) + self.assertEqual(inspected_source, source) diff --git a/bpython/test/test_keys.py b/bpython/test/test_keys.py index 003c8642b..23e8798cc 100644 --- a/bpython/test/test_keys.py +++ b/bpython/test/test_keys.py @@ -1,5 +1,7 @@ import unittest -import bpython.keys as keys + +from bpython import keys + class TestCLIKeys(unittest.TestCase): def test_keymap_map(self): @@ -9,28 +11,28 @@ def test_keymap_map(self): def test_keymap_setitem(self): """Verify keys.KeyMap correctly setting items.""" - keys.cli_key_dispatch['simon'] = 'awesome'; - self.assertEqual(keys.cli_key_dispatch['simon'], 'awesome') + keys.cli_key_dispatch["simon"] = "awesome" + self.assertEqual(keys.cli_key_dispatch["simon"], "awesome") def test_keymap_delitem(self): """Verify keys.KeyMap correctly removing items.""" - keys.cli_key_dispatch['simon'] = 'awesome' - del keys.cli_key_dispatch['simon'] - if 'simon' in keys.cli_key_dispatch.map: - raise Exception('Key still exists in dictionary') + keys.cli_key_dispatch["simon"] = "awesome" + del keys.cli_key_dispatch["simon"] + if "simon" in keys.cli_key_dispatch.map: + raise Exception("Key still exists in dictionary") def test_keymap_getitem(self): """Verify keys.KeyMap correctly looking up items.""" - self.assertEqual(keys.cli_key_dispatch['C-['], (chr(27), '^[')) - self.assertEqual(keys.cli_key_dispatch['F11'], ('KEY_F(11)',)) - self.assertEqual(keys.cli_key_dispatch['C-a'], ('\x01', '^A')) + self.assertEqual(keys.cli_key_dispatch["C-["], (chr(27), "^[")) + self.assertEqual(keys.cli_key_dispatch["F11"], ("KEY_F(11)",)) + self.assertEqual(keys.cli_key_dispatch["C-a"], ("\x01", "^A")) def test_keymap_keyerror(self): """Verify keys.KeyMap raising KeyError when getting undefined key""" - def raiser(): - keys.cli_key_dispatch['C-asdf'] - keys.cli_key_dispatch['C-qwerty'] - self.assertRaises(KeyError, raiser); + with self.assertRaises(KeyError): + keys.cli_key_dispatch["C-asdf"] + keys.cli_key_dispatch["C-qwerty"] + class TestUrwidKeys(unittest.TestCase): def test_keymap_map(self): @@ -40,29 +42,28 @@ def test_keymap_map(self): def test_keymap_setitem(self): """Verify keys.KeyMap correctly setting items.""" - keys.urwid_key_dispatch['simon'] = 'awesome'; - self.assertEqual(keys.urwid_key_dispatch['simon'], 'awesome') + keys.urwid_key_dispatch["simon"] = "awesome" + self.assertEqual(keys.urwid_key_dispatch["simon"], "awesome") def test_keymap_delitem(self): """Verify keys.KeyMap correctly removing items.""" - keys.urwid_key_dispatch['simon'] = 'awesome' - del keys.urwid_key_dispatch['simon'] - if 'simon' in keys.urwid_key_dispatch.map: - raise Exception('Key still exists in dictionary') + keys.urwid_key_dispatch["simon"] = "awesome" + del keys.urwid_key_dispatch["simon"] + if "simon" in keys.urwid_key_dispatch.map: + raise Exception("Key still exists in dictionary") def test_keymap_getitem(self): """Verify keys.KeyMap correctly looking up items.""" - self.assertEqual(keys.urwid_key_dispatch['F11'], 'f11') - self.assertEqual(keys.urwid_key_dispatch['C-a'], 'ctrl a') - self.assertEqual(keys.urwid_key_dispatch['M-a'], 'meta a') + self.assertEqual(keys.urwid_key_dispatch["F11"], "f11") + self.assertEqual(keys.urwid_key_dispatch["C-a"], "ctrl a") + self.assertEqual(keys.urwid_key_dispatch["M-a"], "meta a") def test_keymap_keyerror(self): """Verify keys.KeyMap raising KeyError when getting undefined key""" - def raiser(): - keys.urwid_key_dispatch['C-asdf'] - keys.urwid_key_dispatch['C-qwerty'] - self.assertRaises(KeyError, raiser); + with self.assertRaises(KeyError): + keys.urwid_key_dispatch["C-asdf"] + keys.urwid_key_dispatch["C-qwerty"] -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/bpython/test/test_line_properties.py b/bpython/test/test_line_properties.py index 6131e0755..017978277 100644 --- a/bpython/test/test_line_properties.py +++ b/bpython/test/test_line_properties.py @@ -1,39 +1,58 @@ -import unittest import re +from typing import Optional, Tuple +import unittest -from bpython.line import current_word, current_dict_key, current_dict, current_string, current_object, current_object_attribute, current_from_import_from, current_from_import_import, current_import, current_method_definition_name, current_single_word, current_string_literal_attr +from bpython.line import ( + LinePart, + current_word, + current_dict_key, + current_dict, + current_string, + current_object, + current_object_attribute, + current_from_import_from, + current_from_import_import, + current_import, + current_method_definition_name, + current_single_word, + current_expression_attribute, + current_dotted_attribute, +) def cursor(s): """'ab|c' -> (2, 'abc')""" - cursor_offset = s.index('|') - line = s[:cursor_offset] + s[cursor_offset+1:] + cursor_offset = s.index("|") + line = s[:cursor_offset] + s[cursor_offset + 1 :] return cursor_offset, line -def decode(s): + +def decode(s: str) -> tuple[tuple[int, str], LinePart | None]: """'ad' -> ((3, 'abcd'), (1, 3, 'bdc'))""" - if not s.count('|') == 1: - raise ValueError('match helper needs | to occur once') - if not ((s.count('<') == s.count('>') == 1 or s.count('<') == s.count('>') == 0)): - raise ValueError('match helper needs <, and > to occur just once') - matches = list(re.finditer(r'[<>|]', s)) + if not s.count("|") == 1: + raise ValueError("match helper needs | to occur once") + if s.count("<") != s.count(">") or s.count("<") not in (0, 1): + raise ValueError("match helper needs <, and > to occur just once") + matches = list(re.finditer(r"[<>|]", s)) assert len(matches) in [1, 3], [m.group() for m in matches] d = {} for i, m in enumerate(matches): d[m.group(0)] = m.start() - i - s = s[:m.start() - i] + s[m.end() - i:] - assert len(d) in [1,3], 'need all the parts just once! %r' % d + s = s[: m.start() - i] + s[m.end() - i :] + assert len(d) in [1, 3], "need all the parts just once! %r" % d - if '<' in d: - return (d['|'], s), (d['<'], d['>'], s[d['<']:d['>']]) + if "<" in d: + return (d["|"], s), LinePart(d["<"], d[">"], s[d["<"] : d[">"]]) else: - return (d['|'], s), None + return (d["|"], s), None + + +def line_with_cursor(cursor_offset: int, line: str) -> str: + return line[:cursor_offset] + "|" + line[cursor_offset:] -def line_with_cursor(cursor_offset, line): - return line[:cursor_offset] + '|' + line[cursor_offset:] -def encode(cursor_offset, line, result): +def encode(cursor_offset: int, line: str, result: LinePart | None) -> str: """encode(3, 'abdcd', (1, 3, 'bdc')) -> ad' Written for prettier assert error messages @@ -41,16 +60,20 @@ def encode(cursor_offset, line, result): encoded_line = line_with_cursor(cursor_offset, line) if result is None: return encoded_line - start, end, value = result + start = result.start + end = result.stop + value = result.word assert line[start:end] == value if start < cursor_offset: - encoded_line = encoded_line[:start] + '<' + encoded_line[start:] + encoded_line = encoded_line[:start] + "<" + encoded_line[start:] else: - encoded_line = encoded_line[:start+1] + '<' + encoded_line[start+1:] + encoded_line = ( + encoded_line[: start + 1] + "<" + encoded_line[start + 1 :] + ) if end < cursor_offset: - encoded_line = encoded_line[:end+1] + '>' + encoded_line[end+1:] + encoded_line = encoded_line[: end + 1] + ">" + encoded_line[end + 1 :] else: - encoded_line = encoded_line[:end+2] + '>' + encoded_line[end+2:] + encoded_line = encoded_line[: end + 2] + ">" + encoded_line[end + 2 :] return encoded_line @@ -68,86 +91,140 @@ def assertAccess(self, s): (cursor_offset, line), match = decode(s) result = self.func(cursor_offset, line) - self.assertEqual(result, match, "%s(%r) result\n%r (%r) doesn't match expected\n%r (%r)" % (self.func.__name__, line_with_cursor(cursor_offset, line), encode(cursor_offset, line, result), result, s, match)) + self.assertEqual( + result, + match, + "%s(%r) result\n%r (%r) doesn't match expected\n%r (%r)" + % ( + self.func.__name__, + line_with_cursor(cursor_offset, line), + encode(cursor_offset, line, result), + result, + s, + match, + ), + ) + class TestHelpers(LineTestCase): def test_I(self): - self.assertEqual(cursor('asd|fgh'), (3, 'asdfgh')) + self.assertEqual(cursor("asd|fgh"), (3, "asdfgh")) def test_decode(self): - self.assertEqual(decode('ad'), ((3, 'abdcd'), (1, 4, 'bdc'))) - self.assertEqual(decode('a|d'), ((1, 'abdcd'), (1, 4, 'bdc'))) - self.assertEqual(decode('ad|'), ((5, 'abdcd'), (1, 4, 'bdc'))) + self.assertEqual( + decode("ad"), ((3, "abdcd"), LinePart(1, 4, "bdc")) + ) + self.assertEqual( + decode("a|d"), ((1, "abdcd"), LinePart(1, 4, "bdc")) + ) + self.assertEqual( + decode("ad|"), ((5, "abdcd"), LinePart(1, 4, "bdc")) + ) def test_encode(self): - self.assertEqual(encode(3, 'abdcd', (1, 4, 'bdc')), 'ad') - self.assertEqual(encode(1, 'abdcd', (1, 4, 'bdc')), 'a|d') - self.assertEqual(encode(4, 'abdcd', (1, 4, 'bdc')), 'ad') - self.assertEqual(encode(5, 'abdcd', (1, 4, 'bdc')), 'ad|') + self.assertEqual(encode(3, "abdcd", LinePart(1, 4, "bdc")), "ad") + self.assertEqual(encode(1, "abdcd", LinePart(1, 4, "bdc")), "a|d") + self.assertEqual(encode(4, "abdcd", LinePart(1, 4, "bdc")), "ad") + self.assertEqual(encode(5, "abdcd", LinePart(1, 4, "bdc")), "ad|") def test_assert_access(self): def dumb_func(cursor_offset, line): - return (0, 2, 'ab') + return LinePart(0, 2, "ab") + self.func = dumb_func - self.assertAccess('d') + self.assertAccess("d") + class TestCurrentWord(LineTestCase): def setUp(self): self.func = current_word def test_simple(self): - self.assertAccess('|') - self.assertAccess('|asdf') - self.assertAccess('') - self.assertAccess('') - self.assertAccess('') - self.assertAccess('asdf + ') - self.assertAccess(' + asdf') + self.assertAccess("|") + self.assertAccess("|asdf") + self.assertAccess("") + self.assertAccess("") + self.assertAccess("") + self.assertAccess("asdf + ") + self.assertAccess(" + asdf") def test_inside(self): - self.assertAccess('') - self.assertAccess('') + self.assertAccess("") + self.assertAccess("") def test_dots(self): - self.assertAccess('') - self.assertAccess('') - self.assertAccess('') - self.assertAccess('stuff[stuff] + {123: 456} + ') - self.assertAccess('stuff[]') - self.assertAccess('stuff[asdf[]') + self.assertAccess("") + self.assertAccess("") + self.assertAccess("") + self.assertAccess("stuff[stuff] + {123: 456} + ") + self.assertAccess("stuff[]") + self.assertAccess("stuff[asdf[]") + + def test_non_dots(self): + self.assertAccess("].asdf|") + self.assertAccess(").asdf|") + self.assertAccess("foo[0].asdf|") + self.assertAccess("foo().asdf|") + self.assertAccess("foo().|") + self.assertAccess("foo().asdf.|") + self.assertAccess("foo[0].asdf.|") + + def test_open_paren(self): + self.assertAccess("") + # documenting current behavior - TODO is this intended? + class TestCurrentDictKey(LineTestCase): def setUp(self): self.func = current_dict_key + def test_simple(self): - self.assertAccess('asdf|') - self.assertAccess('asdf|') - self.assertAccess('asdf[<>|') - self.assertAccess('asdf[<>|]') - self.assertAccess('object.dict[') - self.assertAccess('asdf|') - self.assertAccess('asdf[<(>|]') - self.assertAccess('asdf[<(1>|]') - self.assertAccess('asdf[<(1,>|]') - self.assertAccess('asdf[<(1, >|]') - self.assertAccess('asdf[<(1, 2)>|]') - #TODO self.assertAccess('d[d[<12|>') + self.assertAccess("asdf|") + self.assertAccess("asdf|") + self.assertAccess("asdf[<>|") + self.assertAccess("asdf[<>|]") + self.assertAccess("object.dict[") + self.assertAccess("asdf|") + self.assertAccess("asdf[<(>|]") + self.assertAccess("asdf[<(1>|]") + self.assertAccess("asdf[<(1,>|]") + self.assertAccess("asdf[<(1,)>|]") + self.assertAccess("asdf[<(1, >|]") + self.assertAccess("asdf[<(1, 2)>|]") + # TODO self.assertAccess('d[d[<12|>') self.assertAccess("d[<'a>|") + self.assertAccess("object.dict['a'bcd'], object.dict[<'abc>|") + self.assertAccess("object.dict[<'a'bcd'>|], object.dict['abc") + self.assertAccess(r"object.dict[<'a\'\\\"\n\\'>|") + self.assertAccess("object.dict[<\"abc'>|") + self.assertAccess("object.dict[<(1, 'apple', 2.134>|]") + self.assertAccess("object.dict[<(1, 'apple', 2.134)>|]") + self.assertAccess("object.dict[<-1000>|") + self.assertAccess("object.dict[<-0.23948>|") + self.assertAccess("object.dict[<'\U0001ffff>|") + self.assertAccess(r"object.dict[<'a\'\\\"\n\\'>|]") + self.assertAccess(r"object.dict[<'a\'\\\"\n\\|[[]'>") + self.assertAccess('object.dict[<"a]bc[|]">]') + self.assertAccess("object.dict[<'abcd[]>|") + class TestCurrentDict(LineTestCase): def setUp(self): self.func = current_dict + def test_simple(self): - self.assertAccess('asdf|') - self.assertAccess('asdf|') - self.assertAccess('[|') - self.assertAccess('[|]') - self.assertAccess('[abc|') - self.assertAccess('asdf|') + self.assertAccess("asdf|") + self.assertAccess("asdf|") + self.assertAccess("[|") + self.assertAccess("[|]") + self.assertAccess("[abc|") + self.assertAccess("asdf|") + class TestCurrentString(LineTestCase): def setUp(self): self.func = current_string + def test_closed(self): self.assertAccess('""') self.assertAccess('""') @@ -157,6 +234,7 @@ def test_closed(self): self.assertAccess("''''''") self.assertAccess('""""""') self.assertAccess('asdf.afd("a") + ""') + def test_open(self): self.assertAccess('"') self.assertAccess('"') @@ -167,96 +245,143 @@ def test_open(self): self.assertAccess('"""') self.assertAccess('asdf.afd("a") + "') + class TestCurrentObject(LineTestCase): def setUp(self): self.func = current_object + def test_simple(self): - self.assertAccess('.attr1|') - self.assertAccess('.|') - self.assertAccess('Object|') - self.assertAccess('Object|.') - self.assertAccess('.|') - self.assertAccess('.attr2|') - self.assertAccess('.att|r1.attr2') - self.assertAccess('stuff[stuff] + {123: 456} + .attr2|') - self.assertAccess('stuff[asd|fg]') - self.assertAccess('stuff[asdf[asd|fg]') + self.assertAccess(".attr1|") + self.assertAccess(".|") + self.assertAccess("Object|") + self.assertAccess("Object|.") + self.assertAccess(".|") + self.assertAccess(".attr2|") + self.assertAccess(".att|r1.attr2") + self.assertAccess("stuff[stuff] + {123: 456} + .attr2|") + self.assertAccess("stuff[asd|fg]") + self.assertAccess("stuff[asdf[asd|fg]") + class TestCurrentAttribute(LineTestCase): def setUp(self): self.func = current_object_attribute + def test_simple(self): - self.assertAccess('Object.') - self.assertAccess('Object.attr1.') - self.assertAccess('Object..attr2') - self.assertAccess('stuff[stuff] + {123: 456} + Object.attr1.') - self.assertAccess('stuff[asd|fg]') - self.assertAccess('stuff[asdf[asd|fg]') - self.assertAccess('Object.attr1.<|attr2>') - self.assertAccess('Object..attr2') + self.assertAccess("Object.") + self.assertAccess("Object.attr1.") + self.assertAccess("Object..attr2") + self.assertAccess("stuff[stuff] + {123: 456} + Object.attr1.") + self.assertAccess("stuff[asd|fg]") + self.assertAccess("stuff[asdf[asd|fg]") + self.assertAccess("Object.attr1.<|attr2>") + self.assertAccess("Object..attr2") + class TestCurrentFromImportFrom(LineTestCase): def setUp(self): self.func = current_from_import_from + def test_simple(self): - self.assertAccess('from import path') - self.assertAccess('from import path|') - self.assertAccess('if True|: from sys import path') - self.assertAccess('if True: |from sys import path') - self.assertAccess('if True: from import p|ath') - self.assertAccess('if True: from sys imp|ort path') - self.assertAccess('if True: from sys import |path') - self.assertAccess('if True: from sys import path.stu|ff') - self.assertAccess('if True: from import sep|') - self.assertAccess('from ') + self.assertAccess("from import path") + self.assertAccess("from import path|") + self.assertAccess("if True|: from sys import path") + self.assertAccess("if True: |from sys import path") + self.assertAccess("if True: from import p|ath") + self.assertAccess("if True: from sys imp|ort path") + self.assertAccess("if True: from sys import |path") + self.assertAccess("if True: from sys import path.stu|ff") + self.assertAccess("if True: from import sep|") + self.assertAccess("from ") + class TestCurrentFromImportImport(LineTestCase): def setUp(self): self.func = current_from_import_import + def test_simple(self): - self.assertAccess('from sys import ') - self.assertAccess('from sys import ') - self.assertAccess('from sys import |path') - self.assertAccess('from sys| import path') - self.assertAccess('from s|ys import path') - self.assertAccess('from |sys import path') - self.assertAccess('from xml.dom import ') - self.assertAccess('from xml.dom import Node.as|d') # because syntax error + self.assertAccess("from sys import ") + self.assertAccess("from sys import ") + self.assertAccess("from sys import |path") + self.assertAccess("from sys| import path") + self.assertAccess("from s|ys import path") + self.assertAccess("from |sys import path") + self.assertAccess("from xml.dom import ") + # because syntax error + self.assertAccess("from xml.dom import Node.as|d") + class TestCurrentImport(LineTestCase): def setUp(self): self.func = current_import + def test_simple(self): - self.assertAccess('import ') - self.assertAccess('import ') - self.assertAccess('import |path') - self.assertAccess('import path, ') - self.assertAccess('import path another|') - self.assertAccess('if True: import ') - self.assertAccess('if True: import ') - self.assertAccess('if True: import ') - self.assertAccess('if True: import as something') + self.assertAccess("import ") + self.assertAccess("import ") + self.assertAccess("import |path") + self.assertAccess("import path, ") + self.assertAccess("import path another|") + self.assertAccess("if True: import ") + self.assertAccess("if True: import ") + self.assertAccess("if True: import ") + self.assertAccess("if True: import as something") + class TestMethodDefinitionName(LineTestCase): def setUp(self): self.func = current_method_definition_name + def test_simple(self): - self.assertAccess('def ') - self.assertAccess(' def bar(x, y)|:') - self.assertAccess(' def (x, y)') + self.assertAccess("def ") + self.assertAccess(" def bar(x, y)|:") + self.assertAccess(" def (x, y)") -class TestMethodDefinitionName(LineTestCase): + +class TestSingleWord(LineTestCase): def setUp(self): self.func = current_single_word + def test_simple(self): - self.assertAccess('foo.bar|') - self.assertAccess('.foo|') - self.assertAccess(' ') + self.assertAccess("foo.bar|") + self.assertAccess(".foo|") + self.assertAccess(" ") + -class TestCurrentStringLiteral(LineTestCase): +class TestCurrentExpressionAttribute(LineTestCase): def setUp(self): - self.func = current_string_literal_attr + self.func = current_expression_attribute + def test_simple(self): + self.assertAccess("Object..") + self.assertAccess("Object.<|attr1>.") + self.assertAccess("Object.(|)") + self.assertAccess("Object.another.(|)") + self.assertAccess("asdf asdf asdf.(abc|)") + + def test_without_dot(self): + self.assertAccess("Object|") + self.assertAccess("Object|.") + self.assertAccess("|Object.") + + def test_with_whitespace(self): + self.assertAccess("Object. ") + self.assertAccess("Object .") + self.assertAccess("Object . ") + self.assertAccess("Object .asdf attr|") + self.assertAccess("Object . attr") + self.assertAccess("Object. asdf attr|") + self.assertAccess("Object. attr") + self.assertAccess("Object . asdf attr|") + self.assertAccess("Object . attr") + + def test_indexing(self): + self.assertAccess("abc[def].") + self.assertAccess("abc[def].<|ghi>") + self.assertAccess("abc[def].") + self.assertAccess("abc[def].gh |i") + self.assertAccess("abc[def]|") + + def test_strings(self): self.assertAccess('"hey".') self.assertAccess('"hey"|') self.assertAccess('"hey"|.a') @@ -264,5 +389,18 @@ def test_simple(self): self.assertAccess('"hey".asdf d|') self.assertAccess('"hey".<|>') -if __name__ == '__main__': + +class TestCurrentDottedAttribute(LineTestCase): + def setUp(self): + self.func = current_dotted_attribute + + def test_simple(self): + self.assertAccess("|") + self.assertAccess("(|") + self.assertAccess("[|") + self.assertAccess("m.body[0].value|") + self.assertAccess("m.body[0].attr.value|") + + +if __name__ == "__main__": unittest.main() diff --git a/bpython/test/test_manual_readline.py b/bpython/test/test_manual_readline.py index 004037c0b..445e78b30 100644 --- a/bpython/test/test_manual_readline.py +++ b/bpython/test/test_manual_readline.py @@ -1,6 +1,25 @@ -from bpython.curtsiesfrontend.manual_readline import * import unittest +from bpython.curtsiesfrontend.manual_readline import ( + left_arrow, + right_arrow, + beginning_of_line, + forward_word, + back_word, + end_of_line, + delete, + last_word_pos, + backspace, + delete_from_cursor_back, + delete_from_cursor_forward, + delete_rest_of_word, + delete_word_to_cursor, + transpose_character_before_cursor, + UnconfiguredEdits, + delete_word_from_cursor_back, +) + + class TestManualReadline(unittest.TestCase): def setUp(self): self._line = "this is my test string" @@ -12,11 +31,11 @@ def test_left_arrow_at_zero(self): pos = 0 expected = (pos, self._line) result = left_arrow(pos, self._line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_left_arrow_at_non_zero(self): - for i in xrange(1, len(self._line)): - expected = (i-1, self._line) + for i in range(1, len(self._line)): + expected = (i - 1, self._line) result = left_arrow(i, self._line) self.assertEqual(expected, result) @@ -24,143 +43,150 @@ def test_right_arrow_at_end(self): pos = len(self._line) expected = (pos, self._line) result = right_arrow(pos, self._line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_right_arrow_at_non_end(self): - for i in xrange(len(self._line) - 1): + for i in range(len(self._line) - 1): expected = (i + 1, self._line) result = right_arrow(i, self._line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_beginning_of_line(self): expected = (0, self._line) - for i in xrange(len(self._line)): + for i in range(len(self._line)): result = beginning_of_line(i, self._line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_end_of_line(self): expected = (len(self._line), self._line) - for i in xrange(len(self._line)): + for i in range(len(self._line)): result = end_of_line(i, self._line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_forward_word(self): line = "going from here to_here" - #012345678901234567890123 start_pos = 11 next_word_pos = 15 expected = (next_word_pos, line) result = forward_word(start_pos, line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) start_pos = 15 next_word_pos = 23 expected = (next_word_pos, line) result = forward_word(start_pos, line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_forward_word_tabs(self): line = "going from here to_here" - #01234567890123456789012345678 start_pos = 11 next_word_pos = 15 expected = (next_word_pos, line) result = forward_word(start_pos, line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) start_pos = 15 next_word_pos = 28 expected = (next_word_pos, line) result = forward_word(start_pos, line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_forward_word_end(self): line = "going from here to_here" - #012345678901234567890123 start_pos = 16 next_word_pos = 23 expected = (next_word_pos, line) result = forward_word(start_pos, line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) start_pos = 22 next_word_pos = 23 expected = (next_word_pos, line) result = forward_word(start_pos, line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) start_pos = 23 next_word_pos = 23 expected = (next_word_pos, line) result = forward_word(start_pos, line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_forward_word_empty(self): line = "" - #0 start_pos = 0 next_word_pos = 0 expected = (next_word_pos, line) result = forward_word(start_pos, line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_back_word(self): line = "going to here from_here" start_pos = 14 prev_word_pos = 9 - self.assertEquals(line[start_pos], 'f') - self.assertEquals(line[prev_word_pos], 'h') + self.assertEqual(line[start_pos], "f") + self.assertEqual(line[prev_word_pos], "h") expected = (prev_word_pos, line) result = back_word(start_pos, line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_last_word_pos(self): line = "a word" expected = 2 result = last_word_pos(line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_last_word_pos_single_word(self): line = "word" expected = 0 result = last_word_pos(line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_delete(self): line = "deletion line" pos = 3 expected = (3, "deltion line") result = delete(pos, line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_delete_from_cursor_back(self): line = "everything before this will be deleted" expected = (0, "this will be deleted") result = delete_from_cursor_back(line.find("this"), line) - self.assertEquals(expected, result) + self.assertEqual(expected, result) def test_delete_from_cursor_forward(self): line = "everything after this will be deleted" pos = line.find("this") expected = (pos, "everything after ") - result = delete_from_cursor_forward(line.find("this"), line) - self.assertEquals(expected, result) + result = delete_from_cursor_forward(line.find("this"), line)[:-1] + self.assertEqual(expected, result) + self.assertEqual(delete_from_cursor_forward(0, ""), (0, "", "")) def test_delete_rest_of_word(self): - self.try_stages(['z|s;df asdf d s;a;a', - 'z|;df asdf d s;a;a', - 'z| asdf d s;a;a', - 'z| d s;a;a', - 'z| s;a;a', - 'z|;a;a', - 'z|;a', - 'z|'], delete_rest_of_word) + self.try_stages_kill( + [ + "z|s;df asdf d s;a;a", + "z|;df asdf d s;a;a", + "z| asdf d s;a;a", + "z| d s;a;a", + "z| s;a;a", + "z|;a;a", + "z|;a", + "z|", + "z|", + ], + delete_rest_of_word, + ) def test_delete_word_to_cursor(self): - self.try_stages([ - ' a;d sdf ;a;s;d; fjksald|a', - ' a;d sdf ;a;s;d; |a', - ' a;d sdf |a', - ' a;d |a', - ' |a', - '|a', - ], delete_word_to_cursor) + self.try_stages_kill( + [ + " a;d sdf ;a;s;d; fjksald|a", + " a;d sdf ;a;s;d; |a", + " a;d sdf |a", + " a;d |a", + " |a", + "|a", + "|a", + ], + delete_word_to_cursor, + ) def test_yank_prev_killed_text(self): pass @@ -169,38 +195,149 @@ def test_yank_prev_prev_killed_text(self): pass def try_stages(self, strings, func): - if not all('|' in s for s in strings): + if not all("|" in s for s in strings): + raise ValueError("Need to use '|' to specify cursor") + + stages = [(s.index("|"), s.replace("|", "")) for s in strings] + for (initial_pos, initial), (final_pos, final) in zip( + stages[:-1], stages[1:] + ): + self.assertEqual(func(initial_pos, initial), (final_pos, final)) + + def try_stages_kill(self, strings, func): + if not all("|" in s for s in strings): raise ValueError("Need to use '|' to specify cursor") - stages = [(s.index('|'), s.replace('|', '')) for s in strings] - for (initial_pos, initial), (final_pos, final) in zip(stages[:-1], stages[1:]): - self.assertEquals(func(initial_pos, initial), (final_pos, final)) + stages = [(s.index("|"), s.replace("|", "")) for s in strings] + for (initial_pos, initial), (final_pos, final) in zip( + stages[:-1], stages[1:] + ): + self.assertEqual( + func(initial_pos, initial)[:-1], (final_pos, final) + ) def test_transpose_character_before_cursor(self): - self.try_stages(["as|df asdf", - "ads|f asdf", - "adfs| asdf", - "adf s|asdf", - "adf as|sdf"], transpose_character_before_cursor) + self.try_stages( + [ + "as|df asdf", + "ads|f asdf", + "adfs| asdf", + "adf s|asdf", + "adf as|sdf", + ], + transpose_character_before_cursor, + ) + + def test_transpose_empty_line(self): + self.assertEqual(transpose_character_before_cursor(0, ""), (0, "")) + + def test_transpose_first_character(self): + self.assertEqual(transpose_character_before_cursor(0, "a"), (0, "a")) + self.assertEqual(transpose_character_before_cursor(0, "as"), (0, "as")) + + def test_transpose_end_of_line(self): + self.assertEqual(transpose_character_before_cursor(1, "a"), (1, "a")) + self.assertEqual(transpose_character_before_cursor(2, "as"), (2, "sa")) def test_transpose_word_before_cursor(self): pass def test_backspace(self): - self.assertEquals(backspace(2, 'as'), (1, 'a')) - self.assertEquals(backspace(3, 'as '), (2, 'as')) + self.assertEqual(backspace(2, "as"), (1, "a")) + self.assertEqual(backspace(3, "as "), (2, "as")) def test_delete_word_from_cursor_back(self): - self.try_stages([ - "asd;fljk asd;lfjas;dlkfj asdlk jasdf;ljk|", - "asd;fljk asd;lfjas;dlkfj asdlk jasdf;|", - "asd;fljk asd;lfjas;dlkfj asdlk |", - "asd;fljk asd;lfjas;dlkfj |", - "asd;fljk asd;lfjas;|", - "asd;fljk asd;|", - "asd;fljk |", - "asd;|", - "|"], delete_word_from_cursor_back) - -if __name__ == '__main__': + self.try_stages_kill( + [ + "asd;fljk asd;lfjas;dlkfj asdlk jasdf;ljk|", + "asd;fljk asd;lfjas;dlkfj asdlk jasdf;|", + "asd;fljk asd;lfjas;dlkfj asdlk |", + "asd;fljk asd;lfjas;dlkfj |", + "asd;fljk asd;lfjas;|", + "asd;fljk asd;|", + "asd;fljk |", + "asd;|", + "|", + "|", + ], + delete_word_from_cursor_back, + ) + + self.try_stages_kill( + [" (( asdf |", " (( |", "|"], delete_word_from_cursor_back + ) + + +class TestEdits(unittest.TestCase): + def setUp(self): + self.edits = UnconfiguredEdits() + + def test_seq(self): + def f(cursor_offset, line): + return ("hi", 2) + + self.edits.add("a", f) + self.assertIn("a", self.edits) + self.assertEqual(self.edits["a"], f) + self.assertEqual( + self.edits.call("a", cursor_offset=3, line="hello"), ("hi", 2) + ) + with self.assertRaises(KeyError): + self.edits["b"] + with self.assertRaises(KeyError): + self.edits.call("b") + + def test_functions_with_bad_signatures(self): + def f(something): + return (1, 2) + + with self.assertRaises(TypeError): + self.edits.add("a", f) + + def g(cursor_offset, line, something, something_else): + return (1, 2) + + with self.assertRaises(TypeError): + self.edits.add("a", g) + + def test_functions_with_bad_return_values(self): + def f(cursor_offset, line): + return ("hi",) + + with self.assertRaises(ValueError): + self.edits.add("a", f) + + def g(cursor_offset, line): + return ("hi", 1, 2, 3) + + with self.assertRaises(ValueError): + self.edits.add("b", g) + + def test_config(self): + def f(cursor_offset, line): + return ("hi", 2) + + def g(cursor_offset, line): + return ("hey", 3) + + self.edits.add_config_attr("att", f) + self.assertNotIn("att", self.edits) + + class config: + att = "c" + + key_dispatch = {"c": "c"} + configured_edits = self.edits.mapping_with_config(config, key_dispatch) + self.assertTrue(configured_edits.__contains__, "c") + self.assertNotIn("c", self.edits) + with self.assertRaises(NotImplementedError): + configured_edits.add_config_attr("att2", g) + with self.assertRaises(NotImplementedError): + configured_edits.add("d", g) + self.assertEqual( + configured_edits.call("c", cursor_offset=5, line="asfd"), ("hi", 2) + ) + + +if __name__ == "__main__": unittest.main() diff --git a/bpython/test/test_pager.py b/bpython/test/test_pager.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bpython/test/test_preprocess.py b/bpython/test/test_preprocess.py new file mode 100644 index 000000000..8e8a36304 --- /dev/null +++ b/bpython/test/test_preprocess.py @@ -0,0 +1,107 @@ +import difflib +import inspect +import re +import unittest + +from code import compile_command as compiler +from codeop import CommandCompiler +from functools import partial + +from bpython.curtsiesfrontend.interpreter import code_finished_will_parse +from bpython.curtsiesfrontend.preprocess import preprocess +from bpython.test.fodder import original, processed + + +preproc = partial(preprocess, compiler=CommandCompiler()) + + +def get_fodder_source(test_name): + pattern = rf"#StartTest-{test_name}\n(.*?)#EndTest" + orig, xformed = ( + re.search(pattern, inspect.getsource(module), re.DOTALL) + for module in [original, processed] + ) + + if not orig: + raise ValueError( + f"Can't locate test {test_name} in original fodder file" + ) + if not xformed: + raise ValueError( + f"Can't locate test {test_name} in processed fodder file" + ) + return orig.group(1), xformed.group(1) + + +class TestPreprocessing(unittest.TestCase): + def assertCompiles(self, source): + finished, parsable = code_finished_will_parse(source, compiler) + return finished and parsable + + def test_indent_empty_lines_nops(self): + self.assertEqual(preproc("hello"), "hello") + self.assertEqual(preproc("hello\ngoodbye"), "hello\ngoodbye") + self.assertEqual(preproc("a\n b\nc\n"), "a\n b\nc\n") + + def assertShowWhitespaceEqual(self, a, b): + self.assertEqual( + a, + b, + "".join( + difflib.context_diff( + a.replace(" ", "~").splitlines(True), + b.replace(" ", "~").splitlines(True), + fromfile="actual", + tofile="expected", + n=5, + ) + ), + ) + + def assertDefinitionIndented(self, obj): + name = obj.__name__ + obj2 = getattr(processed, name) + orig = inspect.getsource(obj) + xformed = inspect.getsource(obj2) + self.assertShowWhitespaceEqual(preproc(orig), xformed) + self.assertCompiles(xformed) + + def assertLinesIndented(self, test_name): + orig, xformed = get_fodder_source(test_name) + self.assertShowWhitespaceEqual(preproc(orig), xformed) + self.assertCompiles(xformed) + + def assertIndented(self, obj_or_name): + if isinstance(obj_or_name, str): + self.assertLinesIndented(obj_or_name) + else: + self.assertDefinitionIndented(obj_or_name) + + def test_empty_line_between_methods(self): + self.assertIndented(original.BlankLineBetweenMethods) + + def test_empty_line_within_class(self): + self.assertIndented(original.BlankLineInFunction) + + def test_blank_lines_in_for_loop(self): + self.assertIndented("blank_lines_in_for_loop") + + @unittest.skip( + "More advanced technique required: need to try compiling and " + "backtracking" + ) + def test_blank_line_in_try_catch(self): + self.assertIndented("blank_line_in_try_catch") + + @unittest.skip( + "More advanced technique required: need to try compiling and " + "backtracking" + ) + def test_blank_line_in_try_catch_else(self): + self.assertIndented("blank_line_in_try_catch_else") + + def test_blank_trailing_line(self): + self.assertIndented("blank_trailing_line") + + def test_tabs(self): + self.assertIndented(original.tabs) diff --git a/bpython/test/test_repl.py b/bpython/test/test_repl.py index 86692dc91..a32ef90e8 100644 --- a/bpython/test/test_repl.py +++ b/bpython/test/test_repl.py @@ -1,134 +1,91 @@ +import collections +import inspect import os +import socket import sys +import tempfile import unittest + +from typing import List, Tuple from itertools import islice -from mock import Mock, MagicMock -try: - from unittest import skip -except ImportError: - def skip(f): - return lambda self: None +from pathlib import Path +from unittest import mock + +from bpython import config, repl, autocomplete +from bpython.line import LinePart +from bpython.test import ( + MagicIterMock, + FixLanguageTestCase as TestCase, + TEST_CONFIG, +) + -py3 = (sys.version_info[0] == 3) +pypy = "PyPy" in sys.version -from bpython import config, repl, cli, autocomplete def setup_config(conf): - config_struct = config.Struct() - config.loadini(config_struct, os.devnull) - if 'autocomplete_mode' in conf: - config_struct.autocomplete_mode = conf['autocomplete_mode'] + config_struct = config.Config(TEST_CONFIG) + if conf is not None and "autocomplete_mode" in conf: + config_struct.autocomplete_mode = conf["autocomplete_mode"] return config_struct -class FakeHistory(repl.History): +class FakeHistory(repl.History): def __init__(self): pass def reset(self): pass -class FakeRepl(repl.Repl): - def __init__(self, conf={}): - repl.Repl.__init__(self, repl.Interpreter(), setup_config(conf)) - self.current_line = "" - self.cursor_offset = 0 - -class FakeCliRepl(cli.CLIRepl, FakeRepl): - def __init__(self): - self.s = '' - self.cpos = 0 - self.rl_history = FakeHistory() - -class TestHistory(unittest.TestCase): - def setUp(self): - self.history = repl.History('#%d' % x for x in range(1000)) - def test_is_at_start(self): - self.history.first() - - self.assertNotEqual(self.history.index, 0) - self.assertTrue(self.history.is_at_end) - self.history.forward() - self.assertFalse(self.history.is_at_end) - - def test_is_at_end(self): - self.history.last() - - self.assertEqual(self.history.index, 0) - self.assertTrue(self.history.is_at_start) - self.assertFalse(self.history.is_at_end) - - def test_first(self): - self.history.first() - - self.assertFalse(self.history.is_at_start) - self.assertTrue(self.history.is_at_end) - - def test_last(self): - self.history.last() - - self.assertTrue(self.history.is_at_start) - self.assertFalse(self.history.is_at_end) - - def test_back(self): - self.assertEqual(self.history.back(), '#999') - self.assertNotEqual(self.history.back(), '#999') - self.assertEqual(self.history.back(), '#997') - for x in range(997): - self.history.back() - self.assertEqual(self.history.back(), '#0') +class FakeRepl(repl.Repl): + def __init__(self, conf=None): + super().__init__(repl.Interpreter(), setup_config(conf)) + self._current_line = "" + self._cursor_offset = 0 - def test_forward(self): - self.history.first() + def _get_current_line(self) -> str: + return self._current_line - self.assertEqual(self.history.forward(), '#1') - self.assertNotEqual(self.history.forward(), '#1') - self.assertEqual(self.history.forward(), '#3') - # 1000 == entries 4 == len(range(1, 3) ===> '#1000' (so +1) - for x in range(1000 - 4 - 1): - self.history.forward() - self.assertEqual(self.history.forward(), '#999') + def _set_current_line(self, val: str) -> None: + self._current_line = val - def test_append(self): - self.history.append('print "foo\n"\n') - self.history.append('\n') + def _get_cursor_offset(self) -> int: + return self._cursor_offset - self.assertEqual(self.history.back(), 'print "foo\n"') + def _set_cursor_offset(self, val: int) -> None: + self._cursor_offset = val - @skip("I don't understand this test") - def test_enter(self): - self.history.enter('#lastnumber!') + def getstdout(self) -> str: + raise NotImplementedError - self.assertEqual(self.history.back(), '#999') - self.assertEqual(self.history.forward(), '#lastnumber!') + def reprint_line( + self, lineno: int, tokens: list[tuple[repl._TokenType, str]] + ) -> None: + raise NotImplementedError - def test_reset(self): - self.history.enter('#lastnumber!') - self.history.reset() + def reevaluate(self): + raise NotImplementedError - self.assertEqual(self.history.back(), '#999') - self.assertEqual(self.history.forward(), '') class TestMatchesIterator(unittest.TestCase): - def setUp(self): - self.matches = ['bobby', 'bobbies', 'bobberina'] + self.matches = ["bobby", "bobbies", "bobberina"] self.matches_iterator = repl.MatchesIterator() - self.matches_iterator.current_word = 'bob' - self.matches_iterator.orig_line = 'bob' - self.matches_iterator.orig_cursor_offset = len('bob') + self.matches_iterator.current_word = "bob" + self.matches_iterator.orig_line = "bob" + self.matches_iterator.orig_cursor_offset = len("bob") self.matches_iterator.matches = self.matches def test_next(self): - self.assertEqual(self.matches_iterator.next(), self.matches[0]) + self.assertEqual(next(self.matches_iterator), self.matches[0]) for x in range(len(self.matches) - 1): - self.matches_iterator.next() + next(self.matches_iterator) - self.assertEqual(self.matches_iterator.next(), self.matches[0]) - self.assertEqual(self.matches_iterator.next(), self. matches[1]) - self.assertNotEqual(self.matches_iterator.next(), self.matches[1]) + self.assertEqual(next(self.matches_iterator), self.matches[0]) + self.assertEqual(next(self.matches_iterator), self.matches[1]) + self.assertNotEqual(next(self.matches_iterator), self.matches[1]) def test_previous(self): self.assertEqual(self.matches_iterator.previous(), self.matches[2]) @@ -145,7 +102,7 @@ def test_nonzero(self): then True once we active a match. """ self.assertFalse(self.matches_iterator) - self.matches_iterator.next() + next(self.matches_iterator) self.assertTrue(self.matches_iterator) def test_iter(self): @@ -153,35 +110,41 @@ def test_iter(self): self.assertEqual(list(slice), self.matches * 3) def test_current(self): - self.assertRaises(ValueError, self.matches_iterator.current) - self.matches_iterator.next() + with self.assertRaises(ValueError): + self.matches_iterator.current() + next(self.matches_iterator) self.assertEqual(self.matches_iterator.current(), self.matches[0]) def test_update(self): slice = islice(self.matches_iterator, 0, 3) self.assertEqual(list(slice), self.matches) - newmatches = ['string', 'str', 'set'] - completer = Mock() - completer.locate.return_value = (0, 1, 's') - self.matches_iterator.update(1, 's', newmatches, completer) + newmatches = ["string", "str", "set"] + completer = mock.Mock() + completer.locate.return_value = LinePart(0, 1, "s") + self.matches_iterator.update(1, "s", newmatches, completer) newslice = islice(newmatches, 0, 3) self.assertNotEqual(list(slice), self.matches) self.assertEqual(list(newslice), newmatches) def test_cur_line(self): - completer = Mock() - completer.locate.return_value = (0, - self.matches_iterator.orig_cursor_offset, - self.matches_iterator.orig_line) + completer = mock.Mock() + completer.locate.return_value = LinePart( + 0, + self.matches_iterator.orig_cursor_offset, + self.matches_iterator.orig_line, + ) self.matches_iterator.completer = completer - self.assertRaises(ValueError, self.matches_iterator.cur_line) + with self.assertRaises(ValueError): + self.matches_iterator.cur_line() - self.assertEqual(self.matches_iterator.next(), self.matches[0]) - self.assertEqual(self.matches_iterator.cur_line(), - (len(self.matches[0]), self.matches[0])) + self.assertEqual(next(self.matches_iterator), self.matches[0]) + self.assertEqual( + self.matches_iterator.cur_line(), + (len(self.matches[0]), self.matches[0]), + ) def test_is_cseq(self): self.assertTrue(self.matches_iterator.is_cseq()) @@ -193,69 +156,217 @@ def setUp(self): self.repl.push("def spam(a, b, c):\n", False) self.repl.push(" pass\n", False) self.repl.push("\n", False) + self.repl.push("class Spam(object):\n", False) + self.repl.push(" def spam(self, a, b, c):\n", False) + self.repl.push(" pass\n", False) + self.repl.push("\n", False) + self.repl.push("class SpammitySpam(object):\n", False) + self.repl.push(" def __init__(self, a, b, c):\n", False) + self.repl.push(" pass\n", False) + self.repl.push("\n", False) + self.repl.push("class WonderfulSpam(object):\n", False) + self.repl.push(" def __new__(self, a, b, c):\n", False) + self.repl.push(" pass\n", False) + self.repl.push("\n", False) + self.repl.push("o = Spam()\n", False) + self.repl.push("\n", False) - def setInputLine(self, line): + def set_input_line(self, line): """Set current input line of the test REPL.""" self.repl.current_line = line self.repl.cursor_offset = len(line) def test_func_name(self): - for (line, expected_name) in [("spam(", "spam"), - ("spam(map([]", "map"), - ("spam((), ", "spam")]: - self.setInputLine(line) + for line, expected_name in [ + ("spam(", "spam"), + # map pydoc has no signature in pypy + ("spam(any([]", "any") if pypy else ("spam(map([]", "map"), + ("spam((), ", "spam"), + ]: + self.set_input_line(line) + self.assertTrue(self.repl.get_args()) + self.assertEqual(self.repl.current_func.__name__, expected_name) + + def test_func_name_method_issue_479(self): + for line, expected_name in [ + ("o.spam(", "spam"), + # map pydoc has no signature in pypy + ("o.spam(any([]", "any") if pypy else ("o.spam(map([]", "map"), + ("o.spam((), ", "spam"), + ]: + self.set_input_line(line) self.assertTrue(self.repl.get_args()) self.assertEqual(self.repl.current_func.__name__, expected_name) def test_syntax_error_parens(self): for line in ["spam(]", "spam([)", "spam())"]: - self.setInputLine(line) + self.set_input_line(line) # Should not explode self.repl.get_args() def test_kw_arg_position(self): - self.setInputLine("spam(a=0") + self.set_input_line("spam(a=0") self.assertTrue(self.repl.get_args()) - self.assertEqual(self.repl.argspec[3], "a") + self.assertEqual(self.repl.arg_pos, "a") - self.setInputLine("spam(1, b=1") + self.set_input_line("spam(1, b=1") self.assertTrue(self.repl.get_args()) - self.assertEqual(self.repl.argspec[3], "b") + self.assertEqual(self.repl.arg_pos, "b") - self.setInputLine("spam(1, c=2") + self.set_input_line("spam(1, c=2") self.assertTrue(self.repl.get_args()) - self.assertEqual(self.repl.argspec[3], "c") + self.assertEqual(self.repl.arg_pos, "c") def test_lambda_position(self): - self.setInputLine("spam(lambda a, b: 1, ") + self.set_input_line("spam(lambda a, b: 1, ") self.assertTrue(self.repl.get_args()) - self.assertTrue(self.repl.argspec) + self.assertTrue(self.repl.funcprops) # Argument position - self.assertEqual(self.repl.argspec[3], 1) + self.assertEqual(self.repl.arg_pos, 1) + @unittest.skipIf(pypy, "range pydoc has no signature in pypy") def test_issue127(self): - self.setInputLine("x=range(") + self.set_input_line("x=range(") self.assertTrue(self.repl.get_args()) self.assertEqual(self.repl.current_func.__name__, "range") - self.setInputLine("{x:range(") + self.set_input_line("{x:range(") self.assertTrue(self.repl.get_args()) self.assertEqual(self.repl.current_func.__name__, "range") - self.setInputLine("foo(1, 2, x,range(") + self.set_input_line("foo(1, 2, x,range(") self.assertEqual(self.repl.current_func.__name__, "range") - self.setInputLine("(x,range(") + self.set_input_line("(x,range(") self.assertEqual(self.repl.current_func.__name__, "range") def test_nonexistent_name(self): - self.setInputLine("spamspamspam(") + self.set_input_line("spamspamspam(") self.assertFalse(self.repl.get_args()) + def test_issue572(self): + self.set_input_line("SpammitySpam(") + self.assertTrue(self.repl.get_args()) -class TestRepl(unittest.TestCase): + self.set_input_line("WonderfulSpam(") + self.assertTrue(self.repl.get_args()) + + @unittest.skipIf(pypy, "pypy pydoc doesn't have this") + def test_issue583(self): + self.repl = FakeRepl() + self.repl.push("a = 1.2\n", False) + self.set_input_line("a.is_integer(") + self.repl.set_docstring() + self.assertIsNot(self.repl.docstring, None) + + def test_methods_of_expressions(self): + self.set_input_line("'a'.capitalize(") + self.assertTrue(self.repl.get_args()) + + self.set_input_line("(1 + 1.1).as_integer_ratio(") + self.assertTrue(self.repl.get_args()) + + +class TestArgspecInternal(unittest.TestCase): + def test_function_expressions(self): + te = self.assertTupleEqual + fa = lambda line: repl.Repl._funcname_and_argnum(line) + for line, (func, argnum) in [ + ("spam(", ("spam", 0)), + ("spam((), ", ("spam", 1)), + ("spam.eggs((), ", ("spam.eggs", 1)), + ("spam[abc].eggs((), ", ("spam[abc].eggs", 1)), + ("spam[0].eggs((), ", ("spam[0].eggs", 1)), + ("spam[a + b]eggs((), ", ("spam[a + b]eggs", 1)), + ("spam().eggs((), ", ("spam().eggs", 1)), + ("spam(1, 2).eggs((), ", ("spam(1, 2).eggs", 1)), + ("spam(1, f(1)).eggs((), ", ("spam(1, f(1)).eggs", 1)), + ("[0].eggs((), ", ("[0].eggs", 1)), + ("[0][0]((), {}).eggs((), ", ("[0][0]((), {}).eggs", 1)), + ("a + spam[0].eggs((), ", ("spam[0].eggs", 1)), + ("spam(", ("spam", 0)), + ("spam(map([]", ("map", 0)), + ("spam((), ", ("spam", 1)), + ]: + te(fa(line), (func, argnum)) + + +class TestGetSource(unittest.TestCase): + def setUp(self): + self.repl = FakeRepl() + + def set_input_line(self, line): + """Set current input line of the test REPL.""" + self.repl.current_line = line + self.repl.cursor_offset = len(line) + + def assert_get_source_error_for_current_function(self, func, msg): + self.repl.current_func = func + with self.assertRaises(repl.SourceNotFound): + self.repl.get_source_of_current_name() + try: + self.repl.get_source_of_current_name() + except repl.SourceNotFound as e: + self.assertEqual(msg, e.args[0]) + else: + self.fail("Should have raised SourceNotFound") + + def test_current_function(self): + self.set_input_line("INPUTLINE") + self.repl.current_func = inspect.getsource + self.assertIn( + "text of the source code", self.repl.get_source_of_current_name() + ) + + self.assert_get_source_error_for_current_function( + [], "No source code found for INPUTLINE" + ) + + self.assert_get_source_error_for_current_function( + list.pop, "No source code found for INPUTLINE" + ) + + @unittest.skipIf(pypy, "different errors for PyPy") + def test_current_function_cpython(self): + self.set_input_line("INPUTLINE") + self.assert_get_source_error_for_current_function( + collections.defaultdict.copy, "No source code found for INPUTLINE" + ) + if sys.version_info[:2] >= (3, 13): + self.assert_get_source_error_for_current_function( + collections.defaultdict, "source code not available" + ) + else: + self.assert_get_source_error_for_current_function( + collections.defaultdict, "could not find class definition" + ) + + def test_current_line(self): + self.repl.interp.locals["a"] = socket.socket + self.set_input_line("a") + self.assertIn("dup(self)", self.repl.get_source_of_current_name()) - def setInputLine(self, line): + +# TODO add tests for various failures without using current function + + +class TestEditConfig(TestCase): + def setUp(self): + self.repl = FakeRepl() + self.repl.interact.confirm = lambda msg: True + self.repl.interact.notify = lambda msg: None + self.repl.config.editor = "true" + + def test_create_config(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = Path(tmp_dir) / "newdir" / "config" + self.repl.config.config_path = config_path + self.repl.edit_config() + self.assertTrue(config_path.exists()) + + +class TestRepl(unittest.TestCase): + def set_input_line(self, line): """Set current input line of the test REPL.""" self.repl.current_line = line self.repl.cursor_offset = len(line) @@ -264,244 +375,174 @@ def setUp(self): self.repl = FakeRepl() def test_current_string(self): - self.setInputLine('a = "2"') - self.repl.cpos = 0 #TODO factor cpos out of repl.Repl + self.set_input_line('a = "2"') + # TODO factor cpos out of repl.Repl + self.repl.cpos = 0 self.assertEqual(self.repl.current_string(), '"2"') - self.setInputLine('a = "2" + 2') - self.assertEqual(self.repl.current_string(), '') + self.set_input_line('a = "2" + 2') + self.assertEqual(self.repl.current_string(), "") def test_push(self): self.repl = FakeRepl() self.repl.push("foobar = 2") - self.assertEqual(self.repl.interp.locals['foobar'], 2) + self.assertEqual(self.repl.interp.locals["foobar"], 2) # COMPLETE TESTS # 1. Global tests def test_simple_global_complete(self): - self.repl = FakeRepl({'autocomplete_mode': autocomplete.SIMPLE}) - self.setInputLine("d") + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.SIMPLE} + ) + self.set_input_line("d") self.assertTrue(self.repl.complete()) - self.assertTrue(hasattr(self.repl.matches_iter, 'matches')) - self.assertEqual(self.repl.matches_iter.matches, - ['def', 'del', 'delattr(', 'dict(', 'dir(', 'divmod(']) + self.assertTrue(hasattr(self.repl.matches_iter, "matches")) + self.assertEqual( + self.repl.matches_iter.matches, + ["def", "del", "delattr(", "dict(", "dir(", "divmod("], + ) - @skip("disabled while non-simple completion is disabled") def test_substring_global_complete(self): - self.repl = FakeRepl({'autocomplete_mode': autocomplete.SUBSTRING}) - self.setInputLine("time") + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.SUBSTRING} + ) + self.set_input_line("time") self.assertTrue(self.repl.complete()) - self.assertTrue(hasattr(self.repl.completer,'matches')) - self.assertEqual(self.repl.completer.matches, - ['RuntimeError(', 'RuntimeWarning(']) + self.assertTrue(hasattr(self.repl.matches_iter, "matches")) + self.assertEqual( + self.repl.matches_iter.matches, ["RuntimeError(", "RuntimeWarning("] + ) - @skip("disabled while non-simple completion is disabled") def test_fuzzy_global_complete(self): - self.repl = FakeRepl({'autocomplete_mode': autocomplete.FUZZY}) - self.setInputLine("doc") + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.FUZZY} + ) + self.set_input_line("doc") self.assertTrue(self.repl.complete()) - self.assertTrue(hasattr(self.repl.completer,'matches')) - self.assertEqual(self.repl.completer.matches, - ['UnboundLocalError(', '__doc__']) + self.assertTrue(hasattr(self.repl.matches_iter, "matches")) + self.assertEqual( + self.repl.matches_iter.matches, + ["ChildProcessError(", "UnboundLocalError(", "__doc__"], + ) # 2. Attribute tests def test_simple_attribute_complete(self): - self.repl = FakeRepl({'autocomplete_mode': autocomplete.SIMPLE}) - self.setInputLine("Foo.b") + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.SIMPLE} + ) + self.set_input_line("Foo.b") code = "class Foo():\n\tdef bar(self):\n\t\tpass\n" for line in code.split("\n"): self.repl.push(line) self.assertTrue(self.repl.complete()) - self.assertTrue(hasattr(self.repl.matches_iter,'matches')) - self.assertEqual(self.repl.matches_iter.matches, - ['Foo.bar']) + self.assertTrue(hasattr(self.repl.matches_iter, "matches")) + self.assertEqual(self.repl.matches_iter.matches, ["Foo.bar"]) - @skip("disabled while non-simple completion is disabled") def test_substring_attribute_complete(self): - self.repl = FakeRepl({'autocomplete_mode': autocomplete.SUBSTRING}) - self.setInputLine("Foo.az") + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.SUBSTRING} + ) + self.set_input_line("Foo.az") code = "class Foo():\n\tdef baz(self):\n\t\tpass\n" for line in code.split("\n"): self.repl.push(line) self.assertTrue(self.repl.complete()) - self.assertTrue(hasattr(self.repl.completer,'matches')) - self.assertEqual(self.repl.completer.matches, - ['Foo.baz']) + self.assertTrue(hasattr(self.repl.matches_iter, "matches")) + self.assertEqual(self.repl.matches_iter.matches, ["Foo.baz"]) - @skip("disabled while non-simple completion is disabled") def test_fuzzy_attribute_complete(self): - self.repl = FakeRepl({'autocomplete_mode': autocomplete.FUZZY}) - self.setInputLine("Foo.br") + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.FUZZY} + ) + self.set_input_line("Foo.br") code = "class Foo():\n\tdef bar(self):\n\t\tpass\n" for line in code.split("\n"): self.repl.push(line) self.assertTrue(self.repl.complete()) - self.assertTrue(hasattr(self.repl.completer,'matches')) - self.assertEqual(self.repl.completer.matches, - ['Foo.bar']) + self.assertTrue(hasattr(self.repl.matches_iter, "matches")) + self.assertEqual(self.repl.matches_iter.matches, ["Foo.bar"]) - # 3. Edge Cases + # 3. Edge cases def test_updating_namespace_complete(self): - self.repl = FakeRepl({'autocomplete_mode': autocomplete.SIMPLE}) - self.setInputLine("foo") + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.SIMPLE} + ) + self.set_input_line("foo") self.repl.push("foobar = 2") self.assertTrue(self.repl.complete()) - self.assertTrue(hasattr(self.repl.matches_iter,'matches')) - self.assertEqual(self.repl.matches_iter.matches, - ['foobar']) + self.assertTrue(hasattr(self.repl.matches_iter, "matches")) + self.assertEqual(self.repl.matches_iter.matches, ["foobar"]) def test_file_should_not_appear_in_complete(self): - self.repl = FakeRepl({'autocomplete_mode': autocomplete.SIMPLE}) - self.setInputLine("_") + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.SIMPLE} + ) + self.set_input_line("_") self.assertTrue(self.repl.complete()) - self.assertTrue(hasattr(self.repl.matches_iter,'matches')) - self.assertTrue('__file__' not in self.repl.matches_iter.matches) - - -class TestCliRepl(unittest.TestCase): - - def setUp(self): - self.repl = FakeCliRepl() - - def test_atbol(self): - self.assertTrue(self.repl.atbol()) - - self.repl.s = "\t\t" - self.assertTrue(self.repl.atbol()) - - self.repl.s = "\t\tnot an empty line" - self.assertFalse(self.repl.atbol()) - - def test_addstr(self): - self.repl.complete = Mock(True) - - self.repl.s = "foo" - self.repl.addstr("bar") - self.assertEqual(self.repl.s, "foobar") + self.assertTrue(hasattr(self.repl.matches_iter, "matches")) + self.assertNotIn("__file__", self.repl.matches_iter.matches) - self.repl.cpos = 3 - self.repl.addstr('buzz') - self.assertEqual(self.repl.s, "foobuzzbar") + # 4. Parameter names + def test_paremeter_name_completion(self): + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.SIMPLE} + ) + self.set_input_line("foo(ab") -class TestCliReplTab(unittest.TestCase): + code = "def foo(abc=1, abd=2, xyz=3):\n\tpass\n" + for line in code.split("\n"): + self.repl.push(line) - def setUp(self): - self.repl = FakeCliRepl() + self.assertTrue(self.repl.complete()) + self.assertTrue(hasattr(self.repl.matches_iter, "matches")) + self.assertEqual( + self.repl.matches_iter.matches, ["abc=", "abd=", "abs("] + ) + + def test_parameter_advanced_on_class(self): + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.SIMPLE} + ) + self.set_input_line("TestCls(app") + + code = """ + import inspect + + class TestCls: + # A class with boring __init__ typing + def __init__(self, *args, **kwargs): + pass + # But that uses super exotic typings recognized by inspect.signature + __signature__ = inspect.Signature([ + inspect.Parameter("apple", inspect.Parameter.POSITIONAL_ONLY), + inspect.Parameter("apple2", inspect.Parameter.KEYWORD_ONLY), + inspect.Parameter("pinetree", inspect.Parameter.KEYWORD_ONLY), + ]) + """ + code = [x[8:] for x in code.split("\n")] + for line in code: + self.repl.push(line) - # 3 Types of tab complete - def test_simple_tab_complete(self): - self.repl.matches_iter = MagicMock() - if py3: - self.repl.matches_iter.__bool__.return_value = False - else: - self.repl.matches_iter.__nonzero__.return_value = False - self.repl.complete = Mock() - self.repl.print_line = Mock() - self.repl.matches_iter.is_cseq.return_value = False - self.repl.show_list = Mock() - self.repl.argspec = Mock() - self.repl.matches_iter.cur_line.return_value = (None, "foobar") - - self.repl.s = "foo" - self.repl.tab() - self.assertTrue(self.repl.complete.called) - self.repl.complete.assert_called_with(tab=True) - self.assertEqual(self.repl.s, "foobar") - - @skip("disabled while non-simple completion is disabled") - def test_substring_tab_complete(self): - self.repl.s = "bar" - self.repl.config.autocomplete_mode = autocomplete.FUZZY - self.repl.tab() - self.assertEqual(self.repl.s, "foobar") - self.repl.tab() - self.assertEqual(self.repl.s, "foofoobar") - - @skip("disabled while non-simple completion is disabled") - def test_fuzzy_tab_complete(self): - self.repl.s = "br" - self.repl.config.autocomplete_mode = autocomplete.FUZZY - self.repl.tab() - self.assertEqual(self.repl.s, "foobar") - - # Edge Cases - def test_normal_tab(self): - """make sure pressing the tab key will - still in some cases add a tab""" - self.repl.s = "" - self.repl.config = Mock() - self.repl.config.tab_length = 4 - self.repl.complete = Mock() - self.repl.print_line = Mock() - self.repl.tab() - self.assertEqual(self.repl.s, " ") - - def test_back_parameter(self): - self.repl.matches_iter = Mock() - self.repl.matches_iter.matches = True - self.repl.matches_iter.previous.return_value = "previtem" - self.repl.matches_iter.is_cseq.return_value = False - self.repl.show_list = Mock() - self.repl.argspec = Mock() - self.repl.matches_iter.cur_line.return_value = (None, "previtem") - self.repl.print_line = Mock() - self.repl.s = "foo" - self.repl.cpos = 0 - self.repl.tab(back=True) - self.assertTrue(self.repl.matches_iter.previous.called) - self.assertTrue(self.repl.s, "previtem") - - # Attribute Tests - @skip("disabled while non-simple completion is disabled") - def test_fuzzy_attribute_tab_complete(self): - """Test fuzzy attribute with no text""" - self.repl.s = "Foo." - self.repl.config.autocomplete_mode = autocomplete.FUZZY - - self.repl.tab() - self.assertEqual(self.repl.s, "Foo.foobar") - - @skip("disabled while non-simple completion is disabled") - def test_fuzzy_attribute_tab_complete2(self): - """Test fuzzy attribute with some text""" - self.repl.s = "Foo.br" - self.repl.config.autocomplete_mode = autocomplete.FUZZY - - self.repl.tab() - self.assertEqual(self.repl.s, "Foo.foobar") - - # Expand Tests - def test_simple_expand(self): - self.repl.s = "f" - self.cpos = 0 - self.repl.matches_iter = Mock() - self.repl.matches_iter.is_cseq.return_value = True - self.repl.matches_iter.substitute_cseq.return_value = (3, "foo") - self.repl.print_line = Mock() - self.repl.tab() - self.assertEqual(self.repl.s, "foo") - - @skip("disabled while non-simple completion is disabled") - def test_substring_expand_forward(self): - self.repl.config.autocomplete_mode = autocomplete.SUBSTRING - self.repl.s = "ba" - self.repl.tab() - self.assertEqual(self.repl.s, "bar") - - @skip("disabled while non-simple completion is disabled") - def test_fuzzy_expand(self): - pass + with mock.patch( + "bpython.inspection.inspect.getsourcelines", + return_value=(code, None), + ): + self.assertTrue(self.repl.complete()) + self.assertTrue(hasattr(self.repl.matches_iter, "matches")) + self.assertEqual( + self.repl.matches_iter.matches, ["apple2=", "apple="] + ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/bpython/test/test_simpleeval.py b/bpython/test/test_simpleeval.py new file mode 100644 index 000000000..8bdb19296 --- /dev/null +++ b/bpython/test/test_simpleeval.py @@ -0,0 +1,138 @@ +import ast +import numbers +import sys +import unittest + +from bpython.simpleeval import ( + simple_eval, + evaluate_current_expression, + EvaluationError, +) + + +class TestSimpleEval(unittest.TestCase): + def assertMatchesStdlib(self, expr): + self.assertEqual(ast.literal_eval(expr), simple_eval(expr)) + + def test_matches_stdlib(self): + """Should match the stdlib literal_eval if no names or indexing""" + self.assertMatchesStdlib("[1]") + self.assertMatchesStdlib("{(1,): [2,3,{}]}") + self.assertMatchesStdlib("{1, 2}") + + def test_matches_stdlib_set_literal(self): + """set() is evaluated""" + self.assertMatchesStdlib("set()") + + def test_indexing(self): + """Literals can be indexed into""" + self.assertEqual(simple_eval("[1,2][0]"), 1) + + def test_name_lookup(self): + """Names can be looked up in a namespace""" + self.assertEqual(simple_eval("a", {"a": 1}), 1) + self.assertEqual(simple_eval("map"), map) + + def test_name_lookup_indexing(self): + """Names can be looked up in a namespace""" + self.assertEqual(simple_eval("a[b]", {"a": {"c": 1}, "b": "c"}), 1) + + def test_lookup_on_suspicious_types(self): + class FakeDict: + pass + + with self.assertRaises(ValueError): + simple_eval("a[1]", {"a": FakeDict()}) + + class TrickyDict(dict): + def __getitem__(self, index): + self.fail("doing key lookup isn't safe") + + with self.assertRaises(ValueError): + simple_eval("a[1]", {"a": TrickyDict()}) + + class SchrodingersDict(dict): + def __getattribute__(inner_self, attr): + self.fail("doing attribute lookup might have side effects") + + with self.assertRaises(ValueError): + simple_eval("a[1]", {"a": SchrodingersDict()}) + + class SchrodingersCatsDict(dict): + def __getattr__(inner_self, attr): + self.fail("doing attribute lookup might have side effects") + + with self.assertRaises(ValueError): + simple_eval("a[1]", {"a": SchrodingersDict()}) + + def test_operators_on_suspicious_types(self): + class Spam(numbers.Number): + def __add__(inner_self, other): + self.fail("doing attribute lookup might have side effects") + + with self.assertRaises(ValueError): + simple_eval("a + 1", {"a": Spam()}) + + def test_operators_on_numbers(self): + self.assertEqual(simple_eval("-2"), -2) + self.assertEqual(simple_eval("1 + 1"), 2) + self.assertEqual(simple_eval("a - 2", {"a": 1}), -1) + with self.assertRaises(ValueError): + simple_eval("2 * 3") + with self.assertRaises(ValueError): + simple_eval("2 ** 3") + + def test_function_calls_raise(self): + with self.assertRaises(ValueError): + simple_eval("1()") + + def test_nonexistant_names_raise(self): + with self.assertRaises(EvaluationError): + simple_eval("a") + + def test_attribute_access(self): + class Foo: + abc = 1 + + self.assertEqual(simple_eval("foo.abc", {"foo": Foo()}), 1) + + +class TestEvaluateCurrentExpression(unittest.TestCase): + def assertEvaled(self, line, value, ns=None): + assert line.count("|") == 1 + cursor_offset = line.find("|") + line = line.replace("|", "") + self.assertEqual( + evaluate_current_expression(cursor_offset, line, ns), value + ) + + def assertCannotEval(self, line, ns=None): + assert line.count("|") == 1 + cursor_offset = line.find("|") + line = line.replace("|", "") + with self.assertRaises(EvaluationError): + evaluate_current_expression(cursor_offset, line, ns) + + def test_simple(self): + self.assertEvaled("[1].a|bc", [1]) + self.assertEvaled("[1].abc|", [1]) + self.assertEvaled("[1].|abc", [1]) + self.assertEvaled("[1]. |abc", [1]) + self.assertEvaled("[1] .|abc", [1]) + self.assertCannotEval("[1].abc |", [1]) + self.assertCannotEval("[1]. abc |", [1]) + self.assertCannotEval("[2][1].a|bc", [1]) + + def test_nonsense(self): + self.assertEvaled("!@#$ [1].a|bc", [1]) + self.assertEvaled("--- [2][0].a|bc", 2) + self.assertCannotEval('"asdf".centered()[1].a|bc') + self.assertEvaled('"asdf"[1].a|bc', "s") + + def test_with_namespace(self): + self.assertEvaled("a[1].a|bc", "d", {"a": "adsf"}) + self.assertCannotEval("a[1].a|bc", {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/bpython/test/test_wizard.py b/bpython/test/test_wizard.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bpython/translations/__init__.py b/bpython/translations/__init__.py index f3039e52f..069f34653 100644 --- a/bpython/translations/__init__.py +++ b/bpython/translations/__init__.py @@ -2,33 +2,39 @@ import locale import os.path import sys +from typing import Optional, cast, List -from bpython import package_dir +from .. import package_dir -translator = None +translator: gettext.NullTranslations = cast(gettext.NullTranslations, None) -if sys.version_info >= (3, 0): - def _(message): - return translator.gettext(message) -else: - def _(message): - return translator.ugettext(message) +def _(message) -> str: + return translator.gettext(message) -def init(locale_dir=None, languages=None): + +def ngettext(singular, plural, n): + return translator.ngettext(singular, plural, n) + + +def init( + locale_dir: str | None = None, languages: list[str] | None = None +) -> None: try: - locale.setlocale(locale.LC_ALL, '') + locale.setlocale(locale.LC_ALL, "") except locale.Error: # This means that the user's environment is broken. Let's just continue # with the default C locale. - sys.stderr.write("Error: Your locale settings are not supported by " - "the system. Using the fallback 'C' locale instead. " - "Please fix your locale settings.\n") + sys.stderr.write( + "Error: Your locale settings are not supported by " + "the system. Using the fallback 'C' locale instead. " + "Please fix your locale settings.\n" + ) global translator if locale_dir is None: - locale_dir = os.path.join(package_dir, 'translations') - - translator = gettext.translation('bpython', locale_dir, languages, - fallback=True) + locale_dir = os.path.join(package_dir, "translations") + translator = gettext.translation( + "bpython", locale_dir, languages, fallback=True + ) diff --git a/bpython/translations/bpython.pot b/bpython/translations/bpython.pot index 78a4ecad6..9237869da 100644 --- a/bpython/translations/bpython.pot +++ b/bpython/translations/bpython.pot @@ -1,151 +1,347 @@ # Translations template for bpython. -# Copyright (C) 2014 ORGANIZATION +# Copyright (C) 2021 ORGANIZATION # This file is distributed under the same license as the bpython project. -# FIRST AUTHOR , 2014. +# FIRST AUTHOR , 2021. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: bpython mercurial\n" -"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2014-05-13 10:50+0200\n" +"Project-Id-Version: bpython 0.22.dev123\n" +"Report-Msgid-Bugs-To: https://github.com/bpython/bpython/issues\n" +"POT-Creation-Date: 2021-10-12 21:58+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 1.3\n" +"Generated-By: Babel 2.8.0\n" -#: bpython/args.py:53 +#: bpython/args.py:63 +msgid "{} version {} on top of Python {} {}" +msgstr "" + +#: bpython/args.py:72 +msgid "{} See AUTHORS.rst for details." +msgstr "" + +#: bpython/args.py:116 +#, python-format msgid "" -"Usage: %prog [options] [file [args]]\n" +"Usage: %(prog)s [options] [file [args]]\n" "NOTE: If bpython sees an argument it does not know, execution falls back " "to the regular Python interpreter." msgstr "" -#: bpython/args.py:63 +#: bpython/args.py:127 msgid "Use CONFIG instead of default config file." msgstr "" -#: bpython/args.py:65 +#: bpython/args.py:133 msgid "Drop to bpython shell after running file instead of exiting." msgstr "" -#: bpython/args.py:68 +#: bpython/args.py:139 msgid "Don't flush the output to stdout." msgstr "" -#: bpython/args.py:70 +#: bpython/args.py:145 msgid "Print version and exit." msgstr "" -#: bpython/cli.py:325 bpython/urwid.py:553 +#: bpython/args.py:152 +msgid "Set log level for logging" +msgstr "" + +#: bpython/args.py:157 +msgid "Log output file" +msgstr "" + +#: bpython/args.py:168 +msgid "File to execute and additional arguments passed on to the executed script." +msgstr "" + +#: bpython/curtsiesfrontend/interaction.py:107 +#: bpython/urwid.py:539 msgid "y" msgstr "" -#: bpython/cli.py:325 bpython/urwid.py:553 +#: bpython/urwid.py:539 msgid "yes" msgstr "" -#: bpython/cli.py:1001 -msgid "Cannot show source." +#: bpython/curtsies.py:201 +msgid "start by pasting lines of a file into session" msgstr "" -#: bpython/cli.py:1751 bpython/urwid.py:615 +#: bpython/curtsies.py:207 +msgid "curtsies arguments" +msgstr "" + +#: bpython/curtsies.py:208 +msgid "Additional arguments specific to the curtsies-based REPL." +msgstr "" + +#: bpython/history.py:250 #, python-format -msgid " <%s> Rewind <%s> Save <%s> Pastebin <%s> Pager <%s> Show Source " +msgid "Error occurred while writing to file %s (%s)" msgstr "" -#: bpython/curtsies.py:24 -msgid "log debug messages to bpython-curtsies.log" +#: bpython/paste.py:85 +msgid "Helper program not found." msgstr "" -#: bpython/curtsies.py:26 -msgid "enter lines of file as though interactively typed" +#: bpython/paste.py:87 +msgid "Helper program could not be run." msgstr "" -#: bpython/repl.py:755 -msgid "Pastebin buffer? (y/N) " +#: bpython/paste.py:93 +#, python-format +msgid "Helper program returned non-zero exit status %d." +msgstr "" + +#: bpython/paste.py:98 +msgid "No output from helper program." +msgstr "" + +#: bpython/paste.py:105 +msgid "Failed to recognize the helper program's output as an URL." +msgstr "" + +#: bpython/repl.py:644 +msgid "Nothing to get source of" msgstr "" -#: bpython/repl.py:756 -msgid "Pastebin aborted" +#: bpython/repl.py:649 +#, python-format +msgid "Cannot get source: %s" msgstr "" -#: bpython/repl.py:763 +#: bpython/repl.py:654 #, python-format -msgid "Duplicate pastebin. Previous URL: %s" +msgid "Cannot access source of %r" msgstr "" -#: bpython/repl.py:777 +#: bpython/repl.py:656 #, python-format -msgid "Pastebin error for URL '%s': %s" +msgid "No source code found for %s" +msgstr "" + +#: bpython/repl.py:801 +msgid "Save to file (Esc to cancel): " +msgstr "" + +#: bpython/repl.py:803 bpython/repl.py:806 bpython/repl.py:830 +msgid "Save cancelled." +msgstr "" + +#: bpython/repl.py:817 +#, python-format +msgid "%s already exists. Do you want to (c)ancel, (o)verwrite or (a)ppend? " +msgstr "" + +#: bpython/repl.py:825 +msgid "overwrite" +msgstr "" + +#: bpython/repl.py:827 +msgid "append" +msgstr "" + +#: bpython/repl.py:839 bpython/repl.py:1143 +#, python-format +msgid "Error writing file '%s': %s" +msgstr "" + +#: bpython/repl.py:841 +#, python-format +msgid "Saved to %s." +msgstr "" + +#: bpython/repl.py:847 +msgid "No clipboard available." +msgstr "" + +#: bpython/repl.py:854 +msgid "Could not copy to clipboard." +msgstr "" + +#: bpython/repl.py:856 +msgid "Copied content to clipboard." msgstr "" -#: bpython/repl.py:781 bpython/repl.py:800 +#: bpython/repl.py:865 +msgid "Pastebin buffer? (y/N) " +msgstr "" + +#: bpython/repl.py:867 +msgid "Pastebin aborted." +msgstr "" + +#: bpython/repl.py:875 +#, python-format +msgid "Duplicate pastebin. Previous URL: %s. Removal URL: %s" +msgstr "" + +#: bpython/repl.py:881 msgid "Posting data to pastebin..." msgstr "" -#: bpython/repl.py:786 +#: bpython/repl.py:885 #, python-format msgid "Upload failed: %s" msgstr "" -#: bpython/repl.py:795 bpython/repl.py:839 +#: bpython/repl.py:894 +#, python-format +msgid "Pastebin URL: %s - Removal URL: %s" +msgstr "" + +#: bpython/repl.py:899 #, python-format msgid "Pastebin URL: %s" msgstr "" -#: bpython/repl.py:812 -msgid "Upload failed: Helper program not found." +#: bpython/repl.py:937 +#, python-format +msgid "Undo how many lines? (Undo will take up to ~%.1f seconds) [1]" msgstr "" -#: bpython/repl.py:815 -msgid "Upload failed: Helper program could not be run." +#: bpython/repl.py:945 bpython/repl.py:949 +msgid "Undo canceled" msgstr "" -#: bpython/repl.py:822 +#: bpython/repl.py:952 #, python-format -msgid "Upload failed: Helper program returned non-zero exit status %s." +msgid "Undoing %d line... (est. %.1f seconds)" +msgid_plural "Undoing %d lines... (est. %.1f seconds)" +msgstr[0] "" +msgstr[1] "" + +#: bpython/repl.py:1128 +msgid "Config file does not exist - create new from default? (y/N)" msgstr "" -#: bpython/repl.py:826 -msgid "Upload failed: No output from helper program." +#: bpython/repl.py:1153 +msgid "bpython config file edited. Restart bpython for changes to take effect." msgstr "" -#: bpython/repl.py:833 -msgid "Upload failed: Failed to recognize the helper program's output as an URL." +#: bpython/repl.py:1158 +#, python-format +msgid "Error editing config file: %s" msgstr "" -#: bpython/urwid.py:1114 -msgid "Run twisted reactor." +#: bpython/urwid.py:606 +#, python-format +msgid " <%s> Rewind <%s> Save <%s> Pastebin <%s> Pager <%s> Show Source " msgstr "" #: bpython/urwid.py:1116 +msgid "Run twisted reactor." +msgstr "" + +#: bpython/urwid.py:1121 msgid "Select specific reactor (see --help-reactors). Implies --twisted." msgstr "" -#: bpython/urwid.py:1119 +#: bpython/urwid.py:1129 msgid "List available reactors for -r." msgstr "" -#: bpython/urwid.py:1121 +#: bpython/urwid.py:1134 msgid "" "twistd plugin to run (use twistd for a list). Use \"--\" to pass further " "options to the plugin." msgstr "" -#: bpython/urwid.py:1124 +#: bpython/urwid.py:1143 msgid "Port to run an eval server on (forces Twisted)." msgstr "" -#: bpython/curtsiesfrontend/repl.py:175 -msgid "welcome to bpython" +#: bpython/urwid.py:1337 +msgid "" +"WARNING: You are using `bpython-urwid`, the urwid backend for `bpython`. " +"This backend has been deprecated in version 0.19 and might disappear in a" +" future version." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:339 +msgid "Welcome to bpython!" msgstr "" -#: bpython/curtsiesfrontend/repl.py:189 +#: bpython/curtsiesfrontend/repl.py:341 #, python-format -msgid " <%s> Rewind <%s> Save <%s> Pastebin <%s> Editor" +msgid "Press <%s> for help." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:681 +#, python-format +msgid "Executing PYTHONSTARTUP failed: %s" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:698 +#, python-format +msgid "Reloaded at %s because %s modified." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1008 +msgid "Session not reevaluated because it was not edited" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1023 +msgid "Session not reevaluated because saved file was blank" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1033 +msgid "Session edited and reevaluated" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1044 +#, python-format +msgid "Reloaded at %s by user." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1050 +msgid "Auto-reloading deactivated." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1055 +msgid "Auto-reloading active, watching for file changes..." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1061 +msgid "Auto-reloading not available because watchdog not installed." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:2011 +msgid "" +"\n" +"Thanks for using bpython!\n" +"\n" +"See http://bpython-interpreter.org/ for more information and http://docs" +".bpython-interpreter.org/ for docs.\n" +"Please report issues at https://github.com/bpython/bpython/issues\n" +"\n" +"Features:\n" +"Try using undo ({config.undo_key})!\n" +"Edit the current line ({config.edit_current_block_key}) or the entire " +"session ({config.external_editor_key}) in an external editor. (currently " +"{config.editor})\n" +"Save sessions ({config.save_key}) or post them to pastebins " +"({config.pastebin_key})! Current pastebin helper: " +"{config.pastebin_helper}\n" +"Reload all modules and rerun session ({config.reimport_key}) to test out " +"changes to a module.\n" +"Toggle auto-reload mode ({config.toggle_file_watch_key}) to re-execute " +"the current session when a module you've imported is modified.\n" +"\n" +"bpython -i your_script.py runs a file in interactive mode\n" +"bpython -t your_script.py pastes the contents of a file into the session\n" +"\n" +"A config file at {config.config_path} customizes keys and behavior of " +"bpython.\n" +"You can also set which pastebin helper and which external editor to use.\n" +"See {example_config_url} for an example config file.\n" +"Press {config.edit_config_key} to edit this config file.\n" msgstr "" diff --git a/bpython/translations/de/LC_MESSAGES/bpython.po b/bpython/translations/de/LC_MESSAGES/bpython.po index 88691c50f..feb534f7f 100644 --- a/bpython/translations/de/LC_MESSAGES/bpython.po +++ b/bpython/translations/de/LC_MESSAGES/bpython.po @@ -1,182 +1,370 @@ # German translations for bpython. -# Copyright (C) 2012-2013 bpython developers +# Copyright (C) 2012-2021 bpython developers # This file is distributed under the same license as the bpython project. -# Sebastian Ramacher , 2012-2013. +# Sebastian Ramacher , 2012-2021. # msgid "" msgstr "" "Project-Id-Version: bpython mercurial\n" "Report-Msgid-Bugs-To: http://github.com/bpython/bpython/issues\n" -"POT-Creation-Date: 2013-10-10 23:29+0200\n" -"PO-Revision-Date: 2013-10-11 14:51+0200\n" +"POT-Creation-Date: 2021-10-12 21:58+0200\n" +"PO-Revision-Date: 2021-02-14 17:31+0100\n" "Last-Translator: Sebastian Ramacher \n" +"Language: de\n" "Language-Team: de \n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 1.3\n" +"Generated-By: Babel 2.8.0\n" -#: bpython/args.py:53 +#: bpython/args.py:63 +msgid "{} version {} on top of Python {} {}" +msgstr "{} Version {} mit Python {} {}" + +#: bpython/args.py:72 +msgid "{} See AUTHORS.rst for details." +msgstr "{} Siehe AUTHORS.rst für mehr Details." + +#: bpython/args.py:116 +#, python-format msgid "" -"Usage: %prog [options] [file [args]]\n" +"Usage: %(prog)s [options] [file [args]]\n" "NOTE: If bpython sees an argument it does not know, execution falls back " "to the regular Python interpreter." msgstr "" +"Verwendung: %(prog)s [Optionen] [Datei [Argumente]]\n" +"Hinweis: Wenn bpython Argumente übergeben bekommt, die nicht verstanden " +"werden, wird der normale Python Interpreter ausgeführt." -#: bpython/args.py:63 +#: bpython/args.py:127 msgid "Use CONFIG instead of default config file." msgstr "Verwende CONFIG antatt der standardmäßigen Konfigurationsdatei." -#: bpython/args.py:65 +#: bpython/args.py:133 msgid "Drop to bpython shell after running file instead of exiting." msgstr "Verbleibe in bpython nach dem Ausführen der Datei." -#: bpython/args.py:68 +#: bpython/args.py:139 msgid "Don't flush the output to stdout." msgstr "Gib Ausgabe beim Beenden nicht ernaut auf stdout aus." -#: bpython/args.py:70 +#: bpython/args.py:145 msgid "Print version and exit." msgstr "Zeige Versionsinformationen an und beende." -#: bpython/cli.py:304 bpython/urwid.py:553 +#: bpython/args.py:152 +msgid "Set log level for logging" +msgstr "Log-Stufe" + +#: bpython/args.py:157 +msgid "Log output file" +msgstr "Datei für Ausgabe von Log-Nachrichten" + +#: bpython/args.py:168 +msgid "File to execute and additional arguments passed on to the executed script." +msgstr "" +"Auszuführende Datei und zusätzliche Argumente, die an das Script " +"übergeben werden sollen." + +#: bpython/curtsiesfrontend/interaction.py:107 +#: bpython/urwid.py:539 msgid "y" msgstr "j" -#: bpython/cli.py:304 bpython/urwid.py:553 +#: bpython/urwid.py:539 msgid "yes" msgstr "ja" -#: bpython/cli.py:977 bpython/gtk_.py:483 -msgid "Cannot show source." -msgstr "Kann Quellcode nicht anzeigen." +#: bpython/curtsies.py:201 +msgid "start by pasting lines of a file into session" +msgstr "" + +#: bpython/curtsies.py:207 +msgid "curtsies arguments" +msgstr "Argumente für curtsies" + +#: bpython/curtsies.py:208 +msgid "Additional arguments specific to the curtsies-based REPL." +msgstr "Zusätzliche Argumente spezifisch für die curtsies-basierte REPL." -#: bpython/cli.py:1677 bpython/urwid.py:615 +#: bpython/history.py:250 #, python-format -msgid " <%s> Rewind <%s> Save <%s> Pastebin <%s> Pager <%s> Show Source " -msgstr "" +msgid "Error occurred while writing to file %s (%s)" +msgstr "Fehler beim Schreiben in Datei %s aufgetreten (%s)" -#: bpython/gtk_.py:90 bpython/gtk_.py:115 -msgid "An error occurred." -msgstr "Ein Fehler ist aufgetreten." +#: bpython/paste.py:85 +msgid "Helper program not found." +msgstr "Hilfsprogramm konnte nicht gefunden werden." -#: bpython/gtk_.py:97 -msgid "Exception details" -msgstr "Ausnahmedetails" +#: bpython/paste.py:87 +msgid "Helper program could not be run." +msgstr "Hilfsprogramm konnte nicht ausgeführt werden." -#: bpython/gtk_.py:149 -msgid "Statusbar" -msgstr "Statusleiste" +#: bpython/paste.py:93 +#, python-format +msgid "Helper program returned non-zero exit status %d." +msgstr "Hilfsprogramm beendete mit Status %d." -#: bpython/gtk_.py:226 -msgid "tooltip" -msgstr "" +#: bpython/paste.py:98 +msgid "No output from helper program." +msgstr "Keine Ausgabe von Hilfsprogramm vorhanden." -#: bpython/gtk_.py:295 -msgid "File to save to" -msgstr "" +#: bpython/paste.py:105 +msgid "Failed to recognize the helper program's output as an URL." +msgstr "Konnte Ausgabe von Hilfsprogramm nicht verarbeiten." -#: bpython/gtk_.py:306 -msgid "Python files" -msgstr "Python Dateien" +#: bpython/repl.py:644 +msgid "Nothing to get source of" +msgstr "Nichts um Quellcode abzurufen" -#: bpython/gtk_.py:311 -msgid "All files" -msgstr "Alle Dateien" +#: bpython/repl.py:649 +#, python-format +msgid "Cannot get source: %s" +msgstr "Kann Quellcode nicht finden: %s" -#: bpython/gtk_.py:771 -msgid "gtk-specific options" -msgstr "" +#: bpython/repl.py:654 +#, python-format +msgid "Cannot access source of %r" +msgstr "Kann auf Quellcode nicht zugreifen: %r" -#: bpython/gtk_.py:772 -msgid "Options specific to bpython's Gtk+ front end" -msgstr "" +#: bpython/repl.py:656 +#, python-format +msgid "No source code found for %s" +msgstr "Quellcode für %s nicht gefunden" -#: bpython/gtk_.py:774 -msgid "Embed bpython" -msgstr "Bette bpython ein" +#: bpython/repl.py:801 +msgid "Save to file (Esc to cancel): " +msgstr "In Datei speichern (Esc um abzubrechen): " -#: bpython/gtk_.py:830 -msgid "Pastebin selection" -msgstr "" +#: bpython/repl.py:803 bpython/repl.py:806 bpython/repl.py:830 +msgid "Save cancelled." +msgstr "Speichern abgebrochen." -#: bpython/repl.py:741 -msgid "Pastebin buffer? (y/N) " -msgstr "" +#: bpython/repl.py:817 +#, python-format +msgid "%s already exists. Do you want to (c)ancel, (o)verwrite or (a)ppend? " +msgstr "%s existiert bereit. (C) abbrechen, (o) überschrieben oder (a) anhängen? " -#: bpython/repl.py:742 -msgid "Pastebin aborted" -msgstr "" +#: bpython/repl.py:825 +msgid "overwrite" +msgstr "überschreiben" -#: bpython/repl.py:749 +#: bpython/repl.py:827 +msgid "append" +msgstr "anhängen" + +#: bpython/repl.py:839 bpython/repl.py:1143 #, python-format -msgid "Duplicate pastebin. Previous URL: %s" -msgstr "" +msgid "Error writing file '%s': %s" +msgstr "Fehler beim Schreiben in Datei '%s': %s" -#: bpython/repl.py:763 +#: bpython/repl.py:841 #, python-format -msgid "Pastebin error for URL '%s': %s" +msgid "Saved to %s." +msgstr "Nach %s gespeichert." + +#: bpython/repl.py:847 +msgid "No clipboard available." +msgstr "Zwischenablage ist nicht verfügbar." + +#: bpython/repl.py:854 +msgid "Could not copy to clipboard." +msgstr "Konnte nicht in Zwischenablage kopieren." + +#: bpython/repl.py:856 +msgid "Copied content to clipboard." +msgstr "Inhalt wurde in Zwischenablage kopiert." + +#: bpython/repl.py:865 +msgid "Pastebin buffer? (y/N) " +msgstr "Buffer bei Pastebin hochladen? (j/N)" + +#: bpython/repl.py:867 +msgid "Pastebin aborted." +msgstr "Hochladen zu Pastebin abgebrochen." + +#: bpython/repl.py:875 +#, python-format +msgid "Duplicate pastebin. Previous URL: %s. Removal URL: %s" msgstr "" +"Duplizierte Daten zu Pastebin hochgeladen. Vorherige URL: %s. URL zum " +"Löschen: %s" -#: bpython/repl.py:767 bpython/repl.py:786 +#: bpython/repl.py:881 msgid "Posting data to pastebin..." -msgstr "Lade Daten hoch..." +msgstr "Lade Daten hoch zu Pastebin..." -#: bpython/repl.py:772 +#: bpython/repl.py:885 #, python-format msgid "Upload failed: %s" msgstr "Hochladen ist fehlgeschlagen: %s" -#: bpython/repl.py:781 bpython/repl.py:825 +#: bpython/repl.py:894 +#, python-format +msgid "Pastebin URL: %s - Removal URL: %s" +msgstr "Pastebin URL: %s - URL zum Löschen: %s" + +#: bpython/repl.py:899 #, python-format msgid "Pastebin URL: %s" +msgstr "Pastebin URL: %s" + +#: bpython/repl.py:937 +#, python-format +msgid "Undo how many lines? (Undo will take up to ~%.1f seconds) [1]" msgstr "" +"Wie viele Zeilen rückgängig machen? (Rückgängigmachen wird bis zu ~%.1f " +"Sekunden brauchen) [1]" -#: bpython/repl.py:798 -msgid "Upload failed: Helper program not found." -msgstr "Hochladen ist fehlgeschlagen: Hilfsprogramm konnte nicht gefunden " -"werden." +#: bpython/repl.py:945 bpython/repl.py:949 +msgid "Undo canceled" +msgstr "Rückgängigmachen abgebrochen" -#: bpython/repl.py:801 -msgid "Upload failed: Helper program could not be run." -msgstr "Hochladen ist fehlgeschlagen: Hilfsprogramm konnte nicht ausgeführt " -"werden." - -#: bpython/repl.py:808 +#: bpython/repl.py:952 #, python-format -msgid "Upload failed: Helper program returned non-zero exit status %s." -msgstr "Hochladen ist fehlgeschlagen: Hilfsprogramm beendete mit Status %s." +msgid "Undoing %d line... (est. %.1f seconds)" +msgid_plural "Undoing %d lines... (est. %.1f seconds)" +msgstr[0] "Mache %d Zeile rückgängig... (ungefähr %.1f Sekunden)" +msgstr[1] "Mache %d Zeilen rückgängig... (ungefähr %.1f Sekunden)" + +#: bpython/repl.py:1128 +msgid "Config file does not exist - create new from default? (y/N)" +msgstr "" +"Konfigurationsdatei existiert nicht. Soll eine neue Datei erstellt " +"werden? (j/N)" -#: bpython/repl.py:812 -msgid "Upload failed: No output from helper program." -msgstr "Hochladen ist fehlgeschlagen: Keine Ausgabe von Hilfsprogramm " -"vorhanden." +#: bpython/repl.py:1153 +msgid "bpython config file edited. Restart bpython for changes to take effect." +msgstr "" +"bpython Konfigurationsdatei bearbeitet. Starte bpython neu damit die " +"Änderungen übernommen werden." -#: bpython/repl.py:819 -msgid "Upload failed: Failed to recognize the helper program's output as an URL." -msgstr "Hochladen ist fehlgeschlagen: Konte Ausgabe von Hilfsprogramm nicht " -"verarbeiten." +#: bpython/repl.py:1158 +#, python-format +msgid "Error editing config file: %s" +msgstr "Fehler beim Bearbeiten der Konfigurationsdatei: %s" -#: bpython/urwid.py:1114 -msgid "Run twisted reactor." +#: bpython/urwid.py:606 +#, python-format +msgid " <%s> Rewind <%s> Save <%s> Pastebin <%s> Pager <%s> Show Source " msgstr "" +" <%s> Rückgängigmachen <%s> Speichern <%s> Pastebin <%s> Pager <%s> " +"Quellcode anzeigen " #: bpython/urwid.py:1116 +msgid "Run twisted reactor." +msgstr "Führe twisted reactor aus." + +#: bpython/urwid.py:1121 msgid "Select specific reactor (see --help-reactors). Implies --twisted." -msgstr "" +msgstr "Wähle reactor aus (siehe --help-reactors). Impliziert --twisted." -#: bpython/urwid.py:1119 +#: bpython/urwid.py:1129 msgid "List available reactors for -r." -msgstr "" +msgstr "Liste verfügbare reactors für -r auf." -#: bpython/urwid.py:1121 +#: bpython/urwid.py:1134 msgid "" "twistd plugin to run (use twistd for a list). Use \"--\" to pass further " "options to the plugin." msgstr "" +"Auszuführendes twistd Plugin (starte twistd für eine Liste). Verwende " +"\"--\" um Optionen an das Plugin zu übergeben." -#: bpython/urwid.py:1124 +#: bpython/urwid.py:1143 msgid "Port to run an eval server on (forces Twisted)." msgstr "" +#: bpython/urwid.py:1337 +msgid "" +"WARNING: You are using `bpython-urwid`, the urwid backend for `bpython`. " +"This backend has been deprecated in version 0.19 and might disappear in a" +" future version." +msgstr "" +"ACHTUNG: `bpython-urwid` wird verwendet, die curses Implementierung von " +"`bpython`. Diese Implementierung wird ab Version 0.19 nicht mehr aktiv " +"unterstützt und wird in einer zukünftigen Version entfernt werden." + +#: bpython/curtsiesfrontend/repl.py:339 +msgid "Welcome to bpython!" +msgstr "Willkommen by bpython!" + +#: bpython/curtsiesfrontend/repl.py:341 +#, python-format +msgid "Press <%s> for help." +msgstr "Drücke <%s> für Hilfe." + +#: bpython/curtsiesfrontend/repl.py:681 +#, python-format +msgid "Executing PYTHONSTARTUP failed: %s" +msgstr "Fehler beim Ausführen von PYTHONSTARTUP: %s" + +#: bpython/curtsiesfrontend/repl.py:698 +#, python-format +msgid "Reloaded at %s because %s modified." +msgstr "Bei %s neugeladen, da %s modifiziert wurde." + +#: bpython/curtsiesfrontend/repl.py:1008 +msgid "Session not reevaluated because it was not edited" +msgstr "Die Sitzung wurde nicht neu ausgeführt, da sie nicht berabeitet wurde" + +#: bpython/curtsiesfrontend/repl.py:1023 +msgid "Session not reevaluated because saved file was blank" +msgstr "Die Sitzung wurde nicht neu ausgeführt, da die gespeicherte Datei leer war" + +#: bpython/curtsiesfrontend/repl.py:1033 +msgid "Session edited and reevaluated" +msgstr "Sitzung bearbeitet und neu ausgeführt" + +#: bpython/curtsiesfrontend/repl.py:1044 +#, python-format +msgid "Reloaded at %s by user." +msgstr "Bei %s vom Benutzer neu geladen." + +#: bpython/curtsiesfrontend/repl.py:1050 +msgid "Auto-reloading deactivated." +msgstr "Automatisches Neuladen deaktiviert." + +#: bpython/curtsiesfrontend/repl.py:1055 +msgid "Auto-reloading active, watching for file changes..." +msgstr "Automatisches Neuladen ist aktiv; beobachte Dateiänderungen..." + +#: bpython/curtsiesfrontend/repl.py:1061 +msgid "Auto-reloading not available because watchdog not installed." +msgstr "" +"Automatisches Neuladen ist nicht verfügbar da watchdog nicht installiert " +"ist." + +#: bpython/curtsiesfrontend/repl.py:2011 +msgid "" +"\n" +"Thanks for using bpython!\n" +"\n" +"See http://bpython-interpreter.org/ for more information and http://docs" +".bpython-interpreter.org/ for docs.\n" +"Please report issues at https://github.com/bpython/bpython/issues\n" +"\n" +"Features:\n" +"Try using undo ({config.undo_key})!\n" +"Edit the current line ({config.edit_current_block_key}) or the entire " +"session ({config.external_editor_key}) in an external editor. (currently " +"{config.editor})\n" +"Save sessions ({config.save_key}) or post them to pastebins " +"({config.pastebin_key})! Current pastebin helper: " +"{config.pastebin_helper}\n" +"Reload all modules and rerun session ({config.reimport_key}) to test out " +"changes to a module.\n" +"Toggle auto-reload mode ({config.toggle_file_watch_key}) to re-execute " +"the current session when a module you've imported is modified.\n" +"\n" +"bpython -i your_script.py runs a file in interactive mode\n" +"bpython -t your_script.py pastes the contents of a file into the session\n" +"\n" +"A config file at {config.config_path} customizes keys and behavior of " +"bpython.\n" +"You can also set which pastebin helper and which external editor to use.\n" +"See {example_config_url} for an example config file.\n" +"Press {config.edit_config_key} to edit this config file.\n" +msgstr "" + diff --git a/bpython/translations/es_ES/LC_MESSAGES/bpython.po b/bpython/translations/es_ES/LC_MESSAGES/bpython.po index f9e2af3d5..d34872816 100644 --- a/bpython/translations/es_ES/LC_MESSAGES/bpython.po +++ b/bpython/translations/es_ES/LC_MESSAGES/bpython.po @@ -7,174 +7,344 @@ msgid "" msgstr "" "Project-Id-Version: bpython 0.9.7\n" "Report-Msgid-Bugs-To: http://github.com/bpython/bpython/issues\n" -"POT-Creation-Date: 2013-10-10 23:29+0200\n" -"PO-Revision-Date: 2013-10-11 14:46+0200\n" -"Last-Translator: Claudia Medde\n" +"POT-Creation-Date: 2021-10-12 21:58+0200\n" +"PO-Revision-Date: 2020-10-29 12:22+0100\n" +"Last-Translator: Sebastian Ramacher \n" +"Language: es_ES\n" "Language-Team: bpython developers\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 1.3\n" +"Generated-By: Babel 2.8.0\n" -#: bpython/args.py:53 +#: bpython/args.py:63 +msgid "{} version {} on top of Python {} {}" +msgstr "" + +#: bpython/args.py:72 +msgid "{} See AUTHORS.rst for details." +msgstr "" + +#: bpython/args.py:116 +#, python-format msgid "" -"Usage: %prog [options] [file [args]]\n" +"Usage: %(prog)s [options] [file [args]]\n" "NOTE: If bpython sees an argument it does not know, execution falls back " "to the regular Python interpreter." msgstr "" -#: bpython/args.py:63 +#: bpython/args.py:127 msgid "Use CONFIG instead of default config file." msgstr "" -#: bpython/args.py:65 +#: bpython/args.py:133 msgid "Drop to bpython shell after running file instead of exiting." msgstr "" -#: bpython/args.py:68 +#: bpython/args.py:139 msgid "Don't flush the output to stdout." msgstr "" -#: bpython/args.py:70 +#: bpython/args.py:145 msgid "Print version and exit." msgstr "" -#: bpython/cli.py:304 bpython/urwid.py:553 +#: bpython/args.py:152 +msgid "Set log level for logging" +msgstr "" + +#: bpython/args.py:157 +msgid "Log output file" +msgstr "" + +#: bpython/args.py:168 +msgid "File to execute and additional arguments passed on to the executed script." +msgstr "" + +#: bpython/curtsiesfrontend/interaction.py:107 +#: bpython/urwid.py:539 msgid "y" msgstr "s" -#: bpython/cli.py:304 bpython/urwid.py:553 +#: bpython/urwid.py:539 msgid "yes" msgstr "si" -#: bpython/cli.py:977 bpython/gtk_.py:483 -msgid "Cannot show source." -msgstr "Imposible mostrar el código fuente" +#: bpython/curtsies.py:201 +msgid "start by pasting lines of a file into session" +msgstr "" + +#: bpython/curtsies.py:207 +msgid "curtsies arguments" +msgstr "" -#: bpython/cli.py:1677 bpython/urwid.py:615 +#: bpython/curtsies.py:208 +msgid "Additional arguments specific to the curtsies-based REPL." +msgstr "" + +#: bpython/history.py:250 #, python-format -msgid " <%s> Rewind <%s> Save <%s> Pastebin <%s> Pager <%s> Show Source " +msgid "Error occurred while writing to file %s (%s)" +msgstr "" + +#: bpython/paste.py:85 +msgid "Helper program not found." +msgstr "" + +#: bpython/paste.py:87 +msgid "Helper program could not be run." msgstr "" -" <%s> Rewind <%s> Salva <%s> Pastebin <%s> Pager <%s> Mostra el " -"código fuente" -#: bpython/gtk_.py:90 bpython/gtk_.py:115 -msgid "An error occurred." +#: bpython/paste.py:93 +#, python-format +msgid "Helper program returned non-zero exit status %d." msgstr "" -#: bpython/gtk_.py:97 -msgid "Exception details" +#: bpython/paste.py:98 +msgid "No output from helper program." msgstr "" -#: bpython/gtk_.py:149 -msgid "Statusbar" -msgstr "Statusbar" +#: bpython/paste.py:105 +msgid "Failed to recognize the helper program's output as an URL." +msgstr "" -#: bpython/gtk_.py:226 -msgid "tooltip" -msgstr "tooltip" +#: bpython/repl.py:644 +msgid "Nothing to get source of" +msgstr "" -#: bpython/gtk_.py:295 -msgid "File to save to" +#: bpython/repl.py:649 +#, python-format +msgid "Cannot get source: %s" msgstr "" -#: bpython/gtk_.py:306 -msgid "Python files" -msgstr "Files Python" +#: bpython/repl.py:654 +#, python-format +msgid "Cannot access source of %r" +msgstr "" -#: bpython/gtk_.py:311 -msgid "All files" -msgstr "Todos los files" +#: bpython/repl.py:656 +#, python-format +msgid "No source code found for %s" +msgstr "" -#: bpython/gtk_.py:771 -msgid "gtk-specific options" +#: bpython/repl.py:801 +msgid "Save to file (Esc to cancel): " msgstr "" -#: bpython/gtk_.py:772 -msgid "Options specific to bpython's Gtk+ front end" +#: bpython/repl.py:803 bpython/repl.py:806 bpython/repl.py:830 +msgid "Save cancelled." msgstr "" -#: bpython/gtk_.py:774 -msgid "Embed bpython" -msgstr "Embed bpython" +#: bpython/repl.py:817 +#, python-format +msgid "%s already exists. Do you want to (c)ancel, (o)verwrite or (a)ppend? " +msgstr "" -#: bpython/gtk_.py:830 -msgid "Pastebin selection" -msgstr "Pastebin la selección" +#: bpython/repl.py:825 +msgid "overwrite" +msgstr "" -#: bpython/repl.py:741 -msgid "Pastebin buffer? (y/N) " +#: bpython/repl.py:827 +msgid "append" msgstr "" -#: bpython/repl.py:742 -msgid "Pastebin aborted" +#: bpython/repl.py:839 bpython/repl.py:1143 +#, python-format +msgid "Error writing file '%s': %s" msgstr "" -#: bpython/repl.py:749 +#: bpython/repl.py:841 #, python-format -msgid "Duplicate pastebin. Previous URL: %s" +msgid "Saved to %s." +msgstr "" + +#: bpython/repl.py:847 +msgid "No clipboard available." +msgstr "" + +#: bpython/repl.py:854 +msgid "Could not copy to clipboard." +msgstr "" + +#: bpython/repl.py:856 +msgid "Copied content to clipboard." +msgstr "" + +#: bpython/repl.py:865 +msgid "Pastebin buffer? (y/N) " +msgstr "" + +#: bpython/repl.py:867 +msgid "Pastebin aborted." msgstr "" -#: bpython/repl.py:763 +#: bpython/repl.py:875 #, python-format -msgid "Pastebin error for URL '%s': %s" +msgid "Duplicate pastebin. Previous URL: %s. Removal URL: %s" msgstr "" -#: bpython/repl.py:767 bpython/repl.py:786 +#: bpython/repl.py:881 msgid "Posting data to pastebin..." msgstr "" -#: bpython/repl.py:772 +#: bpython/repl.py:885 #, python-format msgid "Upload failed: %s" msgstr "" -#: bpython/repl.py:781 bpython/repl.py:825 +#: bpython/repl.py:894 +#, python-format +msgid "Pastebin URL: %s - Removal URL: %s" +msgstr "" + +#: bpython/repl.py:899 #, python-format msgid "Pastebin URL: %s" msgstr "" -#: bpython/repl.py:798 -msgid "Upload failed: Helper program not found." +#: bpython/repl.py:937 +#, python-format +msgid "Undo how many lines? (Undo will take up to ~%.1f seconds) [1]" msgstr "" -#: bpython/repl.py:801 -msgid "Upload failed: Helper program could not be run." +#: bpython/repl.py:945 bpython/repl.py:949 +msgid "Undo canceled" msgstr "" -#: bpython/repl.py:808 +#: bpython/repl.py:952 #, python-format -msgid "Upload failed: Helper program returned non-zero exit status %s." +msgid "Undoing %d line... (est. %.1f seconds)" +msgid_plural "Undoing %d lines... (est. %.1f seconds)" +msgstr[0] "" +msgstr[1] "" + +#: bpython/repl.py:1128 +msgid "Config file does not exist - create new from default? (y/N)" msgstr "" -#: bpython/repl.py:812 -msgid "Upload failed: No output from helper program." +#: bpython/repl.py:1153 +msgid "bpython config file edited. Restart bpython for changes to take effect." msgstr "" -#: bpython/repl.py:819 -msgid "Upload failed: Failed to recognize the helper program's output as an URL." +#: bpython/repl.py:1158 +#, python-format +msgid "Error editing config file: %s" msgstr "" -#: bpython/urwid.py:1114 -msgid "Run twisted reactor." +#: bpython/urwid.py:606 +#, python-format +msgid " <%s> Rewind <%s> Save <%s> Pastebin <%s> Pager <%s> Show Source " msgstr "" +" <%s> Rewind <%s> Salva <%s> Pastebin <%s> Pager <%s> Mostra el " +"código fuente" #: bpython/urwid.py:1116 +msgid "Run twisted reactor." +msgstr "" + +#: bpython/urwid.py:1121 msgid "Select specific reactor (see --help-reactors). Implies --twisted." msgstr "" -#: bpython/urwid.py:1119 +#: bpython/urwid.py:1129 msgid "List available reactors for -r." msgstr "" -#: bpython/urwid.py:1121 +#: bpython/urwid.py:1134 msgid "" "twistd plugin to run (use twistd for a list). Use \"--\" to pass further " "options to the plugin." msgstr "" -#: bpython/urwid.py:1124 +#: bpython/urwid.py:1143 msgid "Port to run an eval server on (forces Twisted)." msgstr "" +#: bpython/urwid.py:1337 +msgid "" +"WARNING: You are using `bpython-urwid`, the urwid backend for `bpython`. " +"This backend has been deprecated in version 0.19 and might disappear in a" +" future version." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:339 +msgid "Welcome to bpython!" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:341 +#, python-format +msgid "Press <%s> for help." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:681 +#, python-format +msgid "Executing PYTHONSTARTUP failed: %s" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:698 +#, python-format +msgid "Reloaded at %s because %s modified." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1008 +msgid "Session not reevaluated because it was not edited" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1023 +msgid "Session not reevaluated because saved file was blank" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1033 +msgid "Session edited and reevaluated" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1044 +#, python-format +msgid "Reloaded at %s by user." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1050 +msgid "Auto-reloading deactivated." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1055 +msgid "Auto-reloading active, watching for file changes..." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1061 +msgid "Auto-reloading not available because watchdog not installed." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:2011 +msgid "" +"\n" +"Thanks for using bpython!\n" +"\n" +"See http://bpython-interpreter.org/ for more information and http://docs" +".bpython-interpreter.org/ for docs.\n" +"Please report issues at https://github.com/bpython/bpython/issues\n" +"\n" +"Features:\n" +"Try using undo ({config.undo_key})!\n" +"Edit the current line ({config.edit_current_block_key}) or the entire " +"session ({config.external_editor_key}) in an external editor. (currently " +"{config.editor})\n" +"Save sessions ({config.save_key}) or post them to pastebins " +"({config.pastebin_key})! Current pastebin helper: " +"{config.pastebin_helper}\n" +"Reload all modules and rerun session ({config.reimport_key}) to test out " +"changes to a module.\n" +"Toggle auto-reload mode ({config.toggle_file_watch_key}) to re-execute " +"the current session when a module you've imported is modified.\n" +"\n" +"bpython -i your_script.py runs a file in interactive mode\n" +"bpython -t your_script.py pastes the contents of a file into the session\n" +"\n" +"A config file at {config.config_path} customizes keys and behavior of " +"bpython.\n" +"You can also set which pastebin helper and which external editor to use.\n" +"See {example_config_url} for an example config file.\n" +"Press {config.edit_config_key} to edit this config file.\n" +msgstr "" + diff --git a/bpython/translations/fr_FR/LC_MESSAGES/bpython.po b/bpython/translations/fr_FR/LC_MESSAGES/bpython.po new file mode 100644 index 000000000..ba1205048 --- /dev/null +++ b/bpython/translations/fr_FR/LC_MESSAGES/bpython.po @@ -0,0 +1,356 @@ +# French (France) translations for bpython. +# Copyright (C) 2010 bpython developers +# This file is distributed under the same license as the bpython project. +# +msgid "" +msgstr "" +"Project-Id-Version: bpython 0.13-442\n" +"Report-Msgid-Bugs-To: http://github.com/bpython/bpython/issues\n" +"POT-Creation-Date: 2021-10-12 21:58+0200\n" +"PO-Revision-Date: 2020-10-29 12:20+0100\n" +"Last-Translator: Sebastian Ramacher \n" +"Language: fr_FR\n" +"Language-Team: bpython developers\n" +"Plural-Forms: nplurals=2; plural=(n > 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.8.0\n" + +#: bpython/args.py:63 +msgid "{} version {} on top of Python {} {}" +msgstr "" + +#: bpython/args.py:72 +msgid "{} See AUTHORS.rst for details." +msgstr "" + +#: bpython/args.py:116 +#, python-format +msgid "" +"Usage: %(prog)s [options] [file [args]]\n" +"NOTE: If bpython sees an argument it does not know, execution falls back " +"to the regular Python interpreter." +msgstr "" +"Utilisation: %(prog)s [options] [fichier [arguments]]\n" +"NOTE: Si bpython ne reconnaît pas un des arguments fournis, " +"l'interpréteur Python classique sera lancé" + +#: bpython/args.py:127 +msgid "Use CONFIG instead of default config file." +msgstr "Utiliser CONFIG à la place du fichier de configuration par défaut." + +#: bpython/args.py:133 +msgid "Drop to bpython shell after running file instead of exiting." +msgstr "" +"Aller dans le shell bpython après l'exécution du fichier au lieu de " +"quitter." + +#: bpython/args.py:139 +msgid "Don't flush the output to stdout." +msgstr "Ne pas purger la sortie vers stdout." + +#: bpython/args.py:145 +msgid "Print version and exit." +msgstr "Afficher la version et quitter." + +#: bpython/args.py:152 +msgid "Set log level for logging" +msgstr "" + +#: bpython/args.py:157 +msgid "Log output file" +msgstr "" + +#: bpython/args.py:168 +msgid "File to execute and additional arguments passed on to the executed script." +msgstr "" + +#: bpython/curtsiesfrontend/interaction.py:107 +#: bpython/urwid.py:539 +msgid "y" +msgstr "o" + +#: bpython/urwid.py:539 +msgid "yes" +msgstr "oui" + +#: bpython/curtsies.py:201 +msgid "start by pasting lines of a file into session" +msgstr "" + +#: bpython/curtsies.py:207 +msgid "curtsies arguments" +msgstr "" + +#: bpython/curtsies.py:208 +msgid "Additional arguments specific to the curtsies-based REPL." +msgstr "" + +#: bpython/history.py:250 +#, python-format +msgid "Error occurred while writing to file %s (%s)" +msgstr "Une erreur s'est produite pendant l'écriture du fichier %s (%s)" + +#: bpython/paste.py:85 +msgid "Helper program not found." +msgstr "programme externe non trouvé." + +#: bpython/paste.py:87 +msgid "Helper program could not be run." +msgstr "impossible de lancer le programme externe." + +#: bpython/paste.py:93 +#, python-format +msgid "Helper program returned non-zero exit status %d." +msgstr "le programme externe a renvoyé un statut de sortie différent de zéro %d." + +#: bpython/paste.py:98 +msgid "No output from helper program." +msgstr "pas de sortie du programme externe." + +#: bpython/paste.py:105 +msgid "Failed to recognize the helper program's output as an URL." +msgstr "la sortie du programme externe ne correspond pas à une URL." + +#: bpython/repl.py:644 +msgid "Nothing to get source of" +msgstr "" + +#: bpython/repl.py:649 +#, python-format +msgid "Cannot get source: %s" +msgstr "Impossible de récupérer le source: %s" + +#: bpython/repl.py:654 +#, python-format +msgid "Cannot access source of %r" +msgstr "Impossible d'accéder au source de %r" + +#: bpython/repl.py:656 +#, python-format +msgid "No source code found for %s" +msgstr "Pas de code source trouvé pour %s" + +#: bpython/repl.py:801 +msgid "Save to file (Esc to cancel): " +msgstr "" + +#: bpython/repl.py:803 bpython/repl.py:806 bpython/repl.py:830 +msgid "Save cancelled." +msgstr "" + +#: bpython/repl.py:817 +#, python-format +msgid "%s already exists. Do you want to (c)ancel, (o)verwrite or (a)ppend? " +msgstr "" + +#: bpython/repl.py:825 +msgid "overwrite" +msgstr "" + +#: bpython/repl.py:827 +msgid "append" +msgstr "" + +#: bpython/repl.py:839 bpython/repl.py:1143 +#, python-format +msgid "Error writing file '%s': %s" +msgstr "Une erreur s'est produite pendant l'écriture du fichier '%s': %s" + +#: bpython/repl.py:841 +#, python-format +msgid "Saved to %s." +msgstr "" + +#: bpython/repl.py:847 +msgid "No clipboard available." +msgstr "Pas de presse-papier disponible." + +#: bpython/repl.py:854 +msgid "Could not copy to clipboard." +msgstr "Impossible de copier vers le presse-papier." + +#: bpython/repl.py:856 +msgid "Copied content to clipboard." +msgstr "Contenu copié vers le presse-papier." + +#: bpython/repl.py:865 +msgid "Pastebin buffer? (y/N) " +msgstr "Tampon Pastebin ? (o/N) " + +#: bpython/repl.py:867 +msgid "Pastebin aborted." +msgstr "Pastebin abandonné." + +#: bpython/repl.py:875 +#, python-format +msgid "Duplicate pastebin. Previous URL: %s. Removal URL: %s" +msgstr "Pastebin dupliqué. URL précédente: %s. URL de suppression: %s" + +#: bpython/repl.py:881 +msgid "Posting data to pastebin..." +msgstr "Envoi des donnés à pastebin..." + +#: bpython/repl.py:885 +#, python-format +msgid "Upload failed: %s" +msgstr "Echec du téléchargement: %s" + +#: bpython/repl.py:894 +#, python-format +msgid "Pastebin URL: %s - Removal URL: %s" +msgstr "URL Pastebin: %s - URL de suppression: %s" + +#: bpython/repl.py:899 +#, python-format +msgid "Pastebin URL: %s" +msgstr "URL Pastebin: %s" + +#: bpython/repl.py:937 +#, python-format +msgid "Undo how many lines? (Undo will take up to ~%.1f seconds) [1]" +msgstr "" + +#: bpython/repl.py:945 bpython/repl.py:949 +msgid "Undo canceled" +msgstr "" + +#: bpython/repl.py:952 +#, python-format +msgid "Undoing %d line... (est. %.1f seconds)" +msgid_plural "Undoing %d lines... (est. %.1f seconds)" +msgstr[0] "" +msgstr[1] "" + +#: bpython/repl.py:1128 +msgid "Config file does not exist - create new from default? (y/N)" +msgstr "Le fichier de configuration n'existe pas - en créér un par défaut? (o/N)" + +#: bpython/repl.py:1153 +msgid "bpython config file edited. Restart bpython for changes to take effect." +msgstr "" + +#: bpython/repl.py:1158 +#, python-format +msgid "Error editing config file: %s" +msgstr "" + +#: bpython/urwid.py:606 +#, python-format +msgid " <%s> Rewind <%s> Save <%s> Pastebin <%s> Pager <%s> Show Source " +msgstr "" +" <%s> Rebobiner <%s> Sauvegarder <%s> Pastebin <%s> Pager <%s> " +"Montrer Source " + +#: bpython/urwid.py:1116 +msgid "Run twisted reactor." +msgstr "Lancer le reactor twisted." + +#: bpython/urwid.py:1121 +msgid "Select specific reactor (see --help-reactors). Implies --twisted." +msgstr "Choisir un reactor spécifique (voir --help-reactors). Nécessite --twisted." + +#: bpython/urwid.py:1129 +msgid "List available reactors for -r." +msgstr "Lister les reactors disponibles pour -r." + +#: bpython/urwid.py:1134 +msgid "" +"twistd plugin to run (use twistd for a list). Use \"--\" to pass further " +"options to the plugin." +msgstr "" +"plugin twistd à lancer (utiliser twistd pour une list). Utiliser \"--\" " +"pour donner plus d'options au plugin." + +#: bpython/urwid.py:1143 +msgid "Port to run an eval server on (forces Twisted)." +msgstr "Port pour lancer un server eval (force Twisted)." + +#: bpython/urwid.py:1337 +msgid "" +"WARNING: You are using `bpython-urwid`, the urwid backend for `bpython`. " +"This backend has been deprecated in version 0.19 and might disappear in a" +" future version." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:339 +msgid "Welcome to bpython!" +msgstr "Bienvenue dans bpython!" + +#: bpython/curtsiesfrontend/repl.py:341 +#, python-format +msgid "Press <%s> for help." +msgstr "Appuyer sur <%s> pour de l'aide." + +#: bpython/curtsiesfrontend/repl.py:681 +#, python-format +msgid "Executing PYTHONSTARTUP failed: %s" +msgstr "L'exécution de PYTHONSTARTUP a échoué: %s" + +#: bpython/curtsiesfrontend/repl.py:698 +#, python-format +msgid "Reloaded at %s because %s modified." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1008 +msgid "Session not reevaluated because it was not edited" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1023 +msgid "Session not reevaluated because saved file was blank" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1033 +msgid "Session edited and reevaluated" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1044 +#, python-format +msgid "Reloaded at %s by user." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1050 +msgid "Auto-reloading deactivated." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1055 +msgid "Auto-reloading active, watching for file changes..." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1061 +msgid "Auto-reloading not available because watchdog not installed." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:2011 +msgid "" +"\n" +"Thanks for using bpython!\n" +"\n" +"See http://bpython-interpreter.org/ for more information and http://docs" +".bpython-interpreter.org/ for docs.\n" +"Please report issues at https://github.com/bpython/bpython/issues\n" +"\n" +"Features:\n" +"Try using undo ({config.undo_key})!\n" +"Edit the current line ({config.edit_current_block_key}) or the entire " +"session ({config.external_editor_key}) in an external editor. (currently " +"{config.editor})\n" +"Save sessions ({config.save_key}) or post them to pastebins " +"({config.pastebin_key})! Current pastebin helper: " +"{config.pastebin_helper}\n" +"Reload all modules and rerun session ({config.reimport_key}) to test out " +"changes to a module.\n" +"Toggle auto-reload mode ({config.toggle_file_watch_key}) to re-execute " +"the current session when a module you've imported is modified.\n" +"\n" +"bpython -i your_script.py runs a file in interactive mode\n" +"bpython -t your_script.py pastes the contents of a file into the session\n" +"\n" +"A config file at {config.config_path} customizes keys and behavior of " +"bpython.\n" +"You can also set which pastebin helper and which external editor to use.\n" +"See {example_config_url} for an example config file.\n" +"Press {config.edit_config_key} to edit this config file.\n" +msgstr "" + diff --git a/bpython/translations/it_IT/LC_MESSAGES/bpython.po b/bpython/translations/it_IT/LC_MESSAGES/bpython.po index d7cc4ce6d..46488bc3c 100644 --- a/bpython/translations/it_IT/LC_MESSAGES/bpython.po +++ b/bpython/translations/it_IT/LC_MESSAGES/bpython.po @@ -7,172 +7,345 @@ msgid "" msgstr "" "Project-Id-Version: bpython 0.9.7\n" "Report-Msgid-Bugs-To: http://github.com/bpython/bpython/issues\n" -"POT-Creation-Date: 2013-10-10 23:29+0200\n" -"PO-Revision-Date: 2013-10-11 14:47+0200\n" -"Last-Translator: Michele Orrù\n" +"POT-Creation-Date: 2021-10-12 21:58+0200\n" +"PO-Revision-Date: 2015-02-02 00:34+0100\n" +"Last-Translator: Sebastian Ramacher \n" +"Language: it_IT\n" "Language-Team: Michele Orrù\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 1.3\n" +"Generated-By: Babel 2.8.0\n" -#: bpython/args.py:53 +#: bpython/args.py:63 +msgid "{} version {} on top of Python {} {}" +msgstr "" + +#: bpython/args.py:72 +msgid "{} See AUTHORS.rst for details." +msgstr "" + +#: bpython/args.py:116 +#, python-format msgid "" -"Usage: %prog [options] [file [args]]\n" +"Usage: %(prog)s [options] [file [args]]\n" "NOTE: If bpython sees an argument it does not know, execution falls back " "to the regular Python interpreter." msgstr "" -#: bpython/args.py:63 +#: bpython/args.py:127 msgid "Use CONFIG instead of default config file." msgstr "" -#: bpython/args.py:65 +#: bpython/args.py:133 msgid "Drop to bpython shell after running file instead of exiting." msgstr "" -#: bpython/args.py:68 +#: bpython/args.py:139 msgid "Don't flush the output to stdout." msgstr "" -#: bpython/args.py:70 +#: bpython/args.py:145 msgid "Print version and exit." msgstr "" -#: bpython/cli.py:304 bpython/urwid.py:553 +#: bpython/args.py:152 +msgid "Set log level for logging" +msgstr "" + +#: bpython/args.py:157 +msgid "Log output file" +msgstr "" + +#: bpython/args.py:168 +msgid "File to execute and additional arguments passed on to the executed script." +msgstr "" + +#: bpython/curtsiesfrontend/interaction.py:107 +#: bpython/urwid.py:539 msgid "y" msgstr "s" -#: bpython/cli.py:304 bpython/urwid.py:553 +#: bpython/urwid.py:539 msgid "yes" msgstr "si" -#: bpython/cli.py:977 bpython/gtk_.py:483 -msgid "Cannot show source." -msgstr "Non è possibile mostrare il codice sorgente" +#: bpython/curtsies.py:201 +msgid "start by pasting lines of a file into session" +msgstr "" + +#: bpython/curtsies.py:207 +msgid "curtsies arguments" +msgstr "" -#: bpython/cli.py:1677 bpython/urwid.py:615 +#: bpython/curtsies.py:208 +msgid "Additional arguments specific to the curtsies-based REPL." +msgstr "" + +#: bpython/history.py:250 #, python-format -msgid " <%s> Rewind <%s> Save <%s> Pastebin <%s> Pager <%s> Show Source " -msgstr " <%s> Rewind <%s> Salva <%s> Pastebin <%s> Pager <%s> Mostra Sorgente" +msgid "Error occurred while writing to file %s (%s)" +msgstr "" -#: bpython/gtk_.py:90 bpython/gtk_.py:115 -msgid "An error occurred." -msgstr "È stato riscontrato un errore" +#: bpython/paste.py:85 +msgid "Helper program not found." +msgstr "" -#: bpython/gtk_.py:97 -msgid "Exception details" -msgstr "Dettagli sull'eccezione" +#: bpython/paste.py:87 +msgid "Helper program could not be run." +msgstr "" -#: bpython/gtk_.py:149 -msgid "Statusbar" -msgstr "Barra di stato" +#: bpython/paste.py:93 +#, python-format +msgid "Helper program returned non-zero exit status %d." +msgstr "" -#: bpython/gtk_.py:226 -msgid "tooltip" -msgstr "tooltip" +#: bpython/paste.py:98 +msgid "No output from helper program." +msgstr "" -#: bpython/gtk_.py:295 -msgid "File to save to" -msgstr "File nel quale salvare" +#: bpython/paste.py:105 +msgid "Failed to recognize the helper program's output as an URL." +msgstr "" -#: bpython/gtk_.py:306 -msgid "Python files" -msgstr "Files python" +#: bpython/repl.py:644 +msgid "Nothing to get source of" +msgstr "" -#: bpython/gtk_.py:311 -msgid "All files" -msgstr "Tutti i files" +#: bpython/repl.py:649 +#, python-format +msgid "Cannot get source: %s" +msgstr "" -#: bpython/gtk_.py:771 -msgid "gtk-specific options" -msgstr "Opzioni specifiche di gtk" +#: bpython/repl.py:654 +#, python-format +msgid "Cannot access source of %r" +msgstr "" -#: bpython/gtk_.py:772 -msgid "Options specific to bpython's Gtk+ front end" -msgstr "Opzioni specifiche riguardo il frontend gtk+ di bpython" +#: bpython/repl.py:656 +#, python-format +msgid "No source code found for %s" +msgstr "" -#: bpython/gtk_.py:774 -msgid "Embed bpython" +#: bpython/repl.py:801 +msgid "Save to file (Esc to cancel): " msgstr "" -#: bpython/gtk_.py:830 -msgid "Pastebin selection" +#: bpython/repl.py:803 bpython/repl.py:806 bpython/repl.py:830 +msgid "Save cancelled." msgstr "" -#: bpython/repl.py:741 -msgid "Pastebin buffer? (y/N) " +#: bpython/repl.py:817 +#, python-format +msgid "%s already exists. Do you want to (c)ancel, (o)verwrite or (a)ppend? " +msgstr "" + +#: bpython/repl.py:825 +msgid "overwrite" msgstr "" -#: bpython/repl.py:742 -msgid "Pastebin aborted" +#: bpython/repl.py:827 +msgid "append" msgstr "" -#: bpython/repl.py:749 +#: bpython/repl.py:839 bpython/repl.py:1143 #, python-format -msgid "Duplicate pastebin. Previous URL: %s" +msgid "Error writing file '%s': %s" msgstr "" -#: bpython/repl.py:763 +#: bpython/repl.py:841 #, python-format -msgid "Pastebin error for URL '%s': %s" +msgid "Saved to %s." msgstr "" -#: bpython/repl.py:767 bpython/repl.py:786 +#: bpython/repl.py:847 +msgid "No clipboard available." +msgstr "" + +#: bpython/repl.py:854 +msgid "Could not copy to clipboard." +msgstr "" + +#: bpython/repl.py:856 +msgid "Copied content to clipboard." +msgstr "" + +#: bpython/repl.py:865 +msgid "Pastebin buffer? (y/N) " +msgstr "" + +#: bpython/repl.py:867 +msgid "Pastebin aborted." +msgstr "" + +#: bpython/repl.py:875 +#, python-format +msgid "Duplicate pastebin. Previous URL: %s. Removal URL: %s" +msgstr "" + +#: bpython/repl.py:881 msgid "Posting data to pastebin..." msgstr "" -#: bpython/repl.py:772 +#: bpython/repl.py:885 #, python-format msgid "Upload failed: %s" msgstr "" -#: bpython/repl.py:781 bpython/repl.py:825 +#: bpython/repl.py:894 +#, python-format +msgid "Pastebin URL: %s - Removal URL: %s" +msgstr "" + +#: bpython/repl.py:899 #, python-format msgid "Pastebin URL: %s" msgstr "" -#: bpython/repl.py:798 -msgid "Upload failed: Helper program not found." +#: bpython/repl.py:937 +#, python-format +msgid "Undo how many lines? (Undo will take up to ~%.1f seconds) [1]" msgstr "" -#: bpython/repl.py:801 -msgid "Upload failed: Helper program could not be run." +#: bpython/repl.py:945 bpython/repl.py:949 +msgid "Undo canceled" msgstr "" -#: bpython/repl.py:808 +#: bpython/repl.py:952 #, python-format -msgid "Upload failed: Helper program returned non-zero exit status %s." +msgid "Undoing %d line... (est. %.1f seconds)" +msgid_plural "Undoing %d lines... (est. %.1f seconds)" +msgstr[0] "" +msgstr[1] "" + +#: bpython/repl.py:1128 +msgid "Config file does not exist - create new from default? (y/N)" msgstr "" -#: bpython/repl.py:812 -msgid "Upload failed: No output from helper program." +#: bpython/repl.py:1153 +msgid "bpython config file edited. Restart bpython for changes to take effect." msgstr "" -#: bpython/repl.py:819 -msgid "Upload failed: Failed to recognize the helper program's output as an URL." +#: bpython/repl.py:1158 +#, python-format +msgid "Error editing config file: %s" msgstr "" -#: bpython/urwid.py:1114 +#: bpython/urwid.py:606 +#, python-format +msgid " <%s> Rewind <%s> Save <%s> Pastebin <%s> Pager <%s> Show Source " +msgstr " <%s> Rewind <%s> Salva <%s> Pastebin <%s> Pager <%s> Mostra Sorgente" + +#: bpython/urwid.py:1116 msgid "Run twisted reactor." msgstr "" -#: bpython/urwid.py:1116 +#: bpython/urwid.py:1121 msgid "Select specific reactor (see --help-reactors). Implies --twisted." msgstr "" -#: bpython/urwid.py:1119 +#: bpython/urwid.py:1129 msgid "List available reactors for -r." msgstr "" -#: bpython/urwid.py:1121 +#: bpython/urwid.py:1134 msgid "" "twistd plugin to run (use twistd for a list). Use \"--\" to pass further " "options to the plugin." msgstr "" -#: bpython/urwid.py:1124 +#: bpython/urwid.py:1143 msgid "Port to run an eval server on (forces Twisted)." msgstr "" +#: bpython/urwid.py:1337 +msgid "" +"WARNING: You are using `bpython-urwid`, the urwid backend for `bpython`. " +"This backend has been deprecated in version 0.19 and might disappear in a" +" future version." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:339 +msgid "Welcome to bpython!" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:341 +#, python-format +msgid "Press <%s> for help." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:681 +#, python-format +msgid "Executing PYTHONSTARTUP failed: %s" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:698 +#, python-format +msgid "Reloaded at %s because %s modified." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1008 +msgid "Session not reevaluated because it was not edited" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1023 +msgid "Session not reevaluated because saved file was blank" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1033 +msgid "Session edited and reevaluated" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1044 +#, python-format +msgid "Reloaded at %s by user." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1050 +msgid "Auto-reloading deactivated." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1055 +msgid "Auto-reloading active, watching for file changes..." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1061 +msgid "Auto-reloading not available because watchdog not installed." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:2011 +msgid "" +"\n" +"Thanks for using bpython!\n" +"\n" +"See http://bpython-interpreter.org/ for more information and http://docs" +".bpython-interpreter.org/ for docs.\n" +"Please report issues at https://github.com/bpython/bpython/issues\n" +"\n" +"Features:\n" +"Try using undo ({config.undo_key})!\n" +"Edit the current line ({config.edit_current_block_key}) or the entire " +"session ({config.external_editor_key}) in an external editor. (currently " +"{config.editor})\n" +"Save sessions ({config.save_key}) or post them to pastebins " +"({config.pastebin_key})! Current pastebin helper: " +"{config.pastebin_helper}\n" +"Reload all modules and rerun session ({config.reimport_key}) to test out " +"changes to a module.\n" +"Toggle auto-reload mode ({config.toggle_file_watch_key}) to re-execute " +"the current session when a module you've imported is modified.\n" +"\n" +"bpython -i your_script.py runs a file in interactive mode\n" +"bpython -t your_script.py pastes the contents of a file into the session\n" +"\n" +"A config file at {config.config_path} customizes keys and behavior of " +"bpython.\n" +"You can also set which pastebin helper and which external editor to use.\n" +"See {example_config_url} for an example config file.\n" +"Press {config.edit_config_key} to edit this config file.\n" +msgstr "" + +#~ msgid "Error editing config file." +#~ msgstr "" + diff --git a/bpython/translations/nl_NL/LC_MESSAGES/bpython.po b/bpython/translations/nl_NL/LC_MESSAGES/bpython.po index 678695723..375f4f32e 100644 --- a/bpython/translations/nl_NL/LC_MESSAGES/bpython.po +++ b/bpython/translations/nl_NL/LC_MESSAGES/bpython.po @@ -7,172 +7,342 @@ msgid "" msgstr "" "Project-Id-Version: bpython 0.9.7.1\n" "Report-Msgid-Bugs-To: http://github.com/bpython/bpython/issues\n" -"POT-Creation-Date: 2013-10-10 23:29+0200\n" -"PO-Revision-Date: 2013-10-11 14:47+0200\n" -"Last-Translator: Simon de Vlieger \n" +"POT-Creation-Date: 2021-10-12 21:58+0200\n" +"PO-Revision-Date: 2020-10-29 12:20+0100\n" +"Last-Translator: Sebastian Ramacher \n" +"Language: nl_NL\n" "Language-Team: bpython developers\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 1.3\n" +"Generated-By: Babel 2.8.0\n" -#: bpython/args.py:53 +#: bpython/args.py:63 +msgid "{} version {} on top of Python {} {}" +msgstr "" + +#: bpython/args.py:72 +msgid "{} See AUTHORS.rst for details." +msgstr "" + +#: bpython/args.py:116 +#, python-format msgid "" -"Usage: %prog [options] [file [args]]\n" +"Usage: %(prog)s [options] [file [args]]\n" "NOTE: If bpython sees an argument it does not know, execution falls back " "to the regular Python interpreter." msgstr "" -#: bpython/args.py:63 +#: bpython/args.py:127 msgid "Use CONFIG instead of default config file." msgstr "" -#: bpython/args.py:65 +#: bpython/args.py:133 msgid "Drop to bpython shell after running file instead of exiting." msgstr "" -#: bpython/args.py:68 +#: bpython/args.py:139 msgid "Don't flush the output to stdout." msgstr "" -#: bpython/args.py:70 +#: bpython/args.py:145 msgid "Print version and exit." msgstr "" -#: bpython/cli.py:304 bpython/urwid.py:553 +#: bpython/args.py:152 +msgid "Set log level for logging" +msgstr "" + +#: bpython/args.py:157 +msgid "Log output file" +msgstr "" + +#: bpython/args.py:168 +msgid "File to execute and additional arguments passed on to the executed script." +msgstr "" + +#: bpython/curtsiesfrontend/interaction.py:107 +#: bpython/urwid.py:539 msgid "y" msgstr "j" -#: bpython/cli.py:304 bpython/urwid.py:553 +#: bpython/urwid.py:539 msgid "yes" msgstr "ja" -#: bpython/cli.py:977 bpython/gtk_.py:483 -msgid "Cannot show source." -msgstr "Kan de broncode niet laden" +#: bpython/curtsies.py:201 +msgid "start by pasting lines of a file into session" +msgstr "" + +#: bpython/curtsies.py:207 +msgid "curtsies arguments" +msgstr "" -#: bpython/cli.py:1677 bpython/urwid.py:615 +#: bpython/curtsies.py:208 +msgid "Additional arguments specific to the curtsies-based REPL." +msgstr "" + +#: bpython/history.py:250 #, python-format -msgid " <%s> Rewind <%s> Save <%s> Pastebin <%s> Pager <%s> Show Source " -msgstr " <%s> Rewind <%s> Opslaan <%s> Pastebin <%s> Pager <%s> Toon broncode" +msgid "Error occurred while writing to file %s (%s)" +msgstr "" -#: bpython/gtk_.py:90 bpython/gtk_.py:115 -msgid "An error occurred." -msgstr "Er is een fout opgetreden" +#: bpython/paste.py:85 +msgid "Helper program not found." +msgstr "" -#: bpython/gtk_.py:97 -msgid "Exception details" -msgstr "Fout details" +#: bpython/paste.py:87 +msgid "Helper program could not be run." +msgstr "" -#: bpython/gtk_.py:149 -msgid "Statusbar" -msgstr "Statusbalk" +#: bpython/paste.py:93 +#, python-format +msgid "Helper program returned non-zero exit status %d." +msgstr "" -#: bpython/gtk_.py:226 -msgid "tooltip" -msgstr "tooltip" +#: bpython/paste.py:98 +msgid "No output from helper program." +msgstr "" -#: bpython/gtk_.py:295 -msgid "File to save to" -msgstr "Bestandsnaaam" +#: bpython/paste.py:105 +msgid "Failed to recognize the helper program's output as an URL." +msgstr "" -#: bpython/gtk_.py:306 -msgid "Python files" -msgstr "Python bestanden" +#: bpython/repl.py:644 +msgid "Nothing to get source of" +msgstr "" -#: bpython/gtk_.py:311 -msgid "All files" -msgstr "Alle bestanden" +#: bpython/repl.py:649 +#, python-format +msgid "Cannot get source: %s" +msgstr "" -#: bpython/gtk_.py:771 -msgid "gtk-specific options" -msgstr "gtk-specifieke opties" +#: bpython/repl.py:654 +#, python-format +msgid "Cannot access source of %r" +msgstr "" -#: bpython/gtk_.py:772 -msgid "Options specific to bpython's Gtk+ front end" -msgstr "Opties specifiek voor bpythons Gtk+ front end" +#: bpython/repl.py:656 +#, python-format +msgid "No source code found for %s" +msgstr "" -#: bpython/gtk_.py:774 -msgid "Embed bpython" -msgstr "Embed bpython" +#: bpython/repl.py:801 +msgid "Save to file (Esc to cancel): " +msgstr "" -#: bpython/gtk_.py:830 -msgid "Pastebin selection" -msgstr "Pastebin de selectie" +#: bpython/repl.py:803 bpython/repl.py:806 bpython/repl.py:830 +msgid "Save cancelled." +msgstr "" -#: bpython/repl.py:741 -msgid "Pastebin buffer? (y/N) " +#: bpython/repl.py:817 +#, python-format +msgid "%s already exists. Do you want to (c)ancel, (o)verwrite or (a)ppend? " msgstr "" -#: bpython/repl.py:742 -msgid "Pastebin aborted" +#: bpython/repl.py:825 +msgid "overwrite" msgstr "" -#: bpython/repl.py:749 +#: bpython/repl.py:827 +msgid "append" +msgstr "" + +#: bpython/repl.py:839 bpython/repl.py:1143 +#, python-format +msgid "Error writing file '%s': %s" +msgstr "" + +#: bpython/repl.py:841 #, python-format -msgid "Duplicate pastebin. Previous URL: %s" +msgid "Saved to %s." +msgstr "" + +#: bpython/repl.py:847 +msgid "No clipboard available." +msgstr "" + +#: bpython/repl.py:854 +msgid "Could not copy to clipboard." +msgstr "" + +#: bpython/repl.py:856 +msgid "Copied content to clipboard." +msgstr "" + +#: bpython/repl.py:865 +msgid "Pastebin buffer? (y/N) " +msgstr "" + +#: bpython/repl.py:867 +msgid "Pastebin aborted." msgstr "" -#: bpython/repl.py:763 +#: bpython/repl.py:875 #, python-format -msgid "Pastebin error for URL '%s': %s" +msgid "Duplicate pastebin. Previous URL: %s. Removal URL: %s" msgstr "" -#: bpython/repl.py:767 bpython/repl.py:786 +#: bpython/repl.py:881 msgid "Posting data to pastebin..." msgstr "" -#: bpython/repl.py:772 +#: bpython/repl.py:885 #, python-format msgid "Upload failed: %s" msgstr "" -#: bpython/repl.py:781 bpython/repl.py:825 +#: bpython/repl.py:894 +#, python-format +msgid "Pastebin URL: %s - Removal URL: %s" +msgstr "" + +#: bpython/repl.py:899 #, python-format msgid "Pastebin URL: %s" msgstr "" -#: bpython/repl.py:798 -msgid "Upload failed: Helper program not found." +#: bpython/repl.py:937 +#, python-format +msgid "Undo how many lines? (Undo will take up to ~%.1f seconds) [1]" msgstr "" -#: bpython/repl.py:801 -msgid "Upload failed: Helper program could not be run." +#: bpython/repl.py:945 bpython/repl.py:949 +msgid "Undo canceled" msgstr "" -#: bpython/repl.py:808 +#: bpython/repl.py:952 #, python-format -msgid "Upload failed: Helper program returned non-zero exit status %s." +msgid "Undoing %d line... (est. %.1f seconds)" +msgid_plural "Undoing %d lines... (est. %.1f seconds)" +msgstr[0] "" +msgstr[1] "" + +#: bpython/repl.py:1128 +msgid "Config file does not exist - create new from default? (y/N)" msgstr "" -#: bpython/repl.py:812 -msgid "Upload failed: No output from helper program." +#: bpython/repl.py:1153 +msgid "bpython config file edited. Restart bpython for changes to take effect." msgstr "" -#: bpython/repl.py:819 -msgid "Upload failed: Failed to recognize the helper program's output as an URL." +#: bpython/repl.py:1158 +#, python-format +msgid "Error editing config file: %s" msgstr "" -#: bpython/urwid.py:1114 +#: bpython/urwid.py:606 +#, python-format +msgid " <%s> Rewind <%s> Save <%s> Pastebin <%s> Pager <%s> Show Source " +msgstr " <%s> Rewind <%s> Opslaan <%s> Pastebin <%s> Pager <%s> Toon broncode" + +#: bpython/urwid.py:1116 msgid "Run twisted reactor." msgstr "" -#: bpython/urwid.py:1116 +#: bpython/urwid.py:1121 msgid "Select specific reactor (see --help-reactors). Implies --twisted." msgstr "" -#: bpython/urwid.py:1119 +#: bpython/urwid.py:1129 msgid "List available reactors for -r." msgstr "" -#: bpython/urwid.py:1121 +#: bpython/urwid.py:1134 msgid "" "twistd plugin to run (use twistd for a list). Use \"--\" to pass further " "options to the plugin." msgstr "" -#: bpython/urwid.py:1124 +#: bpython/urwid.py:1143 msgid "Port to run an eval server on (forces Twisted)." msgstr "" +#: bpython/urwid.py:1337 +msgid "" +"WARNING: You are using `bpython-urwid`, the urwid backend for `bpython`. " +"This backend has been deprecated in version 0.19 and might disappear in a" +" future version." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:339 +msgid "Welcome to bpython!" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:341 +#, python-format +msgid "Press <%s> for help." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:681 +#, python-format +msgid "Executing PYTHONSTARTUP failed: %s" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:698 +#, python-format +msgid "Reloaded at %s because %s modified." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1008 +msgid "Session not reevaluated because it was not edited" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1023 +msgid "Session not reevaluated because saved file was blank" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1033 +msgid "Session edited and reevaluated" +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1044 +#, python-format +msgid "Reloaded at %s by user." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1050 +msgid "Auto-reloading deactivated." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1055 +msgid "Auto-reloading active, watching for file changes..." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:1061 +msgid "Auto-reloading not available because watchdog not installed." +msgstr "" + +#: bpython/curtsiesfrontend/repl.py:2011 +msgid "" +"\n" +"Thanks for using bpython!\n" +"\n" +"See http://bpython-interpreter.org/ for more information and http://docs" +".bpython-interpreter.org/ for docs.\n" +"Please report issues at https://github.com/bpython/bpython/issues\n" +"\n" +"Features:\n" +"Try using undo ({config.undo_key})!\n" +"Edit the current line ({config.edit_current_block_key}) or the entire " +"session ({config.external_editor_key}) in an external editor. (currently " +"{config.editor})\n" +"Save sessions ({config.save_key}) or post them to pastebins " +"({config.pastebin_key})! Current pastebin helper: " +"{config.pastebin_helper}\n" +"Reload all modules and rerun session ({config.reimport_key}) to test out " +"changes to a module.\n" +"Toggle auto-reload mode ({config.toggle_file_watch_key}) to re-execute " +"the current session when a module you've imported is modified.\n" +"\n" +"bpython -i your_script.py runs a file in interactive mode\n" +"bpython -t your_script.py pastes the contents of a file into the session\n" +"\n" +"A config file at {config.config_path} customizes keys and behavior of " +"bpython.\n" +"You can also set which pastebin helper and which external editor to use.\n" +"See {example_config_url} for an example config file.\n" +"Press {config.edit_config_key} to edit this config file.\n" +msgstr "" + diff --git a/bpython/urwid.py b/bpython/urwid.py index 582b551e6..d4899332d 100644 --- a/bpython/urwid.py +++ b/bpython/urwid.py @@ -1,5 +1,3 @@ - -# # The MIT License # # Copyright (c) 2010-2011 Marien Zwart @@ -22,6 +20,8 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +# This whole file typing TODO +# type: ignore """bpython backend based on Urwid. @@ -32,33 +32,17 @@ This is still *VERY* rough. """ - -from __future__ import absolute_import, with_statement, division - import sys import os import time import locale import signal -from types import ModuleType -from optparse import Option - -from pygments.token import Token - -from bpython import args as bpargs, repl, translations -from bpython._py3compat import py3 -from bpython.formatter import theme_map -from bpython.importcompletion import find_coroutine -from bpython.translations import _ - -from bpython.keys import urwid_key_dispatch as key_dispatch - import urwid -if not py3: - import inspect - -Parenthesis = Token.Punctuation.Parenthesis +from . import args as bpargs, repl, translations +from .formatter import theme_map +from .translations import _ +from .keys import urwid_key_dispatch as key_dispatch # Urwid colors are: # 'black', 'dark red', 'dark green', 'brown', 'dark blue', @@ -69,18 +53,16 @@ # blacK, Red, Green, Yellow, Blue, Magenta, Cyan, White, Default COLORMAP = { - 'k': 'black', - 'r': 'dark red', # or light red? - 'g': 'dark green', # or light green? - 'y': 'yellow', - 'b': 'dark blue', # or light blue? - 'm': 'dark magenta', # or light magenta? - 'c': 'dark cyan', # or light cyan? - 'w': 'white', - 'd': 'default', - } - -# Add our keys to the urwid command_map + "k": "black", + "r": "dark red", # or light red? + "g": "dark green", # or light green? + "y": "yellow", + "b": "dark blue", # or light blue? + "m": "dark magenta", # or light magenta? + "c": "dark cyan", # or light cyan? + "w": "white", + "d": "default", +} try: @@ -91,21 +73,18 @@ else: class EvalProtocol(basic.LineOnlyReceiver): + delimiter = "\n" - delimiter = '\n' - - def __init__(self, myrepl): + def __init__(self, myrepl) -> None: self.repl = myrepl - def lineReceived(self, line): + def lineReceived(self, line) -> None: # HACK! # TODO: deal with encoding issues here... self.repl.main_loop.process_input(line) - self.repl.main_loop.process_input(['enter']) - + self.repl.main_loop.process_input(["enter"]) class EvalFactory(protocol.ServerFactory): - def __init__(self, myrepl): self.repl = myrepl @@ -116,38 +95,7 @@ def buildProtocol(self, addr): # If Twisted is not available urwid has no TwistedEventLoop attribute. # Code below will try to import reactor before using TwistedEventLoop. # I assume TwistedEventLoop will be available if that import succeeds. -if urwid.VERSION < (1, 0, 0) and hasattr(urwid, 'TwistedEventLoop'): - class TwistedEventLoop(urwid.TwistedEventLoop): - - """TwistedEventLoop modified to properly stop the reactor. - - urwid 0.9.9 and 0.9.9.1 crash the reactor on ExitMainLoop instead - of stopping it. One obvious way this breaks is if anything used - the reactor's thread pool: that thread pool is not shut down if - the reactor is not stopped, which means python hangs on exit - (joining the non-daemon threadpool threads that never exit). And - the default resolver is the ThreadedResolver, so if we looked up - any names we hang on exit. That is bad enough that we hack up - urwid a bit here to exit properly. - """ - - def handle_exit(self, f): - def wrapper(*args, **kwargs): - try: - return f(*args, **kwargs) - except urwid.ExitMainLoop: - # This is our change. - self.reactor.stop() - except: - # This is the same as in urwid. - # We are obviously not supposed to ever hit this. - import sys - print sys.exc_info() - self._exc_info = sys.exc_info() - self.reactor.crash() - return wrapper -else: - TwistedEventLoop = getattr(urwid, 'TwistedEventLoop', None) +TwistedEventLoop = getattr(urwid, "TwistedEventLoop", None) class StatusbarEdit(urwid.Edit): @@ -156,24 +104,25 @@ class StatusbarEdit(urwid.Edit): This class only adds a single signal that is emitted if the user presses Enter.""" - signals = urwid.Edit.signals + ['prompt_enter'] + signals = urwid.Edit.signals + ["prompt_enter"] def __init__(self, *args, **kwargs): self.single = False - urwid.Edit.__init__(self, *args, **kwargs) + super().__init__(*args, **kwargs) def keypress(self, size, key): if self.single: - urwid.emit_signal(self, 'prompt_enter', self, key) - elif key == 'enter': - urwid.emit_signal(self, 'prompt_enter', self, self.get_edit_text()) + urwid.emit_signal(self, "prompt_enter", self, key) + elif key == "enter": + urwid.emit_signal(self, "prompt_enter", self, self.get_edit_text()) else: - return urwid.Edit.keypress(self, size, key) + return super().keypress(size, key) + -urwid.register_signal(StatusbarEdit, 'prompt_enter') +urwid.register_signal(StatusbarEdit, "prompt_enter") -class Statusbar(object): +class Statusbar: """Statusbar object, ripped off from bpython.cli. This class provides the status bar at the bottom of the screen. @@ -192,20 +141,20 @@ class Statusbar(object): The "widget" attribute is an urwid widget. """ - signals = ['prompt_result'] + signals = ["prompt_result"] def __init__(self, config, s=None, main_loop=None): self.config = config self.timer = None self.main_loop = main_loop - self.s = s or '' + self.s = s or "" - self.text = urwid.Text(('main', self.s)) + self.text = urwid.Text(("main", self.s)) # use wrap mode 'clip' to just cut off at the end of line - self.text.set_wrap_mode('clip') + self.text.set_wrap_mode("clip") - self.edit = StatusbarEdit(('main', '')) - urwid.connect_signal(self.edit, 'prompt_enter', self._on_prompt_enter) + self.edit = StatusbarEdit(("main", "")) + urwid.connect_signal(self.edit, "prompt_enter", self._on_prompt_enter) self.widget = urwid.Columns([self.text, self.edit]) @@ -229,17 +178,17 @@ def _reset_timer(self): def prompt(self, s=None, single=False): """Prompt the user for some input (with the optional prompt 's'). After - the user hit enter the signal 'prompt_result' will be emited and the + the user hit enter the signal 'prompt_result' will be emitted and the status bar will be reset. If single is True, the first keypress will be returned.""" self._reset_timer() self.edit.single = single - self.edit.set_caption(('main', s or '?')) - self.edit.set_edit_text('') + self.edit.set_caption(("main", s or "?")) + self.edit.set_edit_text("") # hide the text and display the edit widget - if not self.edit in self.widget.widget_list: + if self.edit not in self.widget.widget_list: self.widget.widget_list.append(self.edit) if self.text in self.widget.widget_list: self.widget.widget_list.remove(self.text) @@ -248,48 +197,44 @@ def prompt(self, s=None, single=False): def settext(self, s, permanent=False): """Set the text on the status bar to a new value. If permanent is True, the new value will be permanent. If that status bar is in prompt mode, - the prompt will be aborted. """ + the prompt will be aborted.""" self._reset_timer() # hide the edit and display the text widget if self.edit in self.widget.widget_list: self.widget.widget_list.remove(self.edit) - if not self.text in self.widget.widget_list: + if self.text not in self.widget.widget_list: self.widget.widget_list.append(self.text) - self.text.set_text(('main', s)) + self.text.set_text(("main", s)) if permanent: - self.s = s + self.s = s def clear(self): """Clear the status bar.""" - self.settext('') + self.settext("") def _on_prompt_enter(self, edit, new_text): """Reset the statusbar and pass the input from the prompt to the caller via 'prompt_result'.""" self.settext(self.s) - urwid.emit_signal(self, 'prompt_result', new_text) + urwid.emit_signal(self, "prompt_result", new_text) -urwid.register_signal(Statusbar, 'prompt_result') +urwid.register_signal(Statusbar, "prompt_result") -def decoding_input_filter(keys, raw): + +def decoding_input_filter(keys: list[str], _raw: list[int]) -> list[str]: """Input filter for urwid which decodes each key with the locale's preferred encoding.'""" encoding = locale.getpreferredencoding() - converted_keys = list() - for key in keys: - if isinstance(key, basestring): - converted_keys.append(key.decode(encoding)) - else: - converted_keys.append(key) - return converted_keys + return [key.decode(encoding) for key in keys] + def format_tokens(tokensource): for token, text in tokensource: - if text == '\n': + if text == "\n": continue # TODO: something about inversing Parenthesis @@ -299,7 +244,6 @@ def format_tokens(tokensource): class BPythonEdit(urwid.Edit): - """Customized editor *very* tightly interwoven with URWIDRepl. Changes include: @@ -322,19 +266,19 @@ class BPythonEdit(urwid.Edit): - an "edit-pos-changed" signal is emitted when edit_pos changes. """ - signals = ['edit-pos-changed'] + signals = ["edit-pos-changed"] def __init__(self, config, *args, **kwargs): - self._bpy_text = '' + self._bpy_text = "" self._bpy_attr = [] self._bpy_selectable = True self._bpy_may_move_cursor = False self.config = config self.tab_length = config.tab_length - urwid.Edit.__init__(self, *args, **kwargs) + super().__init__(*args, **kwargs) def set_edit_pos(self, pos): - urwid.Edit.set_edit_pos(self, pos) + super().set_edit_pos(pos) self._emit("edit-pos-changed", self.edit_pos) def get_edit_pos(self): @@ -360,7 +304,7 @@ def set_edit_markup(self, markup): self._bpy_text, self._bpy_attr = urwid.decompose_tagmarkup(markup) else: # decompose_tagmarkup in some urwids fails on the empty list - self._bpy_text, self._bpy_attr = '', [] + self._bpy_text, self._bpy_attr = "", [] # This is redundant when we're called off the 'change' signal. # I'm assuming this is cheap, making that ok. self._invalidate() @@ -375,7 +319,7 @@ def get_cursor_coords(self, *args, **kwargs): # urwid gets confused if a nonselectable widget has a cursor position. if not self._bpy_selectable: return None - return urwid.Edit.get_cursor_coords(self, *args, **kwargs) + return super().get_cursor_coords(*args, **kwargs) def render(self, size, focus=False): # XXX I do not want to have to do this, but listbox gets confused @@ -383,40 +327,40 @@ def render(self, size, focus=False): # we just became unselectable, then having this render a cursor) if not self._bpy_selectable: focus = False - return urwid.Edit.render(self, size, focus=focus) + return super().render(size, focus=focus) def get_pref_col(self, size): # Need to make this deal with us being nonselectable if not self._bpy_selectable: - return 'left' - return urwid.Edit.get_pref_col(self, size) + return "left" + return super().get_pref_col(size) def move_cursor_to_coords(self, *args): if self._bpy_may_move_cursor: - return urwid.Edit.move_cursor_to_coords(self, *args) + return super().move_cursor_to_coords(*args) return False def keypress(self, size, key): - if urwid.command_map[key] in ['cursor up', 'cursor down']: + if urwid.command_map[key] in ("cursor up", "cursor down"): # Do not handle up/down arrow, leave them for the repl. return key self._bpy_may_move_cursor = True try: - if urwid.command_map[key] == 'cursor max left': + if urwid.command_map[key] == "cursor max left": self.edit_pos = 0 - elif urwid.command_map[key] == 'cursor max right': + elif urwid.command_map[key] == "cursor max right": self.edit_pos = len(self.get_edit_text()) - elif urwid.command_map[key] == 'clear word': + elif urwid.command_map[key] == "clear word": # ^w if self.edit_pos == 0: return line = self.get_edit_text() # delete any space left of the cursor - p = len(line[:self.edit_pos].strip()) - line = line[:p] + line[self.edit_pos:] + p = len(line[: self.edit_pos].strip()) + line = line[:p] + line[self.edit_pos :] # delete a full word - np = line.rfind(' ', 0, p) + np = line.rfind(" ", 0, p) if np == -1: line = line[p:] np = 0 @@ -424,20 +368,20 @@ def keypress(self, size, key): line = line[:np] + line[p:] self.set_edit_text(line) self.edit_pos = np - elif urwid.command_map[key] == 'clear line': + elif urwid.command_map[key] == "clear line": line = self.get_edit_text() - self.set_edit_text(line[self.edit_pos:]) + self.set_edit_text(line[self.edit_pos :]) self.edit_pos = 0 - elif key == 'backspace': + elif key == "backspace": line = self.get_edit_text() cpos = len(line) - self.edit_pos if not (cpos or len(line) % self.tab_length or line.strip()): - self.set_edit_text(line[:-self.tab_length]) + self.set_edit_text(line[: -self.tab_length]) else: - return urwid.Edit.keypress(self, size, key) + return super().keypress(size, key) else: # TODO: Add in specific keypress fetching code here - return urwid.Edit.keypress(self, size, key) + return super().keypress(size, key) return None finally: self._bpy_may_move_cursor = False @@ -445,21 +389,23 @@ def keypress(self, size, key): def mouse_event(self, *args): self._bpy_may_move_cursor = True try: - return urwid.Edit.mouse_event(self, *args) + return super().mouse_event(*args) finally: self._bpy_may_move_cursor = False + class BPythonListBox(urwid.ListBox): """Like `urwid.ListBox`, except that it does not eat up and down keys. """ + def keypress(self, size, key): - if key not in ["up", "down"]: + if key not in ("up", "down"): return urwid.ListBox.keypress(self, size, key) return key -class Tooltip(urwid.BoxWidget): +class Tooltip(urwid.Widget): """Container inspired by Overlay to position our tooltip. bottom_w should be a BoxWidget. @@ -471,8 +417,11 @@ class Tooltip(urwid.BoxWidget): from the bottom window and hides it if there is no cursor. """ + _sizing = frozenset(["box"]) + _selectable = True + def __init__(self, bottom_w, listbox): - self.__super.__init__() + super().__init__() self.bottom_w = bottom_w self.listbox = listbox @@ -488,11 +437,10 @@ def keypress(self, size, key): def mouse_event(self, size, event, button, col, row, focus): # TODO: pass to top widget if visible and inside it. - if not hasattr(self.bottom_w, 'mouse_event'): + if not hasattr(self.bottom_w, "mouse_event"): return False - return self.bottom_w.mouse_event( - size, event, button, col, row, focus) + return self.bottom_w.mouse_event(size, event, button, col, row, focus) def get_cursor_coords(self, size): return self.bottom_w.get_cursor_coords(size) @@ -521,7 +469,7 @@ def render(self, size, focus=False): # - It is a loop. # (ideally it would check how much free space there is, # instead of repeatedly trying smaller sizes) - while 'bottom' in self.listbox.ends_visible((maxcol - 2, rows - 3)): + while "bottom" in self.listbox.ends_visible((maxcol - 2, rows - 3)): rows -= 1 # If we're displaying above the cursor move the top edge down: @@ -530,8 +478,7 @@ def render(self, size, focus=False): # Render *both* windows focused. This is probably not normal in urwid, # but it works nicely. - top_c = self.top_w.render((maxcol, rows), - focus and self.tooltip_focus) + top_c = self.top_w.render((maxcol, rows), focus and self.tooltip_focus) combi_c = urwid.CanvasOverlay(top_c, bottom_c, 0, y) # Use the cursor coordinates from the bottom canvas. @@ -539,22 +486,24 @@ def render(self, size, focus=False): canvas.cursor = cursor return canvas + class URWIDInteraction(repl.Interaction): def __init__(self, config, statusbar, frame): - repl.Interaction.__init__(self, config, statusbar) + super().__init__(config) + self.statusbar = statusbar self.frame = frame - urwid.connect_signal(statusbar, 'prompt_result', self._prompt_result) + urwid.connect_signal(statusbar, "prompt_result", self._prompt_result) self.callback = None def confirm(self, q, callback): """Ask for yes or no and call callback to return the result""" def callback_wrapper(result): - callback(result.lower() in (_('y'), _('yes'))) + callback(result.lower() in (_("y"), _("yes"))) self.prompt(q, callback_wrapper, single=True) - def notify(self, s, n=10): + def notify(self, s, n=10, wait_for_keypress=False): return self.statusbar.message(s, n) def prompt(self, s, callback=None, single=False): @@ -563,14 +512,14 @@ def prompt(self, s, callback=None, single=False): callback can already start a new prompt.""" if self.callback is not None: - raise Exception('Prompt already in progress') + raise Exception("Prompt already in progress") self.callback = callback self.statusbar.prompt(s, single=single) - self.frame.set_focus('footer') + self.frame.set_focus("footer") def _prompt_result(self, text): - self.frame.set_focus('body') + self.frame.set_focus("body") if self.callback is not None: # The callback might want to start another prompt, so reset it # before calling the callback. @@ -578,13 +527,15 @@ def _prompt_result(self, text): self.callback = None callback(text) + def file_prompt(self, s: str) -> str | None: + raise NotImplementedError -class URWIDRepl(repl.Repl): - _time_between_redraws = .05 # seconds +class URWIDRepl(repl.Repl): + _time_between_redraws = 0.05 # seconds def __init__(self, event_loop, palette, interpreter, config): - repl.Repl.__init__(self, interpreter, config) + super().__init__(interpreter, config) self._redraw_handle = None self._redraw_pending = False @@ -595,29 +546,45 @@ def __init__(self, event_loop, palette, interpreter, config): self.tooltip = urwid.ListBox(urwid.SimpleListWalker([])) self.tooltip.grid = None self.overlay = Tooltip(self.listbox, self.tooltip) - self.stdout_hist = '' + self.stdout_hist = "" # native str (unicode in Py3) self.frame = urwid.Frame(self.overlay) - if urwid.get_encoding_mode() == 'narrow': + if urwid.get_encoding_mode() == "narrow": input_filter = decoding_input_filter else: input_filter = None # This constructs a raw_display.Screen, which nabs sys.stdin/out. self.main_loop = urwid.MainLoop( - self.frame, palette, - event_loop=event_loop, unhandled_input=self.handle_input, - input_filter=input_filter, handle_mouse=False) + self.frame, + palette, + event_loop=event_loop, + unhandled_input=self.handle_input, + input_filter=input_filter, + handle_mouse=False, + ) # String is straight from bpython.cli - self.statusbar = Statusbar(config, - _(" <%s> Rewind <%s> Save <%s> Pastebin " - " <%s> Pager <%s> Show Source ") % - (config.undo_key, config.save_key, config.pastebin_key, - config.last_output_key, config.show_source_key), self.main_loop) + self.statusbar = Statusbar( + config, + _( + " <%s> Rewind <%s> Save <%s> Pastebin " + " <%s> Pager <%s> Show Source " + ) + % ( + config.undo_key, + config.save_key, + config.pastebin_key, + config.last_output_key, + config.show_source_key, + ), + self.main_loop, + ) self.frame.set_footer(self.statusbar.widget) - self.interact = URWIDInteraction(self.config, self.statusbar, self.frame) + self.interact = URWIDInteraction( + self.config, self.statusbar, self.frame + ) self.edits = [] self.edit = None @@ -631,10 +598,10 @@ def __init__(self, event_loop, palette, interpreter, config): # Subclasses of Repl need to implement echo, current_line, cw def echo(self, orig_s): - s = orig_s.rstrip('\n') + s = orig_s.rstrip("\n") if s: if self.current_output is None: - self.current_output = urwid.Text(('output', s)) + self.current_output = urwid.Text(("output", s)) if self.edit is None: self.listbox.body.append(self.current_output) # Focus the widget we just added to force the @@ -652,8 +619,9 @@ def echo(self, orig_s): else: # XXX this assumes this all has "output" markup applied. self.current_output.set_text( - ('output', self.current_output.text + s)) - if orig_s.endswith('\n'): + ("output", self.current_output.text + s) + ) + if orig_s.endswith("\n"): self.current_output = None # If we hit this repeatedly in a loop the redraw is rather @@ -676,7 +644,8 @@ def maybe_redraw(loop, self): self._redraw_handle = None self._redraw_handle = self.main_loop.set_alarm_in( - self._time_between_redraws, maybe_redraw, self) + self._time_between_redraws, maybe_redraw, self + ) self._redraw_time = time.time() else: self._redraw_pending = True @@ -689,12 +658,18 @@ def maybe_redraw(loop, self): def _get_current_line(self): if self.edit is None: - return '' + return "" return self.edit.get_edit_text() + def _set_current_line(self, line): self.edit.set_edit_text(line) - current_line = property(_get_current_line, _set_current_line, None, - "Return the current line (the one the cursor is in).") + + current_line = property( + _get_current_line, + _set_current_line, + None, + "Return the current line (the one the cursor is in).", + ) def cw(self): """Return the current word (incomplete word left of cursor).""" @@ -708,13 +683,12 @@ def cw(self): return # Stolen from cli. TODO: clean up and split out. - if (not text or - (not text[-1].isalnum() and text[-1] not in ('.', '_'))): + if not text or (not text[-1].isalnum() and text[-1] not in (".", "_")): return # Seek backwards in text for the first non-identifier char: for i, c in enumerate(reversed(text)): - if not c.isalnum() and c not in ('.', '_'): + if not c.isalnum() and c not in (".", "_"): break else: # No non-identifiers, return everything. @@ -730,10 +704,16 @@ def cpos(self): def _get_cursor_offset(self): return self.edit.edit_pos + def _set_cursor_offset(self, offset): self.edit.edit_pos = offset - cursor_offset = property(_get_cursor_offset, _set_cursor_offset, None, - "The cursor offset from the beginning of the line") + + cursor_offset = property( + _get_cursor_offset, + _set_cursor_offset, + None, + "The cursor offset from the beginning of the line", + ) def _populate_completion(self): widget_list = self.tooltip.body @@ -741,16 +721,18 @@ def _populate_completion(self): widget_list.pop() # This is just me flailing around wildly. TODO: actually write. if self.complete(): - if self.argspec: + if self.funcprops: # This is mostly just stolen from the cli module. - func_name, args, is_bound, in_arg = self.argspec - args, varargs, varkw, defaults = args[:4] - if py3: - kwonly, kwonly_defaults = args[4:] - else: - kwonly, kwonly_defaults = [], {} - markup = [('bold name', func_name), - ('name', ': (')] + func_name = self.funcprops.func + args = self.funcprops.argspec.args + is_bound = self.funcprops.is_bound_method + in_arg = self.arg_pos + varargs = self.funcprops.argspec.varargs + varkw = self.funcprops.argspec.varkwargs + defaults = self.funcprops.argspec.defaults + kwonly = self.funcprops.argspec.kwonly + kwonly_defaults = self.funcprops.argspec.kwonly_defaults or {} + markup = [("bold name", func_name), ("name", ": (")] # the isinstance checks if we're in a positional arg # (instead of a keyword arg), I think @@ -765,62 +747,61 @@ def _populate_completion(self): else: kw = None - if not k and str(i) == 'self': - color = 'name' + if not k and str(i) == "self": + color = "name" else: - color = 'token' + color = "token" if k == in_arg or i == in_arg: - color = 'bold ' + color + color = "bold " + color - if not py3: - # See issue #138: We need to format tuple unpacking correctly - # We use the undocumented function inspection.strseq() for - # that. Fortunately, that madness is gone in Python 3. - markup.append((color, inspect.strseq(i, str))) - else: - markup.append((color, str(i))) + markup.append((color, str(i))) if kw is not None: - markup.extend([('punctuation', '='), - ('token', kw)]) + markup.extend([("punctuation", "="), ("token", kw)]) if k != len(args) - 1: - markup.append(('punctuation', ', ')) + markup.append(("punctuation", ", ")) if varargs: if args: - markup.append(('punctuation', ', ')) - markup.append(('token', '*' + varargs)) + markup.append(("punctuation", ", ")) + markup.append(("token", "*" + varargs)) if kwonly: if not varargs: if args: - markup.append(('punctuation', ', ')) - markup.append(('punctuation', '*')) + markup.append(("punctuation", ", ")) + markup.append(("punctuation", "*")) for arg in kwonly: if arg == in_arg: - color = 'bold token' + color = "bold token" else: - color = 'token' - markup.extend([('punctuation', ', '), - (color, arg)]) + color = "token" + markup.extend([("punctuation", ", "), (color, arg)]) if arg in kwonly_defaults: - markup.extend([('punctuation', '='), - ('token', kwonly_defaults[arg])]) + markup.extend( + [ + ("punctuation", "="), + ("token", repr(kwonly_defaults[arg])), + ] + ) if varkw: if args or varargs or kwonly: - markup.append(('punctuation', ', ')) - markup.append(('token', '**' + varkw)) - markup.append(('punctuation', ')')) + markup.append(("punctuation", ", ")) + markup.append(("token", "**" + varkw)) + markup.append(("punctuation", ")")) widget_list.append(urwid.Text(markup)) if self.matches_iter.matches: attr_map = {} - focus_map = {'main': 'operator'} - texts = [urwid.AttrMap(urwid.Text(('main', match)), - attr_map, focus_map) - for match in self.matches_iter.matches] + focus_map = {"main": "operator"} + texts = [ + urwid.AttrMap( + urwid.Text(("main", match)), attr_map, focus_map + ) + for match in self.matches_iter.matches + ] width = max(text.original_widget.pack()[0] for text in texts) - gridflow = urwid.GridFlow(texts, width, 1, 0, 'left') + gridflow = urwid.GridFlow(texts, width, 1, 0, "left") widget_list.append(gridflow) self.tooltip.grid = gridflow self.overlay.tooltip_focus = False @@ -834,7 +815,7 @@ def _populate_completion(self): if self.docstring: # TODO: use self.format_docstring? needs a width/height... docstring = self.docstring - widget_list.append(urwid.Text(('comment', docstring))) + widget_list.append(urwid.Text(("comment", docstring))) def reprint_line(self, lineno, tokens): edit = self.edits[-len(self.buffer) + lineno - 1] @@ -844,7 +825,7 @@ def getstdout(self): """This method returns the 'spoofed' stdout buffer, for writing to a file or sending to a pastebin or whatever.""" - return self.stdout_hist + '\n' + return self.stdout_hist + "\n" def ask_confirmation(self, q): """Ask for yes or no and return boolean""" @@ -853,17 +834,16 @@ def ask_confirmation(self, q): except ValueError: return False - return reply.lower() in ('y', 'yes') + return reply.lower() in ("y", "yes") def reevaluate(self): """Clear the buffer, redraw the screen and re-evaluate the history""" self.evaluating = True - self.stdout_hist = '' - self.f_string = '' + self.stdout_hist = "" + self.f_string = "" self.buffer = [] self.scr.erase() - self.s_hist = [] # Set cursor position to -1 to prevent paren matching self.cpos = -1 @@ -871,54 +851,45 @@ def reevaluate(self): self.iy, self.ix = self.scr.getyx() for line in self.history: - if py3: - self.stdout_hist += line + '\n' - else: - self.stdout_hist += line.encode(locale.getpreferredencoding()) + '\n' + self.stdout_hist += line + "\n" self.print_line(line) - self.s_hist[-1] += self.f_string # I decided it was easier to just do this manually # than to make the print_line and history stuff more flexible. - self.scr.addstr('\n') + self.scr.addstr("\n") more = self.push(line) self.prompt(more) self.iy, self.ix = self.scr.getyx() self.cpos = 0 indent = repl.next_indentation(self.s, self.config.tab_length) - self.s = '' + self.s = "" self.scr.refresh() if self.buffer: - for _ in xrange(indent): + for unused in range(indent): self.tab() self.evaluating = False - #map(self.push, self.history) - #^-- That's how simple this method was at first :( + # map(self.push, self.history) + # ^-- That's how simple this method was at first :( def write(self, s): """For overriding stdout defaults""" - if '\x04' in s: - for block in s.split('\x04'): + if "\x04" in s: + for block in s.split("\x04"): self.write(block) return - if s.rstrip() and '\x03' in s: - t = s.split('\x03')[1] + if s.rstrip() and "\x03" in s: + t = s.split("\x03")[1] else: t = s - if not py3 and isinstance(t, unicode): - t = t.encode(locale.getpreferredencoding()) - if not self.stdout_hist: self.stdout_hist = t else: self.stdout_hist += t self.echo(s) - self.s_hist.append(s.rstrip()) - def push(self, s, insert_into_history=True): # Restore the original SIGINT handler. This is needed to be able @@ -928,8 +899,8 @@ def push(self, s, insert_into_history=True): signal.signal(signal.SIGINT, signal.default_int_handler) # Pretty blindly adapted from bpython.cli try: - return repl.Repl.push(self, s, insert_into_history) - except SystemExit, e: + return super().push(s, insert_into_history) + except SystemExit as e: self.exit_value = e.args raise urwid.ExitMainLoop() except KeyboardInterrupt: @@ -953,12 +924,12 @@ def keyboard_interrupt(self): self.edit.make_readonly() self.edit = None self.buffer = [] - self.echo('KeyboardInterrupt') + self.echo("KeyboardInterrupt") self.prompt(False) else: # I do not quite remember if this is reachable, but let's # be safe. - self.echo('KeyboardInterrupt') + self.echo("KeyboardInterrupt") def prompt(self, more): # Clear current output here, or output resulting from the @@ -967,27 +938,24 @@ def prompt(self, more): self.current_output = None # XXX is this the right place? self.rl_history.reset() - # XXX what is s_hist? # We need the caption to use unicode as urwid normalizes later # input to be the same type, using ascii as encoding. If the # caption is bytes this breaks typing non-ascii into bpython. - # Currently this decodes using ascii as I do not know where - # ps1 is getting loaded from. If anyone wants to make - # non-ascii prompts work feel free to fix this. if not more: - caption = ('prompt', self.ps1.decode('ascii')) + caption = ("prompt", self.ps1) self.stdout_hist += self.ps1 else: - caption = ('prompt_more', self.ps2.decode('ascii')) + caption = ("prompt_more", self.ps2) self.stdout_hist += self.ps2 self.edit = BPythonEdit(self.config, caption=caption) - urwid.connect_signal(self.edit, 'change', self.on_input_change) - urwid.connect_signal(self.edit, 'edit-pos-changed', - self.on_edit_pos_changed) + urwid.connect_signal(self.edit, "change", self.on_input_change) + urwid.connect_signal( + self.edit, "edit-pos-changed", self.on_edit_pos_changed + ) # Do this after connecting the change signal handler: - self.edit.insert_text(4 * self.next_indentation() * ' ') + self.edit.insert_text(4 * self.next_indentation() * " ") self.edits.append(self.edit) self.listbox.body.append(self.edit) self.listbox.set_focus(len(self.listbox.body) - 1) @@ -1004,7 +972,8 @@ def on_input_change(self, edit, text): # If we call this synchronously the get_edit_text() in repl.cw # still returns the old text... self.main_loop.set_alarm_in( - 0, lambda *args: self._populate_completion()) + 0, lambda *args: self._populate_completion() + ) def on_edit_pos_changed(self, edit, position): """Gets called when the cursor position inside the edit changed. @@ -1016,42 +985,42 @@ def on_edit_pos_changed(self, edit, position): def handle_input(self, event): # Since most of the input handling here should be handled in the edit # instead, we return here early if the edit doesn't have the focus. - if self.frame.get_focus() != 'body': + if self.frame.get_focus() != "body": return - if event == 'enter': + if event == "enter": inp = self.edit.get_edit_text() self.history.append(inp) self.edit.make_readonly() - # XXX what is this s_hist thing? - self.stdout_hist += inp.encode(locale.getpreferredencoding()) + '\n' + self.stdout_hist += inp + self.stdout_hist += "\n" self.edit = None # This may take a while, so force a redraw first: self.main_loop.draw_screen() more = self.push(inp) self.prompt(more) - elif event == 'ctrl d': + elif event == "ctrl d": # ctrl+d on an empty line exits, otherwise deletes if self.edit is not None: if not self.edit.get_edit_text(): raise urwid.ExitMainLoop() else: - self.main_loop.process_input(['delete']) - elif urwid.command_map[event] == 'cursor up': + self.main_loop.process_input(["delete"]) + elif urwid.command_map[event] == "cursor up": # "back" from bpython.cli self.rl_history.enter(self.edit.get_edit_text()) - self.edit.set_edit_text('') + self.edit.set_edit_text("") self.edit.insert_text(self.rl_history.back()) - elif urwid.command_map[event] == 'cursor down': + elif urwid.command_map[event] == "cursor down": # "fwd" from bpython.cli self.rl_history.enter(self.edit.get_edit_text()) - self.edit.set_edit_text('') + self.edit.set_edit_text("") self.edit.insert_text(self.rl_history.forward()) - elif urwid.command_map[event] == 'next selectable': + elif urwid.command_map[event] == "next selectable": self.tab() - elif urwid.command_map[event] == 'prev selectable': + elif urwid.command_map[event] == "prev selectable": self.tab(True) - #else: + # else: # self.echo(repr(event)) def tab(self, back=False): @@ -1076,7 +1045,7 @@ def tab(self, back=False): if not num_spaces: num_spaces = self.config.tab_length - self.edit.insert_text(' ' * num_spaces) + self.edit.insert_text(" " * num_spaces) return True if not self.matches_iter: @@ -1084,8 +1053,6 @@ def tab(self, back=False): cw = self.current_string() or self.cw() if not cw: return True - else: - cw = self.matches_iter.current_word if self.matches_iter.is_cseq(): cursor, text = self.matches_iter.substitute_cseq() @@ -1095,7 +1062,7 @@ def tab(self, back=False): if back: self.matches_iter.previous() else: - self.matches_iter.next() + next(self.matches_iter) cursor, text = self.matches_iter.cur_line() self.edit.set_edit_text(text) self.edit.edit_pos = cursor @@ -1106,44 +1073,84 @@ def tab(self, back=False): finally: self._completion_update_suppressed = False + def main(args=None, locals_=None, banner=None): translations.init() + def options_callback(group): + group.add_argument( + "--twisted", + "-T", + action="store_true", + help=_("Run twisted reactor."), + ) + group.add_argument( + "--reactor", + "-r", + help=_( + "Select specific reactor (see --help-reactors). " + "Implies --twisted." + ), + ) + group.add_argument( + "--help-reactors", + action="store_true", + help=_("List available reactors for -r."), + ) + group.add_argument( + "--plugin", + "-p", + help=_( + "twistd plugin to run (use twistd for a list). " + 'Use "--" to pass further options to the plugin.' + ), + ) + group.add_argument( + "--server", + "-s", + type=int, + help=_("Port to run an eval server on (forces Twisted)."), + ) + # TODO: maybe support displays other than raw_display? - config, options, exec_args = bpargs.parse(args, ( - 'Urwid options', None, [ - Option('--twisted', '-T', action='store_true', - help=_('Run twisted reactor.')), - Option('--reactor', '-r', - help=_('Select specific reactor (see --help-reactors). ' - 'Implies --twisted.')), - Option('--help-reactors', action='store_true', - help=_('List available reactors for -r.')), - Option('--plugin', '-p', - help=_('twistd plugin to run (use twistd for a list). ' - 'Use "--" to pass further options to the plugin.')), - Option('--server', '-s', type='int', - help=_('Port to run an eval server on (forces Twisted).')), - ])) + config, options, exec_args = bpargs.parse( + args, + ( + "Urwid options", + None, + options_callback, + ), + ) if options.help_reactors: try: from twisted.application import reactors + # Stolen from twisted.application.app (twistd). for r in reactors.getReactorTypes(): - print ' %-4s\t%s' % (r.shortName, r.description) + print(f" {r.shortName:<4}\t{r.description}") except ImportError: - sys.stderr.write('No reactors are available. Please install ' - 'twisted for reactor support.\n') + sys.stderr.write( + "No reactors are available. Please install " + "twisted for reactor support.\n" + ) return palette = [ - (name, COLORMAP[color.lower()], 'default', - 'bold' if color.isupper() else 'default') - for name, color in config.color_scheme.iteritems()] - palette.extend([ - ('bold ' + name, color + ',bold', background, monochrome) - for name, color, background, monochrome in palette]) + ( + name, + COLORMAP[color.lower()], + "default", + "bold" if color.isupper() else "default", + ) + for name, color in config.color_scheme.items() + ] + palette.extend( + [ + ("bold " + name, color + ",bold", background, monochrome) + for name, color, background, monochrome in palette + ] + ) if options.server or options.plugin: options.twisted = True @@ -1152,8 +1159,10 @@ def main(args=None, locals_=None, banner=None): try: from twisted.application import reactors except ImportError: - sys.stderr.write('No reactors are available. Please install ' - 'twisted for reactor support.\n') + sys.stderr.write( + "No reactors are available. Please install " + "twisted for reactor support.\n" + ) return try: # XXX why does this not just return the reactor it installed? @@ -1161,16 +1170,17 @@ def main(args=None, locals_=None, banner=None): if reactor is None: from twisted.internet import reactor except reactors.NoSuchReactor: - sys.stderr.write('Reactor %s does not exist\n' % ( - options.reactor,)) + sys.stderr.write(f"Reactor {options.reactor} does not exist\n") return event_loop = TwistedEventLoop(reactor) elif options.twisted: try: from twisted.internet import reactor except ImportError: - sys.stderr.write('No reactors are available. Please install ' - 'twisted for reactor support.\n') + sys.stderr.write( + "No reactors are available. Please install " + "twisted for reactor support.\n" + ) return event_loop = TwistedEventLoop(reactor) else: @@ -1179,46 +1189,47 @@ def main(args=None, locals_=None, banner=None): event_loop = None # TODO: there is also a glib event loop. Do we want that one? - # __main__ construction from bpython.cli - if locals_ is None: - main_mod = sys.modules['__main__'] = ModuleType('__main__') - locals_ = main_mod.__dict__ - + extend_locals = {} if options.plugin: try: from twisted import plugin from twisted.application import service except ImportError: - sys.stderr.write('No twisted plugins are available. Please install ' - 'twisted for twisted plugin support.\n') + sys.stderr.write( + "No twisted plugins are available. Please install " + "twisted for twisted plugin support.\n" + ) return for plug in plugin.getPlugins(service.IServiceMaker): if plug.tapname == options.plugin: break else: - sys.stderr.write('Plugin %s does not exist\n' % (options.plugin,)) + sys.stderr.write(f"Plugin {options.plugin} does not exist\n") return plugopts = plug.options() plugopts.parseOptions(exec_args) serv = plug.makeService(plugopts) - locals_['service'] = serv + extend_locals["service"] = serv reactor.callWhenRunning(serv.startService) exec_args = [] - interpreter = repl.Interpreter(locals_, locale.getpreferredencoding()) + interpreter = repl.Interpreter(locals_) + # TODO: replace with something less hack-ish + interpreter.locals.update(extend_locals) # This nabs sys.stdin/out via urwid.MainLoop myrepl = URWIDRepl(event_loop, palette, interpreter, config) if options.server: factory = EvalFactory(myrepl) - reactor.listenTCP(options.server, factory, interface='127.0.0.1') + reactor.listenTCP(options.server, factory, interface="127.0.0.1") if options.reactor: # Twisted sets a sigInt handler that stops the reactor unless # it sees a different custom signal handler. def sigint(*args): reactor.callFromThread(myrepl.keyboard_interrupt) + signal.signal(signal.SIGINT, sigint) # Save stdin, stdout and stderr for later restoration @@ -1232,6 +1243,7 @@ def sigint(*args): # are called before we get around to starting the mainloop # (urwid raises an exception if we try to draw to the screen # before starting it). + def run_with_screen_before_mainloop(): try: # Currently we just set this to None because I do not @@ -1244,7 +1256,7 @@ def run_with_screen_before_mainloop(): # cannot re-enter the reactor. If using urwid's own # mainloop we *might* be able to do something similar and # re-enter its mainloop. - sys.stdin = None #FakeStdin(myrepl) + sys.stdin = None # FakeStdin(myrepl) sys.stdout = myrepl sys.stderr = myrepl @@ -1261,7 +1273,8 @@ def run_with_screen_before_mainloop(): # up an equivalent to reactor.callFromThread (which # is what our Twisted sigint handler does) myrepl.main_loop.set_alarm_in( - 0, lambda *args: myrepl.keyboard_interrupt()) + 0, lambda *args: myrepl.keyboard_interrupt() + ) continue break @@ -1277,31 +1290,37 @@ def start(main_loop, user_data): if not options.interactive: raise urwid.ExitMainLoop() if not exec_args: - sys.path.insert(0, '') + sys.path.insert(0, "") # this is CLIRepl.startup inlined. - filename = os.environ.get('PYTHONSTARTUP') + filename = os.environ.get("PYTHONSTARTUP") if filename and os.path.isfile(filename): - with open(filename, 'r') as f: - if py3: - interpreter.runsource(f.read(), filename, 'exec') - else: - interpreter.runsource(f.read(), filename, 'exec', - encode=False) + with open(filename) as f: + interpreter.runsource(f.read(), filename, "exec") if banner is not None: myrepl.write(banner) - myrepl.write('\n') + myrepl.write("\n") + + # XXX these deprecation warnings need to go at some point + myrepl.write( + _( + "WARNING: You are using `bpython-urwid`, the urwid backend for `bpython`. This backend has been deprecated in version 0.19 and might disappear in a future version." + ) + ) + myrepl.write("\n") + myrepl.start() # This bypasses main_loop.set_alarm_in because we must *not* # hit the draw_screen call (it's unnecessary and slow). def run_find_coroutine(): - if find_coroutine(): + if myrepl.module_gatherer.find_coroutine(): main_loop.event_loop.alarm(0, run_find_coroutine) run_find_coroutine() - myrepl.main_loop.screen.run_wrapper(run_with_screen_before_mainloop) + with myrepl.main_loop.screen.start(): + run_with_screen_before_mainloop() if config.flush_output and not options.quiet: sys.stdout.write(myrepl.getstdout()) @@ -1309,17 +1328,19 @@ def run_find_coroutine(): sys.stdout.flush() return repl.extract_exit_value(myrepl.exit_value) + def load_urwid_command_map(config): - urwid.command_map[key_dispatch[config.up_one_line_key]] = 'cursor up' - urwid.command_map[key_dispatch[config.down_one_line_key]] = 'cursor down' - urwid.command_map[key_dispatch['C-a']] = 'cursor max left' - urwid.command_map[key_dispatch['C-e']] = 'cursor max right' - urwid.command_map[key_dispatch[config.pastebin_key]] = 'pastebin' - urwid.command_map[key_dispatch['C-f']] = 'cursor right' - urwid.command_map[key_dispatch['C-b']] = 'cursor left' - urwid.command_map[key_dispatch['C-d']] = 'delete' - urwid.command_map[key_dispatch[config.clear_word_key]] = 'clear word' - urwid.command_map[key_dispatch[config.clear_line_key]] = 'clear line' + urwid.command_map[key_dispatch[config.up_one_line_key]] = "cursor up" + urwid.command_map[key_dispatch[config.down_one_line_key]] = "cursor down" + urwid.command_map[key_dispatch["C-a"]] = "cursor max left" + urwid.command_map[key_dispatch["C-e"]] = "cursor max right" + urwid.command_map[key_dispatch[config.pastebin_key]] = "pastebin" + urwid.command_map[key_dispatch["C-f"]] = "cursor right" + urwid.command_map[key_dispatch["C-b"]] = "cursor left" + urwid.command_map[key_dispatch["C-d"]] = "delete" + urwid.command_map[key_dispatch[config.clear_word_key]] = "clear word" + urwid.command_map[key_dispatch[config.clear_line_key]] = "clear line" + """ 'clear_screen': 'C-l', @@ -1335,5 +1356,5 @@ def load_urwid_command_map(config): 'up_one_line': 'C-p', 'yank_from_buffer': 'C-y'}, """ -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/data/bpython b/data/bpython deleted file mode 100755 index 780e7a0de..000000000 --- a/data/bpython +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python - -import sys -from bpython.cli import main - -sys.exit(main()) diff --git a/data/bpython-curtsies b/data/bpython-curtsies deleted file mode 100755 index ac4ced087..000000000 --- a/data/bpython-curtsies +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python - -import sys -from bpython.curtsies import main - -sys.exit(main()) diff --git a/data/bpython-urwid b/data/bpython-urwid deleted file mode 100755 index 3d8f01b3e..000000000 --- a/data/bpython-urwid +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python - -import sys -from bpython.urwid import main - -sys.exit(main()) diff --git a/bpython/logo.png b/data/bpython.png similarity index 100% rename from bpython/logo.png rename to data/bpython.png diff --git a/data/bpython.desktop b/data/org.bpython-interpreter.bpython.desktop similarity index 80% rename from data/bpython.desktop rename to data/org.bpython-interpreter.bpython.desktop index 09907029e..d28912bcc 100644 --- a/data/bpython.desktop +++ b/data/org.bpython-interpreter.bpython.desktop @@ -1,5 +1,5 @@ [Desktop Entry] -Icon=/usr/share/pixmaps/python.xpm +Icon=bpython Name=bpython Comment=A fancy interface to the python interpreter! Exec=/usr/bin/bpython @@ -7,3 +7,4 @@ Terminal=true Type=Application Categories=Development;Utility;ConsoleOnly; StartupNotify=true +Keywords=Python;REPL;interpreter; diff --git a/data/org.bpython-interpreter.bpython.metainfo.xml b/data/org.bpython-interpreter.bpython.metainfo.xml new file mode 100644 index 000000000..4830595be --- /dev/null +++ b/data/org.bpython-interpreter.bpython.metainfo.xml @@ -0,0 +1,54 @@ + + + + org.bpython-interpreter.bpython + CC0-1.0 + MIT + bpython interpreter + Fancy interface for the Python interpreter + +

+ bpython is a fancy interface to the Python interpreter. It has the + following features: +

+
    +
  • In-line syntax highlighting.
  • +
  • Readline-like autocomplete with suggestion displayed as you type.
  • +
  • Expected parameter list for any Python function.
  • +
  • "Rewind" function to pop the last line of code from memory and re-evaluate.
  • +
  • Send the code you've entered off to a pastebin.
  • +
  • Save the code you've entered to a file.
  • +
  • Auto-indentation.
  • +
+
+ org.bpython-interpreter.bpython.desktop + https://www.bpython-interpreter.org/ + https://github.com/bpython/bpython/issues + + + https://bpython-interpreter.org/images/8.png + + + https://bpython-interpreter.org/images/1.png + + + https://bpython-interpreter.org/images/2.png + + + https://bpython-interpreter.org/images/3.png + + + https://bpython-interpreter.org/images/4.png + + + https://bpython-interpreter.org/images/5.png + + + https://bpython-interpreter.org/images/6.png + + + https://bpython-interpreter.org/images/7.png + + + bpython@googlegroups.com +
diff --git a/doc/sphinx/source/authors.rst b/doc/sphinx/source/authors.rst index 697474ef1..d475229e0 100644 --- a/doc/sphinx/source/authors.rst +++ b/doc/sphinx/source/authors.rst @@ -5,28 +5,4 @@ Authors If you contributed to bpython and want to be on this list please find us (:ref:`community`) and let us know! -bpython is written and maintained by Bob Farrell -. - -Other contributors are (in alphabetical order): - -* Thomas Ballinger -* Federico Ceratto -* Ingrid Cheung -* Maja Frydrychowicz -* Martha Girdler -* Eike Hein -* Allison Kaptur -* Jason Laster -* Brandon Navra -* Michele Orrù -* Pavel Panchekha -* Sebastian Ramacher -* Amjith Ramanujam -* Andreas Stührk -* Simon de Vlieger -* Marien Zwart - -A big thanks goes out to all the people who help us out by either submitting -patches, helping us determine problems, our package maintainers and of course -everybody who creates issues for us to fix. +.. include:: ../../../AUTHORS.rst diff --git a/doc/sphinx/source/bpaste.rst b/doc/sphinx/source/bpaste.rst index 9f33bb3e0..a91ef6317 100644 --- a/doc/sphinx/source/bpaste.rst +++ b/doc/sphinx/source/bpaste.rst @@ -7,6 +7,5 @@ configured by default to paste to this pastebin. Removal ------- -If you want a paste removed from the pastebin you can email Simon at -simon@ikanobori.jp and he will remove the paste for you, be sure to mention the -paste URL. +If you want a paste removed from the pastebin you can use the removal link as +shown by bpython. Refer to https://bpaste.net/removal if you lost it. diff --git a/doc/sphinx/source/bpdb.rst b/doc/sphinx/source/bpdb.rst index e0b0174f1..eca017139 100644 --- a/doc/sphinx/source/bpdb.rst +++ b/doc/sphinx/source/bpdb.rst @@ -14,4 +14,4 @@ This will drop you into bpdb instead of pdb, which works exactly like pdb except that you can additionally start bpython at the current stack frame by issuing the command `Bpython` or `B`. -You can exit bpython with `^D` to return to bpdb. +You can exit bpython with `^d` to return to bpdb. diff --git a/doc/sphinx/source/changelog.rst b/doc/sphinx/source/changelog.rst index 6a8f25bed..29e651cab 100644 --- a/doc/sphinx/source/changelog.rst +++ b/doc/sphinx/source/changelog.rst @@ -1,536 +1,3 @@ -Changelog -========= - -0.13 ----- - -There are a few new features, a bunch of bugfixes, and a new frontend -for bpython in this release. - -* Dictionary key completion, thanks to Maja Frydrychowicz (#226). - To use normal completion and ignore these key completions, type a space. -* Edit current line in external editor: ctrl-x (#161) - -Fixes: - -* Python 2.5 compatibility, thanks to Michael Schuller (#279). Python 2.5 - is not officially supported, but after few changes Michael introduced, he - says it's working fine. -* FakeStream has flush(), so works correctly with - django.core.email.backends.console thanks to Marc Sibson (#259) -* FakeStdin has fileno() (#232) -* Changes to sys.ps1 and sys.ps2 are respected thanks to Michael Schulle (#267) -* atexit registered functions run on exit (#258) -* fixed an error on exit code when running a script with bpython script.py (#260) -* setup.py extras are used to define dependencies for urwid and - curtsies frontends - -There's a new frontend for bpython: bpython-curtsies. Curtsies is a terminal -wrapper written to making native scrolling work in bpython. (#56, #245) -Try bpython-curtsies for the bpython experience with a vanilla python -layout. (demo: -http://ballingt.com/assets/bpython-curtsies-scroll-demo-large.gif) - -This curtsies frontend addresses some issues unfixed in bpython-cli, and has -a few extra features: - -* Editing full interpreter history in external editor with F7, which is rerun - as in rewind -* A new interpreter is used for rewind, unless bpython-curtsies was started - with custom locals or in interactive mode (#71) -* Ctrl-c behaves more like vanilla python (#177) -* Completion still works if cursor at the end of the line (#147) -* Movement keys meta-b, meta-f, and meta-backspace, ctrl-left and ctrl-right - are all honored (#246, #201) -* Non-ascii characters work in the file save prompt (#236) -* New --type / -t option to run the contents of a file as though they were - typed into the bpython-curtsies prompt - -A few things about bpython-curtsies are worse than regular bpython: - -* Bad things can happen when using several threads (#265) -* output prints slowly (#262) -* bpython-curtsies can't be backgrounded and resumed correctly (via ctrl-z, - fg) (#274) - -There are two new options in the new [curtsies] section of the bpython config - -* list_above: whether completion window can cover text above the current line; - defaults to True -* fill_terminal: whether bpython-curtsies should be fullscreen (like bpython); - defaults to False - -0.12 ----- - -We want to give special thanks to the Hacker School project- -(https://www.hackerschool.com/) for choosing bpython as their pet hacking -project. In special we would like to thank the following people for contributing -their code to bpython: - -- Martha Girdler -- Allison Kaptur -- Ingrid Cheung - -We'd also like to thank Eike Hein for contributing his pastebin code which now -makes it possible to paste using a 3rd party program unlocking a whole slew of -pastebins for bpython users. - -* Added a new pastebin_helper config option to name an executable that should - perform pastebin upload on bpython's behalf. If set, this overrides - pastebin_url. Data is supplied to the helper via STDIN, and it is expected - to return a pastebin URL as the first word of its output. -* Fixed a bug causing pastebin upload to fail after a previous attempt was - unsuccessful. A duplicate pastebin error would be displayed in this case, - despite the original upload having failed. -* Added more key shortcuts to bpython.urwid -* Smarter dedenting after certain expressions -* #74 fixed broken completion when auto_display_list was disabled - -We also have done numerous cleanup actions including building the man pages from -our documentation. Including the documentation in the source directory. Some -minor changes to the README to have EOL 79 and changes to urwid to work better -without twisted installed. - -* Fix ungetch issues with Python 3.3. See issues #230, #231. - -v0.11 ------ - -A bugfix/cleanup release .The fixed bugs are: - -* #204: "import math" not autocompleting on python 3.2 - -Otherwise lots of small additions to the to be replacement for our ncurses -frontend, the urwid frontend. - -I'd like to specifically thank Amjith Ramanujam for his work on history search -which was further implemented and is in working order right now. - -v0.10.1 -------- - -A bugfix release. The fixed bugs are: - -* #197: find_modules crashes on non-readable directories -* #198: Source tarball lacks .po files - -v0.10 ------ -As a highlight of the release, Michele Orrù added i18n support to bpython. - -Some issues have been resolved as well: - -* Config files are now located according to the XDG Base Directory - Specification. The support for the old bpythonrc files has been - dropped and ~/.bpython.ini as config file location is no longer supported. - See issue #91. -* Fixed some issues with tuple unpacking in argspec. See issues #133 and #138. -* Fixed a crash with non-ascii filenames in import completion. See issue #139. -* Fixed a crash caused by inspect.findsource() raising an IndexError - which happens in some situations. See issue #94. -* Non-ascii input should work now under Python 3. -* Issue #165: C-a and C-e do the right thing now in urwid. -* The short command-line option "-c config" was dropped as it conflicts with - vanilla Python's "-c command" option. See issue #186. - -v0.9.7.1 --------- - -A bugfix release. The fixed bugs are: - -* #128: bpython-gtk is broken -* #134: crash when using pastebin and no active internet connection - -v0.9.7 ------- - -Well guys. It's been some time since the latest release, six months have passed -We have added a whole slew of new features, and closed a number of bugs as well. - -We also have a new frontend for bpython. Marien Zwart contributed a urwid -frontend as an alternative for the curses frontend. Be aware that there still -is a lot to fix for this urwid frontend (a lot of the keyboard shortcuts do not -yet work for example) but please give it a good spin. Urwid also optionally -integrates with a Twisted reactor and through that with things like the GTK -event loop. - -At the same time we have done a lot of work on the GTK frontend. The GTK -frontend is now 'usable'. Please give that a spin as well by running bpython-gtk -on you system. - -We also welcome a new contributor in the name of Michele Orrù who we hope will -help us fix even more bugs and improve functionality. - -As always, please submit any bugs you might find to our bugtracker. - -* Pastebin confirmation added; we were getting a lot of people accidentally - pastebinning sensitive information so I think this is a good idea. -* Don't read PYTHONSTARTUP when executed with -i. -* BPDB was merged in. BPDB is an extension to PDB which allows you to press B - in a PDB session which will let you be dropped into a bpython sessions with - the current PDB locals(). For usage, see the documentation. -* The clear word shortcut (default: C-w) now deletes to the buffer. -* More tests have been added to bpython. -* The pastebin now checks for a previous paste (during the session) with the - exact same content to guard against twitchy fingers pastebinning multiple - times. -* Let import completion return "import " instead of "import". - -* GTK now has pastebin, both for full log as well as the current selection. -* GTK now has write2file. -* GTK now has a menu. -* GTK now has a statusbar. -* GTK now has show source functionality. -* GTK saves the pastebin url to the clipboard. -* GTK now has it's own configuration section. -* Set focus to the GTK text widget to allow for easier embedding in PIDA and - others which fixes issues #121. - -* #87: Add a closed attribute to Repl to fix mercurial.ui.ui expecting stderr - to have this attribute. -* #108: Unicode characters in docsrting crash bpython -* #118: Load_theme is not defined. -* #99: Configurable font now documented. -* #123: Pastebin can't handle 'ESC' key -* #124: Unwanted input when using / keys in the statusbar prompt. - - -v0.9.6.2 --------- -Unfortunately another bugfix release as I (Bob) broke py3 support. - -* #84: bpython doesn't work with Python 3 - Thanks very much to Henry Prêcheur for both the bug report and the - patch. - -v0.9.6.1 --------- -A quick bugfix release (this should not become a habit). - -* #82: Crash on saving file. - -v0.9.6 ------- -A bugfix/feature release (and a start at gtk). Happy Christmas everyone! - -* #67: Make pastebin URL really configurable. -* #68: Set a__main__ module and set interpreter's namespace to that module. -* #70: Implement backward completion on backward tab. -* #62: Hide matches starting with a _ unless explicitly typed. -* #72: Auto dedentation -* #78: Theme without a certain value raises exception - -- add the possibility for a banner to be shown on bpython startup (when - embedded or in code) written by Caio Romao. -- add a hack to add a write() method to our fake stdin object -- Don't use curses interface when stdout is not attached to a terminal. -- PEP-8 conformance. -- Only restore indentation when inside a block. -- Do not decrease the lineno in tracebacks for Py3 -- Do not add internal code to history. -- Make paren highlighting more accurate. -- Catch SyntaxError in import completion. -- Remove globals for configuration. -- rl_history now stays the same, also after undo. - -v0.9.5.2 --------- - -A bugfix release. Fixed issues: - -* #60: Filename expansion: Cycling completions and deleting -* #61: Filename expansion: Directory names with '.'s get mangled - -Other fixes without opened issues: - -* Encode items in the suggestion list properly -* Expand usernames in file completion correctly -* future imports in startup scripts can influence interpreter's behaviour now -* Show the correct docstring for types without a own __init__ method - -v0.9.5.1 --------- - -Added missing data files to the tarball. - - -v0.9.5 ------- -Fixed issues: - -* #25 Problems with DEL, Backspace and C-u over multiple lines -* #49 Sending last output to $PAGER -* #51 Ability to embed bpython shell into an existing script -* #52 FakeStdin.readlines() is broken -* #53 Error on printing null character -* #54 Parsing/introspection ncurses viewer neglects parenthesis - -bpython has added a view source shortcut to show the source of the current -function. - -The history file is now really configurable. This issue was reported -in Debian's bugtracker. - -bpython has now some basic support for Python 3 (requires Pygments >=1.1.1). -As a result, setuptools is now optional. - -The pastebin URL is now configurable and the default pastebin is now -bpaste.net - -Argument names are now shown as completion suggestions and one can -tab through the completion list. - -v0.9.4 ------- -Bugfix release (mostly) - -* when typing a float literal bpython autocompletes int methods (#36) -* Autocompletion for file names (#40) -* Indenting doesn't reset (#27) -* bpython configuration has moved from ~/.bpython.ini to ~/.bpython/config (currently still supporting fallback) -* leftovers of statusbar when exiting bpython cleaned up -* bpython now does not crash when a 'popup' goes out of window bounds -* numerous fixes and improvements to parentheses highlighting -* made *all* keys configurable (except for arrow keys/pgup/pgdown) - -v0.9.3 ------- -This release was a true whopper! - -* Full unicode support -* Configurable hotkey support -* Theming support -* Pastemode, disables syntax highlighting during a paste for faster pasting, highlights when done -* Parentheses matching -* Argument highlighting - -v0.9.2 ------- -* help() now uses an external pager if available. -* Fix for highlighting prefixed strings. -* Fix to reset string highlighting after a SyntaxError. -* bpython now uses optparse for option parsing and it supports --version now. -* Configuration files are no longer passed by the first command line argument but by the -c command line switch. -* Fix for problem related to editing lines in the history (#10) - -v0.9.1 ------- -* Fixed a small but annoying bug with sys.argv ini file passing -* Fix for Python 2.6 to monkeypatch they way it detects callables in rlcompleter -* Config file conversion fix - -v0.9.0 ------- -* Module import completion added. -* Changed to paste.pocoo.org due to rafb.net no longer offering a pastebin service. -* Switched to .ini file format for config file. -* White background-friendly colour scheme added. -* C-l now clears the screen. -* SyntaxError now correctly added to history to prevent it garbling up on a redraw. - -Probably some other things, but I hate changelogs. :) - -v0.8.0 ------- - -It's been a long while since the last release and there've been numerous little -bugfixes and extras here and there so I'm putting this out as 0.8.0. Check the -hg commit history if you want more info: -http://bitbucket.org/bobf/bpython/ - -v0.7.2 ------- -Menno sent me some patches to fix some stuff: - -* Socket error handled when submitting to a pastebin. -* Resizing could crash if you resize small enough. - -Other stuff: - -* 'self' in arg list is now highlighted a different colour. -* flush_output option added to config to control whether output is flushed to stdout or not on exit. -* Piping something to bpython made it lock up as stdin was not the keyboard - bpython just executes stdin and exits instead of trying to do something clever. -* Mark Florisson (eggy) gave me a patch that stops weird breakage when unicode objects get added into the output buffer - they now get encoded into the output encoding. -* Bohdan Vlasyuk sent me a patch that fixes a problem with the above patch from Mark if sys.__stdout__.encoding didn't exist. -* Save to file now outputs executable code (i.e. without the >>> and ... and with "# OUT: " prepended to all output lines). I never used this feature much but someone asked for this behaviour. - -v0.7.1 ------- -* Added support for a history file, defaults to ~/.pythonhist and 100 lines but is configurable from the rc file (see sample-rc). -* Charles Duffy has added a yank/put thing - C-k and C-y. He also ran the code through some PEP-8 checker thing and fixed up a few old habits I manage to break but didn't manage to fix the code to reflect this - thank you! -* Jørgen Tjernø has fixed up the autoindentation issues we encountered when bringing soft tabs in. -* SyntaxError, ValueError and OverflowError are now caught properly (code.InteractiveInterpreter treats these as different to other exceptions as it doesn't print the whole traceback, so a different handler is called). This was discovered as I was trying to stop autoindentation from occurring on a SyntaxError, which has also been fixed. -* '.' now in sys.path on startup. - -v0.7.0 ------- -C-d behaviour changed so it no longer exits if the current line isn't empty. - -Extra linebreak added to end of stdout flush. - -pygments and pyparsing are now dependencies. - -Jørgen Tjernø has done lots of cool things like write a manpage and .desktop -file and improved the way tabbing works and also added home, end and del key -handling as well as C-w for deleting words - thanks a lot! - -raw_input() and all its friends now work fine. - -PYTHONSTARTUP handled without blowing up on stupid errors (it now parses the -file at once instead of feeding it to the repl line-by-line). - -v0.6.4 ------- -KeyboardInterrupt handler clears the list window properly now. - -v0.6.3 ------- -Forgot to switch rpartition to split for 2.4 compat. - -v0.6.2 ------- -The help() now works (as far as I can see) exactly the same -as the vanilla help() in the regular interpreter. I copied some -code from pydoc.py to make it handle the special cases, e.g. -help('keywords') -help('modules') -etc. - -v0.6.1 ------- -Somehow it escaped my attention that the list window was never -fully using the rightmost column, except for the first row. This -is because me and numbers don't have the best relationship. I think -stability is really improving with the latest spat of bugfixes, -keep me informed of any bugs. - -v0.6.0 ------- -No noticeable changes except that bpython should now work with -Python 2.4. Personally I think it's silly to make a development -tool work with an out of date version of Python but some people -seem to disagree. The only real downside is that I had to do a -horrible version of all() using reduce(), otherwise there's no -real differences in the code. - -v0.5.3 ------- -Now you can configure a ~/.bpythonrc file (or pass a rc file at the -command line (bpython /foo/bar). See README for details. - -v0.5.2 ------- -help() actually displays the full help page, and I fixed up the -ghetto pager a little. - -v0.5.1 ------- -Now you can hit tab to display the autocomplete list, rather than -have it pop up automatically as you type which, apparently, annoys -Brendogg. - -v0.5.0 ------- -A few people have commented that the help() built-in function -doesn't work so well with bpython, since Python will try to output -the help string to PAGER (usually "less") which obviously makes -everything go wrong when curses is involved. With a bit of hackery -I've written my own ghetto pager and injected my own help function -into the interpreter when it initialises in an attempt to rectify this. -As such, it's pretty untested but it seems to be working okay for me. -Suggestions/bug reports/patches are welcome regarding this. - -v0.4.2 ------- -Well, hopefully we're one step closer to making the list sizing -stuff work. I really hate doing code for that kind of thing as I -never get it quite right, but with perseverence it should end up -being completely stable; it's not the hardest thing in the world. - -Various cosmetic fixes have been put in at the request of a bunch -of people who were kind enough to send me emails regarding their -experiences. - -PYTHONSTARTUP is now dealt with and used properly, as per the vanilla -interpreter. - -v0.4.1 ------- -It looks like the last release was actually pretty bug-free, aside -from one tiny bug that NEVER ACTUALLY HAPPENS but someone was bugging -me about it anyway, oh well. - -v0.4.0 ------- -It's been quite a long time since the last update, due to several -uninteresting and invalid excuses, but I finally reworked the list -drawing procedures so the crashing seems to have been taken care of -to an extent. If it still crashes, the way I've written it will hopefully -allow a much more robust way of fixing it, one that might actually work. - -v0.3.2 ------- -Thanks to Aaron Gallagher for pointing out a case where the hugely -inefficient list generation routines were actually making a significant -issue; they're much more efficient now and should hopefully not cause -any more problems. - -v0.3.1 ------- -Thanks to Klaus Alexander Seis for the expanduser() patch. -Auto indent works on multiple levels now. - -v0.3.0 ------- -Now with auto-indent. Let me know if it's annoying. - -v0.2.4 ------- -Thanks a lot to Angus Gibson for submitting a patch to fix a problem -I was having with initialising the keyboard stuff in curses properly. - -Also a big thanks to John Beisley for providing the patch that shows -a class __init__ method's argspec on class instantiation. - -I've fixed up the argspec display so it handles really long argspecs -(e.g. subprocess.Popen()) and doesn't crash if something horrible -happens (rather, it avoids letting something horrible happen). - -I decided to add a key that will get rid of the autocomplete window, -since it can get in the way. C-l seemed like a good choice, since -it would work well as a side-effect of redrawing the screen (at -least that makes sense to me). In so doing I also cleaned up a lot -of the reevaluating and resizing code so that a lot of the strange -output seen on Rewind/resize seems to be gone. - -v0.2.3 ------- -The fix for the last bug broke the positioning of the autocomplete -box, whoops. - -v0.2.2 ------- -That pesky bug keeps coming up. I think it's finally nailed but -it's just a matter of testing and hoping. I hate numbers. - -v0.2.1 ------- -I'm having a bit of trouble with some integer division that's -causing trouble when a certain set of circumstances arise, -and I think I've taken care of that little bug, since it's -a real pain in the ass and only creeps up when I'm actually -doing something useful, so I'll test it for a bit and release -it as hopefully a bug fixed version. - -v0.2.0 ------- -A little late in the day to start a changelog, but here goes... -This version fixed another annoying little bug that was causing -crashes given certain exact circumstances. I always find it's the -way with curses and sizing of windows and things... - -I've also got bpython to try looking into pydoc if no matches -are found for the argspec, which means the builtins have argspecs -too now, hooray. +.. _changelog: +.. include:: ../../../CHANGELOG.rst diff --git a/doc/sphinx/source/community.rst b/doc/sphinx/source/community.rst index 76c8da1cd..911bbc4f6 100644 --- a/doc/sphinx/source/community.rst +++ b/doc/sphinx/source/community.rst @@ -4,25 +4,25 @@ Community ========= Do you need help with using bpython? Do you want to thank the contributors personally? Or maybe you want to help out, contribute some code or resources -or want to help in making bpython known to other persons? +or want to help in making bpython known to other persons? These are the places where you can find us. IRC --- -You can find us in #bpython on the Freenode network (irc.freenode.net). Don't -worry when you get no response (this does not usually happen) but we are all -from Europe and when you get to the channel during our nighttime you might have -to wait a while for a response. +You can find us in `#bpython `_ on the `OFTC +`_ network. Don't worry when you get no response (this does +not usually happen) but we are all from Europe and when you get to the channel +during our nighttime you might have to wait a while for a response. -Mailinglist ------------ -We have a mailinglist at `google groups `_. -You can post questions there and releases are announced on the mailing -list. +Mailing List +------------ +We have a mailing list at `google groups +`_. You can post questions there and +releases are announced on the mailing list. Website ------- Our main website is http://bpython-interpreter.org/, our documentation can be -found at http://docs.bpython-interpreter.org/ and our pastebin can be found at +found at http://docs.bpython-interpreter.org/, and our pastebin can be found at http://bpaste.net/. diff --git a/doc/sphinx/source/conf.py b/doc/sphinx/source/conf.py index 805ef7e8f..2ef900498 100644 --- a/doc/sphinx/source/conf.py +++ b/doc/sphinx/source/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # bpython documentation build configuration file, created by # sphinx-quickstart on Mon Jun 8 11:58:16 2009. @@ -11,12 +10,12 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os +import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.append(os.path.abspath('.')) +# sys.path.append(os.path.abspath('.')) # -- General configuration ----------------------------------------------------- @@ -25,187 +24,201 @@ extensions = [] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8' +# source_encoding = 'utf-8' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'bpython' -copyright = u'2008-2014 Bob Farrell, Andreas Stuehrk et al.' +project = "bpython" +copyright = "2008-2022 Bob Farrell, Andreas Stuehrk, Sebastian Ramacher, Thomas Ballinger, et al." # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = 'mercurial' + +version_file = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "../../../bpython/_version.py" +) + +with open(version_file) as vf: + version = vf.read().strip().split("=")[-1].replace("'", "") + # The full version, including alpha/beta/rc tags. -release = 'mercurial' +release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of documents that shouldn't be included in the build. -unused_docs = ['configuration-options'] +unused_docs = ["configuration-options"] # List of directories, relative to source directory, that shouldn't be searched # for source files. exclude_trees = [] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. -html_theme = 'nature' +html_theme = "nature" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -html_logo = 'logo.png' +html_logo = "logo.png" # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -html_last_updated_fmt = '%b %d, %Y' +html_last_updated_fmt = "%b %d, %Y" # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_use_modindex = True +# html_use_modindex = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -html_use_opensearch = '' +html_use_opensearch = "" # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = '' +# html_file_suffix = '' # Output file base name for HTML help builder. -htmlhelp_basename = 'bpythondoc' +htmlhelp_basename = "bpythondoc" # -- Options for LaTeX output -------------------------------------------------- # The paper size ('letter' or 'a4'). -#latex_paper_size = 'letter' +# latex_paper_size = 'letter' # The font size ('10pt', '11pt' or '12pt'). -#latex_font_size = '10pt' +# latex_font_size = '10pt' # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ('index', 'bpython.tex', u'bpython Documentation', - u'Robert Farrell', 'manual'), -] +# latex_documents = [ +# ('index', 'bpython.tex', u'bpython Documentation', +# u'Robert Farrell', 'manual'), +# ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # Additional stuff for the LaTeX preamble. -#latex_preamble = '' +# latex_preamble = '' # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_use_modindex = True +# latex_use_modindex = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('man-bpython', 'bpython', - u'a fancy {curses, GTK+, urwid} interface to the Python interactive interpreter', - [], 1), - ('man-bpython-config', 'bpython-config', - u'user configuration file for bpython', - [], 5) + ( + "man-bpython", + "bpython", + "a fancy {curtsies, curses, urwid} interface to the Python interactive interpreter", + [], + 1, + ), + ( + "man-bpython-config", + "bpython-config", + "user configuration file for bpython", + [], + 5, + ), ] # If true, show URL addresses after external links. -#man_show_urls = False - +# man_show_urls = False diff --git a/doc/sphinx/source/configuration-options.rst b/doc/sphinx/source/configuration-options.rst index c25b38a57..1521542ed 100644 --- a/doc/sphinx/source/configuration-options.rst +++ b/doc/sphinx/source/configuration-options.rst @@ -3,6 +3,11 @@ General This refers to the ``[general]`` section in your `$XDG_CONFIG_HOME/bpython/config` file. +arg_spec +^^^^^^^^ +Display the arg spec (list of arguments) for callables, when possible (default: +True). + auto_display_list ^^^^^^^^^^^^^^^^^ Display the autocomplete list as you type (default: True). @@ -10,65 +15,91 @@ When this is off, you can hit tab to see the suggestions. autocomplete_mode ^^^^^^^^^^^^^^^^^ -There are three modes for autocomplete. simple, substring, and fuzzy. Simple -matches methods with a common prefix, substring matches methods with a common -subsequence, and fuzzy matches methods with common characters (default: simple). +There are four modes for autocomplete: ``none``, ``simple``, ``substring``, and +``fuzzy``. Simple matches methods with a common prefix, substring matches +methods with a common subsequence, and fuzzy matches methods with common +characters (default: simple). None disables autocompletion. .. versionadded:: 0.12 -syntax +brackets_completion +^^^^^^^^^^^^^^^^^^^ +Whether opening character of the pairs ``()``, ``[]``, ``""``, and ``''`` should be auto-closed +(default: False). + +.. versionadded:: 0.23 + +.. _configuration_color_scheme: + +color_scheme +^^^^^^^^^^^^ +See :ref:`themes` for more information. + +Color schemes should be put in ``$XDG_CONFIG_HOME/bpython/``. For example, to +use the theme ``$XDG_CONFIG_HOME/bpython/foo.theme`` set ``color_scheme = foo`` + +Leave blank or set to "default" to use the default (builtin) theme. + +complete_magic_methods +^^^^^^^^^^^^^^^^^^^^^^ +Whether magic methods should be auto completed (default: True). + +dedent_after +^^^^^^^^^^^^ +Number of blank lines required before next line will be dedented (default: 1). +If set to 0, automatic dedenting never occurs. + +editor ^^^^^^ -Syntax highlighting as you type (default: True). +Editor for externally editing the current line, session, or config file. -arg_spec -^^^^^^^^ -Display the arg spec (list of arguments) for callables, when possible (default: -True). +.. versionadded:: 0.13 + +flush_output +^^^^^^^^^^^^ +Whether to flush all output to stdout on exit (default: True). + +Only relevant to bpython-curses and bpython-urwid. + +highlight_show_source +^^^^^^^^^^^^^^^^^^^^^ +Whether the source code of an object should be syntax highlighted (default: True). + +hist_duplicates +^^^^^^^^^^^^^^^ +Whether to store duplicate entries in the history (default: True). hist_file ^^^^^^^^^ History file (default: ``~/.pythonhist``). -paste_time -^^^^^^^^^^ -The time between lines before pastemode is activated in seconds (default: 0.02). - hist_length ^^^^^^^^^^^ -Number of lines to store in history (set to 0 to disable) (default: 100) +Number of lines to store in history (set to 0 to disable) (default: 100). -tab_length +paste_time ^^^^^^^^^^ -Soft tab size (default 4, see pep-8) - -pastebin_url -^^^^^^^^^^^^ -The pastebin url to post to (without a trailing slash). This pastebin has to be -a pastebin which uses LodgeIt. Examples are: http://paste.pocoo.org/xmlrpc/ and -http://bpaste.net/xmlrpc/ (default: http://bpaste.net/xmlrpc/) +The time between keypresses before pastemode is deactivated in bpython-curses (default: 0.02). -pastebin_private +pastebin_confirm ^^^^^^^^^^^^^^^^ -If the pastebin supports a private option to make a random paste id, use it. -Default: True). +Whether pasting to a pastebin needs to be confirmed before sending the data +(default: True). -.. versionadded:: 0.12 +pastebin_expiry +^^^^^^^^^^^^^^^ +Time duration after which a paste should expire. Valid values are ``1day``, +``1week`` and ``1month`` (default: ``1week``). -pastebin_show_url -^^^^^^^^^^^^^^^^^ -The url under which the new paste can be reached. ``$paste_id`` will be replaced -by the ID of the new paste. Examples are: http://bpaste.net/show/$paste_id/ and -http://paste.pocoo.org/show/$paste_id/ (default: -http://bpaste.net/show/$paste_id/) +.. versionadded:: 0.14 pastebin_helper ^^^^^^^^^^^^^^^ The name of a helper executable that should perform pastebin upload on bpython's -behalf. If set, this overrides `pastebin_url`. It also overrides -`pastebin_show_url`, as the helper is expected to return the full URL to the -pastebin as the first word of its output. The data is supplied to the helper via -STDIN. +behalf. If set, this overrides `pastebin_url`. The helper is expected to return +the full URL to the pastebin as the first word of its output. The data is +supplied to the helper via STDIN. An example helper program is ``pastebinit``, available for most systems. The following helper program can be used to create `gists @@ -79,56 +110,51 @@ following helper program can be used to create `gists #!/usr/bin/env python import sys - import urllib2 + import requests import json def do_gist_json(s): """ Use json to post to github. """ gist_public = False - gist_url = 'https://api.github.com/gists' - - data = {'description': None, - 'public': None, - 'files' : { - 'sample': { 'content': None } - }} - data['description'] = 'Gist from BPython' - data['public'] = gist_public - data['files']['sample']['content'] = s - - req = urllib2.Request(gist_url, json.dumps(data), {'Content-Type': 'application/json'}) - try: - res = urllib2.urlopen(req) - except HTTPError, e: - return e + gist_url = "https://api.github.com/gists" + + data = { + "description": "Gist from bpython", + "public": gist_public, + "files": { + "sample": { + "content": s + }, + }, + } + + headers = { + "Content-Type": "application/json", + "X-Github-Username": "YOUR_USERNAME", + "Authorization": "token YOUR_TOKEN", + } try: + res = requests.post(gist_url, data=json.dumps(payload), headers=headers) + res.raise_for_status() json_res = json.loads(res.read()) - return json_res['html_url'] - except HTTPError, e: - return e + return json_res["html_url"] + except requests.exceptions.HTTPError as err: + return err + if __name__ == "__main__": s = sys.stdin.read() - print do_gist_json(s) + print(do_gist_json(s)) .. versionadded:: 0.12 -.. _configuration_color_scheme: - -color_scheme -^^^^^^^^^^^^ -See :ref:`themes` for more information. - -Color schemes should be put in ``$XDG_CONFIG_HOME/bpython/``. For example, to -use the theme ``$XDG_CONFIG_HOME/bpython/foo.theme`` set ``color_scheme = foo`` - -Leave blank or set to "default" to use the default (builtin) theme. - -flush_output +pastebin_url ^^^^^^^^^^^^ -Whether to flush all output to stdout on exit (default: True). +The pastebin url to post to (without a trailing slash). This pastebin has to be +a pastebin which provides a similar interface to ``bpaste.net``'s JSON +interface (default: https://bpaste.net). save_append_py ^^^^^^^^^^^^^^ @@ -136,11 +162,33 @@ Whether to append ``.py`` to the filename while saving the input to a file. .. versionadded:: 0.13 -editor +single_undo_time +^^^^^^^^^^^^^^^^ +Time duration an undo must be predicted to take before prompting +to undo multiple lines at once. Use -1 to never prompt, or 0 to always prompt. +(default: 1.0) + +.. versionadded:: 0.14 + +syntax ^^^^^^ -Editor for externally editing the current line. +Syntax highlighting as you type (default: True). -.. versionadded:: 0.13 +tab_length +^^^^^^^^^^ +Soft tab size (default 4, see PEP-8). + +unicode_box +^^^^^^^^^^^ +Whether to use Unicode characters to draw boxes (default: True). + +.. versionadded:: 0.14 + +import_completion_skiplist +^^^^^^^^^^^^^^^^^^^^^^^^^^ +A `:`-seperated list of patterns to skip when processing modules for import completion. + +.. versionadded:: 0.21 Keyboard -------- @@ -154,50 +202,50 @@ telling you the key does not exist in bpython.keys. Valid keys are: -* Control + any alphanumeric character (C-a through A-z, also a few others). +* Control + any alphanumeric character (C-a through C-z, also a few others). * Any function key ranging from F1 to F12. -pastebin -^^^^^^^^ -Default: +backspace +^^^^^^^^^ +Default: C-h -last_output -^^^^^^^^^^^ -Default: F9 +Delete character in front of the cursor. -Shows the last output in the systems $PAGER. +.. versionadded:: 0.14 -reimport -^^^^^^^^ -Default: F6 +beginning_of_line +^^^^^^^^^^^^^^^^^ +Default: C-a -Reruns entire session, reloading all modules by clearing the sys.modules cache. +Move to the beginning of the line. .. versionadded:: 0.14 -save -^^^^ -Default: C-s +clear_line +^^^^^^^^^^ +Default: C-u -Saves the current session to a file (prompts for filename) +Clears to the beginning of the line. -undo -^^^^ -Default: C-r +clear_screen +^^^^^^^^^^^^ +Default: C-l -Rewinds the last action. +Clears the screen to the top. -up_one_line -^^^^^^^^^^^ -Default: C-p +clear_word +^^^^^^^^^^ +Default: C-w -Move the cursor up, by one line. +Clear the word the cursor is currently on. -down_one_line -^^^^^^^^^^^^^ -Default: C-n +copy_clipboard +^^^^^^^^^^^^^^ +Default: F10 -Move the cursor down, by one line. +Copy the entire session to clipboard. + +.. versionadded:: 0.14 cut_to_buffer ^^^^^^^^^^^^^ @@ -205,41 +253,41 @@ Default: C-k Cuts the current line to the buffer. -search +delete ^^^^^^ -Default: C-o +Default: C-d -Search up for any lines containing what is on the current line. +Delete character under the cursor. -yank_from_buffer -^^^^^^^^^^^^^^^^ -Default: C-y +down_one_line +^^^^^^^^^^^^^ +Default: C-n -Pastes the current line from the buffer (the one you previously cutted) +Move the cursor down, by one line. -clear_word -^^^^^^^^^^ -Default: C-w +edit_config +^^^^^^^^^^^ +Default: F3 -Clear the word the cursor is currently on. +Edit bpython configuration in external editor. -clear_line -^^^^^^^^^^ -Default: C-u +.. versionadded:: 0.14 -Clears to the beginning of the line. +edit_current_block +^^^^^^^^^^^^^^^^^^ +Default: C-x -clear_screen -^^^^^^^^^^^^ -Default: C-l +Edit current block in external editor. -Clears the screen to the top. +.. versionadded:: 0.14 -show_source +end_of_line ^^^^^^^^^^^ -Default: F2 +Default: C-e -Shows the source of the currently being completed (python) function. +Move to the of the line. + +.. versionadded:: 0.14 exit ^^^^ @@ -251,10 +299,121 @@ external_editor ^^^^^^^^^^^^^^^ Default: F7 -Edit current line in an external editor. +Edit the entire session in an external editor. .. versionadded:: 0.13 +help +^^^^ +Default: F1 + +Brings up sincerely cheerful description of bpython features and current key bindings. + +.. versionadded:: 0.14 + +incremental_search +^^^^^^^^^^^^^^^^^^ +Default: M-s + +Perform incremental search on all stored lines in the history. + +.. versionadded:: 0.15 + +last_output +^^^^^^^^^^^ +Default: F9 + +Shows the last output in the systems $PAGER. Only works in bpython-curses. + +left +^^^^ +Default: C-b + +Move a character to the left. + +.. versionadded:: 0.14 + +pastebin +^^^^^^^^ +Default: F8 + +reimport +^^^^^^^^ +Default: F6 + +Reruns entire session, reloading all modules by clearing the sys.modules cache. + +.. versionadded:: 0.14 + +reverse_incremental_search +^^^^^^^^^^^^^^^^^^^^^^^^^^ +Default: M-r + +Perform reverse incremental search on all stored lines in the history. + +.. versionadded:: 0.15 + +right +^^^^^ +Default: C-f + +Move a character to the right. + +.. versionadded:: 0.14 + +save +^^^^ +Default: C-s + +Saves the current session to a file (prompts for filename) + +search +^^^^^^ +Default: C-o + +Search up for any lines containing what is on the current line. + +show_source +^^^^^^^^^^^ +Default: F2 + +Shows the source of the currently being completed (python) function. + +toggle_file_watch +^^^^^^^^^^^^^^^^^ +Default: F5 + +Toggles file watching behaviour; re-runs entire bpython session whenever an imported +module is modified. + +.. versionadded:: 0.14 + +transpose_chars +^^^^^^^^^^^^^^^ +Default: C-t + +Transpose current character with the one left of it. + +.. versionadded:: 0.14 + +undo +^^^^ +Default: C-r + +Rewinds the last action. + +up_one_line +^^^^^^^^^^^ +Default: C-p + +Move the cursor up, by one line. + +yank_from_buffer +^^^^^^^^^^^^^^^^ +Default: C-y + +Pastes the current line from the buffer (the one you previously cut) + CLI --- This refers to the ``[cli]`` section in your config file. @@ -273,30 +432,12 @@ Default: False Trims lines starting with '>>> ' when set to True. -GTK ---- -This refers to the ``[gtk]`` section in your `$XDG_CONFIG_HOME/bpython/config` -file. - -font -^^^^ -Default: Monospace 10 - -The font to be used by the GTK version. - curtsies -------- This refers to the ``[curtsies]`` section in your config file. .. versionadded:: 0.13 -fill_terminal -^^^^^^^^^^^^^ -Default: False - -Whether bpython should clear the screen on start, and always display a status -bar at the bottom. - list_above ^^^^^^^^^^ Default: False @@ -315,3 +456,9 @@ When the cursor is at the end of a line, pressing right arrow or ctrl-f will complete the full line. This option also turns on substring history search, highlighting the matching section in previous result. + +Sample config +------------- + +.. include:: ../../../bpython/sample-config + :literal: diff --git a/doc/sphinx/source/configuration.rst b/doc/sphinx/source/configuration.rst index e45ee206f..1f559e15f 100644 --- a/doc/sphinx/source/configuration.rst +++ b/doc/sphinx/source/configuration.rst @@ -2,14 +2,11 @@ Configuration ============= -You can copy the supplied sample-config to your home directory and move it to -``$XDG_CONFIG_HOME/bpython/config`` [#f1]_. bpython tries to find -``$XDG_CONFIG_HOME/bpython/config`` and use it as its configuration, if the -file does not exist bpython will use its documented defaults. - -.. :: Footnotes - -.. [#f1] ``$XDG_CONFIG_HOME`` defaults to ``~/.config`` if not set. +You can edit the config file by pressing F3 (default). If a config file does not +exist you will asked if you would like to create a file. By default it will be +saved to ``$XDG_CONFIG_HOME/bpython/config`` [#f1]_. .. include:: configuration-options.rst +.. :: Footnotes +.. [#f1] ``$XDG_CONFIG_HOME`` defaults to ``~/.config`` if not set. diff --git a/doc/sphinx/source/contributing.rst b/doc/sphinx/source/contributing.rst index 131493fd6..3b93089df 100644 --- a/doc/sphinx/source/contributing.rst +++ b/doc/sphinx/source/contributing.rst @@ -5,54 +5,95 @@ Contributing to bpython Thanks for working on bpython! -On the `GitHub issue tracker`_ some issues are labeled bite-size_ +On the `GitHub issue tracker`_ some issues are labeled bite-size_ - these are particularly good ones to start out with. See our section about the :ref:`community` for a list of resources. -`#bpython` on freenode is particularly useful, but you might have to wait for a while -to get a question answered depending on the time of day. +`#bpython `_ on OFTC is particularly useful, +but you might have to wait for a while to get a question answered depending on +the time of day. Getting your development environment set up ------------------------------------------- -Using a virtual environment is probably a good idea. Create a virtual environment with +bpython supports Python 3.9 and newer. The code is compatible with all +supported versions. + +Using a virtual environment is probably a good idea. Create a virtual +environment with .. code-block:: bash + # determines Python version used $ virtualenv bpython-dev - $ source bpython-dev/bin/activate # this step is necssary every time you work on bpython - - $ deactivate # back to normal system environment + # necessary every time you work on bpython + $ source bpython-dev/bin/activate + +Fork bpython in the GitHub web interface. Be sure to include the tags +in your fork by un-selecting the option to copy only the main branch. -Fork `bpython` in the GitHub web interface, then clone the repo: +Then, clone the forked repo: .. code-block:: bash $ git clone git@github.com:YOUR_GITHUB_USERNAME/bpython.git + # or "git clone https://github.com/YOUR_GITHUB_USERNAME/bpython.git" -Next install this development version of `bpython`: +Next install your development copy of bpython and its dependencies: .. code-block:: bash - $ pip install pygments curtsies greenlet watchdog urwid # install all the dependencies - $ pip install sphinx mock # development dependencies $ cd bpython - $ python setup.py develop + # install bpython and required dependencies + $ pip install -e . + # install optional dependencies + $ pip install watchdog urwid + # development dependencies + $ pip install sphinx pytest - $ bpython-curtsies # this runs your modified copy of bpython! + # this runs your modified copy of bpython! + $ bpython + +.. note:: + + Many requirements are also available from your distribution's package + manager. On Debian/Ubuntu based systems, the following packages can be + used: + + .. code-block:: bash + + $ sudo apt install python3-greenlet python3-pygments python3-requests + $ sudo apt install python3-watchdog python3-urwid + $ sudo apt install python3-sphinx python3-pytest + + You also need to run `virtualenv` with `--system-site-packages` packages, if + you want to use the packages provided by your distribution. + +.. note:: -As a first dev task, I recommend getting `bpython` to print your name every time you hit a specific key. + Installation of some dependencies with ``pip`` requires Python headers and + a C compiler. These are also available from your package manager. -To run tests: + .. code-block:: bash - $ python -m unittest discover bpython + $ sudo apt install gcc python3-dev -To build the docs: ------------------- +As a first dev task, I recommend getting `bpython` to print your name every +time you hit a specific key. -The documentation is included in the regular `bpython` repository. After -checking out the `bpython` repository and installing `sphinx` as described in +To run tests from the bpython directory: + +.. code-block:: bash + + $ pytest + + +Building the documentation +-------------------------- + +The documentation is included in the bpython repository. After +checking out the bpython repository and installing `sphinx` as described in the previous step, you can run the following command in your checkout of the repository to build the documentation: @@ -63,13 +104,12 @@ repository to build the documentation: Afterwards you can point your browser to `doc/sphinx/build/html/index.html`. Don't forget to recreate the HTML after you make changes. - -To hack on the site or theme +Hacking on the site or theme ---------------------------- -The site (and it's theme as well) is stored in a separate repository and built using -pelican. To start hacking on the site you need to start out with a checkout and -probably a virtual environment: +The site (and its theme as well) is stored in a separate repository and built +using pelican. To start hacking on the site you need to start out with a +checkout and probably a virtual environment: .. code-block:: bash @@ -77,7 +117,7 @@ probably a virtual environment: $ source bpython-site-dev/bin/activate $ pip install pelican -Fork bsite and bsite-theme in the GitHub web interface, then clone the +Fork bsite and bsite-theme in the GitHub web interface, then clone the repositories: .. code-block:: bash @@ -92,11 +132,14 @@ included configuration file. .. code-block:: bash $ source bpython-site-dev/bin/activate - $ cd bsite # if you want to fiddle on the text of the site otherwise go into bsite-theme - $ pelican -t ../bsite-theme -s pelicanconf.py # if you checked out the theme in a different place, use that path + # if you want to fiddle on the text of the site otherwise go into + # bsite-theme + $ cd bsite + # if you checked out the theme in a different place, use that path + $ pelican -t ../bsite-theme -s pelicanconf.py -After this you can open the `output/index.html` in your favourite browser and see -if your changes had an effect. +After this you can open the `output/index.html` in your favourite browser and +see if your changes had an effect. -.. _GitHub issue tracker: https://github.com/bpython/bpython/issues +.. _GitHub issue tracker: https://github.com/bpython/bpython/issues .. _bite-size: https://github.com/bpython/bpython/labels/bitesize diff --git a/doc/sphinx/source/django.rst b/doc/sphinx/source/django.rst index ec3f4d9bb..c9535c4a8 100644 --- a/doc/sphinx/source/django.rst +++ b/doc/sphinx/source/django.rst @@ -12,15 +12,15 @@ out of the box models and views for a lot of stuff. For those people wanting to use bpython with their Django installation you can follow the following steps. Written by Chanita Siridechkun. The following instructions make bpython try to import a setting module in the current folder -and let django set up it's enviroment with the settings module (if found) if -bpython can't find the settings module nothing happens and no enviroment gets +and let django set up its environment with the settings module (if found) if +bpython can't find the settings module nothing happens and no environment gets set up. The addition also checks if settings contains a PINAX_ROOT (if you use Pinax), if it finds this key it will do some additional Pinax setup. The Pinax addition was written by Skylar Saveland. -bpython uses something called the PYTHONSTARTUP enviroment variable. This is +bpython uses something called the PYTHONSTARTUP environment variable. This is also used by the vanilla Python REPL. Add the following lines to your ``.profile`` or equivalent file on your operating diff --git a/doc/sphinx/source/index.rst b/doc/sphinx/source/index.rst index c551b113d..f209d2971 100644 --- a/doc/sphinx/source/index.rst +++ b/doc/sphinx/source/index.rst @@ -1,8 +1,3 @@ -.. bpython documentation master file, created by - sphinx-quickstart on Mon Jun 8 11:58:16 2009. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - bpython documentation ===================== Welcome to the bpython documentation files. This is where you @@ -25,7 +20,7 @@ Contents: django windows changelog - sourcecode bpaste tips bpdb + simplerepl diff --git a/doc/sphinx/source/man-bpython.rst b/doc/sphinx/source/man-bpython.rst index 2242633a7..fe37a25fe 100644 --- a/doc/sphinx/source/man-bpython.rst +++ b/doc/sphinx/source/man-bpython.rst @@ -8,9 +8,9 @@ Synopsis **bpython** [*options*] [*file* [*args*]] -**bpython-urwid** [*options*] [*file* [*args*]] +**bpython-curses** [*options*] [*file* [*args*]] -**bpython-curtsies** [*options*] [*file* [*args*]] +**bpython-urwid** [*options*] [*file* [*args*]] Description @@ -19,7 +19,7 @@ The idea is to provide the user with all the features in-line, much like modern IDEs, but in a simple, lightweight package that can be run in a terminal window. In-line syntax highlighting. - Hilights commands as you type! + Highlights commands as you type! Readline-like autocomplete with suggestions displayed as you type. Press tab to complete expressions when there's only one suggestion. @@ -35,7 +35,7 @@ Rewind. functions. Pastebin code/write to file. - This posts the current buffer to a pastebin (paste.pocoo.org) or writes it + This posts the current buffer to a pastebin (bpaste.net) or writes it to a file. Flush curses screen to stdout. @@ -49,15 +49,23 @@ The long and short forms of options, shown here as alternatives, are equivalent. If :program:`bpython` sees an argument it does not know, execution falls back to the regular Python interpreter. +The following options are supported by all frontends: + +--config= Use instead of default config file. +-h, --help Show the help message and exit. +-i, --interactive Drop to bpython shell after running file instead of + exiting. The PYTHONSTARTUP file is not read. +-q, --quiet Do not flush the output to stdout. +-V, --version Print :program:`bpython`'s version and exit. +-l , --log-level= Set logging level +-L , --log-output= Set log output file + +In addition to the above options, :program:`bpython` also supports the following +options: ---config= Use instead of default config file. --h, --help Show the help message and exit. --i, --interactive Drop to bpython shell after running file instead of exiting. - The PYTHONSTARTUP file is not read. --q, --quiet Do not flush the output to stdout. --V, --version Print :program:`bpython`'s version and exit. +-p file, --paste=file Paste in the contents of a file at startup. -In addition to the above options, :program:`bpython-urwid` also supports the +In addition to the common options, :program:`bpython-urwid` also supports the following options if Twisted is available: -r , --reactor= Use Twisted's instead of urwid's @@ -69,7 +77,7 @@ following options if Twisted is available: plugins. Use -- to pass options to the plugin. -s , --server= Run an eval server on port . This - options forces the use of a Twisted reactor. + option forces the use of a Twisted reactor. Keys ---- diff --git a/doc/sphinx/source/releases.rst b/doc/sphinx/source/releases.rst index 06d20a624..7d789f166 100644 --- a/doc/sphinx/source/releases.rst +++ b/doc/sphinx/source/releases.rst @@ -5,25 +5,36 @@ Releases Release schedule ---------------- -bpython does not have a set release cycle. The developers will decide together when the time is ripe to release a version. -For information what happens after the decision is made to make a release you should read the 'Release Path' section. +bpython does not have a set release cycle. The developers will decide together +when the time is ripe to release a version. For information what happens after +the decision is made to make a release you should read the 'Release Path' +section. Release Path ------------ -After it is decided to release a new version of bpython the following checklist is followed: +After it is decided to release a new version of bpython the following checklist +is followed: * The repository is frozen, nobody pushes until the version is built. -* Bob (:ref:`authors`) makes a tarball of the new version and sends it to Simon (:ref:`authors`) who will host it on the bpython website. + +* Bob (:ref:`authors`) makes a tarball of the new version and sends it to Simon + (:ref:`authors`) who will host it on the bpython website. + * The package is then downloaded by all of the people who like to test it. + * Everybody checks if there are no great problems: * Version numbers correct? + * CHANGELOG is correct? + * AUTHORS? -* After everybody says 'yes' the website and pypi are updated to point to this new version. +* After everybody says 'yes' the website and PyPI are updated to point to this + new version. - * Simon (:ref:`authors`) also checks if all numbers on the website have been updated. + * Simon (:ref:`authors`) also checks if all numbers on the website have been + updated. * 24 hours later package maintainers could update their stuff. @@ -34,7 +45,7 @@ A checklist to perform some manual tests before a release: Check that all of the following work before a release: -* Runs under Python 2.5, 2.6 and 3.1 (after 2to3). +* Runs under Python 3.9 - 3.13 * Save * Rewind * Pastebin @@ -48,4 +59,3 @@ Check that all of the following work before a release: * Command line arguments correctly passed to scripts * Delegate to standard Python appropriately * Update CHANGELOG -* Update __version__ diff --git a/doc/sphinx/source/simplerepl.py b/doc/sphinx/source/simplerepl.py new file mode 100644 index 000000000..8496f0dd6 --- /dev/null +++ b/doc/sphinx/source/simplerepl.py @@ -0,0 +1,133 @@ +# The MIT License +# +# Copyright (c) 2015 the bpython authors. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +"""An example bpython repl without a nice UI for testing and to demonstrate +the methods of bpython.curtsiesrepl.repl.BaseRepl that must be overridden. +""" + + +import time +import logging + +from bpython import translations +from bpython.config import Config, default_config_path +from bpython.curtsiesfrontend import events as bpythonevents +from bpython.curtsiesfrontend.repl import BaseRepl +from bpython.importcompletion import ModuleGatherer + +from curtsies.configfile_keynames import keymap as key_dispatch + + +logger = logging.getLogger(__name__) + + +class SimpleRepl(BaseRepl): + def __init__(self, config): + self.requested_events = [] + BaseRepl.__init__(self, config, window=None) + + def _request_refresh(self): + self.requested_events.append(bpythonevents.RefreshRequestEvent()) + + def _schedule_refresh(self, when="now"): + if when == "now": + self.request_refresh() + else: + dt = round(when - time.time(), 1) + self.out(f"please refresh in {dt} seconds") + + def _request_reload(self, files_modified=("?",)): + e = bpythonevents.ReloadEvent() + e.files_modified = files_modified + self.requested_events.append(e) + self.out("please hit enter to trigger a refresh") + + def request_undo(self, n=1): + self.requested_events.append(bpythonevents.UndoEvent(n=n)) + + def out(self, msg): + if hasattr(self, "orig_stdout"): + self.orig_stdout.write(f"{msg}\n") + self.orig_stdout.flush() + else: + print(msg) + + def on_suspend(self): + pass + + def after_suspend(self): + self.out("please hit enter to trigger a refresh") + + def print_output(self): + arr, cpos = self.paint() + arr[cpos[0] : cpos[0] + 1, cpos[1] : cpos[1] + 1] = ["~"] + + def print_padded(s): + return self.out(s.center(self.width + 8, "X")) + + print_padded("") + print_padded(' enter -> "/", rewind -> "\\", ') + print_padded(' reload -> "|", pastebin -> "$", ') + print_padded(' "~" is the cursor ') + print_padded("") + self.out("X``" + ("`" * (self.width + 2)) + "``X") + for line in arr: + self.out("X```" + line.ljust(self.width) + "```X") + logger.debug("line:") + logger.debug(repr(line)) + self.out("X``" + ("`" * (self.width + 2)) + "``X") + self.out("X" * (self.width + 8)) + return max(len(arr) - self.height, 0) + + def get_input(self): + chars = list(self.orig_stdin.readline()[:-1]) + while chars or self.requested_events: + if self.requested_events: + self.process_event(self.requested_events.pop()) + continue + c = chars.pop(0) + if c in "/": + c = "\n" + elif c in "\\": + c = key_dispatch[self.config.undo_key][0] + elif c in "$": + c = key_dispatch[self.config.pastebin_key][0] + elif c in "|": + c = key_dispatch[self.config.reimport_key][0] + self.process_event(c) + + +def main(args=None, locals_=None, banner=None): + translations.init() + config = Config(default_config_path()) + module_gatherer = ModuleGatherer() + while module_gatherer.find_coroutine(): + pass + with SimpleRepl(config) as r: + r.width = 50 + r.height = 10 + while True: + r.print_output() + r.get_input() + + +if __name__ == "__main__": + main() diff --git a/doc/sphinx/source/simplerepl.rst b/doc/sphinx/source/simplerepl.rst new file mode 100644 index 000000000..8a088ad73 --- /dev/null +++ b/doc/sphinx/source/simplerepl.rst @@ -0,0 +1,9 @@ +.. _simplerepl: + +A Simple REPL +============= + +The following code listing shows a simple example REPL implemented using `bpython` and `curtsies`. + +.. literalinclude:: simplerepl.py + :language: python diff --git a/doc/sphinx/source/sourcecode.rst b/doc/sphinx/source/sourcecode.rst deleted file mode 100644 index bdd9b5cc7..000000000 --- a/doc/sphinx/source/sourcecode.rst +++ /dev/null @@ -1,64 +0,0 @@ -.. _sourcecode: - -Sourcecode -========== - -Warning, large parts of source code are still undocumented till we include -the automatic generation of this documentation by adding in restructed text -comments. - -bpython.cli ------------ - -.. module:: cli - :platform: POSIX - :synopsis: Basic interpreter. - -.. function:: log(x) - - Function to log anything in x to /tmp/bpython.log - -.. function:: parsekeywordpairs(signature) - - Not documented yet. - - :param signature: string - :rtype: dictionary - -.. function:: fixlongargs(f, argspec) - - Functions taking default arguments that are references to other objects - whose str() is too big will cause breakage, so we swap out the object - itself with the name it was referenced with in the source by parsing the - source itself ! - -.. class:: FakeStdin - -.. method:: FakeStdin.__init__(self, interface) - - Take the curses Repl on init and assume it provides a get_key method - which, fortunately, it does.""" - -.. method:: FakeStdin.isatty(self) - - Spoof into thinking this is a tty - - :rtype: Boolean - :returns: True - - -.. method:: FakeStdin.readline(self) - - I can't think of any reason why anything other than readline would - be useful in the context of an interactive interpreter so this is the - only one I've done anything with. The others are just there in case - someone does something weird to stop it from blowing up.""" - - :rtype: string - -bpython.keys ------------- - -.. module:: keys - :platform: POSIX - :synopsis: Keyboard mappings diff --git a/doc/sphinx/source/themes.rst b/doc/sphinx/source/themes.rst index 3dab790eb..86773609f 100644 --- a/doc/sphinx/source/themes.rst +++ b/doc/sphinx/source/themes.rst @@ -2,7 +2,7 @@ Themes ====== -This chapter is about bpython's themeing capabalities. +This chapter is about bpython's theming capabilities. bpython uses .theme files placed in your ``$XDG_CONFIG_HOME/bpython`` directory [#f1]_. You can set the theme in the :ref:`configuration_color_scheme` option @@ -20,7 +20,7 @@ Available Colors * w = white * d = default, this will make the switch default to the bpython default theme -Any letter writting uppercase will make the switch bold. +Any letter writing uppercase will make the switch bold. Available Switches ------------------ @@ -38,6 +38,7 @@ Available Switches * main * prompt * prompt_more +* right_arrow_suggestion Default Theme ------------- @@ -74,6 +75,7 @@ The default theme included in bpython is as follows: main = c prompt = c prompt_more = g + right_arrow_suggestion = K .. :: Footnotes diff --git a/doc/sphinx/source/tips.rst b/doc/sphinx/source/tips.rst index 58df274c7..0745e3bfa 100644 --- a/doc/sphinx/source/tips.rst +++ b/doc/sphinx/source/tips.rst @@ -2,8 +2,8 @@ Tips and tricks =============== -There are various tricks and tips to bpython. We currently list one of -them on this page. If you know any more. Don't hesitate to let us know +There are various tricks and tips to bpython. We currently list one of them on +this page. If you know any more, don't hesitate to let us know (:ref:`community`)! bpython and multiple python versions @@ -14,11 +14,15 @@ to us by Simon Liedtke. Do a source checkout of bpython and add the following to your `.profile` equivalent file. - alias bpython2.6='PYTHONPATH=~/python/bpython python2.6 -m bpython.cli' +.. code-block:: bash + + alias bpython3.5='PYTHONPATH=~/python/bpython python3.5 -m bpython' Where the `~/python/bpython`-path is the path to where your bpython source code resides. -You can offcourse add multiple aliasses (make sure you have pygments installed -on all python versions though), so you can run bpython with 2.6, 2.7 and the 3 -series. +You can of course add multiple aliases. + +.. note:: + + Make sure you have the dependencies installed on all Python versions. diff --git a/doc/sphinx/source/windows.rst b/doc/sphinx/source/windows.rst index c2e71bc6b..5374f70fb 100644 --- a/doc/sphinx/source/windows.rst +++ b/doc/sphinx/source/windows.rst @@ -7,9 +7,3 @@ other platforms as well. There are no official binaries for bpython on Windows (though this is something we plan on providing in the future). - -The easiest way to get `bpython.cli` (the curses frontend running) is to install -an unofficial windows binary for pdcurses from: -http://www.lfd.uci.edu/~gohlke/pythonlibs/#curses. After this you can just -`pip install bpython` and run bpython like you would on a Linux system (e.g. -by typing `bpython` on your prompt). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..40efff3e6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools >= 62.4.0"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 80 +target_version = ["py311"] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.hg + | \.eggs + | \.mypy_cache + | \.tox + | venv + | _build + | buck-out + | build + | dist + | bpython/test/fodder + | doc +)/ +''' diff --git a/requirements.txt b/requirements.txt index 95f1fbda3..cc8fbff84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ Pygments -argparse -curtsies -distribute +curtsies >=0.4.0 +cwcwidth greenlet +pyxdg +requests +setuptools>=62.4.0 diff --git a/sample-config b/sample-config deleted file mode 100644 index d031556fe..000000000 --- a/sample-config +++ /dev/null @@ -1,40 +0,0 @@ -# This is a standard python config file -# Valid values can be True, False, integer numbers, strings -# By default bpython will look for $XDG_CONFIG_HOME/bpython/config -# ($XDG_CONFIG_HOME defaults to ~/.config) or you can specify a file with the -# --config option on the command line - -# General section tag -[general] - -# Display the autocomplete list as you type (default: True). -# When this is off, you can hit tab to see the suggestions. -auto_display_list = True - -# Syntax highlighting as you type (default: True). -syntax = True - -# Display the arg spec (list of arguments) for callables, -# when possible (default: True). -arg_spec = True - -# History file (default: ~/.pythonhist): -hist_file = ~/.pythonhist - -# Number of lines to store in history (set to 0 to disable) (default: 100): -hist_length = 100 - -# Soft tab size (default: 4, see pep-8): -tab_length = 4 - -# Color schemes should be put in $XDG_CONFIG_HOME/bpython/ e.g. to use the theme -# $XDG_CONFIG_HOME/bpython/foo.theme set color_scheme = foo. Leave blank or set -# to "default" to use the default theme -color_scheme = default - -# External editor to use for editing the current line, block, or full history -editor = vi - -[keyboard] -pastebin = F8 -save = C-s diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..e17199211 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,84 @@ +[metadata] +name = bpython +description = A fancy curses interface to the Python interactive interpreter +long_description = file: README.rst +long_description_content_type = text/x-rst +license = MIT +license_files = LICENSE +author = Bob Farrell, Andreas Stuehrk, Sebastian Ramacher, Thomas Ballinger, et al. +author_email = bpython@googlegroups.com +url = https://www.bpython-interpreter.org/ +project_urls = + GitHub = https://github.com/bpython/bpython + Documentation = https://docs.bpython-interpreter.org +classifiers = + Programming Language :: Python :: 3 + +[options] +python_requires = >=3.11 +packages = + bpython + bpython.curtsiesfrontend + bpython.test + bpython.test.fodder + bpython.translations + bpdb +install_requires = + curtsies >=0.4.0 + cwcwidth + greenlet + pygments + pyxdg + requests + typing_extensions ; python_version < "3.11" + +[options.extras_require] +clipboard = pyperclip +jedi = jedi >= 0.16 +urwid = urwid >=1.0 +watch = watchdog + +[options.entry_points] +console_scripts = + bpython = bpython.curtsies:main + bpython-urwid = bpython.urwid:main [urwid] + bpdb = bpdb:main + +[init_catalog] +domain = bpython +input_file = bpython/translations/bpython.pot +output_dir = bpython/translations + +[compile_catalog] +domain = bpython +directory = bpython/translations +use_fuzzy = true + +[update_catalog] +domain = bpython +input_file = bpython/translations/bpython.pot +output_dir = bpython/translations + +[extract_messages] +output_file = bpython/translations/bpython.pot +msgid_bugs_address = https://github.com/bpython/bpython/issues + +[build_sphinx_man] +builder = man +source_dir = doc/sphinx/source +build_dir = build + +[mypy] +warn_return_any = True +warn_unused_configs = True +mypy_path=stubs +files=bpython + +[mypy-jedi] +ignore_missing_imports = True + +[mypy-urwid] +ignore_missing_imports = True + +[mypy-twisted.*] +ignore_missing_imports = True diff --git a/setup.py b/setup.py index 3a050ff63..de10eaf44 100755 --- a/setup.py +++ b/setup.py @@ -1,197 +1,397 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - +#!/usr/bin/env python3 import os import platform -import sys - -from distutils.command.build import build +import re +import subprocess -from bpython import __version__, package_dir - -try: - from setuptools import setup - from setuptools.command.install import install as _install - using_setuptools = True -except ImportError: - from distutils.core import setup - from distutils.command.install import install as _install - using_setuptools = False +from setuptools import setup, Command +from setuptools.command.build import build try: - from distutils.command.build_py import build_py_2to3 as build_py -except ImportError: - from distutils.command.build_py import build_py + from babel.messages import frontend as babel -try: - from babel.messages.frontend import compile_catalog as _compile_catalog - from babel.messages.frontend import extract_messages as _extract_messages - from babel.messages.frontend import update_catalog as _update_catalog - from babel.messages.frontend import init_catalog as _init_catalog using_translations = True except ImportError: using_translations = False try: import sphinx - from sphinx.setup_command import BuildDoc - if sphinx.__version__ == '1.1.2': - # Sphinx 1.1.2 is buggy and building bpython with that version fails. - # See #241. - using_sphinx = False - else: - using_sphinx = True + + # Sphinx 1.5 and newer support Python 3.6 + using_sphinx = sphinx.__version__ >= "1.5" except ImportError: using_sphinx = False -class install(_install): - """Force install to run build target.""" +if using_sphinx: + import sys + from io import StringIO + + from setuptools.errors import ExecError + from sphinx.application import Sphinx + from sphinx.cmd.build import handle_exception + from sphinx.util.console import color_terminal, nocolor + from sphinx.util.docutils import docutils_namespace, patch_docutils + from sphinx.util.osutil import abspath + + class BuildDoc(Command): + """ + Distutils command to build Sphinx documentation. + The Sphinx build can then be triggered from distutils, and some Sphinx + options can be set in ``setup.py`` or ``setup.cfg`` instead of Sphinx's + own configuration file. + For instance, from `setup.py`:: + # this is only necessary when not using setuptools/distribute + from sphinx.setup_command import BuildDoc + cmdclass = {'build_sphinx': BuildDoc} + name = 'My project' + version = '1.2' + release = '1.2.0' + setup( + name=name, + author='Bernard Montgomery', + version=release, + cmdclass=cmdclass, + # these are optional and override conf.py settings + command_options={ + 'build_sphinx': { + 'project': ('setup.py', name), + 'version': ('setup.py', version), + 'release': ('setup.py', release)}}, + ) + Or add this section in ``setup.cfg``:: + [build_sphinx] + project = 'My project' + version = 1.2 + release = 1.2.0 + """ + + description = "Build Sphinx documentation" + user_options = [ + ("fresh-env", "E", "discard saved environment"), + ("all-files", "a", "build all files"), + ("source-dir=", "s", "Source directory"), + ("build-dir=", None, "Build directory"), + ("config-dir=", "c", "Location of the configuration directory"), + ( + "builder=", + "b", + "The builder (or builders) to use. Can be a comma- " + 'or space-separated list. Defaults to "html"', + ), + ("warning-is-error", "W", "Turn warning into errors"), + ("project=", None, "The documented project's name"), + ("version=", None, "The short X.Y version"), + ( + "release=", + None, + "The full version, including alpha/beta/rc tags", + ), + ( + "today=", + None, + "How to format the current date, used as the " + "replacement for |today|", + ), + ("link-index", "i", "Link index.html to the master doc"), + ("copyright", None, "The copyright string"), + ("pdb", None, "Start pdb on exception"), + ("verbosity", "v", "increase verbosity (can be repeated)"), + ( + "nitpicky", + "n", + "nit-picky mode, warn about all missing references", + ), + ("keep-going", None, "With -W, keep going when getting warnings"), + ] + boolean_options = [ + "fresh-env", + "all-files", + "warning-is-error", + "link-index", + "nitpicky", + ] + + def initialize_options(self) -> None: + self.fresh_env = self.all_files = False + self.pdb = False + self.source_dir: str = None + self.build_dir: str = None + self.builder = "html" + self.warning_is_error = False + self.project = "" + self.version = "" + self.release = "" + self.today = "" + self.config_dir: str = None + self.link_index = False + self.copyright = "" + # Link verbosity to distutils' (which uses 1 by default). + self.verbosity = self.distribution.verbose - 1 # type: ignore + self.traceback = False + self.nitpicky = False + self.keep_going = False + + def _guess_source_dir(self) -> str: + for guess in ("doc", "docs"): + if not os.path.isdir(guess): + continue + for root, dirnames, filenames in os.walk(guess): + if "conf.py" in filenames: + return root + return os.curdir + + def finalize_options(self) -> None: + self.ensure_string_list("builder") + + if self.source_dir is None: + self.source_dir = self._guess_source_dir() + self.announce("Using source directory %s" % self.source_dir) + + self.ensure_dirname("source_dir") + + if self.config_dir is None: + self.config_dir = self.source_dir + + if self.build_dir is None: + build = self.get_finalized_command("build") + self.build_dir = os.path.join(abspath(build.build_base), "sphinx") # type: ignore + + self.doctree_dir = os.path.join(self.build_dir, "doctrees") + + self.builder_target_dirs = [ + (builder, os.path.join(self.build_dir, builder)) + for builder in self.builder + ] + + def run(self) -> None: + if not color_terminal(): + nocolor() + if not self.verbose: # type: ignore + status_stream = StringIO() + else: + status_stream = sys.stdout # type: ignore + confoverrides = {} + if self.project: + confoverrides["project"] = self.project + if self.version: + confoverrides["version"] = self.version + if self.release: + confoverrides["release"] = self.release + if self.today: + confoverrides["today"] = self.today + if self.copyright: + confoverrides["copyright"] = self.copyright + if self.nitpicky: + confoverrides["nitpicky"] = self.nitpicky + + for builder, builder_target_dir in self.builder_target_dirs: + app = None + + try: + confdir = self.config_dir or self.source_dir + with patch_docutils(confdir), docutils_namespace(): + app = Sphinx( + self.source_dir, + self.config_dir, + builder_target_dir, + self.doctree_dir, + builder, + confoverrides, + status_stream, + freshenv=self.fresh_env, + warningiserror=self.warning_is_error, + verbosity=self.verbosity, + keep_going=self.keep_going, + ) + app.build(force_all=self.all_files) + if app.statuscode: + raise ExecError( + "caused by %s builder." % app.builder.name + ) + except Exception as exc: + handle_exception(app, self, exc, sys.stderr) + if not self.pdb: + raise SystemExit(1) from exc + + if not self.link_index: + continue + + src = app.config.root_doc + app.builder.out_suffix # type: ignore + dst = app.builder.get_outfilename("index") # type: ignore + os.symlink(src, dst) + + +# version handling + + +def git_describe_to_python_version(version): + """Convert output from git describe to PEP 440 conforming versions.""" + + version_info = version.split("-") + if len(version_info) < 2: + return "unknown" + + # we always have $version-$release + release_type = version_info[1] + + version_data = { + "version": version_info[0], + "release_type": release_type, + } + if len(version_info) == 4: + version_data["commits"] = version_info[2] + else: + version_data["commits"] = 0 + + if release_type == "release": + if len(version_info) == 2: + # format: $version-release + # This is the case at time of the release. + fmt = "{version}" + elif len(version_info) == 4: + # format: $version-release-$commits-$hash + # This is the case after a release. + fmt = "{version}-{commits}" + elif release_type == "dev": + # format: $version-dev-$commits-$hash or $version-dev + fmt = "{version}.dev{commits}" + else: + match = re.match(r"^(alpha|beta|rc)(\d*)$", release_type) + if match is None: + return "unknown" + + if len(version_info) == 2: + fmt = "{version}{release_type}" + elif len(version_info) == 4: + fmt = "{version}{release_type}-{commits}" + + return fmt.format(**version_data) + + +version_file = "bpython/_version.py" +version = "unknown" + +try: + # get version from git describe + proc = subprocess.Popen( + ["git", "describe", "--tags", "--first-parent"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + stdout = proc.communicate()[0].strip() + stdout = stdout.decode("ascii") + + if proc.returncode == 0: + version = git_describe_to_python_version(stdout) +except OSError: + pass + +if version == "unknown": + try: + # get version from existing version file + with open(version_file) as vf: + version = ( + vf.read() + .strip() + .split("=")[-1] + .replace("'", "") + .replace('"', "") + ) + version = version.strip() + except OSError: + pass + +if version == "unknown": + # get version from directory name (tarballs downloaded from tags) + # directories are named bpython-X.Y-release in this case + basename = os.path.basename(os.path.dirname(__file__)) + basename_components = basename.split("-") + if ( + len(basename_components) == 3 + and basename_components[0] == "bpython" + and basename_components[2] == "release" + ): + version = basename_components[1] + +with open(version_file, "w") as vf: + vf.write("# Auto-generated file, do not edit!\n") + vf.write(f'__version__ = "{version}"\n') + + +class custom_build(build): def run(self): - self.run_command('build') - _install.run(self) + if using_translations: + self.run_command("compile_catalog") + if using_sphinx: + self.run_command("build_sphinx_man") + -cmdclass = dict(build_py=build_py, build=build, install=install) -translations_dir = os.path.join(package_dir, 'translations') +cmdclass = {"build": custom_build} + + +translations_dir = os.path.join("bpython", "translations") # localization options if using_translations: - class compile_catalog(_compile_catalog): - def initialize_options(self): - """Simply set default domain and directory attributes to the - correct path for bpython.""" - _compile_catalog.initialize_options(self) - - self.domain = 'bpython' - self.directory = translations_dir - self.use_fuzzy = True - - class update_catalog(_update_catalog): - def initialize_options(self): - """Simply set default domain and directory attributes to the - correct path for bpython.""" - _update_catalog.initialize_options(self) - - self.domain = 'bpython' - self.output_dir = translations_dir - self.input_file = os.path.join(translations_dir, 'bpython.pot') - - class extract_messages(_extract_messages): - def initialize_options(self): - """Simply set default domain and output file attributes to the - correct values for bpython.""" - _extract_messages.initialize_options(self) - - self.domain = 'bpython' - self.output_file = os.path.join(translations_dir, 'bpython.pot') - - class init_catalog(_init_catalog): - def initialize_options(self): - """Simply set default domain, input file and output directory - attributes to the correct values for bpython.""" - _init_catalog.initialize_options(self) - - self.domain = 'bpython' - self.output_dir = translations_dir - self.input_file = os.path.join(translations_dir, 'bpython.pot') - - build.sub_commands.insert(0, ('compile_catalog', None)) - - cmdclass['compile_catalog'] = compile_catalog - cmdclass['extract_messages'] = extract_messages - cmdclass['update_catalog'] = update_catalog - cmdclass['init_catalog'] = init_catalog + cmdclass["compile_catalog"] = babel.compile_catalog + cmdclass["extract_messages"] = babel.extract_messages + cmdclass["update_catalog"] = babel.update_catalog + cmdclass["init_catalog"] = babel.init_catalog if using_sphinx: - class BuildDocMan(BuildDoc): - def initialize_options(self): - BuildDoc.initialize_options(self) - self.builder = 'man' - self.source_dir = 'doc/sphinx/source' - self.build_dir = 'build' - - build.sub_commands.insert(0, ('build_sphinx_man', None)) - cmdclass['build_sphinx_man'] = BuildDocMan - - if platform.system() in ['FreeBSD', 'OpenBSD']: - man_dir = 'man' + cmdclass["build_sphinx"] = BuildDoc + cmdclass["build_sphinx_man"] = BuildDoc + + if platform.system() in ("FreeBSD", "OpenBSD"): + man_dir = "man" else: - man_dir = 'share/man' + man_dir = "share/man" # manual pages man_pages = [ - (os.path.join(man_dir, 'man1'), ['build/man/bpython.1']), - (os.path.join(man_dir, 'man5'), ['build/man/bpython-config.5']), + (os.path.join(man_dir, "man1"), ["build/man/bpython.1"]), + (os.path.join(man_dir, "man5"), ["build/man/bpython-config.5"]), ] else: man_pages = [] data_files = [ # desktop shortcut - (os.path.join('share', 'applications'), ['data/bpython.desktop']) + ( + os.path.join("share", "applications"), + ["data/org.bpython-interpreter.bpython.desktop"], + ), + # AppData + ( + os.path.join("share", "metainfo"), + ["data/org.bpython-interpreter.bpython.metainfo.xml"], + ), + # icon + (os.path.join("share", "pixmaps"), ["data/bpython.png"]), ] data_files.extend(man_pages) -extras_require = { - 'urwid' : ['urwid'] -} - -packages = ['bpython', 'bpython.test', 'bpython.translations', 'bpdb'] - -entry_points = { - 'console_scripts': [ - 'bpython = bpython.cli:main', - 'bpython-urwid = bpython.urwid:main [urwid]' - ] -} - -scripts = [] if using_setuptools else ['data/bpython', - 'data/bpython-urwid'] - -if sys.version_info[:2] >= (2, 6): - # curtsies only supports 2.6 and onwards - extras_require['curtsies'] = ['curtsies >=0.1.7, <0.2.0', 'greenlet'] - extras_require['watch'] = ['watchdog'] - packages.append("bpython.curtsiesfrontend") - entry_points['console_scripts'].append( - 'bpython-curtsies = bpython.curtsies:main [curtsies]') - if not using_setuptools: - scripts.append('data/bpython-curtsies') - # translations -mo_files = list() +mo_files = [] for language in os.listdir(translations_dir): - mo_subpath = os.path.join(language, 'LC_MESSAGES', 'bpython.mo') + mo_subpath = os.path.join(language, "LC_MESSAGES", "bpython.mo") if os.path.exists(os.path.join(translations_dir, mo_subpath)): mo_files.append(mo_subpath) + setup( - name="bpython", - version = __version__, - author = "Bob Farrell, Andreas Stuehrk et al.", - author_email = "robertanthonyfarrell@gmail.com", - description = "Fancy Interface to the Python Interpreter", - license = "MIT/X", - url = "http://www.bpython-interpreter.org/", - long_description = """bpython is a fancy interface to the Python - interpreter for Unix-like operating systems.""", - install_requires = [ - 'pygments' - ], - extras_require = extras_require, - tests_require = ['mock'], - packages = packages, - data_files = data_files, - package_data = { - 'bpython': ['logo.png'], - 'bpython.translations': mo_files, - 'bpython.test': ['test.config', 'test.theme'] + version=version, + data_files=data_files, + package_data={ + "bpython": ["sample-config"], + "bpython.translations": mo_files, + "bpython.test": ["test.config", "test.theme"], }, - entry_points = entry_points, - scripts = scripts, - cmdclass = cmdclass, - test_suite = 'bpython.test' + cmdclass=cmdclass, + test_suite="bpython.test", + zip_safe=False, ) # vim: fileencoding=utf-8 sw=4 ts=4 sts=4 ai et sta diff --git a/stubs/greenlet.pyi b/stubs/greenlet.pyi new file mode 100644 index 000000000..778c827ef --- /dev/null +++ b/stubs/greenlet.pyi @@ -0,0 +1,9 @@ +from typing import Any, Callable + +__version__: str + +def getcurrent() -> None: ... + +class greenlet: + def __init__(self, func: Callable[[], Any]): ... + def switch(self, value: Any = None) -> Any: ... diff --git a/stubs/msvcrt.pyi b/stubs/msvcrt.pyi new file mode 100644 index 000000000..2e99c9008 --- /dev/null +++ b/stubs/msvcrt.pyi @@ -0,0 +1,7 @@ +# The real types seem only available on the Windows platform, +# but it seems annoying to need to run typechecking once per platform +# https://github.com/python/typeshed/blob/master/stdlib/msvcrt.pyi +def locking(__fd: int, __mode: int, __nbytes: int) -> None: ... + +LK_NBLCK: int +LK_UNLCK: int diff --git a/stubs/pyperclip.pyi b/stubs/pyperclip.pyi new file mode 100644 index 000000000..3968c20a6 --- /dev/null +++ b/stubs/pyperclip.pyi @@ -0,0 +1,3 @@ +def copy(content: str): ... + +class PyperclipException(Exception): ... diff --git a/stubs/rlcompleter.pyi b/stubs/rlcompleter.pyi new file mode 100644 index 000000000..bbc871ada --- /dev/null +++ b/stubs/rlcompleter.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def get_class_members(class_: Any): ... diff --git a/bpython/test/test_formatter.py b/stubs/watchdog/__init__.pyi similarity index 100% rename from bpython/test/test_formatter.py rename to stubs/watchdog/__init__.pyi diff --git a/stubs/watchdog/events.pyi b/stubs/watchdog/events.pyi new file mode 100644 index 000000000..ded1fe942 --- /dev/null +++ b/stubs/watchdog/events.pyi @@ -0,0 +1,5 @@ +class FileSystemEvent: + @property + def src_path(self) -> str: ... + +class FileSystemEventHandler: ... diff --git a/stubs/watchdog/observers.pyi b/stubs/watchdog/observers.pyi new file mode 100644 index 000000000..c4596f2d9 --- /dev/null +++ b/stubs/watchdog/observers.pyi @@ -0,0 +1,8 @@ +from .events import FileSystemEventHandler + +class Observer: + def start(self): ... + def schedule( + self, observer: FileSystemEventHandler, dirname: str, recursive: bool + ): ... + def unschedule_all(self): ... diff --git a/stubs/xdg.pyi b/stubs/xdg.pyi new file mode 100644 index 000000000..db7d63e03 --- /dev/null +++ b/stubs/xdg.pyi @@ -0,0 +1,4 @@ +from typing import ClassVar + +class BaseDirectory: + xdg_config_home: ClassVar[str] diff --git a/light.theme b/theme/light.theme similarity index 96% rename from light.theme rename to theme/light.theme index 144b48ee1..448d9dc6a 100644 --- a/light.theme +++ b/theme/light.theme @@ -26,3 +26,4 @@ output = b main = b prompt = r prompt_more = g +right_arrow_suggestion = K diff --git a/sample.theme b/theme/sample.theme similarity index 96% rename from sample.theme rename to theme/sample.theme index ee932be78..af1e3cab7 100644 --- a/sample.theme +++ b/theme/sample.theme @@ -27,3 +27,4 @@ output = w main = c prompt = c prompt_more = g +right_arrow_suggestion = K diff --git a/windows.theme b/theme/windows.theme similarity index 100% rename from windows.theme rename to theme/windows.theme