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 f5a04a965..7a81cbfe2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ env .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.install.sh b/.travis.install.sh deleted file mode 100755 index deaf5714a..000000000 --- a/.travis.install.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -set -e -set -x - -pip install setuptools - -if [[ $RUN == nosetests ]]; then - # core dependencies - pip install -r requirements.txt - # filewatch specific dependencies - pip install watchdog - # jedi specific dependencies - pip install jedi - # translation specific dependencies - pip install babel - # Python 2.7 specific dependencies - if [[ $TRAVIS_PYTHON_VERSION == 2.7 ]]; then - # dependencies for crasher tests - pip install Twisted urwid - fi - case $TRAVIS_PYTHON_VERSION in - 2*|pypy) - # test specific dependencies - pip install mock - ;; - esac - # build and install - python setup.py install -elif [[ $RUN == build_sphinx ]]; then - # documentation specific dependencies - pip install 'sphinx >=1.1.3' -fi diff --git a/.travis.script.sh b/.travis.script.sh deleted file mode 100755 index f8619bb48..000000000 --- a/.travis.script.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash -set -e -set -x - -if [[ $RUN == build_sphinx ]]; then - python setup.py build_sphinx - python setup.py build_sphinx_man -elif [[ $RUN == nosetests ]]; then - cd build/lib/ - nosetests -v bpython/test -fi diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 879817cd4..000000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -language: python -sudo: false -dist: xenial -notifications: - webhooks: - - secure: "QXcEHVnOi5mZpONkHSu1tydj8EK3G7xJ7Wv/WYhJ5soNUpEJgi6YwR1WcxSjo7qyi8hTL+4jc+ID0TpKDeS1lpXF41kG9xf5kdxw5OL0EnMkrP9okUN0Ip8taEhd8w+6+dGmfZrx2nXOg1kBU7W5cE90XYqEtNDVXXgNeilT+ik=" - -python: - - "2.7" - - "3.5" - - "3.6" - - "3.7" - - "3.8" - - "pypy" - - "pypy3" - -env: - - RUN=nosetests - - RUN=build_sphinx - -matrix: - allow_failures: - - python: "pypy" - - python: "pypy3" - -install: - - ./.travis.install.sh - -script: - - ./.travis.script.sh diff --git a/AUTHORS b/AUTHORS.rst similarity index 100% rename from AUTHORS rename to AUTHORS.rst diff --git a/CHANGELOG b/CHANGELOG.rst similarity index 83% rename from CHANGELOG rename to CHANGELOG.rst index 2425e6a96..34dd4fb54 100644 --- a/CHANGELOG +++ b/CHANGELOG.rst @@ -1,20 +1,253 @@ Changelog ========= +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 decorted functions. + +* #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 @@ -26,10 +259,12 @@ Support for Python 3.8 has been added. Support for Python 3.4 has been dropped. ---- 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. @@ -44,6 +279,7 @@ Support for Python 3.3 has been dropped. ------ Fixes: + * Reverted #670 temporarily due to performance impact on large strings being output. @@ -51,11 +287,13 @@ Fixes: ---- 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. @@ -75,9 +313,11 @@ Fixes: ---- 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. @@ -711,7 +951,7 @@ Suggestions/bug reports/patches are welcome regarding this. ----- 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 diff --git a/LICENSE b/LICENSE index 72d02ff63..46f642f27 100644 --- a/LICENSE +++ b/LICENSE @@ -72,3 +72,31 @@ 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 badb93f1e..eb5d7f2fb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,16 +1,16 @@ include .pycheckrc -include AUTHORS -include CHANGELOG +include AUTHORS.rst +include CHANGELOG.rst include LICENSE include data/bpython.png include data/org.bpython-interpreter.bpython.desktop include data/org.bpython-interpreter.bpython.appdata.xml -include doc/sphinx/source/conf.py +include doc/sphinx/source/*.py include doc/sphinx/source/*.rst include doc/sphinx/source/logo.png -include *.theme 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 e2f6583bf..dd307d33b 100644 --- a/README.rst +++ b/README.rst @@ -1,29 +1,26 @@ .. image:: https://img.shields.io/pypi/v/bpython :target: https://pypi.org/project/bpython -.. image:: https://travis-ci.org/bpython/bpython.svg?branch=master - :target: https://travis-ci.org/bpython/bpython - -.. image:: https://readthedocs.org/projects/pinnwand/badge/?version=latest - :target: https://pinnwand.readthedocs.io/en/latest/ +.. image:: https://readthedocs.org/projects/bpython/badge/?version=latest + :target: https://docs.bpython-interpreter.org/en/latest/ .. image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/ambv/black -*********************************************************************** -bpython: A fancy curses interface to the Python interactive interpreter -*********************************************************************** +**************************************************************** +bpython: A fancy interface to the Python interactive interpreter +**************************************************************** `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:: http://i.imgur.com/jf8mCtP.gif +.. image:: https://bpython-interpreter.org/images/math.gif :alt: bpython - :width: 646 - :height: 300 + :width: 566 + :height: 348 :align: center bpython does **not** aim to be a complete IDE - the focus is on implementing a @@ -32,19 +29,18 @@ 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.. +etc. You can find more about bpython - including `full documentation`_ - at our `homepage`_. -.. contents:: - :local: - :depth: 1 - :backlinks: none - ========================== Installation & Basic Usage ========================== + +Installation using Pip +---------------------- + If you have `pip`_ installed, you can simply run: .. code-block:: bash @@ -64,9 +60,8 @@ Features & Examples 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 is - tried first, which works with any Python function, and then pydoc if that - fails. + 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 @@ -96,27 +91,15 @@ your config file as **~/.config/bpython/config** (i.e. Dependencies ============ * Pygments -* requests -* curtsies >= 0.1.18 +* curtsies >= 0.4.0 * greenlet -* six >= 1.5 -* Sphinx != 1.1.2 (optional, for the documentation) -* mock (optional, for the testsuite) +* pyxdg +* requests +* Sphinx >= 1.5 (optional, for the documentation) * babel (optional, for internationalization) -* watchdog (optional, for monitoring imported modules for changes) * jedi (optional, for experimental multiline completion) - -Python 2 before 2.7.7 ---------------------- -If you are using Python 2 before 2.7.7, the following dependency is also -required: - -* requests[security] - -cffi ----- -If you have problems installing cffi, which is needed by OpenSSL, please take a -look at `cffi docs`_. +* watchdog (optional, for monitoring imported modules for changes) +* pyperclip (optional, for copying to the clipboard) bpython-urwid ------------- @@ -124,75 +107,86 @@ bpython-urwid * urwid -========== -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`_. +=================================== +Installation via OS Package Manager +=================================== -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`_. +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: -=================== -CLI Windows Support -=================== +.. code-block:: bash -Dependencies ------------- -`Curses`_ Use the appropriate version compiled by Christoph Gohlke. + $ apt install bpython -`pyreadline`_ Use the version in the cheeseshop. +In case you are using an older version, run -Recommended ------------ -Obtain the less program from GnuUtils. This makes the pager work as intended. -It can be obtained from cygwin or GnuWin32 or msys +.. code-block:: bash -Current version is tested with ------------------------------- -* Curses 2.2 -* pyreadline 1.7 + $ apt-get install bpython -Curses Notes ------------- -The curses used has a bug where the colours are displayed incorrectly: +Arch Linux +---------- +Arch Linux uses ``pacman`` as the default package manager; you can use it to install ``bpython``: -* red is swapped with blue -* cyan is swapped with yellow +.. code-block:: bash -To correct this I have provided a windows.theme file. + $ pacman -S bpython -This curses implementation has 16 colors (dark and light versions of the -colours) +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 + + $ guix install bpython + +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 + +.. code-block:: bash + + $ sudo port install py-bpython -============ -Alternatives -============ -`ptpython`_ +========== +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`_. -`IPython`_ +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`_. -Feel free to get in touch if you know of any other alternatives that people -may be interested to try. +Hope to see you there! -.. _ptpython: https://github.com/jonathanslenders/ptpython -.. _ipython: https://ipython.org/ .. _homepage: http://www.bpython-interpreter.org .. _full documentation: http://docs.bpython-interpreter.org/ -.. _cffi docs: https://cffi.readthedocs.org/en/release-0.8/#macos-x .. _issues tracker: http://github.com/bpython/bpython/issues/ .. _pip: https://pip.pypa.io/en/latest/index.html -.. _project homepage: http://bpython-interpreter.org/community +.. _project homepage: http://bpython-interpreter.org .. _community: http://docs.bpython-interpreter.org/community.html .. _Robert: robertanthonyfarrell@gmail.com .. _bpython: http://www.bpython-interpreter.org/ diff --git a/bpdb/__init__.py b/bpdb/__init__.py index b1696e51b..9ce932d38 100644 --- a/bpdb/__init__.py +++ b/bpdb/__init__.py @@ -1,9 +1,7 @@ -# encoding: utf-8 - # 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 @@ -23,23 +21,25 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import print_function, absolute_import - import os import sys import traceback import bpython +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) @@ -55,8 +55,7 @@ def post_mortem(t=None): t = sys.exc_info()[2] if t is None: raise ValueError( - "A valid traceback must be passed if no " - "exception is being handled" + "A valid traceback must be passed if no exception is being handled." ) p = BPdb() @@ -75,12 +74,8 @@ def main(): ) options, args = parser.parse_args(sys.argv) if options.version: - print("bpdb on top of bpython version", __version__, end="") - 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 if len(args) < 2: @@ -90,7 +85,7 @@ def main(): # 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. @@ -105,22 +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(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( + "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" + f"Post mortem debugger finished. The {mainpyfile} will be restarted." ) diff --git a/bpdb/__main__.py b/bpdb/__main__.py index 0afaa17f8..5cbd26503 100644 --- a/bpdb/__main__.py +++ b/bpdb/__main__.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - # The MIT License # # Copyright (c) 2013 Sebastian Ramacher @@ -22,8 +20,6 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import absolute_import - import sys if __name__ == "__main__": diff --git a/bpdb/debugger.py b/bpdb/debugger.py index 0da7b1024..38469541a 100644 --- a/bpdb/debugger.py +++ b/bpdb/debugger.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - # The MIT License # # Copyright (c) 2008 Bob Farrell @@ -22,33 +20,31 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import print_function - import pdb import bpython class BPdb(pdb.Pdb): - """ PDB with BPython support. """ + """PDB with BPython support.""" - def __init__(self, *args, **kwargs): - pdb.Pdb.__init__(self, *args, **kwargs) + 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) + super().postloop() # cmd.Cmd commands - def do_Bpython(self, arg): + 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): + def help_Bpython(self) -> None: print("B(python)") print("") print( diff --git a/bpython/__init__.py b/bpython/__init__.py index 6d4a9aaa0..7d7bd28e0 100644 --- a/bpython/__init__.py +++ b/bpython/__init__.py @@ -20,20 +20,24 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import absolute_import - import os.path +from typing import Any try: - from ._version import __version__ as version + 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=None, banner=None): +def embed(locals_=None, args=None, banner=None) -> Any: if args is None: args = ["-i", "-q"] diff --git a/bpython/__main__.py b/bpython/__main__.py index 28c488702..9693dadf7 100644 --- a/bpython/__main__.py +++ b/bpython/__main__.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - # The MIT License # # Copyright (c) 2015 Sebastian Ramacher @@ -22,7 +20,6 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import absolute_import import sys diff --git a/bpython/_internal.py b/bpython/_internal.py index e3907c7b1..bfcfce46f 100644 --- a/bpython/_internal.py +++ b/bpython/_internal.py @@ -1,31 +1,27 @@ -# encoding: utf-8 - -from __future__ import absolute_import - import pydoc import sys 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): + 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) diff --git a/bpython/_py3compat.py b/bpython/_py3compat.py deleted file mode 100644 index bebf4efd4..000000000 --- a/bpython/_py3compat.py +++ /dev/null @@ -1,90 +0,0 @@ -# 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 -# 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. - - -""" - 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 -""" - -from __future__ import absolute_import - -import sys -import threading - -py3 = sys.version_info[0] == 3 - - -if py3: - from pygments.lexers import Python3Lexer as PythonLexer -else: - from pygments.lexers import PythonLexer - - -if py3 or sys.version_info[:3] >= (2, 7, 3): - - def prepare_for_exec(arg, encoding=None): - return arg - - -else: - - def prepare_for_exec(arg, encoding=None): - return arg.encode(encoding) - - -if py3: - - def try_decode(s, encoding): - return s - - -else: - - def try_decode(s, encoding): - """Try to decode s which is str names. Return None if not decodable""" - if not isinstance(s, unicode): - try: - return s.decode(encoding) - except UnicodeDecodeError: - return None - return s - - -if py3: - - def is_main_thread(): - return threading.main_thread() == threading.current_thread() - - -else: - - def is_main_thread(): - return isinstance(threading.current_thread(), threading._MainThread) diff --git a/bpython/args.py b/bpython/args.py index 6aea8a197..ac78267a9 100644 --- a/bpython/args.py +++ b/bpython/args.py @@ -1,149 +1,282 @@ -# encoding: utf-8 +# 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 print_function, absolute_import - +import argparse import code -import imp +import importlib.util +import logging import os import sys -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__ -from .config import default_config_path, loadini, Struct +from . import __version__, __copyright__ +from .config import default_config_path, Config from .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(): - return "bpython version %s on top of Python %s %s" % ( +def version_banner(base: str = "bpython") -> str: + return _("{} version {} on top of Python {} {}").format( + base, __version__, sys.version.split()[0], sys.executable, ) -def parse(args, extras=None, ignore_stdin=False): +def copyright_banner() -> str: + return _("{} See AUTHORS.rst for details.").format(__copyright__) + + +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.: + 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', - [optparse.Option('-f', action='store_true', dest='f', help='Explode'), - optparse.Option('-l', action='store_true', dest='l', help='Love')])) + ( + '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( + parser = RaisingArgumentParser( usage=_( - "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." ) ) - # 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( + parser.add_argument( "--config", default=default_config_path(), + type=Path, help=_("Use CONFIG instead of default config file."), ) - parser.add_option( + parser.add_argument( "--interactive", "-i", action="store_true", - help=_( - "Drop to bpython shell after running file " "instead of exiting." - ), + help=_("Drop to bpython shell after running file instead of exiting."), ) - parser.add_option( + parser.add_argument( "--quiet", "-q", action="store_true", - help=_("Don't flush the output to stdout."), + help=_("Don't print version banner."), ) - parser.add_option( + 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(version_banner()) - print( - "(C) 2008-2016 Bob Farrell, Andreas Stuehrk, Sebastian Ramacher, Thomas Ballinger, et al. " - "See AUTHORS for detail." - ) + 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()) - config = Struct() - loadini(config, options.config) + import cwcwidth + import greenlet + import pygments + import requests + import xdg - return config, options, args + 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") -def exec_code(interpreter, args): + # versions of optional dependencies + try: + import pyperclip + + 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") + + 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) + + 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: - source = sourcefile.read() + 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]))) - mod = imp.new_module("__console__") - sys.modules["__console__"] = mod - interpreter.locals = mod.__dict__ - interpreter.locals["__file__"] = args[0] + 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 53e1ea0f9..77887ef4b 100644 --- a/bpython/autocomplete.py +++ b/bpython/autocomplete.py @@ -1,8 +1,7 @@ -# coding: utf-8 - # The MIT License # # 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 @@ -21,70 +20,100 @@ # 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 unicode_literals, 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 __main__ import abc import glob +import itertools import keyword import logging import os import re import rlcompleter -from six.moves import range, builtins -from six import string_types, iteritems +import builtins + +from enum import Enum +from typing import ( + Any, + Optional, +) +from collections.abc import Iterator, Sequence from . import inspection -from . import importcompletion from . import line as lineparts from .line import LinePart -from ._py3compat import py3, try_decode from .lazyre import LazyReCompile from .simpleeval import safe_eval, evaluate_current_expression, EvaluationError +from .importcompletion import ModuleGatherer + -if not py3: - from types import InstanceType, ClassType +logger = logging.getLogger(__name__) # Autocomplete modes -SIMPLE = "simple" -SUBSTRING = "substring" -FUZZY = "fuzzy" +class AutocompleteModes(Enum): + NONE = "none" + SIMPLE = "simple" + SUBSTRING = "substring" + FUZZY = "fuzzy" + + @classmethod + def from_string(cls, value: str) -> Optional["AutocompleteModes"]: + if value.upper() in cls.__members__: + return cls.__members__[value.upper()] + return None -ALL_MODES = (SIMPLE, SUBSTRING, FUZZY) MAGIC_METHODS = tuple( - "__%s__" % s + f"__{s}__" for s in ( + "new", "init", + "del", "repr", "str", + "bytes", + "format", "lt", "le", "eq", "ne", "gt", "ge", - "cmp", "hash", - "nonzero", - "unicode", + "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", @@ -94,8 +123,33 @@ "and", "xor", "or", - "div", - "truediv", + "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", @@ -103,26 +157,29 @@ "complex", "int", "float", - "oct", - "hex", "index", - "coerce", + "round", + "trunc", + "floor", + "ceil", "enter", "exit", + "await", + "aiter", + "anext", + "aenter", + "aexit", ) ) -if py3: - KEYWORDS = frozenset(keyword.kwlist) -else: - KEYWORDS = frozenset(name.decode("ascii") for name in keyword.kwlist) +KEYWORDS = frozenset(keyword.kwlist) -def after_last_dot(name): +def _after_last_dot(name: str) -> str: return name.rstrip(".").rsplit(".")[-1] -def few_enough_underscores(current, match): +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 @@ -133,40 +190,49 @@ def few_enough_underscores(current, match): return True elif current.startswith("_") and not match.startswith("__"): return True - elif match.startswith("_"): - return False - else: - return True + return not match.startswith("_") -def method_match_simple(word, size, text): +def _method_match_none(word: str, size: int, text: str) -> bool: + return False + + +def _method_match_simple(word: str, size: int, text: str) -> bool: return word[:size] == text -def method_match_substring(word, size, text): +def _method_match_substring(word: str, size: int, text: str) -> bool: return text in word -def method_match_fuzzy(word, size, text): - s = r".*%s.*" % ".*".join(list(text)) - return re.search(s, word) +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 = { - SIMPLE: method_match_simple, - SUBSTRING: method_match_substring, - FUZZY: method_match_fuzzy, +_MODES_MAP = { + AutocompleteModes.NONE: _method_match_none, + AutocompleteModes.SIMPLE: _method_match_simple, + AutocompleteModes.SUBSTRING: _method_match_substring, + AutocompleteModes.FUZZY: _method_match_fuzzy, } -class BaseCompletionType(object): +class BaseCompletionType: """Describes different completion types""" - def __init__(self, shown_before_tab=True, mode=SIMPLE): + 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] + self.method_match = _MODES_MAP[mode] - def matches(self, cursor_offset, line, **kwargs): + @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. @@ -181,10 +247,11 @@ def matches(self, cursor_offset, line, **kwargs): * determine whether suggestions should be `shown_before_tab` * `substitute(cur, line, match)` in a match for what's found with `target` - """ + """ raise NotImplementedError - def locate(self, cursor_offset, line): + @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 @@ -192,18 +259,21 @@ def locate(self, cursor_offset, line): the cursor.""" raise NotImplementedError - def format(self, word): + def format(self, word: str) -> str: return word - def substitute(self, 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""" lpart = self.locate(cursor_offset, line) + assert lpart offset = lpart.start + len(match) - changed_line = line[: lpart.start] + match + line[lpart.end :] + changed_line = line[: lpart.start] + match + line[lpart.stop :] return offset, changed_line @property - def shown_before_tab(self): + 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 @@ -212,22 +282,32 @@ def shown_before_tab(self): class CumulativeCompleter(BaseCompletionType): """Returns combined matches from several completers""" - def __init__(self, completers, mode=SIMPLE): + def __init__( + self, + completers: Sequence[BaseCompletionType], + mode: AutocompleteModes = AutocompleteModes.SIMPLE, + ) -> None: if not completers: raise ValueError( "CumulativeCompleter requires at least one completer" ) - self._completers = completers + self._completers: Sequence[BaseCompletionType] = completers - super(CumulativeCompleter, self).__init__(True, mode) + super().__init__(True, mode) - def locate(self, current_offset, line): - return self._completers[0].locate(current_offset, line) + 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): + def format(self, word: str) -> str: return self._completers[0].format(word) - def matches(self, cursor_offset, line, **kwargs): + def matches( + self, cursor_offset: int, line: str, **kwargs: Any + ) -> set[str] | None: return_value = None all_matches = set() for completer in self._completers: @@ -242,42 +322,44 @@ def matches(self, cursor_offset, line, **kwargs): class ImportCompletion(BaseCompletionType): - def matches(self, cursor_offset, line, **kwargs): - return importcompletion.complete(cursor_offset, line) + def __init__( + self, + module_gatherer: ModuleGatherer, + mode: AutocompleteModes = AutocompleteModes.SIMPLE, + ): + super().__init__(False, mode) + self.module_gatherer = module_gatherer - def locate(self, current_offset, line): - return lineparts.current_word(current_offset, line) + def matches( + self, cursor_offset: int, line: str, **kwargs: Any + ) -> set[str] | None: + return self.module_gatherer.complete(cursor_offset, line) - def format(self, word): - return after_last_dot(word) + 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) -class FilenameCompletion(BaseCompletionType): - def __init__(self, mode=SIMPLE): - super(FilenameCompletion, self).__init__(False, mode) - if py3: +def _safe_glob(pathname: str) -> Iterator[str]: + return glob.iglob(glob.escape(pathname) + "*") - def safe_glob(self, pathname): - return glob.iglob(glob.escape(pathname) + "*") - else: - - def safe_glob(self, pathname): - try: - return glob.glob(pathname + "*") - except re.error: - # see #491 - return tuple() +class FilenameCompletion(BaseCompletionType): + def __init__(self, mode: AutocompleteModes = AutocompleteModes.SIMPLE): + super().__init__(False, mode) - def matches(self, cursor_offset, line, **kwargs): + 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 matches = set() username = cs.word.split(os.path.sep, 1)[0] user_dir = os.path.expanduser(username) - for filename in self.safe_glob(os.path.expanduser(cs.word)): + for filename in _safe_glob(os.path.expanduser(cs.word)): if os.path.isdir(filename): filename += os.path.sep if cs.word.startswith("~"): @@ -285,11 +367,10 @@ def matches(self, cursor_offset, line, **kwargs): matches.add(filename) return matches - def locate(self, current_offset, line): - return lineparts.current_string(current_offset, line) + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return lineparts.current_string(cursor_offset, line) - def format(self, filename): - filename.rstrip(os.sep).rsplit(os.sep)[-1] + def format(self, filename: str) -> str: if os.sep in filename[:-1]: return filename[filename.rindex(os.sep, 0, -1) + 1 :] else: @@ -297,14 +378,16 @@ def format(self, filename): class AttrCompletion(BaseCompletionType): - attr_matches_re = LazyReCompile(r"(\w+(\.\w+)*)\.(\w*)") - def matches(self, cursor_offset, line, **kwargs): - if "locals_" not in kwargs: - return None - locals_ = kwargs["locals_"] - + 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 @@ -314,167 +397,166 @@ def matches(self, cursor_offset, line, **kwargs): assert "." in r.word - for i in range(1, len(r.word) + 1): - if r.word[-i] == "[": - i -= 1 - break - methodtext = r.word[-i:] - matches = set( - "".join([r.word[:-i], m]) + i = r.word.rfind("[") + 1 + methodtext = r.word[i:] + matches = { + "".join([r.word[:i], m]) for m in self.attr_matches(methodtext, locals_) - ) + } - return set( + return { m for m in matches - if few_enough_underscores(r.word.split(".")[-1], m.split(".")[-1]) - ) + if _few_enough_underscores(r.word.split(".")[-1], m.split(".")[-1]) + } - def locate(self, current_offset, line): - return lineparts.current_dotted_attribute(current_offset, line) + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return lineparts.current_dotted_attribute(cursor_offset, line) - def format(self, word): - return after_last_dot(word) + def format(self, word: str) -> str: + return _after_last_dot(word) - def attr_matches(self, text, namespace): - """Taken from rlcompleter.py and bent to my will. - """ + def attr_matches( + self, text: str, namespace: dict[str, Any] + ) -> Iterator[str]: + """Taken from rlcompleter.py and bent to my will.""" - # 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 = self.attr_matches_re.match(text) if not m: - return [] + 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 [] + return (_ for _ in ()) try: obj = safe_eval(expr, namespace) except EvaluationError: - return [] - with inspection.AttrCleaner(obj): - matches = self.attr_lookup(obj, expr, attr) - return matches + return (_ for _ in ()) + return self.attr_lookup(obj, expr, attr) - def attr_lookup(self, obj, expr, attr): - """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.""" + def attr_lookup(self, obj: Any, expr: str, attr: str) -> Iterator[str]: + """Second half of attr_matches.""" words = self.list_attributes(obj) - if hasattr(obj, "__class__"): + if inspection.hasattr_safe(obj, "__class__"): words.append("__class__") - words = words + rlcompleter.get_class_members(obj.__class__) - if not isinstance(obj.__class__, abc.ABCMeta): + 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 - if not py3 and isinstance(obj, (InstanceType, ClassType)): - # Account for the __dict__ in an old-style class. - words.append("__dict__") - - matches = [] n = len(attr) - for word in words: - if self.method_match(word, n, attr) and word != "__builtins__": - matches.append("%s.%s" % (expr, word)) - return matches - - if py3: + return ( + f"{expr}.{word}" + for word in words + if self.method_match(word, n, attr) and word != "__builtins__" + ) - def list_attributes(self, obj): + 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) - else: - - def list_attributes(self, obj): - if isinstance(obj, InstanceType): - try: - return dir(obj) - except Exception: - # This is a case where we can not prevent user code from - # running. We return a default list attributes on error - # instead. (#536) - return ["__doc__", "__module__"] - else: - return dir(obj) - class DictKeyCompletion(BaseCompletionType): - def matches(self, cursor_offset, line, **kwargs): - if "locals_" not in kwargs: + 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 - locals_ = kwargs["locals_"] r = self.locate(cursor_offset, line) if r is None: return None - _, _, dexpr = lineparts.current_dict(cursor_offset, line) + current_dict_parts = lineparts.current_dict(cursor_offset, line) + if current_dict_parts is None: + return None + + dexpr = current_dict_parts.word try: obj = safe_eval(dexpr, locals_) except EvaluationError: return None if isinstance(obj, dict) and obj.keys(): - matches = set( - "{0!r}]".format(k) - for k in obj.keys() - if repr(k).startswith(r.word) - ) + matches = { + f"{k!r}]" for k in obj.keys() if repr(k).startswith(r.word) + } return matches if matches else None else: return None - def locate(self, current_offset, line): - return lineparts.current_dict_key(current_offset, line) + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return lineparts.current_dict_key(cursor_offset, line) - def format(self, match): + def format(self, match: str) -> str: return match[:-1] class MagicMethodCompletion(BaseCompletionType): - def matches(self, cursor_offset, line, **kwargs): - if "current_block" not in kwargs: + 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 - current_block = kwargs["current_block"] r = self.locate(cursor_offset, line) if r is None: return None if "class" not in current_block: return None - return set(name for name in MAGIC_METHODS if name.startswith(r.word)) + return {name for name in MAGIC_METHODS if name.startswith(r.word)} - def locate(self, current_offset, line): - return lineparts.current_method_definition_name(current_offset, line) + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return lineparts.current_method_definition_name(cursor_offset, line) class GlobalCompletion(BaseCompletionType): - def matches(self, cursor_offset, line, **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. """ - if "locals_" not in kwargs: + if locals_ is None: return None - locals_ = kwargs["locals_"] r = self.locate(cursor_offset, line) if r is None: return None - matches = set() n = len(r.word) - for word in KEYWORDS: - if self.method_match(word, n, r.word): - matches.add(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 iteritems(nspace): - word = try_decode(word, "ascii") + for word, val in nspace.items(): # if identifier isn't ascii, don't complete (syntax error) if word is None: continue @@ -485,91 +567,122 @@ def matches(self, cursor_offset, line, **kwargs): matches.add(_callable_postfix(val, word)) return matches if matches else None - def locate(self, current_offset, line): - return lineparts.current_single_word(current_offset, line) + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return lineparts.current_single_word(cursor_offset, line) class ParameterNameCompletion(BaseCompletionType): - def matches(self, cursor_offset, line, **kwargs): - if "argspec" not in kwargs: + def matches( + self, + cursor_offset: int, + line: str, + *, + funcprops: inspection.FuncProps | None = None, + **kwargs: Any, + ) -> set[str] | None: + if funcprops is None: return None - argspec = kwargs["argspec"] - if not argspec: - return None r = self.locate(cursor_offset, line) if r is None: return None - if argspec: - matches = set( - name + "=" - for name in argspec[1][0] - if isinstance(name, string_types) and name.startswith(r.word) - ) - if py3: - matches.update( - name + "=" - for name in argspec[1][4] - if name.startswith(r.word) - ) + + 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, current_offset, line): - return lineparts.current_word(current_offset, line) + 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, current_offset, line): - return lineparts.current_expression_attribute(current_offset, line) - - def matches(self, cursor_offset, line, **kwargs): - if "locals_" not in kwargs: - return None - locals_ = kwargs["locals_"] - + 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() - with inspection.AttrCleaner(obj): - # strips leading dot - matches = [m[1:] for m in self.attr_lookup(obj, "", attr.word)] - return set(m for m in matches if few_enough_underscores(attr.word, m)) + # 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): - def matches(self, cursor_offset, line, **kwargs): + class MultilineJediCompletion(BaseCompletionType): # type: ignore [no-redef] + def matches( + self, cursor_offset: int, line: str, **kwargs: Any + ) -> set[str] | None: return None + def locate(self, cursor_offset: int, line: str) -> LinePart | None: + return None else: - class JediCompletion(BaseCompletionType): - def matches(self, cursor_offset, line, **kwargs): - if "history" not in kwargs: + 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 - history = kwargs["history"] - if not lineparts.current_word(cursor_offset, line): - return None - history = "\n".join(history) + "\n" + line + assert cursor_offset <= len(line), "{!r} {!r}".format( + cursor_offset, + line, + ) + combined_history = "\n".join(itertools.chain(history, (line,))) try: - script = jedi.Script( - history, len(history.splitlines()), cursor_offset, "fake.py" + script = jedi.Script(combined_history, path="fake.py") + completions = script.complete( + combined_history.count("\n") + 1, cursor_offset ) - completions = script.completions() except (jedi.NotFoundError, IndexError, KeyError): # IndexError for #483 # KeyError for #544 @@ -582,10 +695,9 @@ def matches(self, cursor_offset, line, **kwargs): else: self._orig_start = None return None + assert isinstance(self._orig_start, int) - first_letter = line[self._orig_start : self._orig_start + 1] - - matches = [try_decode(c.name, "ascii") for c in completions] + matches = [c.name for c in completions] if any( not m.lower().startswith(matches[0][0].lower()) for m in matches ): @@ -594,34 +706,27 @@ def matches(self, cursor_offset, line, **kwargs): return None else: # case-sensitive matches only - return set(m for m in matches if m.startswith(first_letter)) + first_letter = line[self._orig_start] + return {m for m in matches if m.startswith(first_letter)} - def locate(self, cursor_offset, line): + 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]) - class MultilineJediCompletion(JediCompletion): - def matches(self, cursor_offset, line, **kwargs): - if "current_block" not in kwargs or "history" not in kwargs: - return None - current_block = kwargs["current_block"] - history = kwargs["history"] - if "\n" in current_block: - assert cursor_offset <= len(line), "%r %r" % ( - cursor_offset, - line, - ) - results = super(MultilineJediCompletion, self).matches( - cursor_offset, line, history=history - ) - return results - else: - return None - - -def get_completer(completers, cursor_offset, line, **kwargs): +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 @@ -630,7 +735,7 @@ def get_completer(completers, cursor_offset, line, **kwargs): line is a string of the current line kwargs (all optional): locals_ is a dictionary of the environment - argspec is an inspect.ArgSpec instance for the current function where + 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 @@ -638,48 +743,68 @@ def get_completer(completers, cursor_offset, line, **kwargs): 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, **kwargs) + 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 = logging.getLogger(__name__) logger.debug( - "Completer {} failed with unhandled exception: {}".format( - completer, e - ) + "Completer %r failed with unhandled exception: %s", completer, e ) continue if matches is not None: - return sorted(matches), (completer if matches else None) + return sorted(matches, key=_cmpl_sort), ( + completer if matches else None + ) return [], None -def get_default_completer(mode=SIMPLE): +def get_default_completer( + mode: AutocompleteModes, module_gatherer: ModuleGatherer +) -> tuple[BaseCompletionType, ...]: return ( - DictKeyCompletion(mode=mode), - ImportCompletion(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), + ( + 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 get_completer_bpython(cursor_offset, line, **kwargs): - """""" - return get_completer(get_default_completer(), cursor_offset, line, **kwargs) - - -def _callable_postfix(value, word): +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 diff --git a/bpython/cli.py b/bpython/cli.py deleted file mode 100644 index 9aee3d5c6..000000000 --- a/bpython/cli.py +++ /dev/null @@ -1,2063 +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 -# Prerequisites -# - 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, absolute_import - -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 - -from six.moves import range - -# These are used for syntax highlighting -from pygments import format -from pygments.formatters import TerminalFormatter -from ._py3compat import PythonLexer -from pygments.token import Token -from .formatter import BPythonFormatter - -# This for completion -from . import importcompletion - -# This for config -from .config import Struct, getpreferredencoding - -# This for keys -from .keys import cli_key_dispatch as key_dispatch - -# This for i18n -from . import translations -from .translations import _ - -from . import repl -from . import args as bpargs -from ._py3compat import py3 -from .pager import page -from .args import parse as argsparse - -if not py3: - import inspect - - -# --- module globals --- -stdscr = None -colors = None - -DO_RESIZE = False -# --- - - -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( - list(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): - super(CLIInteraction, self).__init__(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, wait_for_keypress=False): - 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): - super(CLIRepl, self).__init__(interp, config) - self.interp.writetb = self.writetb - self.scr = scr - self.stdout_hist = "" # native str (bytes in Py2, unicode in Py3) - 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 occurred in the interpreter. It is - used to prevent autoindentation from occurring 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 range(self.iy + 1, max_y): - self.scr.move(y, 0) - self.scr.clrtoeol() - - def complete(self, tab=False): - """Get Autocomplete 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, - self.arg_pos, - topline=self.funcprops, - 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 range(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, in_arg, 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.func - args = topline.argspec.args - kwargs = topline.argspec.defaults - _args = topline.argspec.varargs - _kwargs = topline.argspec.varkwargs - is_bound_method = topline.is_bound_method - if py3: - kwonly = topline.argspec.kwonly - kwonly_defaults = topline.argspec.kwonly_defaults 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 - n = self.prompt_undo() - if n > 0: - self.undo(n=n) - 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.copy_clipboard_key]: - self.copy2clipboard() - 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]: - try: - source = self.get_source_of_current_name() - except repl.SourceNotFound as e: - self.statusbar.message("%s" % (e,)) - else: - if config.highlight_show_source: - source = format( - PythonLexer().get_tokens(source), TerminalFormatter() - ) - page(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 == "\x03": - raise KeyboardInterrupt() - - elif key[0:3] == "PAD" and key not 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) - ) - if py3: - self.stdout_hist += self.ps1 - else: - self.stdout_hist += self.ps1.encode(getpreferredencoding()) - 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)) - if py3: - self.stdout_hist += self.ps2 - else: - self.stdout_hist += self.ps2.encode(getpreferredencoding()) - 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 as 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 range(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 range(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, arg_pos, 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, arg_pos, 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: - 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 unnecessary 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 height 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 next(self.matches_iter) - ) - try: - self.show_list( - self.matches_iter.matches, - self.arg_pos, - topline=self.funcprops, - 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 range(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 height 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. :) - - commands = ( - (_("Rewind"), config.undo_key), - (_("Save"), config.save_key), - (_("Pastebin"), config.pastebin_key), - (_("Pager"), config.last_output_key), - (_("Show Source"), config.show_source_key), - ) - - message = " ".join( - "<%s> %s" % (key, command) for command, key in commands if key - ) - - statusbar = Statusbar( - scr, main_win, background, config, message, 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: - # can't determine actual size - return default values - sizex, sizey = stdscr.getmaxyx() - - 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) - - 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: - bpargs.exec_code(interpreter, args) - except SystemExit as 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, "") - try: - clirepl.startup() - except OSError as e: - # Handle this with a proper error message. - if e.errno != errno.ENOENT: - raise - - if banner is not None: - clirepl.write(banner) - clirepl.write("\n") - - # XXX these deprecation warnings need to go at some point - clirepl.write( - _( - "WARNING: You are using `bpython-cli`, the curses backend for `bpython`. This backend has been deprecated in version 0.19 and might disappear in a future version." - ) - ) - clirepl.write("\n") - - if sys.version_info[0] == 2: - # XXX these deprecation warnings need to go at some point - clirepl.write( - _( - "WARNING: You are using `bpython` on Python 2. Support for Python 2 has been deprecated in version 0.19 and might disappear in a future version." - ) - ) - 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 = argsparse(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__": - sys.exit(main()) - -# vim: sw=4 ts=4 sts=4 ai et diff --git a/bpython/clipboard.py b/bpython/clipboard.py deleted file mode 100644 index aee429b9e..000000000 --- a/bpython/clipboard.py +++ /dev/null @@ -1,81 +0,0 @@ -# encoding: utf-8 - -# The MIT License -# -# 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 -# 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 __future__ import absolute_import - -import subprocess -import os -import platform -from locale import getpreferredencoding - - -class CopyFailed(Exception): - pass - - -class XClipboard(object): - """Manage clipboard with xclip.""" - - def copy(self, content): - process = subprocess.Popen( - ["xclip", "-i", "-selection", "clipboard"], stdin=subprocess.PIPE - ) - process.communicate(content.encode(getpreferredencoding())) - if process.returncode != 0: - raise CopyFailed() - - -class OSXClipboard(object): - """Manage clipboard with pbcopy.""" - - def copy(self, content): - process = subprocess.Popen(["pbcopy", "w"], stdin=subprocess.PIPE) - process.communicate(content.encode(getpreferredencoding())) - if process.returncode != 0: - raise CopyFailed() - - -def command_exists(command): - process = subprocess.Popen( - ["which", command], stderr=subprocess.STDOUT, stdout=subprocess.PIPE - ) - process.communicate() - - return process.returncode == 0 - - -def get_clipboard(): - """Get best clipboard handling implementation for current system.""" - - if platform.system() == "Darwin": - if command_exists("pbcopy"): - return OSXClipboard() - if ( - platform.system() in ("Linux", "FreeBSD", "OpenBSD") - and os.getenv("DISPLAY") is not None - ): - if command_exists("xclip"): - return XClipboard() - - return None diff --git a/bpython/config.py b/bpython/config.py index 456479b7a..c309403fd 100644 --- a/bpython/config.py +++ b/bpython/config.py @@ -1,28 +1,62 @@ -# encoding: utf-8 - -from __future__ import unicode_literals, absolute_import +# 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 import locale +from configparser import ConfigParser from itertools import chain -from six import iterkeys, iteritems -from six.moves.configparser import ConfigParser +from pathlib import Path +from typing import Any, Dict +from collections.abc import MutableMapping, Mapping +from xdg import BaseDirectory + +from .autocomplete import AutocompleteModes -from .autocomplete import SIMPLE as default_completion, ALL_MODES +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 Struct(object): - """Simple class for instantiating objects we can add arbitrary attributes - to and use for various arbitrary things.""" +class UnknownColorCode(Exception): + def __init__(self, key: str, color: str) -> None: + self.key = key + self.color = color -def getpreferredencoding(): +def getpreferredencoding() -> str: """Get the user's preferred encoding.""" return locale.getpreferredencoding() or sys.getdefaultencoding() -def can_encode(c): +def can_encode(c: str) -> bool: try: c.encode(getpreferredencoding()) return True @@ -30,39 +64,59 @@ def can_encode(c): return False -def supports_box_chars(): +def supports_box_chars() -> bool: """Check if the encoding supports Unicode box characters.""" return all(map(can_encode, "│─└┘┌┐")) -def get_config_home(): +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 iterkeys(default_values): + +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 iteritems(default_values[section]): + for opt, val in default_values[section].items(): if not config.has_option(section, opt): - config.set(section, opt, "%s" % (val,)) - + 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) +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", + } - config = ConfigParser() - defaults = { + defaults: dict[str, dict[str, Any]] = { "general": { "arg_spec": True, "auto_display_list": True, @@ -71,8 +125,28 @@ def loadini(struct, configfile): "complete_magic_methods": True, "dedent_after": 1, "default_autoreload": False, - "editor": os.environ.get("VISUAL", os.environ.get("EDITOR", "vi")), + "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", @@ -81,14 +155,13 @@ def loadini(struct, configfile): "pastebin_confirm": True, "pastebin_expiry": "1week", "pastebin_helper": "", - "pastebin_removal_url": "https://bpaste.net/remove/$removal_id", - "pastebin_show_url": "https://bpaste.net/show/$paste_id", - "pastebin_url": "https://bpaste.net/json/new", + "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": { "backspace": "C-h", @@ -110,6 +183,7 @@ def loadini(struct, configfile): "last_output": "F9", "left": "C-b", "pastebin": "F8", + "redo": "C-g", "reimport": "F6", "reverse_incremental_search": "M-r", "right": "C-f", @@ -123,203 +197,202 @@ def loadini(struct, configfile): "up_one_line": "C-p", "yank_from_buffer": "C-y", }, - "cli": {"suggestion_width": 0.8, "trim_prompts": False,}, - "curtsies": {"list_above": False, "right_arrow_completion": True,}, + "cli": { + "suggestion_width": 0.8, + "trim_prompts": False, + }, + "curtsies": { + "list_above": False, + "right_arrow_completion": True, + }, } - default_keys_to_commands = dict( - (value, key) for (key, value) in iteritems(defaults["keyboard"]) - ) + def __init__(self, config_path: Path | None = None) -> None: + """Loads .ini configuration file and stores its values.""" - fill_config_with_default_values(config, defaults) - try: - 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() + 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) - 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) - ) - sys.exit(1) + ) + sys.exit(1) - def get_key_no_doublebind(command): - default_commands_to_keys = defaults["keyboard"] - requested_key = config.get("keyboard", command) + default_keys_to_commands = { + value: key for (key, value) in self.defaults["keyboard"].items() + } - try: - default_command = default_keys_to_commands[requested_key] - - if default_commands_to_keys[default_command] == config.get( - "keyboard", default_command - ): - setattr(struct, "%s_key" % default_command, "") - except KeyError: - pass - - return requested_key - - struct.config_path = config_path - - 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.single_undo_time = config.getfloat("general", "single_undo_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.default_autoreload = config.getboolean( - "general", "default_autoreload" - ) - - struct.pastebin_key = get_key_no_doublebind("pastebin") - struct.copy_clipboard_key = get_key_no_doublebind("copy_clipboard") - struct.save_key = get_key_no_doublebind("save") - struct.search_key = get_key_no_doublebind("search") - struct.show_source_key = get_key_no_doublebind("show_source") - struct.suspend_key = get_key_no_doublebind("suspend") - struct.toggle_file_watch_key = get_key_no_doublebind("toggle_file_watch") - struct.undo_key = get_key_no_doublebind("undo") - struct.reimport_key = get_key_no_doublebind("reimport") - struct.reverse_incremental_search_key = get_key_no_doublebind( - "reverse_incremental_search" - ) - struct.incremental_search_key = get_key_no_doublebind("incremental_search") - struct.up_one_line_key = get_key_no_doublebind("up_one_line") - struct.down_one_line_key = get_key_no_doublebind("down_one_line") - struct.cut_to_buffer_key = get_key_no_doublebind("cut_to_buffer") - struct.yank_from_buffer_key = get_key_no_doublebind("yank_from_buffer") - struct.clear_word_key = get_key_no_doublebind("clear_word") - struct.backspace_key = get_key_no_doublebind("backspace") - struct.clear_line_key = get_key_no_doublebind("clear_line") - struct.clear_screen_key = get_key_no_doublebind("clear_screen") - struct.delete_key = get_key_no_doublebind("delete") - - struct.left_key = get_key_no_doublebind("left") - struct.right_key = get_key_no_doublebind("right") - struct.end_of_line_key = get_key_no_doublebind("end_of_line") - struct.beginning_of_line_key = get_key_no_doublebind("beginning_of_line") - struct.transpose_chars_key = get_key_no_doublebind("transpose_chars") - struct.exit_key = get_key_no_doublebind("exit") - struct.last_output_key = get_key_no_doublebind("last_output") - struct.edit_config_key = get_key_no_doublebind("edit_config") - struct.edit_current_block_key = get_key_no_doublebind("edit_current_block") - struct.external_editor_key = get_key_no_doublebind("external_editor") - struct.help_key = get_key_no_doublebind("help") - - struct.pastebin_confirm = config.getboolean("general", "pastebin_confirm") - struct.pastebin_url = config.get("general", "pastebin_url") - struct.pastebin_show_url = config.get("general", "pastebin_show_url") - struct.pastebin_removal_url = config.get("general", "pastebin_removal_url") - struct.pastebin_expiry = config.get("general", "pastebin_expiry") - 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_right_arrow_completion = config.getboolean( - "curtsies", "right_arrow_completion" - ) - - color_scheme_name = config.get("general", "color_scheme") + def get_key_no_doublebind(command: str) -> str: + default_commands_to_keys = self.defaults["keyboard"] + requested_key = config.get("keyboard", command) - 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", - } + 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 - if color_scheme_name == "default": - struct.color_scheme = default_colors - else: - struct.color_scheme = dict() + return requested_key - theme_filename = color_scheme_name + ".theme" - path = os.path.expanduser( - os.path.join(get_config_home(), theme_filename) + self.config_path = ( + config_path.absolute() if config_path is not None else None ) - try: - load_theme(struct, path, struct.color_scheme, default_colors) - except EnvironmentError: - sys.stderr.write( - "Could not load theme '%s'.\n" % (color_scheme_name,) + 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") ) - sys.exit(1) + 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" + ) + - # expand path of history file - struct.hist_file = os.path.expanduser(struct.hist_file) - - # verify completion mode - if struct.autocomplete_mode not in ALL_MODES: - struct.autocomplete_mode = default_completion - - # set box drawing characters - if config.getboolean("general", "unicode_box") and supports_box_chars(): - struct.left_border = "│" - struct.right_border = "│" - struct.top_border = "─" - struct.bottom_border = "─" - struct.left_bottom_corner = "└" - struct.right_bottom_corner = "┘" - struct.left_top_corner = "┌" - struct.right_top_corner = "┐" - else: - struct.left_border = "|" - struct.right_border = "|" - struct.top_border = "-" - struct.bottom_border = "-" - struct.left_bottom_corner = "+" - struct.right_bottom_corner = "+" - struct.left_top_corner = "+" - struct.right_top_corner = "+" - - -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) + 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) + 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 iteritems(default_colors): + for k, v in default_colors.items(): if k not in colors: colors[k] = v diff --git a/bpython/curtsies.py b/bpython/curtsies.py index b5921a109..ae48a6007 100644 --- a/bpython/curtsies.py +++ b/bpython/curtsies.py @@ -1,42 +1,62 @@ -# encoding: utf-8 - -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 argparse import collections -import io import logging import sys -from optparse import Option import curtsies -import curtsies.window -import curtsies.input import curtsies.events +import curtsies.input +import curtsies.window -from .curtsiesfrontend.repl import BaseRepl +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 . import args as bpargs -from . import translations -from .translations import _ -from .importcompletion import find_iterator -from .curtsiesfrontend import events as bpythonevents -from . import inspection +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__) -repl = None # global for `from bpython.curtsies import repl` -# WARNING Will be a problem if more than one repl is ever instantiated this way +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, locals_, banner, interp=None): + 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 ) - self.window = curtsies.window.CursorAwareWindow( + window = curtsies.window.CursorAwareWindow( sys.stdout, sys.stdin, keep_last_line=True, @@ -44,52 +64,72 @@ def __init__(self, config, locals_, banner, interp=None): extra_bytes_callback=self.input_generator.unget_bytes, ) - self._request_refresh = self.input_generator.event_trigger( - bpythonevents.RefreshRequestEvent + self._request_refresh_callback: Callable[[], None] = ( + self.input_generator.event_trigger(events.RefreshRequestEvent) ) - self._schedule_refresh = self.input_generator.scheduled_event_trigger( - bpythonevents.ScheduledRefreshRequestEvent + self._schedule_refresh_callback = ( + self.input_generator.scheduled_event_trigger( + events.ScheduledRefreshRequestEvent + ) ) - self._request_reload = self.input_generator.threadsafe_event_trigger( - bpythonevents.ReloadEvent + self._request_reload_callback = ( + self.input_generator.threadsafe_event_trigger(events.ReloadEvent) ) - self.interrupting_refresh = self.input_generator.threadsafe_event_trigger( - lambda: None + self._interrupting_refresh_callback = ( + self.input_generator.threadsafe_event_trigger(lambda: None) ) - self.request_undo = self.input_generator.event_trigger( - bpythonevents.UndoEvent + self._request_undo_callback = self.input_generator.event_trigger( + events.UndoEvent ) with self.input_generator: pass # temp hack to get .original_stty - super(FullCurtsiesRepl, self).__init__( + super().__init__( + config, + window, locals_=locals_, - config=config, banner=banner, interp=interp, orig_tcattrs=self.input_generator.original_stty, ) - def get_term_hw(self): + 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): + def get_cursor_vertical_diff(self) -> int: return self.window.get_cursor_vertical_diff() - def get_top_usable_line(self): + def get_top_usable_line(self) -> int: return self.window.top_usable_row - def on_suspend(self): + def on_suspend(self) -> None: self.window.__exit__(None, None, None) self.input_generator.__exit__(None, None, None) - def after_suspend(self): + def after_suspend(self) -> None: self.input_generator.__enter__() self.window.__enter__() self.interrupting_refresh() - def process_event_and_paint(self, e): + 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: @@ -107,14 +147,18 @@ def process_event_and_paint(self, e): scrolled = self.window.render_to_terminal(array, cursor_pos) self.scroll_offset += scrolled - def mainloop(self, interactive=True, paste=None): + 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(bpythonevents.RunStartupFileEvent()) + self.process_event(events.RunStartupFileEvent()) # handle paste if paste: @@ -123,7 +167,7 @@ def mainloop(self, interactive=True, paste=None): # do a display before waiting for first event self.process_event_and_paint(None) inputs = combined_events(self.input_generator) - for unused in find_iterator: + while self.module_gatherer.find_coroutine(): e = inputs.send(0) if e is not None: self.process_event_and_paint(e) @@ -132,57 +176,45 @@ def mainloop(self, interactive=True, paste=None): self.process_event_and_paint(e) -def main(args=None, locals_=None, banner=None, welcome_message=None): +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 options", - None, - [ - Option( - "--log", - "-L", - action="count", - help=_("log debug messages to bpython.log"), - ), - Option( - "--paste", - "-p", - action="store_true", - help=_("start by pasting lines of a file into session"), - ), - ], + _("curtsies arguments"), + _("Additional arguments specific to the curtsies-based REPL."), + curtsies_arguments, ), ) - if options.log is None: - options.log = 0 - logging_levels = [logging.ERROR, logging.INFO, logging.DEBUG] - level = logging_levels[min(len(logging_levels) - 1, options.log)] - logging.getLogger("curtsies").setLevel(level) - logging.getLogger("bpython").setLevel(level) - if options.log: - handler = logging.FileHandler(filename="bpython.log") - logging.getLogger("curtsies").addHandler(handler) - logging.getLogger("curtsies").propagate = False - logging.getLogger("bpython").addHandler(handler) - logging.getLogger("bpython").propagate = False interp = None paste = None + exit_value: tuple[Any, ...] = () if exec_args: if not options: raise ValueError("don't pass in exec_args without options") - exit_value = () if options.paste: paste = curtsies.events.PasteEvent() encoding = inspection.get_encoding_file(exec_args[0]) - with io.open(exec_args[0], encoding=encoding) as f: + with open(exec_args[0], encoding=encoding) as f: sourcecode = f.read() paste.events.extend(sourcecode) else: @@ -201,32 +233,31 @@ def main(args=None, locals_=None, banner=None, welcome_message=None): print(bpargs.version_banner()) if banner is not None: print(banner) - - if sys.version_info[0] == 2: - # XXX these deprecation warnings need to go at some point - print( - _( - "WARNING: You are using `bpython` on Python 2. Support for Python 2 has been deprecated in version 0.19 and might disappear in a future version." - ) + 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 ) - global repl 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 - exit_value = repl.mainloop(True, paste) + repl.mainloop(True, paste) except (SystemExitFromCodeRunner, SystemExit) as e: exit_value = e.args return extract_exit_value(exit_value) -def _combined_events(event_provider, paste_threshold): +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() + queue: collections.deque = collections.deque() while True: e = event_provider.send(timeout) if isinstance(e, curtsies.events.Event): @@ -251,7 +282,9 @@ def _combined_events(event_provider, paste_threshold): timeout = yield queue.popleft() -def combined_events(event_provider, paste_threshold=3): +def combined_events( + event_provider: SupportsEventGeneration, paste_threshold: int = 3 +) -> SupportsEventGeneration: g = _combined_events(event_provider, paste_threshold) next(g) return g diff --git a/bpython/curtsiesfrontend/_internal.py b/bpython/curtsiesfrontend/_internal.py index 4f17cb41f..72572b0b1 100644 --- a/bpython/curtsiesfrontend/_internal.py +++ b/bpython/curtsiesfrontend/_internal.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - # The MIT License # # Copyright (c) 2015 the bpython authors. @@ -23,42 +21,46 @@ # THE SOFTWARE. import pydoc +from types import TracebackType +from typing import Literal -import bpython._internal -from bpython._py3compat import py3 -from bpython.repl import getpreferredencoding +from .. import _internal -class NopPydocPager(object): +class NopPydocPager: def __enter__(self): self._orig_pager = pydoc.pager pydoc.pager = self - def __exit__(self, *args): + 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(bpython._internal._Helper): +class _Helper(_internal._Helper): def __init__(self, repl=None): self._repl = repl pydoc.pager = self.pager - super(_Helper, self).__init__() + super().__init__() - def pager(self, output): - if not py3 and isinstance(output, str): - output = output.decode(getpreferredencoding()) - self._repl.pager(output) + def pager(self, output, title=""): + self._repl.pager(output, title) def __call__(self, *args, **kwargs): if self._repl.reevaluating: with NopPydocPager(): - return super(_Helper, self).__call__(*args, **kwargs) + return super().__call__(*args, **kwargs) else: - return super(_Helper, self).__call__(*args, **kwargs) + return super().__call__(*args, **kwargs) # vim: sw=4 ts=4 sts=4 ai et diff --git a/bpython/curtsiesfrontend/coderunner.py b/bpython/curtsiesfrontend/coderunner.py index 6087edb08..f059fab88 100644 --- a/bpython/curtsiesfrontend/coderunner.py +++ b/bpython/curtsiesfrontend/coderunner.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - """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 @@ -13,17 +11,16 @@ """ import code -import signal import greenlet import logging +import signal -from bpython._py3compat import py3, is_main_thread -from bpython.config import getpreferredencoding +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""" @@ -32,7 +29,7 @@ class SystemExitFromCodeRunner(SystemExit): greenlet""" -class RequestFromCodeRunner(object): +class RequestFromCodeRunner: """Message from the code runner""" @@ -55,11 +52,11 @@ class Unfinished(RequestFromCodeRunner): class SystemExitRequest(RequestFromCodeRunner): """Running code raised a SystemExit""" - def __init__(self, args): + def __init__(self, *args): self.args = args -class CodeRunner(object): +class CodeRunner: """Runs user code in an interpreter. Running code requests a refresh by calling @@ -204,8 +201,8 @@ def request_from_main_context(self, force_refresh=False): return value -class FakeOutput(object): - def __init__(self, coderunner, on_write, fileno=1): +class FakeOutput: + def __init__(self, coderunner, on_write, real_fileobj): """Fakes sys.stdout or sys.stderr on_write should always take unicode @@ -215,11 +212,9 @@ def __init__(self, coderunner, on_write, fileno=1): """ self.coderunner = coderunner self.on_write = on_write - self.real_fileno = fileno + self._real_fileobj = real_fileobj def write(self, s, *args, **kwargs): - if not py3 and isinstance(s, str): - s = s.decode(getpreferredencoding(), "ignore") self.on_write(s, *args, **kwargs) return self.coderunner.request_from_main_context(force_refresh=True) @@ -227,7 +222,7 @@ def write(self, s, *args, **kwargs): # have a method called fileno. One example is pwntools. This # is not a widespread issue, but is annoying. def fileno(self): - return self.real_fileno + return self._real_fileobj.fileno() def writelines(self, l): for s in l: @@ -238,3 +233,7 @@ def flush(self): def isatty(self): return True + + @property + def encoding(self): + return self._real_fileobj.encoding diff --git a/bpython/curtsiesfrontend/events.py b/bpython/curtsiesfrontend/events.py index 15065d36e..4f9c13e55 100644 --- a/bpython/curtsiesfrontend/events.py +++ b/bpython/curtsiesfrontend/events.py @@ -1,7 +1,7 @@ -# encoding: utf-8 - """Non-keyboard events used in bpython curtsies REPL""" + import time +from collections.abc import Sequence import curtsies.events @@ -9,17 +9,17 @@ class ReloadEvent(curtsies.events.Event): """Request to rerun REPL session ASAP because imported modules changed""" - def __init__(self, files_modified=("?",)): + def __init__(self, files_modified: Sequence[str] = ("?",)) -> None: self.files_modified = files_modified - def __repr__(self): - return "" % (" & ".join(self.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): + def __repr__(self) -> str: return "" @@ -29,11 +29,11 @@ class ScheduledRefreshRequestEvent(curtsies.events.ScheduledEvent): Used to schedule the disappearance of status bar message that only shows for a few seconds""" - def __init__(self, when): - super(ScheduledRefreshRequestEvent, self).__init__(when) + def __init__(self, when: float) -> None: + super().__init__(when) - def __repr__(self): - return "" % ( + def __repr__(self) -> str: + return "".format( self.when - time.time() ) @@ -45,5 +45,5 @@ class RunStartupFileEvent(curtsies.events.Event): class UndoEvent(curtsies.events.Event): """Request to undo.""" - def __init__(self, n=1): + def __init__(self, n: int = 1) -> None: self.n = n diff --git a/bpython/curtsiesfrontend/filewatch.py b/bpython/curtsiesfrontend/filewatch.py index 37ea8f509..b9778c97a 100644 --- a/bpython/curtsiesfrontend/filewatch.py +++ b/bpython/curtsiesfrontend/filewatch.py @@ -1,40 +1,42 @@ -# encoding: utf-8 - 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) - def reset(self): - self.dirs = defaultdict(set) - del self.modules_to_add_later[:] - self.old_dirs = defaultdict(set) + super().__init__() + + def reset(self) -> None: + self.dirs.clear() + self.modules_to_add_later.clear() self.observer.unschedule_all() - def _add_module(self, path): + def _add_module(self, path: str) -> None: """Add a python module to track changes""" path = os.path.abspath(path) for suff in importcompletion.SUFFIXES: @@ -46,10 +48,10 @@ def _add_module(self, path): self.observer.schedule(self, dirname, recursive=False) 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 track_module(self, path): + def track_module(self, path: str) -> None: """ Begins tracking this if activated, or remembers to track later. """ @@ -58,9 +60,9 @@ def track_module(self, path): else: self._add_module_later(path) - def activate(self): + def activate(self) -> None: if self.activated: - raise ValueError("%r is already activated." % (self,)) + raise ValueError(f"{self!r} is already activated.") if not self.started: self.started = True self.observer.start() @@ -68,17 +70,18 @@ def activate(self): 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.modules_to_add_later.clear() self.activated = True - def deactivate(self): + def deactivate(self) -> None: if not self.activated: - raise ValueError("%r is not activated." % (self,)) + raise ValueError(f"{self!r} is not activated.") self.observer.unschedule_all() 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(files_modified=[event.src_path]) + 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 65b554ead..17b178de6 100644 --- a/bpython/curtsiesfrontend/interaction.py +++ b/bpython/curtsiesfrontend/interaction.py @@ -1,17 +1,14 @@ -# encoding: utf-8 - -from __future__ import unicode_literals - import greenlet import time -import curtsies.events as events +from curtsies import events -from bpython.repl import Interaction as BpythonInteraction -from bpython.curtsiesfrontend.events import RefreshRequestEvent -from bpython.curtsiesfrontend.manual_readline import edit_keys +from ..translations import _ +from ..repl import Interaction +from ..curtsiesfrontend.events import RefreshRequestEvent +from ..curtsiesfrontend.manual_readline import edit_keys -class StatusBar(BpythonInteraction): +class StatusBar(Interaction): """StatusBar and Interaction for Repl Passing of control back and forth between calls that use interact api @@ -42,7 +39,7 @@ def __init__( 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) @@ -51,7 +48,7 @@ def __init__( self.request_refresh = request_refresh self.schedule_refresh = schedule_refresh - super(StatusBar, self).__init__(config) + super().__init__(config) def push_permanent_message(self, msg): self._message = "" @@ -61,7 +58,7 @@ 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): @@ -81,7 +78,7 @@ def _check_for_expired_message(self): ): 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, RefreshRequestEvent): @@ -91,7 +88,7 @@ def process_event(self, e): for ee in e.events: # strip control seq self.add_normal_character(ee if len(ee) == 1 else ee[-1]) - elif e in [""] or isinstance(e, events.SigIntEvent): + elif e == "" or isinstance(e, events.SigIntEvent): self.request_context.switch(False) self.escape() elif e in edit_keys: @@ -107,7 +104,7 @@ def process_event(self, e): self.escape() self.request_context.switch(line) elif self.in_confirm: - if e in ("y", "Y"): + if e.lower() == _("y"): self.request_context.switch(True) else: self.request_context.switch(False) @@ -152,7 +149,7 @@ def should_show_message(self): return bool(self.current_line) # interaction interface - should be called from other greenlets - def notify(self, msg, n=3, wait_for_keypress=False): + def notify(self, msg, n=3.0, wait_for_keypress=False): self.request_context = greenlet.getcurrent() self.message_time = n self.message(msg, schedule_refresh=wait_for_keypress) @@ -160,7 +157,7 @@ def notify(self, msg, n=3, wait_for_keypress=False): self.request_refresh() self.main_context.switch(msg) - # below Really ought to be called from greenlets other than main because + # 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""" @@ -170,9 +167,8 @@ def confirm(self, q): return self.main_context.switch(q) def file_prompt(self, s): - """Expected to return a file name, given """ + """Expected to return a file name, given""" self.request_context = greenlet.getcurrent() self.prompt = s self.in_prompt = True - result = self.main_context.switch(s) - return result + return self.main_context.switch(s) diff --git a/bpython/curtsiesfrontend/interpreter.py b/bpython/curtsiesfrontend/interpreter.py index 57c694ce4..9382db6bc 100644 --- a/bpython/curtsiesfrontend/interpreter.py +++ b/bpython/curtsiesfrontend/interpreter.py @@ -1,16 +1,17 @@ -# encoding: utf-8 - import sys -from six import iteritems, text_type +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 +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 bpython.curtsiesfrontend.parse import parse -from bpython.repl import Interpreter as ReplInterpreter +from ..curtsiesfrontend.parse import parse +from ..repl import Interpreter as ReplInterpreter default_colors = { @@ -45,11 +46,14 @@ class BPythonFormatter(Formatter): See the Pygments source for more info; it's pretty straightforward.""" - def __init__(self, color_scheme, **options): - self.f_strings = {} - for k, v in iteritems(color_scheme): - self.f_strings[k] = "\x01%s" % (v,) - super(BPythonFormatter, self).__init__(**options) + 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 = "" @@ -57,44 +61,47 @@ def format(self, tokensource, outfile): for token, text in tokensource: while token not in self.f_strings: token = token.parent - o += "%s\x03%s\x04" % (self.f_strings[token], text) + o += f"{self.f_strings[token]}\x03{text}\x04" outfile.write(parse(o.rstrip())) class Interp(ReplInterpreter): - def __init__(self, locals=None, encoding=None): + 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(Interp, self).__init__(locals, encoding) + super().__init__(locals) # typically changed after being instantiated # but used when interpreter used corresponding REPL - def write(err_line): + def write(err_line: str | FmtStr) -> None: """Default stderr handler for tracebacks Accepts FmtStrs so interpreters can output them""" - sys.stderr.write(text_type(err_line)) + sys.stderr.write(str(err_line)) - self.write = write + self.write = write # type: ignore self.outfile = self - def writetb(self, lines): + 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, lexer): + 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")}) - tokens = list(lexer.get_tokens(tbtext)) + traceback_code_formatter = BPythonFormatter({Token: "d"}) no_format_mode = False cur_line = [] - for token, text in tokens: + for token, text in lexer.get_tokens(tbtext): if text.endswith("\n"): cur_line.append((token, text)) if no_format_mode: @@ -105,7 +112,7 @@ def format(self, tbtext, lexer): cur_line, self.outfile ) cur_line = [] - elif text == " " and cur_line == []: + elif text == " " and len(cur_line) == 0: no_format_mode = True cur_line.append((token, text)) else: @@ -113,7 +120,9 @@ def format(self, tbtext, lexer): assert cur_line == [], cur_line -def code_finished_will_parse(s, compiler): +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 @@ -122,9 +131,6 @@ def code_finished_will_parse(s, compiler): False, True means code block is unfinished False, False isn't possible - an predicted error makes code block done""" try: - finished = bool(compiler(s)) - code_will_parse = True + return bool(compiler(s)), True except (ValueError, SyntaxError, OverflowError): - finished = True - code_will_parse = False - return finished, code_will_parse + return True, False diff --git a/bpython/curtsiesfrontend/manual_readline.py b/bpython/curtsiesfrontend/manual_readline.py index 7448d4bf8..3d02c024a 100644 --- a/bpython/curtsiesfrontend/manual_readline.py +++ b/bpython/curtsiesfrontend/manual_readline.py @@ -1,52 +1,42 @@ -# encoding: utf-8 - """implementations of simple readline edit operations just the ones that fit the model of transforming the current line and the cursor location based on http://www.bigsmoke.us/readline/shortcuts""" -from bpython.lazyre import LazyReCompile - import inspect -from six import iteritems -from bpython._py3compat import py3 + +from ..lazyre import LazyReCompile +from ..line import cursor_on_closing_char_pair INDENT = 4 # TODO Allow user config of keybindings for these actions -if not py3: - getargspec = lambda func: inspect.getargspec(func)[0] -else: - getargspec = lambda func: inspect.signature(func).parameters +getargspec = lambda func: inspect.signature(func).parameters -class AbstractEdits(object): - +class AbstractEdits: default_kwargs = { "line": "hello world", "cursor_offset": 5, "cut_buffer": "there", } - def __contains__(self, key): - try: - self[key] - except KeyError: - return False - else: - return True + 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("key %r already has a mapping" % (key,)) + raise ValueError(f"key {key!r} already has a mapping") params = getargspec(func) - args = dict( - (k, v) for k, v in iteritems(self.default_kwargs) if k in params - ) + 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"): @@ -63,35 +53,30 @@ def add(self, key, func, overwrite=False): ) self.cut_buffer_edits[key] = func else: - raise ValueError( - "return type of function %r not recognized" % (func,) - ) + 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( - "config attribute %r already has a mapping" % (config_attr,) + 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 = dict((k, v) for k, v in kwargs.items() if k in params) + args = {k: v for k, v in kwargs.items() if k in params} return func(**args) - def call_without_cut(self, key, **kwargs): - """Looks up the function and calls it, returning only line and cursor - offset""" - r = self.call_for_two(key, **kwargs) - return r[:2] + 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("key %r not mapped" % (key,)) + raise KeyError(f"key {key!r} not mapped") def __delitem__(self, key): if key in self.simple_edits: @@ -99,7 +84,7 @@ def __delitem__(self, key): elif key in self.cut_buffer_edits: del self.cut_buffer_edits[key] else: - raise KeyError("key %r not mapped" % (key,)) + raise KeyError(f"key {key!r} not mapped") class UnconfiguredEdits(AbstractEdits): @@ -119,11 +104,6 @@ class UnconfiguredEdits(AbstractEdits): Keys can't be added twice, config attributes can't be added twice. """ - def __init__(self): - self.simple_edits = {} - self.cut_buffer_edits = {} - self.awaiting_config = {} - def mapping_with_config(self, config, key_dispatch): """Creates a new mapping object by applying a config object""" return ConfiguredEdits( @@ -162,16 +142,15 @@ def __init__( config, key_dispatch, ): - self.simple_edits = dict(simple_edits) - self.cut_buffer_edits = dict(cut_buffer_edits) + 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(ConfiguredEdits, self).add(key, func, overwrite=True) + 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): + def add(self, key, func, overwrite=False): raise NotImplementedError("Config already set on this mapping") @@ -259,6 +238,18 @@ def backspace(cursor_offset, line): 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:]) diff --git a/bpython/curtsiesfrontend/parse.py b/bpython/curtsiesfrontend/parse.py index 33f8c01ea..122f1ee9f 100644 --- a/bpython/curtsiesfrontend/parse.py +++ b/bpython/curtsiesfrontend/parse.py @@ -1,40 +1,57 @@ -# coding: utf-8 -from __future__ import unicode_literals - -from functools import partial import re +from functools import partial +from typing import Any +from collections.abc import Callable -from bpython.lazyre import LazyReCompile - -from curtsies.termformatconstants import FG_COLORS, BG_COLORS, colors 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 -cnames = dict(zip("krgybmcwd", colors + ("default",))) +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(l, default="k"): +def func_for_letter( + letter_color_code: str, default: str = "k" +) -> Callable[..., FmtStr]: """Returns FmtStr constructor for a bpython-style color code""" - if l == "d": - l = default - elif l == "D": - l = default.upper() - return partial(fmtstr, fg=cnames[l.lower()], bold=l.isupper()) + 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(l, default="k"): - if l == "d": - l = default - return cnames[l.lower()] +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): +def parse(s: str) -> FmtStr: """Returns a FmtStr object from a bpython-formatted colored string""" rest = s stuff = [] - while True: - if not rest: - break + while rest: start, rest = peel_off_string(rest) stuff.append(start) return ( @@ -44,26 +61,24 @@ def parse(s): ) -def fs_from_match(d): +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()] + 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 = colors[ - (colors.index(color) + (len(colors) // 2)) % len(colors) - ] + color = INVERSE_COLORS[color] else: - color = cnames[d["bg"].lower()] + color = CNAMES[d["bg"].lower()] if color != "default": atts["bg"] = BG_COLORS[color] if d["bold"]: @@ -85,7 +100,7 @@ def fs_from_match(d): ) -def peel_off_string(s): +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() diff --git a/bpython/curtsiesfrontend/preprocess.py b/bpython/curtsiesfrontend/preprocess.py index 8169ee46a..f48a79bf7 100644 --- a/bpython/curtsiesfrontend/preprocess.py +++ b/bpython/curtsiesfrontend/preprocess.py @@ -1,32 +1,38 @@ -# encoding: utf-8 - """Tools for preparing code to be run in the REPL (removing blank lines, etc)""" -from bpython.lazyre import LazyReCompile +from codeop import CommandCompiler +from re import Match +from itertools import tee, islice, chain -# TODO specifically catch IndentationErrors instead of any syntax errors +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, compiler): +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""" - lines = s.split("\n") + initial_lines = s.split("\n") ends_with_newline = False - if lines and not lines[-1]: + if initial_lines and not initial_lines[-1]: ends_with_newline = True - lines.pop() + initial_lines.pop() result_lines = [] - for p_line, line, n_line in zip([""] + lines[:-1], lines, lines[1:] + [""]): + 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: - p_indent = indent_empty_lines_re.match(p_line).group() - n_indent = indent_empty_lines_re.match(n_line).group() + # "\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) @@ -34,17 +40,14 @@ def indent_empty_lines(s, compiler): return "\n".join(result_lines) + ("\n" if ends_with_newline else "") -def leading_tabs_to_spaces(s): - lines = s.split("\n") - result_lines = [] - - def tab_to_space(m): +def leading_tabs_to_spaces(s: str) -> str: + def tab_to_space(m: Match[str]) -> str: return len(m.group()) * 4 * " " - for line in lines: - result_lines.append(tabs_to_spaces_re.sub(tab_to_space, line)) - return "\n".join(result_lines) + return "\n".join( + tabs_to_spaces_re.sub(tab_to_space, line) for line in s.split("\n") + ) -def preprocess(s, compiler): +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 066129dbf..928be253e 100644 --- a/bpython/curtsiesfrontend/repl.py +++ b/bpython/curtsiesfrontend/repl.py @@ -1,9 +1,6 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import contextlib import errno -import greenlet +import itertools import logging import os import re @@ -13,81 +10,66 @@ import tempfile import time import unicodedata -from six.moves import range +from enum import Enum +from types import FrameType, TracebackType +from typing import ( + Any, + Literal, +) +from collections.abc import Iterable, Sequence +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 bpython._py3compat import PythonLexer from pygments.formatters import TerminalFormatter +from pygments.lexers import Python3Lexer -import blessings - -import curtsies -from curtsies import FSArray, fmtstr, FmtStr, Termmode -from curtsies import fmtfuncs -from curtsies import events - -import bpython -from bpython.repl import Repl as BpythonRepl, SourceNotFound -from bpython.config import ( - Struct, - loadini, - default_config_path, - getpreferredencoding, +from . import events as bpythonevents, sitefix, replpainter as paint +from ..config import Config +from .coderunner import ( + CodeRunner, + FakeOutput, ) -from bpython.formatter import BPythonFormatter -from bpython import autocomplete -from bpython.translations import _ -from bpython._py3compat import py3, is_main_thread -from bpython.pager import get_pager_command - -from bpython.curtsiesfrontend import replpainter as paint -from bpython.curtsiesfrontend import sitefix -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 edit_keys -from bpython.curtsiesfrontend import events as bpythonevents -from bpython.curtsiesfrontend.parse import parse as bpythonparse -from bpython.curtsiesfrontend.parse import func_for_letter, color_for_letter -from bpython.curtsiesfrontend.preprocess import preprocess -from bpython.curtsiesfrontend.interpreter import ( +from .filewatch import ModuleChangedEventHandler +from .interaction import StatusBar +from .interpreter import ( Interp, code_finished_will_parse, ) - -from curtsies.configfile_keynames import keymap as key_dispatch - -if not py3: - import imp - import pkgutil - +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 logger = logging.getLogger(__name__) INCONSISTENT_HISTORY_MSG = "#<---History inconsistent with output shown--->" CONTIGUITY_BROKEN_MSG = "#<---History contiguity broken by rewind--->" -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_file_location} 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. -""" 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. @@ -99,78 +81,85 @@ # i.e. control characters like '' will be stripped MAX_EVENTS_POSSIBLY_NOT_PASTE = 20 -# This is needed for is_nop and should be removed once is_nop is fixed. -if py3: - unicode = str + +class SearchMode(Enum): + NO_SEARCH = 0 + INCREMENTAL_SEARCH = 1 + REVERSE_INCREMENTAL_SEARCH = 2 -class FakeStdin(object): +class LineType(Enum): + """Used when adding a tuple to all_logical_lines, to get input / output values + having to actually type/know the strings""" + + INPUT = "input" + OUTPUT = "output" + + +class FakeStdin: """The stdin object user code will reference 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, repl, configured_edit_keys=None): + 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.cursor_offset = 0 self.old_num_lines = 0 - self.readline_results = [] - if configured_edit_keys: + self.readline_results: list[str] = [] + if configured_edit_keys is not None: self.rl_char_sequences = configured_edit_keys else: 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) + 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_context = True - self.has_focus = False - self.current_line = "" - self.cursor_offset = 0 - self.repl.run_code_and_maybe_finish() - elif e in ("",): - self.get_last_word() - - elif e in [""]: - pass - elif e in [""]: - if 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.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") + 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.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): + def add_input_character(self, e: str) -> None: if e == "": e = " " if e.startswith("<") and e.endswith(">"): @@ -178,40 +167,60 @@ def add_input_character(self, e): 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 + + 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_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): + 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 @@ -219,17 +228,18 @@ def close(self): 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? -class ReevaluateFakeStdin(object): +class ReevaluateFakeStdin: """Stdin mock used during reevaluation (undo) so raw_inputs don't have to be reentered""" - def __init__(self, fakestdin, repl): + def __init__(self, fakestdin: FakeStdin, repl: "BaseRepl"): self.fakestdin = fakestdin self.repl = repl self.readline_results = fakestdin.readline_results[:] @@ -243,56 +253,59 @@ def readline(self): return value -class ImportLoader(object): +class ImportLoader: + """Wrapper for module loaders to watch their paths with watchdog.""" + def __init__(self, watcher, loader): self.watcher = watcher self.loader = loader - def load_module(self, name): - module = self.loader.load_module(name) - if hasattr(module, "__file__"): - self.watcher.track_module(module.__file__) - return module - + def __getattr__(self, name): + if name == "create_module" and hasattr(self.loader, name): + return self._create_module + return getattr(self.loader, name) -if not py3: - # Remember that pkgutil.ImpLoader is an old style class. - class ImpImportLoader(pkgutil.ImpLoader): - def __init__(self, watcher, *args): - self.watcher = watcher - pkgutil.ImpLoader.__init__(self, *args) + 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 - def load_module(self, name): - module = pkgutil.ImpLoader.load_module(self, name) - if hasattr(module, "__file__"): - self.watcher.track_module(module.__file__) - return module +class ImportFinder: + """Wrapper for finders in sys.meta_path to wrap all loaders with ImportLoader.""" -class ImportFinder(object): - def __init__(self, watcher, old_meta_path): + def __init__(self, watcher, finder): self.watcher = watcher - self.old_meta_path = old_meta_path + self.finder = finder - def find_module(self, fullname, path=None): - for finder in self.old_meta_path: - loader = finder.find_module(fullname, path) - if loader is not None: - return ImportLoader(self.watcher, loader) + def __getattr__(self, name): + if name == "find_spec" and hasattr(self.finder, name): + return self._find_spec + return getattr(self.finder, name) - if not py3: - # Python 2 does not have the default finders stored in - # sys.meta_path. Use imp to perform the actual importing. - try: - result = imp.find_module(fullname, path) - return ImpImportLoader(self.watcher, fullname, *result) - except ImportError: - return None + 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 - return None +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 -class BaseRepl(BpythonRepl): + return ps if wcswidth(ps) >= 0 else default_ps + + +class BaseRepl(Repl): """Python Repl Reacts to events like @@ -316,11 +329,12 @@ class BaseRepl(BpythonRepl): def __init__( self, - locals_=None, - config=None, - banner=None, - interp=None, - orig_tcattrs=None, + 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 @@ -331,10 +345,7 @@ def __init__( """ logger.debug("starting init") - - if config is None: - config = Struct() - loadini(config, default_config_path()) + self.window = window # If creating a new interpreter on undo would be unsafe because initial # state was passed in @@ -342,18 +353,7 @@ def __init__( if interp is None: interp = Interp(locals=locals_) - interp.write = self.send_to_stdouterr - if banner is None: - if config.help_key: - banner = ( - _("Welcome to bpython!") - + " " - + _("Press <%s> for help.") % config.help_key - ) - else: - banner = None - # only one implemented currently - config.autocomplete_mode = autocomplete.SIMPLE + 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 @@ -368,7 +368,9 @@ def __init__( ) self.edit_keys = edit_keys.mapping_with_config(config, key_dispatch) logger.debug("starting parent init") - super(BaseRepl, self).__init__(interp, config) + # 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) @@ -377,32 +379,40 @@ def __init__( # so we're just using the same object self.interact = self.status_bar - # line currently being edited, without ps1 (usually '>>> ') + # logical line currently being edited, without ps1 (usually '>>> ') self._current_line = "" # current line of output - stdout and stdin go here - self.current_stdouterr_line = "" + self.current_stdouterr_line: str | FmtStr = "" - # lines separated whenever logical line - # length goes over what the terminal width - # was at the time of original output - self.display_lines = [] + # 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 = [] + 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 - # from the left, 0 means first char + # cursor position relative to start of current_line, 0 is first char self._cursor_offset = 0 - self.orig_tcattrs = orig_tcattrs + self.orig_tcattrs: list[Any] | None = orig_tcattrs self.coderunner = CodeRunner(self.interp, self.request_refresh) @@ -411,12 +421,12 @@ def __init__( self.stdout = FakeOutput( self.coderunner, self.send_to_stdouterr, - fileno=sys.__stdout__.fileno(), + real_fileobj=sys.__stdout__, ) self.stderr = FakeOutput( self.coderunner, self.send_to_stdouterr, - fileno=sys.__stderr__.fileno(), + real_fileobj=sys.__stderr__, ) self.stdin = FakeStdin(self.coderunner, self, self.edit_keys) @@ -434,7 +444,7 @@ def __init__( # 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 = [None] * 50 + self.last_events: list[str | None] = [None] * 50 # displays prev events in a column on the right hand side self.presentation_mode = False @@ -445,15 +455,19 @@ def __init__( # whether auto reloading active self.watching_files = config.default_autoreload - # 'reverse_incremental_search', 'incremental_search' or None - self.incr_search_mode = None - + self.incr_search_mode = SearchMode.NO_SEARCH self.incr_search_target = "" self.original_modules = set(sys.modules.keys()) - self.width = None - self.height = None + # 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) @@ -464,19 +478,19 @@ def __init__( # The methods below should be overridden, but the default implementations # below can be used as well. - def get_cursor_vertical_diff(self): + 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): + def get_top_usable_line(self) -> int: """Return the top line of display that can be rewritten""" return 0 - def get_term_hw(self): + 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="now"): + 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 @@ -500,7 +514,7 @@ def _request_refresh(self): RefreshRequestEvent.""" raise NotImplementedError - def _request_reload(self, files_modified=("?",)): + def _request_reload(self, files_modified: Sequence[str]) -> None: """Like request_refresh, but for reload requests events.""" raise NotImplementedError @@ -530,15 +544,15 @@ def request_refresh(self): else: self._request_refresh() - def request_reload(self, files_modified=()): + 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=files_modified) + self._request_reload(files_modified) - def schedule_refresh(self, when="now"): + def schedule_refresh(self, when: float = 0) -> None: """Schedule a ScheduledRefreshRequestEvent for when. - Such a event should interrupt if blockied waiting for keyboard input""" + Such a event should interrupt if blocked waiting for keyboard input""" if self.reevaluating or self.paste_mode: self.fake_refresh_requested = True else: @@ -561,12 +575,20 @@ def __enter__(self): self.orig_meta_path = sys.meta_path if self.watcher: - sys.meta_path = [ImportFinder(self.watcher, self.orig_meta_path)] + 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 @@ -577,8 +599,9 @@ def __exit__(self, *args): signal.signal(signal.SIGTSTP, self.orig_sigtstp_handler) sys.meta_path = self.orig_meta_path + return False - def sigwinch_handler(self, signum, frame): + 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() @@ -594,9 +617,9 @@ def sigwinch_handler(self, signum, frame): self.scroll_offset, ) - def sigtstp_handler(self, signum, frame): + def sigtstp_handler(self, signum: int, frame: FrameType | None) -> None: self.scroll_offset = len(self.lines_for_display) - self.__exit__() + self.__exit__(None, None, None) self.on_suspend() os.kill(os.getpid(), signal.SIGTSTP) self.after_suspend() @@ -609,7 +632,7 @@ def clean_up_current_line_for_exit(self): self.unhighlight_paren() # Event handling - def process_event(self, e): + def process_event(self, e: events.Event | str) -> bool | None: """Returns True if shutting down, otherwise returns None. Mostly mutates state of Repl object""" @@ -619,10 +642,10 @@ def process_event(self, e): else: self.last_events.append(e) self.last_events.pop(0) - return self.process_key_event(e) - - def process_control_event(self, e): + self.process_key_event(e) + return None + 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 @@ -636,7 +659,7 @@ def process_control_event(self, e): 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): @@ -667,21 +690,20 @@ def process_control_event(self, e): elif isinstance(e, bpythonevents.RunStartupFileEvent): try: self.startup() - except IOError as e: + except OSError as err: self.status_bar.message( - _("Executing PYTHONSTARTUP failed: %s") % (e,) + _("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") self.keyboard_interrupt() - return elif isinstance(e, bpythonevents.ReloadEvent): if self.watching_files: @@ -693,8 +715,9 @@ def process_control_event(self, e): else: raise ValueError("Don't know how to handle event type: %r" % e) + return None - def process_key_event(self, 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: @@ -711,19 +734,20 @@ def process_key_event(self, e): ) 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 ("",): + elif e == "": self.on_control_d() - elif e in ("",): + elif e == "": self.operate_and_get_next() - elif e in ("",): + elif e == "": self.get_last_word() elif e in key_dispatch[self.config.reverse_incremental_search_key]: self.incremental_search(reverse=True) @@ -731,7 +755,7 @@ def process_key_event(self, e): self.incremental_search() elif ( e in (("",) + key_dispatch[self.config.backspace_key]) - and self.incr_search_mode + 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: @@ -748,7 +772,7 @@ def process_key_event(self, e): 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]: @@ -761,10 +785,12 @@ def process_key_event(self, e): self.on_enter() 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.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 @@ -778,15 +804,80 @@ def process_key_event(self, e): # 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 [""]: - self.incr_search_mode = None - elif e in [""]: + 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 get_last_word(self): + 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 @@ -800,7 +891,7 @@ def get_last_word(self): ) def incremental_search(self, reverse=False, include_current=False): - if self.incr_search_mode is None: + if self.incr_search_mode == SearchMode.NO_SEARCH: self.rl_history.enter(self.current_line) self.incr_search_target = "" else: @@ -829,9 +920,9 @@ def incremental_search(self, reverse=False, include_current=False): clear_special_mode=False, ) if reverse: - self.incr_search_mode = "reverse_incremental_search" + self.incr_search_mode = SearchMode.REVERSE_INCREMENTAL_SEARCH else: - self.incr_search_mode = "incremental_search" + self.incr_search_mode = SearchMode.INCREMENTAL_SEARCH def readline_kill(self, e): func = self.edit_keys[e] @@ -848,14 +939,18 @@ def readline_kill(self, e): else: self.cut_buffer = cut - def on_enter(self, insert_into_history=True, reset_rl_history=True): + 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.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 @@ -872,7 +967,7 @@ def only_whitespace_left_of_cursor(): """returns true if all characters before cursor are whitespace""" return not self.current_line[: self.cursor_offset].strip() - logger.debug("self.matches_iter.matches:%r", self.matches_iter.matches) + 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() @@ -881,7 +976,15 @@ def only_whitespace_left_of_cursor(): 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) @@ -893,7 +996,6 @@ def only_whitespace_left_of_cursor(): # using _current_line so we don't trigger a completion reset 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 next(self.matches_iter) @@ -902,6 +1004,24 @@ def only_whitespace_left_of_cursor(): 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 == "": @@ -950,7 +1070,7 @@ def down_one_line(self): ) self._set_cursor_offset(len(self.current_line), reset_rl_history=False) - def process_simple_keypress(self, e): + def process_simple_keypress(self, e: str): # '\n' needed for pastes if e in ("", "", "", "\n", "\r"): self.on_enter() @@ -965,6 +1085,9 @@ def process_simple_keypress(self, e): 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")] while lines and not lines[-1].split(): @@ -977,15 +1100,12 @@ def send_current_block_to_external_editor(self, filename=None): self.cursor_offset = len(self.current_line) def send_session_to_external_editor(self, filename=None): + """ + Sends entire bpython session to external editor to be edited. Usually bound to F7. + """ for_editor = EDIT_SESSION_HEADER - 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") - ) + for_editor += self.get_session_formatted_for_file() + text = self.send_to_external_editor(for_editor) if text == for_editor: self.status_bar.message( @@ -993,13 +1113,15 @@ def send_session_to_external_editor(self, filename=None): ) return lines = text.split("\n") - if not lines[-1].strip(): + if len(lines) and not lines[-1].strip(): lines.pop() # strip last line if empty - if lines[-1].startswith("### "): + if len(lines) and lines[-1].startswith("### "): current_line = lines[-1][4:] else: current_line = "" - from_editor = [line for line in lines if line[:3] != "###"] + 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") @@ -1009,7 +1131,7 @@ def send_session_to_external_editor(self, filename=None): source = preprocess("\n".join(from_editor), self.interp.compile) lines = source.split("\n") self.history = lines - self.reevaluate(insert_into_history=True) + 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")) @@ -1020,7 +1142,7 @@ def clear_modules_and_reevaluate(self): cursor, line = self.cursor_offset, self.current_line for modname in set(sys.modules.keys()) - self.original_modules: del sys.modules[modname] - self.reevaluate(insert_into_history=True) + self.reevaluate(new_code=False) self.cursor_offset, self.current_line = cursor, line self.status_bar.message( _("Reloaded at %s by user.") % (time.strftime("%X"),) @@ -1047,10 +1169,10 @@ def toggle_file_watch(self): ) # Handler Helpers - def add_normal_character(self, char): + def add_normal_character(self, char, narrow_search=True): if len(char) > 1 or is_nop(char): return - if self.incr_search_mode: + if self.incr_search_mode != SearchMode.NO_SEARCH: self.add_to_incremental_search(char) else: self._set_current_line( @@ -1063,27 +1185,33 @@ def add_normal_character(self, char): reset_rl_history=False, clear_special_mode=False, ) - self.cursor_offset += 1 + 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 char is None and not backspace: - raise ValueError("must provide a char or set backspace to True") if backspace: self.incr_search_target = self.incr_search_target[:-1] - else: + elif char is not None: self.incr_search_target += char - if self.incr_search_mode == "reverse_incremental_search": + 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 == "incremental_search": + 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") @@ -1108,17 +1236,21 @@ def predicted_indent(self, line): elif ( line and ":" not in line - and line.strip().startswith(("return", "pass", "raise", "yield")) + 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: @@ -1159,6 +1291,7 @@ def push(self, line, insert_into_history=True): self.coderunner.load_code(code_to_run) self.run_code_and_maybe_finish() + return not code_will_parse def run_code_and_maybe_finish(self, for_code=None): r = self.coderunner.run_code(for_code=for_code) @@ -1218,8 +1351,8 @@ def unhighlight_paren(self): def clear_current_block(self, remove_from_history=True): self.display_buffer = [] if remove_from_history: - for unused in self.buffer: - self.history.pop() + del self.history[-len(self.buffer) :] + del self.all_logical_lines[-len(self.buffer) :] self.buffer = [] self.cursor_offset = 0 self.saved_indent = 0 @@ -1227,6 +1360,9 @@ def clear_current_block(self, remove_from_history=True): self.cursor_offset = len(self.current_line) def get_current_block(self): + """ + Returns the current code block as string (without prompts) + """ return "\n".join(self.buffer + [self.current_line]) def send_to_stdouterr(self, output): @@ -1234,8 +1370,6 @@ def send_to_stdouterr(self, output): Must be able to handle FmtStrs because interpreter pass in tracebacks already formatted.""" - if not output: - return lines = output.split("\n") logger.debug("display_lines: %r", self.display_lines) self.current_stdouterr_line += lines[0] @@ -1254,6 +1388,15 @@ def send_to_stdouterr(self, output): [], ) ) + # 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)) + self.current_stdouterr_line = lines[-1] logger.debug("display_lines: %r", self.display_lines) @@ -1278,7 +1421,7 @@ def current_line_formatted(self): fs = bpythonparse( pygformat(self.tokenize(self.current_line), self.formatter) ) - if self.incr_search_mode: + 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) @@ -1326,25 +1469,24 @@ def display_line_with_prompt(self): """colored line with prompt""" prompt = func_for_letter(self.config.color_scheme["prompt"]) more = func_for_letter(self.config.color_scheme["prompt_more"]) - if self.incr_search_mode == "reverse_incremental_search": + if self.incr_search_mode == SearchMode.REVERSE_INCREMENTAL_SEARCH: return ( - prompt( - "(reverse-i-search)`{}': ".format(self.incr_search_target) - ) - + self.current_line_formatted - ) - elif self.incr_search_mode == "incremental_search": - return ( - prompt("(i-search)`%s': ".format(self.incr_search_target)) + 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""" + """ + 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 ) @@ -1381,13 +1523,30 @@ def current_output_line(self, value): 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 @@ -1540,38 +1699,56 @@ def move_screen_up(current_line_start_row): current_line_height = current_line_end_row - current_line_start_row if self.stdin.has_focus: + 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( - len(self.current_stdouterr_line) + self.stdin.cursor_offset, + stdouterr_width + + wcswidth( + self.stdin.current_line, max(0, self.stdin.cursor_offset) + ), width, ) - assert cursor_column >= 0, cursor_column + 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 - ), + len(self.current_cursor_line_without_suggestion) + + self.cursor_offset, width, ) - assert cursor_column >= 0, ( + 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: + else: # Common case for determining cursor position cursor_row, cursor_column = divmod( - ( - len(self.current_cursor_line_without_suggestion) - - len(self.current_line) - + self.cursor_offset - ), + 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_column >= 0, ( + assert cursor_row >= 0 and cursor_column >= 0, ( + cursor_row, cursor_column, - len(self.current_cursor_line), - len(self.current_line), + self.current_cursor_line_without_suggestion.s, + self.current_line, self.cursor_offset, ) cursor_row += current_line_start_row @@ -1610,9 +1787,11 @@ def move_screen_up(current_line_start_row): self.current_match, self.docstring, self.config, - self.matches_iter.completer.format - if self.matches_iter.completer - else None, + ( + self.matches_iter.completer.format + if self.matches_iter.completer + else None + ), ) if ( @@ -1680,20 +1859,19 @@ def in_paste_mode(self): self.update_completion() def __repr__(self): - s = "" - s += "<" + repr(type(self)) + "\n" - s += " cursor_offset:" + repr(self.cursor_offset) + "\n" - s += " num display lines:" + repr(len(self.display_lines)) + "\n" - s += " lines scrolled down:" + repr(self.scroll_offset) + "\n" - s += ">" - return s - - def _get_current_line(self): + return f"""<{type(self)} + cursor_offset: {self.cursor_offset} + num display lines: {len(self.display_lines)} + lines scrolled down: {self.scroll_offset} +>""" + + def _get_current_line(self) -> str: + """The current line""" return self._current_line def _set_current_line( self, - line, + line: str, update_completion=True, reset_rl_history=True, clear_special_mode=True, @@ -1711,16 +1889,13 @@ def _set_current_line( self.special_mode = None self.unhighlight_paren() - current_line = property( - _get_current_line, _set_current_line, None, "The current line" - ) - - def _get_cursor_offset(self): + 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, + offset: int, update_completion=True, reset_rl_history=False, clear_special_mode=True, @@ -1734,19 +1909,12 @@ def _set_cursor_offset( if reset_rl_history: self.rl_history.reset() if clear_special_mode: - self.incr_search_mode = None + self.incr_search_mode = SearchMode.NO_SEARCH self._cursor_offset = offset if update_completion: self.update_completion() self.unhighlight_paren() - cursor_offset = property( - _get_cursor_offset, - _set_cursor_offset, - None, - "The current cursor offset from the front of the " "line", - ) - def echo(self, msg, redraw=True): """ Notification that redrawing the current line is necessary (we don't @@ -1759,7 +1927,7 @@ def echo(self, msg, redraw=True): @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): @@ -1782,11 +1950,13 @@ def take_back_buffer_line(self): 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: @@ -1795,13 +1965,22 @@ def prompt_undo(self): return self.take_back_empty_line() def prompt_for_undo(): - n = BpythonRepl.prompt_undo(self) + n = super(BaseRepl, self).prompt_undo() if n > 0: self.request_undo(n=n) greenlet.greenlet(prompt_for_undo).switch() - def reevaluate(self, insert_into_history=False): + 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() @@ -1809,6 +1988,7 @@ def reevaluate(self, insert_into_history=False): 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__() @@ -1825,7 +2005,7 @@ def reevaluate(self, insert_into_history=False): 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.on_enter(new_code=new_code) while self.fake_refresh_requested: self.fake_refresh_requested = False self.process_event(bpythonevents.RefreshRequestEvent()) @@ -1862,17 +2042,20 @@ def reevaluate(self, insert_into_history=False): self._cursor_offset = 0 self.current_line = "" - def initialize_interp(self): + def initialize_interp(self) -> None: self.coderunner.interp.locals["_repl"] = self self.coderunner.interp.runsource( - "from bpython.curtsiesfrontend._internal " "import _Helper" + "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"] - del self.coderunner.interp.locals["_Helper"] - def getstdout(self): + 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) @@ -1886,7 +2069,7 @@ 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)) @@ -1903,45 +2086,59 @@ def focus_on_subprocess(self, args): finally: signal.signal(signal.SIGWINCH, prev_sigwinch_handler) - def pager(self, text): - """Runs an external pager on text + def pager(self, text: str, title: str = "") -> None: + """Runs an external pager on text""" - text must be a unicode""" + # TODO: make less handle title command = get_pager_command() with tempfile.NamedTemporaryFile() as tmp: tmp.write(text.encode(getpreferredencoding())) tmp.flush() self.focus_on_subprocess(command + [tmp.name]) - def show_source(self): + def show_source(self) -> None: try: source = self.get_source_of_current_name() except SourceNotFound as e: - self.status_bar.message("%s" % (e,)) + self.status_bar.message(f"{e}") else: if self.config.highlight_show_source: source = pygformat( - PythonLexer().get_tokens(source), TerminalFormatter() + Python3Lexer().get_tokens(source), TerminalFormatter() ) self.pager(source) - def help_text(self): + def help_text(self) -> str: 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=EXAMPLE_CONFIG_URL, - config=self.config, - ) - ) + 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): + def key_help_text(self) -> str: NOT_IMPLEMENTED = ( "suspend", "cut to buffer", @@ -1950,16 +2147,15 @@ def key_help_text(self): "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 [ + 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 == "": @@ -1969,20 +2165,40 @@ def key_help_text(self): 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 + 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 -def is_nop(char): - return unicodedata.category(unicode(char)) == "Cc" + return "\n".join(process()) + @property + def ps1(self): + return _process_ps(super().ps1, ">>> ") + + @property + def ps2(self): + return _process_ps(super().ps2, "... ") -def tabs_to_spaces(line): + +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): - return line.split().pop() if line.split() else "" +def _last_word(line: str) -> str: + split_line = line.split() + return split_line.pop() if split_line else "" def compress_paste_event(paste_event): @@ -2004,29 +2220,27 @@ def compress_paste_event(paste_event): return None -def just_simple_events(event_list): +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 - if e in ("", "", "", "\n", "\r"): + elif e in ("", "", "", "\n", "\r"): simple_events.append("\n") - elif isinstance(e, events.Event): - pass # ignore events elif e == "": simple_events.append(" ") elif len(e) > 1: - pass # get rid of etc. + continue # get rid of etc. else: simple_events.append(e) return simple_events -def is_simple_event(e): +def is_simple_event(e: str | events.Event) -> bool: if isinstance(e, events.Event): return False - if e in ("", "", "", "\n", "\r", ""): - return True - if len(e) > 1: - return False - else: - return True + return ( + e in ("", "", "", "\n", "\r", "") + or len(e) <= 1 + ) diff --git a/bpython/curtsiesfrontend/replpainter.py b/bpython/curtsiesfrontend/replpainter.py index b1fdf3a81..3b63ca4c9 100644 --- a/bpython/curtsiesfrontend/replpainter.py +++ b/bpython/curtsiesfrontend/replpainter.py @@ -1,19 +1,11 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import logging import itertools -from six.moves import range from curtsies import fsarray, fmtstr, FSArray from curtsies.formatstring import linesplit from curtsies.fmtfuncs import bold -from bpython.curtsiesfrontend.parse import func_for_letter -from bpython._py3compat import py3 - -if not py3: - import inspect +from .parse import func_for_letter logger = logging.getLogger(__name__) @@ -26,17 +18,20 @@ 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 = ( - [ + 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), ) ] - if msg - else ([""] if blank_line else []) - ) return display_lines @@ -79,9 +74,11 @@ def matches_lines(rows, columns, matches, current, config, match_format): result = [ fmtstr(" ").join( - color(m.ljust(max_match_width)) - if m != current - else highlight_color(m.ljust(max_match_width)) + ( + 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) @@ -100,9 +97,8 @@ def formatted_argspec(funcprops, arg_pos, columns, config): _args = funcprops.argspec.varargs _kwargs = funcprops.argspec.varkwargs is_bound_method = funcprops.is_bound_method - if py3: - kwonly = funcprops.argspec.kwonly - kwonly_defaults = funcprops.argspec.kwonly_defaults or dict() + 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()) @@ -127,15 +123,10 @@ def formatted_argspec(funcprops, arg_pos, columns, config): if i == arg_pos or arg == arg_pos: color = bolds[color] - if not py3: - s += color(inspect.strseq(arg, unicode)) - else: - s += color(arg) + s += color(arg) if kw is not None: s += punctuation_color("=") - if not py3: - kw = kw.decode("ascii", "replace") s += token_color(kw) if i != len(args) - 1: @@ -144,9 +135,9 @@ def formatted_argspec(funcprops, arg_pos, columns, config): if _args: if args: s += punctuation_color(", ") - s += token_color("*%s" % (_args,)) + s += token_color(f"*{_args}") - if py3 and kwonly: + if kwonly: if not _args: if args: s += punctuation_color(", ") @@ -164,9 +155,9 @@ def formatted_argspec(funcprops, arg_pos, columns, config): s += token_color(repr(default)) if _kwargs: - if args or _args or (py3 and kwonly): + if args or _args or kwonly: s += punctuation_color(", ") - s += token_color("**%s" % (_kwargs,)) + s += token_color(f"**{_kwargs}") s += punctuation_color(")") return linesplit(s, columns) @@ -175,7 +166,7 @@ def formatted_argspec(funcprops, arg_pos, columns, config): def formatted_docstring(docstring, columns, config): if isinstance(docstring, bytes): docstring = docstring.decode("utf8") - elif isinstance(docstring, str if py3 else unicode): + elif isinstance(docstring, str): pass else: # TODO: fail properly here and catch possible exceptions in callers. diff --git a/bpython/curtsiesfrontend/sitefix.py b/bpython/curtsiesfrontend/sitefix.py index 23795a04b..96626c16c 100644 --- a/bpython/curtsiesfrontend/sitefix.py +++ b/bpython/curtsiesfrontend/sitefix.py @@ -1,14 +1,9 @@ -# encoding: utf-8 - import sys - -from six.moves import builtins +import builtins 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) diff --git a/bpython/filelock.py b/bpython/filelock.py index 1e6e17087..c106c4155 100644 --- a/bpython/filelock.py +++ b/bpython/filelock.py @@ -1,8 +1,6 @@ -# encoding: utf-8 - # The MIT License # -# Copyright (c) 2015-2019 Sebastian Ramacher +# 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 @@ -22,112 +20,117 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import absolute_import +from typing import IO, Literal +from types import TracebackType -try: - import fcntl - import errno - has_fcntl = True -except ImportError: - has_fcntl = False +class BaseLock: + """Base class for file locking""" -try: - import msvcrt - import os - - has_msvcrt = True -except ImportError: - has_msvcrt = False - - -class BaseLock(object): - """Base class for file locking - """ - - def __init__(self, fileobj, mode=None, filename=None): - self.fileobj = fileobj + def __init__(self) -> None: self.locked = False - def acquire(self): + def acquire(self) -> None: pass - def release(self): + def release(self) -> None: pass - def __enter__(self): + def __enter__(self) -> "BaseLock": self.acquire() return self - def __exit__(self, *args): + 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): + def __del__(self) -> None: if self.locked: self.release() -class UnixFileLock(BaseLock): - """Simple file locking for Unix using fcntl - """ +try: + import fcntl + import errno - def __init__(self, fileobj, mode=None, filename=None): - super(UnixFileLock, self).__init__(fileobj) + class UnixFileLock(BaseLock): + """Simple file locking for Unix using fcntl""" - if mode is None: - mode = fcntl.LOCK_EX - self.mode = mode + def __init__(self, fileobj, mode: int = 0) -> None: + super().__init__() + self.fileobj = fileobj + self.mode = mode | fcntl.LOCK_EX - def acquire(self): - try: - fcntl.flock(self.fileobj, self.mode) - self.locked = True - except IOError as e: - if e.errno != errno.ENOLCK: - raise e + 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): - self.locked = False - fcntl.flock(self.fileobj, fcntl.LOCK_UN) + 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""" -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 __init__(self, fileobj, mode=None, filename=None): - super(WindowsFileLock, self).__init__(None) - self.filename = "{}.lock".format(filename) + 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) - def acquire(self): - # 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 - self.locked = True + def release(self) -> None: + self.locked = False - def release(self): - self.locked = False + # unlock lock file and remove it + msvcrt.locking(self.fileobj, msvcrt.LK_UNLCK, 1) + os.close(self.fileobj) + self.fileobj = -1 - # unlock lock file and remove it - msvcrt.locking(self.fileobj, msvcrt.LK_UNLCK, 1) - os.close(self.fileobj) - self.fileobj = None + try: + os.remove(self.filename) + except OSError: + pass + + has_msvcrt = True +except ImportError: + has_msvcrt = False - try: - os.remove(self.filename) - except OSError: - pass +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() -if has_fcntl: - FileLock = UnixFileLock -elif has_msvcrt: - FileLock = WindowsFileLock -else: - FileLock = BaseLock # vim: sw=4 ts=4 sts=4 ai et diff --git a/bpython/formatter.py b/bpython/formatter.py index 54b1a264b..8e74ac2c2 100644 --- a/bpython/formatter.py +++ b/bpython/formatter.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - # The MIT License # # Copyright (c) 2008 Bob Farrell @@ -26,10 +24,15 @@ # Pygments really kicks ass, it made it really easy to # get the exact behaviour I wanted, thanks Pygments.:) -from __future__ import absolute_import +# 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 ( + _TokenType, Keyword, Name, Comment, @@ -42,7 +45,6 @@ Literal, Punctuation, ) -from six import iteritems """These format strings are pretty ugly. \x01 represents a colour marker, which @@ -100,25 +102,34 @@ 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 iteritems(theme_map): - 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" - super(BPythonFormatter, self).__init__(**options) - - def format(self, tokensource, outfile): - o = "" + super().__init__(**options) + + def format( + self, + tokensource: Iterable[MutableMapping[_TokenType, str]], + outfile: TextIO, + ) -> None: + o: str = "" for token, text in tokensource: 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()) diff --git a/bpython/history.py b/bpython/history.py index f5c83aa42..27852e837 100644 --- a/bpython/history.py +++ b/bpython/history.py @@ -1,9 +1,7 @@ -# encoding: utf-8 - # The MIT License # # Copyright (c) 2009 the bpython authors. -# Copyright (c) 2012,2015 Sebastian Ramacher +# 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 @@ -23,21 +21,26 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import unicode_literals, absolute_import -import io import os +from pathlib import Path import stat -from itertools import islice -from six.moves import range +from itertools import islice, chain +from typing import TextIO +from collections.abc import Iterable from .translations import _ from .filelock import FileLock -class History(object): +class History: """Stores readline-style history and current place in it""" - def __init__(self, entries=None, duplicates=True, hist_size=100): + def __init__( + self, + entries: Iterable[str] | None = None, + duplicates: bool = True, + hist_size: int = 100, + ) -> None: if entries is None: self.entries = [""] else: @@ -50,10 +53,10 @@ def __init__(self, entries=None, duplicates=True, hist_size=100): self.duplicates = duplicates self.hist_size = hist_size - def append(self, line): + def append(self, line: str) -> None: self.append_to(self.entries, line) - def 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: @@ -65,15 +68,19 @@ def append_to(self, entries, line): pass entries.append(line) - def first(self): + 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=True, search=False, target=None, include_current=False - ): + 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 @@ -89,15 +96,17 @@ def back( return self.entry @property - def entry(self): + 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): - return list(reversed(self.entries + [self.saved_line])) + def entries_by_index(self) -> list[str]: + return list(chain((self.saved_line,), reversed(self.entries))) - def find_match_backward(self, search_term, include_current=False): + 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)): @@ -105,7 +114,9 @@ def find_match_backward(self, search_term, include_current=False): return idx + add return 0 - def find_partial_match_backward(self, search_term, include_current=False): + 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)): @@ -114,8 +125,12 @@ def find_partial_match_backward(self, search_term, include_current=False): return 0 def forward( - self, start=True, search=False, target=None, include_current=False - ): + 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 @@ -133,7 +148,9 @@ def forward( self.index = 0 return self.saved_line - def find_match_forward(self, search_term, include_current=False): + 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): @@ -142,7 +159,9 @@ def find_match_forward(self, search_term, include_current=False): return idx + (0 if include_current else 1) return self.index - def find_partial_match_forward(self, search_term, include_current=False): + 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): @@ -151,59 +170,61 @@ def find_partial_match_forward(self, search_term, include_current=False): return idx + add return self.index - def last(self): + 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): + def is_at_end(self) -> bool: return self.index >= len(self.entries) or self.index == -1 @property - def is_at_start(self): + def is_at_start(self) -> bool: return self.index == 0 - def enter(self, line): + def enter(self, line: str) -> None: if self.index == 0: self.saved_line = line - def reset(self): + def reset(self) -> None: self.index = 0 self.saved_line = "" - def load(self, filename, encoding): - with io.open( - filename, "r", encoding=encoding, errors="ignore" - ) as hfile: - with FileLock(hfile, filename=filename): + 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): - entries = [] + 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, encoding, lines=0): + 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 io.open(fd, "w", encoding=encoding, errors="ignore") as hfile: - with FileLock(hfile, filename=filename): + 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, entries=None, lines=0): + 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, filename, encoding): + def append_reload_and_write( + self, s: str, filename: Path, encoding: str + ) -> None: if not self.hist_size: return self.append(s) @@ -213,8 +234,8 @@ def append_reload_and_write(self, s, filename, encoding): os.O_APPEND | os.O_RDWR | os.O_CREAT, stat.S_IRUSR | stat.S_IWUSR, ) - with io.open(fd, "a+", encoding=encoding, errors="ignore") as hfile: - with FileLock(hfile, filename=filename): + 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) @@ -226,7 +247,7 @@ def append_reload_and_write(self, s, filename, encoding): self.save_to(hfile, entries, self.hist_size) self.entries = entries - except EnvironmentError as err: + except OSError as err: raise RuntimeError( _("Error occurred while writing to file %s (%s)") % (filename, err.strerror) diff --git a/bpython/importcompletion.py b/bpython/importcompletion.py index 0fbc46d58..e22b61f62 100644 --- a/bpython/importcompletion.py +++ b/bpython/importcompletion.py @@ -1,8 +1,7 @@ -# encoding: utf-8 - # 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 @@ -22,9 +21,14 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import absolute_import +import fnmatch +import importlib.machinery +import sys +import warnings +from dataclasses import dataclass +from pathlib import Path +from collections.abc import Generator, Sequence, Iterable -from ._py3compat import py3, try_decode from .line import ( current_word, current_import, @@ -32,207 +36,223 @@ current_from_import_import, ) -import os -import sys -import warnings -from six.moves import filter - -if py3: - import importlib.machinery - - SUFFIXES = importlib.machinery.all_suffixes() -else: - import imp - - SUFFIXES = [suffix for suffix, mode, type in imp.get_suffixes()] - -# 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 set(match[len(prefix) + 1 :] for match in matches) - else: - return set(matches) - - -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 set() - 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: - matches = ("%s.%s" % (module_part, m) for m in matches) - - generator = (try_decode(match, "ascii") for match in matches) - return set(filter(lambda x: x is not None, generator)) +SUFFIXES = importlib.machinery.all_suffixes() +LOADERS = ( + ( + importlib.machinery.ExtensionFileLoader, + importlib.machinery.EXTENSION_SUFFIXES, + ), + ( + importlib.machinery.SourceFileLoader, + importlib.machinery.SOURCE_SUFFIXES, + ), +) -def module_attr_matches(name): - """Only attributes which are modules to replace name with""" - return attr_matches(name, prefix="", only_modules=True) +@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 + if paths is None: + self.modules.update(sys.builtin_module_names) + paths = sys.path -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 + self.find_iterator = self.find_all_modules( + Path(p).resolve() if p else Path.cwd() for p in paths + ) - result = current_word(cursor_offset, line) - if result is None: - return None + def module_matches(self, cw: str, prefix: str = "") -> set[str]: + """Modules names to replace cw with""" - 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 = module_matches(import_import[2], from_import_from[2]) - matches.update(attr_matches(import_import[2], from_import_from[2])) + 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 - matches = module_attr_matches(from_import_from[2]) - matches.update(module_matches(from_import_from[2])) - return matches + 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: + 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} - cur_import = current_import(cursor_offset, line) - if cur_import is not None: - # `import ` completion - matches = module_matches(cur_import[2]) - matches.update(module_attr_matches(cur_import[2])) return matches - 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 = [] - - if py3: - finder = importlib.machinery.FileFinder(path) - - 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 + + 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: + # `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: - is_package = False - with warnings.catch_warnings(): - warnings.simplefilter("ignore", ImportWarning) - if py3: - spec = finder.find_spec(name) - if spec is None: + 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 - if spec.submodule_search_locations is not None: - pathname = spec.submodule_search_locations[0] - is_package = True - else: - pathname = spec.origin - else: - fo, pathname, _ = imp.find_module(name, [path]) - if fo is not None: - fo.close() - else: - # Yay, package - is_package = True - except (ImportError, IOError, SyntaxError): - continue - except UnicodeEncodeError: - # Happens with Python 3 when there is a filename in some - # invalid encoding - continue - else: - if is_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(try_decode(m, "ascii") for m in sys.builtin_module_names) - path = sys.path - - for p in path: - if not p: - p = os.curdir - for module in find_modules(p): - module = try_decode(module, "ascii") - if module is None: - continue - modules.add(module) - yield - - -def find_coroutine(): - global fully_loaded - - if fully_loaded: - return None - - try: - next(find_iterator) - except StopIteration: - fully_loaded = True - - return True - - -def reload(): - """Refresh the list of known modules.""" - modules.clear() - for _ in find_all_modules(): - pass + 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 + + 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 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 + + 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.""" + + 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 + 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 295a8a61f..d3e2d5e56 100644 --- a/bpython/inspection.py +++ b/bpython/inspection.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - # The MIT License # # Copyright (c) 2009-2011 the bpython authors. @@ -23,54 +21,72 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import absolute_import - import inspect -import io import keyword import pydoc -from collections import namedtuple -from six.moves import range +import re +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 ._py3compat import PythonLexer, py3 from .lazyre import LazyReCompile -if not py3: - import types - - _name = LazyReCompile(r"[a-zA-Z_]\w*$") - -ArgSpec = namedtuple( - "ArgSpec", - [ - "args", - "varargs", - "varkwargs", - "defaults", - "kwonly", - "kwonly_defaults", - "annotations", - ], -) -FuncProps = namedtuple("FuncProps", ["func", "argspec", "is_bound_method"]) +class _Repr: + """ + Helper for `ArgSpec`: Returns the given value in `__repr__()`. + """ + + __slots__ = ("value",) + + def __init__(self, value: str) -> None: + self.value = value + + def __repr__(self) -> str: + return self.value + + __str__ = __repr__ + + +@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(object): +class AttrCleaner(ContextManager[None]): """A context manager that tries to make an object not exhibit side-effects - on attribute lookup.""" + on attribute lookup. - def __init__(self, obj): - self.obj = obj + Unless explicitly required, prefer `getattr_safe`.""" - def __enter__(self): + def __init__(self, obj: Any) -> None: + self._obj = obj + + 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 @@ -80,171 +96,174 @@ def __enter__(self): # original methods. :-( # The upshot being that introspecting on an object to display its # attributes will avoid unwanted side-effects. - if is_new_style(self.obj): - __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__) if __getattr__ is not None: setattr(type_, "__getattr__", __getattr__) # /Dark magic + return False -if py3: - - def is_new_style(obj): - return True - - -else: - - def is_new_style(obj): - """Returns True if obj is a new-style class or object""" - return type(obj) not in [types.InstanceType, types.ClassType] - - -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 -getpydocspec_re = LazyReCompile(r"([a-zA-Z_][a-zA-Z0-9_]*?)\((.*?)\)") + +_getpydocspec_re = LazyReCompile( + r"([a-zA-Z_][a-zA-Z0-9_]*?)\((.*?)\)", re.DOTALL +) -def getpydocspec(f, func): +def _getpydocspec(f: Callable) -> ArgSpec | None: try: argspec = pydoc.getdoc(f) except NameError: return None - s = getpydocspec_re.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() + kwonly_args = [] + kwonly_defaults = {} for arg in s.group(2).split(","): arg = arg.strip() if arg.startswith("**"): varkwargs = arg[2:] 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("=") 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 ArgSpec( args, varargs, varkwargs, defaults, kwonly_args, kwonly_defaults, None ) -def getfuncprops(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: @@ -265,45 +284,32 @@ def getfuncprops(func, f): # '__init__' throws xmlrpclib.Fault (see #202) return None try: - if py3: - argspec = get_argspec_from_signature(f) - else: - argspec = list(inspect.getargspec(f)) - - fixlongargs(f, argspec) - if len(argspec) == 4: - argspec = argspec + [list(), dict(), None] - argspec = ArgSpec(*argspec) + 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): - with AttrCleaner(f): - argspec = getpydocspec(f, func) - if argspec is None: + argspec_pydoc = _getpydocspec(f) + if argspec_pydoc is None: return None if inspect.ismethoddescriptor(f): - argspec.args.insert(0, "obj") - fprops = FuncProps(func, argspec, is_bound_method) + 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_callable(obj): - return callable(obj) +def is_eval_safe_name(string: str) -> bool: + return all( + part.isidentifier() and not keyword.iskeyword(part) + for part in string.split(".") + ) -def get_argspec_from_signature(f): +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 @@ -313,20 +319,23 @@ def get_argspec_from_signature(f): """ args = [] - varargs = varkwargs = None + 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 inspect._empty: + 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 inspect._empty: + if parameter.default is not parameter.empty: defaults.append(parameter.default) elif parameter.kind == inspect.Parameter.POSITIONAL_ONLY: args.append(parameter.name) @@ -338,69 +347,55 @@ def get_argspec_from_signature(f): elif parameter.kind == inspect.Parameter.VAR_KEYWORD: varkwargs = parameter.name - # inspect.getfullargspec returns None for 'defaults', 'kwonly_defaults' and - # 'annotations' if there are no values for them. - if not defaults: - defaults = None - - if not kwonly_defaults: - kwonly_defaults = None - - if not annotations: - annotations = None - - return [ + return ArgSpec( args, varargs, varkwargs, - defaults, + defaults if defaults else None, kwonly, - kwonly_defaults, - annotations, - ] + kwonly_defaults if kwonly_defaults else None, + annotations if annotations else None, + ) -get_encoding_line_re = LazyReCompile(r"^.*coding[:=]\s*([-\w.]+).*$") +_get_encoding_line_re = LazyReCompile(r"^.*coding[:=]\s*([-\w.]+).*$") -def get_encoding(obj): +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) + m = _get_encoding_line_re.search(line) if m: return m.group(1) - return "ascii" - - -def get_encoding_comment(source): - """Returns encoding line without the newline, or None is not found""" - for line in source.splitlines()[:2]: - m = get_encoding_line_re.search(line) - if m: - return m.group(0) - return None + return "utf8" -def get_encoding_file(fname): +def get_encoding_file(fname: str) -> str: """Try to obtain encoding information from a Python source file.""" - with io.open(fname, "rt", encoding="ascii", errors="ignore") as f: - for unused in range(2): + with open(fname, encoding="ascii", errors="ignore") as f: + for _ in range(2): line = f.readline() - match = get_encoding_line_re.search(line) + match = _get_encoding_line_re.search(line) if match: return match.group(1) - return "ascii" + return "utf8" -if py3: +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 get_source_unicode(obj): - """Returns a decoded source of object""" - return inspect.getsource(obj) - -else: - - def get_source_unicode(obj): - """Returns a decoded source of object""" - return inspect.getsource(obj).decode(get_encoding(obj)) +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 0f5b9bc28..51f4c0117 100644 --- a/bpython/keys.py +++ b/bpython/keys.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - # The MIT License # # Copyright (c) 2008 Simon de Vlieger @@ -22,18 +20,18 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import absolute_import - import string -from six.moves import range +from typing import TypeVar, Generic + +T = TypeVar("T") -class KeyMap(object): - 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 @@ -41,30 +39,29 @@ def __getitem__(self, key): return self.map[key] else: raise KeyError( - "Configured keymap (%s)" % key - + " does not exist in bpython.keys" + 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()) +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] = ( + cli_key_dispatch[f"C-{c}"] = ( chr(string.ascii_lowercase.index(c) + 1), - "^%s" % c.upper(), + 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), "^[") @@ -75,7 +72,7 @@ def __setitem__(self, key, value): # fill dispatch with function keys for x in range(1, 13): - cli_key_dispatch["F%d" % x] = ("KEY_F(%d)" % x,) + cli_key_dispatch[f"F{x}"] = (f"KEY_F({x})",) for x in range(1, 13): - urwid_key_dispatch["F%d" % x] = "f%d" % x + urwid_key_dispatch[f"F{x}"] = f"f{x}" diff --git a/bpython/lazyre.py b/bpython/lazyre.py index 7371ab042..3d1bd372f 100644 --- a/bpython/lazyre.py +++ b/bpython/lazyre.py @@ -1,8 +1,6 @@ -# encoding: utf-8 - # The MIT License # -# Copyright (c) 2015 Sebastian Ramacher +# 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 @@ -22,42 +20,34 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import absolute_import - import re +from collections.abc import Iterator +from functools import cached_property +from re import Pattern, Match -class LazyReCompile(object): +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, flags=0): + def __init__(self, regex: str, flags: int = 0) -> None: self.regex = regex self.flags = flags - self.compiled = None - - def compile_regex(method): - def _impl(self, *args, **kwargs): - if self.compiled is None: - self.compiled = re.compile(self.regex, self.flags) - return method(self, *args, **kwargs) - return _impl + @cached_property + def compiled(self) -> Pattern[str]: + return re.compile(self.regex, self.flags) - @compile_regex - def finditer(self, *args, **kwargs): + def finditer(self, *args, **kwargs) -> Iterator[Match[str]]: return self.compiled.finditer(*args, **kwargs) - @compile_regex - def search(self, *args, **kwargs): + def search(self, *args, **kwargs) -> Match[str] | None: return self.compiled.search(*args, **kwargs) - @compile_regex - def match(self, *args, **kwargs): + def match(self, *args, **kwargs) -> Match[str] | None: return self.compiled.match(*args, **kwargs) - @compile_regex - def sub(self, *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 af54ab31b..83a75f09e 100644 --- a/bpython/line.py +++ b/bpython/line.py @@ -1,31 +1,35 @@ -# encoding: utf-8 - """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.""" -from __future__ import unicode_literals, absolute_import +import re + +from dataclasses import dataclass from itertools import chain -from collections import namedtuple from .lazyre import LazyReCompile -LinePart = namedtuple("LinePart", ["start", "stop", "word"]) -current_word_re = LazyReCompile(r"(? LinePart | None: """the object.attribute.attribute just before or under the cursor""" - pos = cursor_offset - matches = current_word_re.finditer(line) - start = pos - end = pos + start = cursor_offset + end = cursor_offset word = None - for m in matches: - if m.start(1) < pos and m.end(1) >= pos: + 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) @@ -34,206 +38,269 @@ def current_word(cursor_offset, line): return LinePart(start, end, word) -current_dict_key_re = LazyReCompile(r"""[\w_][\w0-9._]*\[([\w0-9._(), '"]*)""") +# 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, line): +def current_dict_key(cursor_offset: int, line: str) -> LinePart | None: """If in dictionary completion, return the current key""" - matches = current_dict_key_re.finditer(line) - for m in matches: - if m.start(1) <= cursor_offset and m.end(1) >= cursor_offset: + 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 -current_dict_re = LazyReCompile(r"""([\w_][\w0-9._]*)\[([\w0-9._(), '"]*)""") +# 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, line): +def current_dict(cursor_offset: int, line: str) -> LinePart | None: """If in dictionary completion, return the dict that should be used""" - matches = current_dict_re.finditer(line) - for m in matches: - if m.start(2) <= cursor_offset and m.end(2) >= cursor_offset: + 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 -current_string_re = LazyReCompile( +_current_string_re = LazyReCompile( '''(?P(?:""")|"|(?:''\')|')(?:((?P.+?)(?P=open))|''' """(?P.+))""" ) -def current_string(cursor_offset, line): +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): + 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: + if m.start(i) <= cursor_offset <= m.end(i): return LinePart(m.start(i), m.end(i), m.group(i)) return None -current_object_re = LazyReCompile(r"([\w_][\w0-9_]*)[.]") +_current_object_re = LazyReCompile(r"([\w_][\w0-9_]*)[.]") -def current_object(cursor_offset, line): +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 = current_object_re.finditer(word) - s = "" - for m in matches: - if m.end(1) + start < cursor_offset: - if s: - s += "." - s += m.group(1) + 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 LinePart(start, start + len(s), s) + return LinePart(match.start, match.start + len(s), s) -current_object_attribute_re = LazyReCompile(r"([\w_][\w0-9_]*)[.]?") +_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 = current_object_attribute_re.finditer(word) + matches = _current_object_attribute_re.finditer(match.word) next(matches) for m in matches: - if ( - m.start(1) + start <= cursor_offset - and m.end(1) + start >= cursor_offset - ): - return LinePart(m.start(1) + start, m.end(1) + start, m.group(1)) + 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 -current_from_import_from_re = LazyReCompile( - r"from ([\w0-9_.]*)(?:\s+import\s+([\w0-9_]+[,]?\s*)+)*" +_current_from_import_from_re = LazyReCompile( + r"from +([\w0-9_.]*)(?:\s+import\s+([\w0-9_]+[,]?\s*)+)*" ) -def current_from_import_from(cursor_offset, line): +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) """ # TODO allow for as's - tokens = line.split() - if not ("from" in tokens or "import" in tokens): - return None - matches = current_from_import_from_re.finditer(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 + 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 -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_]*)") +_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, line): +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 = current_from_import_import_re_1.search(line) + baseline = _current_from_import_import_re_1.search(line) if baseline is None: return None - match1 = current_from_import_import_re_2.search(line[baseline.end() :]) + match1 = _current_from_import_import_re_2.search(line[baseline.end() :]) if match1 is None: return None - matches = current_from_import_import_re_3.finditer(line[baseline.end() :]) - for m in chain((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: + if start < cursor_offset <= end: return LinePart(start, end, m.group(1)) return None -current_import_re_1 = LazyReCompile(r"import") -current_import_re_2 = LazyReCompile(r"([\w0-9_.]+)") -current_import_re_3 = LazyReCompile(r"[,][ ]([\w0-9_.]*)") +_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, line): +def current_import(cursor_offset: int, line: str) -> LinePart | None: # TODO allow for multiple as's - baseline = current_import_re_1.search(line) + baseline = _current_import_re_1.search(line) if baseline is None: return None - match1 = current_import_re_2.search(line[baseline.end() :]) + match1 = _current_import_re_2.search(line[baseline.end() :]) if match1 is None: return None - matches = current_import_re_3.finditer(line[baseline.end() :]) - for m in chain((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: + if start < cursor_offset <= end: return LinePart(start, end, m.group(1)) + return None -current_method_definition_name_re = LazyReCompile("def\s+([a-zA-Z_][\w]*)") +_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 = current_method_definition_name_re.finditer(line) - for m in matches: - if m.start(1) <= cursor_offset and m.end(1) >= cursor_offset: + 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 -current_single_word_re = LazyReCompile(r"(? LinePart | None: """the un-dotted word just before or under the cursor""" - matches = current_single_word_re.finditer(line) - for m in matches: - if m.start(1) <= cursor_offset and m.end(1) >= cursor_offset: + 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 LinePart(start, end, word) + if match is not None and "." in match.word[1:]: + return match + return None -current_expression_attribute_re = LazyReCompile( +_current_expression_attribute_re = LazyReCompile( r"[.]\s*((?:[\w_][\w0-9_]*)|(?:))" ) -def current_expression_attribute(cursor_offset, line): +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 - matches = current_expression_attribute_re.finditer(line) - for m in matches: - if m.start(1) <= cursor_offset and m.end(1) >= cursor_offset: + 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 2191a5f8d..af9370d6c 100644 --- a/bpython/pager.py +++ b/bpython/pager.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - # The MIT License # # Copyright (c) 2009-2011 Andreas Stuehrk @@ -22,7 +20,8 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import absolute_import +# mypy: disallow_untyped_defs=True +# mypy: disallow_untyped_calls=True import curses import errno @@ -32,15 +31,12 @@ import sys import shlex -from bpython._py3compat import py3 - -def get_pager_command(default="less -rf"): - command = shlex.split(os.environ.get("PAGER", default)) - 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"): pydoc.ttypager(data) @@ -48,7 +44,7 @@ def page_internal(data): 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) @@ -56,16 +52,16 @@ def page(data, use_internal=False): curses.endwin() try: popen = subprocess.Popen(command, stdin=subprocess.PIPE) - if py3 or 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 as e: if e.errno == errno.ENOENT: # pager command not found, fall back to internal pager page_internal(data) return - except IOError as e: if e.errno != errno.EPIPE: raise while True: diff --git a/bpython/paste.py b/bpython/paste.py index b68e9688b..e43ce2f22 100644 --- a/bpython/paste.py +++ b/bpython/paste.py @@ -1,8 +1,6 @@ -# encoding: utf-8 - # The MIT License # -# Copyright (c) 2014-2015 Sebastian Ramacher +# 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 @@ -22,16 +20,15 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import absolute_import - -from locale import getpreferredencoding -from six.moves.urllib_parse import quote as urlquote, urljoin, urlparse -from string import Template import errno -import requests import subprocess +from typing import Protocol +from urllib.parse import urljoin, urlparse + +import requests import unicodedata +from .config import getpreferredencoding from .translations import _ @@ -39,45 +36,43 @@ class PasteFailed(Exception): pass -class PastePinnwand(object): - def __init__(self, url, expiry, show_url, removal_url): +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 - self.show_url = show_url - self.removal_url = removal_url - def paste(self, s): + def paste(self, s: str) -> tuple[str, str]: """Upload to pastebin via json interface.""" - url = urljoin(self.url, "/json/new") - payload = {"code": s, "lexer": "pycon", "expiry": self.expiry} + url = urljoin(self.url, "/api/v1/paste") + payload = { + "expiry": self.expiry, + "files": [{"lexer": "pycon", "content": s}], + } try: - response = requests.post(url, data=payload, verify=True) + response = requests.post(url, json=payload, verify=True) response.raise_for_status() except requests.exceptions.RequestException as exc: - raise PasteFailed(exc.message) + raise PasteFailed(str(exc)) data = response.json() - paste_url_template = Template(self.show_url) - paste_id = urlquote(data["paste_id"]) - paste_url = paste_url_template.safe_substitute(paste_id=paste_id) - - removal_url_template = Template(self.removal_url) - removal_id = urlquote(data["removal_id"]) - removal_url = removal_url_template.safe_substitute( - removal_id=removal_id - ) + paste_url = data["link"] + removal_url = data["removal"] return (paste_url, removal_url) -class PasteHelper(object): - def __init__(self, executable): +class PasteHelper: + def __init__(self, executable: str) -> None: self.executable = executable - def paste(self, s): + def paste(self, s: str) -> tuple[str, None]: """Call out to helper program for pastebin upload.""" try: @@ -87,8 +82,10 @@ def paste(self, s): stdin=subprocess.PIPE, stdout=subprocess.PIPE, ) - helper.stdin.write(s.encode(getpreferredencoding())) - output = helper.communicate()[0].decode(getpreferredencoding()) + 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: @@ -99,23 +96,23 @@ def paste(self, s): if helper.returncode != 0: raise PasteFailed( _( - "Helper program returned non-zero exit " - "status %d." % (helper.returncode,) + "Helper program returned non-zero exit status %d." + % (helper.returncode,) ) ) if not paste_url: raise PasteFailed(_("No output from helper program.")) - else: - 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." - ) + + 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 index d2b1396b5..fa8e17294 100644 --- a/bpython/patch_linecache.py +++ b/bpython/patch_linecache.py @@ -1,26 +1,24 @@ -# encoding: utf-8 - -from __future__ import absolute_import - 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, *args, **kwargs): - super(BPythonLinecache, self).__init__(*args, **kwargs) - self.bpython_history = [] + 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): - try: - return fname.startswith(" 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: @@ -29,55 +27,54 @@ def get_bpython_history(self, key): except (IndexError, ValueError): raise KeyError - def remember_bpython_input(self, source): + 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 = "" % len(self.bpython_history) + filename = f"" self.bpython_history.append( (len(source), None, source.splitlines(True), filename) ) return filename - def __getitem__(self, key): + def __getitem__(self, key: Any) -> Any: if self.is_bpython_filename(key): return self.get_bpython_history(key) - return super(BPythonLinecache, self).__getitem__(key) + return super().__getitem__(key) - def __contains__(self, 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(BPythonLinecache, self).__contains__(key) + return super().__contains__(key) - def __delitem__(self, key): + def __delitem__(self, key: Any) -> None: if not self.is_bpython_filename(key): - return super(BPythonLinecache, self).__delitem__(key) + super().__delitem__(key) -def _bpython_clear_linecache(): - try: +def _bpython_clear_linecache() -> None: + if isinstance(linecache.cache, BPythonLinecache): bpython_history = linecache.cache.bpython_history - except AttributeError: - bpython_history = [] - linecache.cache = BPythonLinecache() - linecache.cache.bpython_history = bpython_history + else: + bpython_history = None + linecache.cache = BPythonLinecache(bpython_history) -# Monkey-patch the linecache module so that we're able +# Monkey-patch the linecache module so that we are able # to hold our command history there and have it persist -linecache.cache = BPythonLinecache(linecache.cache) +linecache.cache = BPythonLinecache(None, linecache.cache) # type: ignore linecache.clearcache = _bpython_clear_linecache -def filename_for_console_input(code_string): +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.""" - try: + if isinstance(linecache.cache, BPythonLinecache): return linecache.cache.remember_bpython_input(code_string) - except AttributeError: + else: # If someone else has patched linecache.cache, better for code to # simply be unavailable to inspect.getsource() than to raise # an exception. diff --git a/bpython/repl.py b/bpython/repl.py index cfac71244..2ced5b7a8 100644 --- a/bpython/repl.py +++ b/bpython/repl.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - # The MIT License # # Copyright (c) 2009-2011 the bpython authors. @@ -23,11 +21,9 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from __future__ import absolute_import - +import abc import code import inspect -import io import os import pkgutil import pydoc @@ -38,55 +34,81 @@ import textwrap import time import traceback +from abc import abstractmethod +from dataclasses import dataclass from itertools import takewhile -from six import itervalues -from types import ModuleType - -from pygments.token import Token - -from . import autocomplete -from . import inspection -from ._py3compat import PythonLexer, py3, prepare_for_exec -from .clipboard import get_clipboard, CopyFailed -from .config import getpreferredencoding +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 . import simpleeval +from .importcompletion import ModuleGatherer -class RuntimeTimer(object): +class RuntimeTimer: """Calculate running time""" - def __init__(self): + def __init__(self) -> None: self.reset_timer() - self.time = time.monotonic if hasattr(time, "monotonic") else time.time - def __enter__(self): - self.start = self.time() + def __enter__(self) -> None: + self.start = time.monotonic() - def __exit__(self, ty, val, tb): - self.last_command = self.time() - self.start + 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 - def reset_timer(self): + def reset_timer(self) -> None: self.running_time = 0.0 self.last_command = 0.0 - def estimate(self): + def estimate(self) -> float: return self.running_time - self.last_command -class Interpreter(code.InteractiveInterpreter, object): +class Interpreter(code.InteractiveInterpreter): """Source code interpreter for use in bpython.""" bpython_input_re = LazyReCompile(r"") - def __init__(self, locals=None, encoding=None): + def __init__( + self, + locals: dict[str, Any] | None = None, + ) -> None: """Constructor. The optional 'locals' argument specifies the dictionary in which code @@ -100,15 +122,9 @@ def __init__(self, locals=None, encoding=None): callback can be added to the Interpreter instance afterwards - more specifically, this is so that autoindentation does not occur after a traceback. - - encoding is only used in Python 2, where it may be necessary to add an - encoding comment to a source bytestring before running it. - encoding must be a bytestring in Python 2 because it will be templated - into a bytestring source as part of an encoding comment. """ - self.encoding = encoding or getpreferredencoding() - self.syntaxerror_callback = None + self.syntaxerror_callback: Callable | None = None if locals is None: # instead of messing with sys.modules, we should modify sys.modules @@ -116,71 +132,26 @@ def __init__(self, locals=None, encoding=None): sys.modules["__main__"] = main_mod = ModuleType("__main__") locals = main_mod.__dict__ - # Unfortunately code.InteractiveInterpreter is a classic class, so no - # super() - code.InteractiveInterpreter.__init__(self, locals) + super().__init__(locals) self.timer = RuntimeTimer() - def reset_running_time(self): - self.running_time = 0 - - def runsource(self, source, filename=None, symbol="single", encode="auto"): + 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 encode is True, - an encoding comment will be added to the source. - On Python 3.X, encode will be ignored. - - encode should only be used for interactive interpreter input, - files should always already have an encoding comment or be ASCII. - By default an encoding line will be added if no filename is given. - - In Python 3, source must be a unicode string - In Python 2, source may be latin-1 bytestring or unicode string, - following the interface of code.InteractiveInterpreter. - - Because adding an encoding comment to a unicode string in Python 2 - would cause a syntax error to be thrown which would reference code - the user did not write, setting encoding to True when source is a - unicode string in Python 2 will throw a ValueError.""" - # str means bytestring in Py2 - if encode and not py3 and isinstance(source, unicode): - if encode != "auto": - raise ValueError("can't add encoding line to unicode input") - encode = False - if encode and filename is not None: - # files have encoding comments or implicit encoding of ASCII - if encode != "auto": - raise ValueError("shouldn't add encoding line to file contents") - encode = False - - if encode and not py3 and isinstance(source, str): - # encoding makes sense for bytestrings, so long as there - # isn't already an encoding comment - comment = inspection.get_encoding_comment(source) - if comment: - # keep the existing encoding comment, but add two lines - # because this interp always adds 2 to stack trace line - # numbers in Python 2 - source = source.replace(comment, b"%s\n\n" % comment, 1) - else: - source = b"# coding: %s\n\n%s" % (self.encoding, source) - elif not py3 and filename is None: - # 2 blank lines still need to be added - # because this interpreter always adds 2 to stack trace line - # numbers in Python 2 when the filename is "" - newlines = u"\n\n" if isinstance(source, unicode) else b"\n\n" - source = newlines + source - # we know we're in Python 2 here, so ok to reference unicode + code.InteractiveInterpreter.runsource.""" + if filename is None: filename = filename_for_console_input(source) with self.timer: - return code.InteractiveInterpreter.runsource( - self, source, filename, symbol - ) + return super().runsource(source, filename, symbol) - def showsyntaxerror(self, filename=None): + 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.""" @@ -190,26 +161,18 @@ def showsyntaxerror(self, filename=None): exc_type, value, sys.last_traceback = sys.exc_info() sys.last_type = exc_type sys.last_value = value - if filename and exc_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 - # strip linecache line number - if self.bpython_input_re.match(filename): - filename = "" - if filename == "" and not py3: - lineno -= 2 - value = SyntaxError(msg, (filename, lineno, offset, line)) - sys.last_value = value + 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): + 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""" @@ -221,14 +184,10 @@ def showtraceback(self): tblist = traceback.extract_tb(tb) del tblist[:1] - for i, (fname, lineno, module, something) in enumerate(tblist): - # strip linecache line number - if self.bpython_input_re.match(fname): - fname = "" - tblist[i] = (fname, lineno, module, something) - # Set the right lineno (encoding header adds an extra line) - if fname == "" and not py3: - tblist[i] = (fname, lineno - 2, 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: @@ -239,14 +198,14 @@ def showtraceback(self): 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 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 @@ -255,78 +214,80 @@ 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): + def __init__(self) -> None: # word being replaced in the original line of text self.current_word = "" # possible replacements for current_word - self.matches = None + 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 = None + self.orig_cursor_offset = -1 # original line (before match replacements) - self.orig_line = None + self.orig_line = "" # class describing the current type of completion - self.completer = None + self.completer: autocomplete.BaseCompletionType | None = None + self.start: int | None = None + self.end: int | None = None - def __nonzero__(self): + def __nonzero__(self) -> bool: """MatchesIterator is False when word hasn't been replaced yet""" return self.index != -1 - def __bool__(self): + def __bool__(self) -> bool: return self.index != -1 @property - def candidate_selected(self): + def candidate_selected(self) -> bool: """True when word selected/replaced, False when word hasn't been replaced yet""" return bool(self) - def __iter__(self): + def __iter__(self) -> "MatchesIterator": return self - def current(self): + def current(self) -> str: if self.index == -1: raise ValueError("No current match.") return self.matches[self.index] - def next(self): - return self.__next__() - - 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): + 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 - ) + assert self.completer is not None + + lp = self.completer.locate(self.orig_cursor_offset, self.orig_line) + assert lp is not None return ( - start + len(match), - self.orig_line[:start] + match + self.orig_line[end:], + lp.start + len(match), + self.orig_line[: lp.start] + match + self.orig_line[lp.stop :], ) - def is_cseq(self): + def is_cseq(self) -> bool: return bool( os.path.commonprefix(self.matches)[len(self.current_word) :] ) - def substitute_cseq(self): + 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 + cseq = os.path.commonprefix(self.matches) new_cursor_offset, new_line = self.substitute(cseq) if len(self.matches) == 1: @@ -339,7 +300,13 @@ def substitute_cseq(self): 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 @@ -353,42 +320,73 @@ def update(self, cursor_offset, current_line, matches, completer): self.matches = matches self.completer = completer 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.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 +class NoInteraction(Interaction): + def __init__(self, config: Config): + super().__init__(config) - def notify(self, s, n=10, wait_for_keypress=False): - raise NotImplementedError + 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): - raise NotImplementedError + def file_prompt(self, s: str) -> str | None: + return None 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 @@ -421,34 +419,87 @@ 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.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, hist_size=config.hist_length ) - self.s_hist = [] - self.history = [] + # 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.funcprops = None - self.arg_pos = 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 = "" @@ -457,19 +508,22 @@ def __init__(self, interp, config): # Necessary to fix mercurial.ui.ui expecting sys.stderr to have this # attribute self.closed = False - self.clipboard = get_clipboard() + self.paster: PasteHelper | PastePinnwand - pythonhist = os.path.expanduser(self.config.hist_file) - if os.path.exists(pythonhist): + if self.config.hist_file.exists(): try: self.rl_history.load( - pythonhist, getpreferredencoding() or "ascii" + self.config.hist_file, + getpreferredencoding() or "ascii", ) - except EnvironmentError: + except OSError: pass + self.module_gatherer = ModuleGatherer( + skiplist=self.config.import_completion_skiplist + ) self.completers = autocomplete.get_default_completer( - config.autocomplete_mode + config.autocomplete_mode, self.module_gatherer ) if self.config.pastebin_helper: self.paster = PasteHelper(self.config.pastebin_helper) @@ -477,32 +531,23 @@ def __init__(self, interp, config): self.paster = PastePinnwand( self.config.pastebin_url, self.config.pastebin_expiry, - self.config.pastebin_show_url, - self.config.pastebin_removal_url, ) @property - def ps1(self): - try: - if not py3: - return sys.ps1.decode(getpreferredencoding()) - else: - return sys.ps1 - except AttributeError: - return u">>> " + 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) + return ">>> " @property - def ps2(self): - try: - if not py3: - return sys.ps2.decode(getpreferredencoding()) - else: - return sys.ps2 - - except AttributeError: - return u"... " + def ps2(self) -> str: + if hasattr(sys, "ps2"): + return str(sys.ps2) + return "... " - def startup(self): + def startup(self) -> None: """ Execute PYTHONSTARTUP file if it exits. Call this after front end-specific initialisation. @@ -510,12 +555,9 @@ def startup(self): filename = os.environ.get("PYTHONSTARTUP") if filename: encoding = inspection.get_encoding_file(filename) - with io.open(filename, "rt", encoding=encoding) as f: + with open(filename, encoding=encoding) as f: source = f.read() - if not py3: - # Early Python 2.7.X need bytes. - source = source.encode(encoding) - self.interp.runsource(source, filename, "exec", encode=False) + self.interp.runsource(source, filename, "exec") def current_string(self, concatenate=False): """If the line ends in a string get it, otherwise return ''""" @@ -529,7 +571,7 @@ def current_string(self, concatenate=False): 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: @@ -548,46 +590,45 @@ def current_string(self, concatenate=False): return "" return "".join(string) - def get_object(self, name): + def get_object(self, name: str) -> Any: attributes = name.split(".") - obj = eval(attributes.pop(0), self.interp.locals) + 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): + 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 list in stack: - # [full_expr, function_expr, arg_number, opening] - # arg_number may be a string if we've encountered a keyword - # argument so we're done counting - stack = [["", "", 0, ""]] + # 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 PythonLexer().get_tokens(line): + for token, value in Python3Lexer().get_tokens(line): if token is Token.Punctuation: if value in "([{": - stack.append(["", "", 0, value]) + stack.append(_FuncExpr("", "", 0, value)) elif value in ")]}": - full, _, _, start = stack.pop() - expr = start + full + value - stack[-1][1] += expr - stack[-1][0] += expr + element = stack.pop() + expr = element.opening + element.full_expr + value + stack[-1].function_expr += expr + stack[-1].full_expr += expr elif value == ",": - try: - stack[-1][2] += 1 - except TypeError: - stack[-1][2] = "" - stack[-1][1] = "" - stack[-1][0] += value - elif value == ":" and stack[-1][3] == "lambda": - expr = stack.pop()[0] + ":" - stack[-1][1] += expr - stack[-1][0] += expr + 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][1] = "" - stack[-1][0] += value + stack[-1].function_expr = "" + stack[-1].full_expr += value elif ( token is Token.Number or token in Token.Number.subtypes @@ -596,25 +637,25 @@ def _funcname_and_argnum(cls, line): or token is Token.Operator and value == "." ): - stack[-1][1] += value - stack[-1][0] += value + stack[-1].function_expr += value + stack[-1].full_expr += value elif token is Token.Operator and value == "=": - stack[-1][2] = stack[-1][1] - stack[-1][1] = "" - stack[-1][0] += 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][1] = value - stack[-1][0] += value + stack[-1].function_expr = value + stack[-1].full_expr += value elif token is Token.Keyword and value == "lambda": - stack.append([value, "", 0, value]) + stack.append(_FuncExpr(value, "", 0, value)) else: - stack[-1][1] = "" - stack[-1][0] += value - while stack[-1][3] in "[{": + stack[-1].function_expr = "" + stack[-1].full_expr += value + while stack[-1].opening in "[{": stack.pop() - _, _, arg_number, _ = stack.pop() - _, func, _, _ = stack.pop() - return func, arg_number + elem1 = stack.pop() + elem2 = stack.pop() + return elem2.function_expr, elem1.keyword or elem1.arg_number except IndexError: return None, None @@ -647,8 +688,6 @@ def get_args(self): if inspect.isclass(f): class_f = None - if hasattr(f, "__init__") and f.__init__ is not object.__init__: - class_f = f.__init__ if ( (not class_f or not inspection.getfuncprops(func, class_f)) and hasattr(f, "__new__") @@ -657,7 +696,6 @@ def get_args(self): # py3 f.__new__.__class__ is not object.__new__.__class__ ): - class_f = f.__new__ if class_f: @@ -677,12 +715,12 @@ def get_args(self): self.arg_pos = None return False - def get_source_of_current_name(self): + 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 = self.current_func + obj: Callable | None = self.current_func try: if obj is None: line = self.current_line @@ -690,19 +728,20 @@ def get_source_of_current_name(self): raise SourceNotFound(_("Nothing to get source of")) if inspection.is_eval_safe_name(line): obj = self.get_object(line) - return inspection.get_source_unicode(obj) + # 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 = _(u"Cannot get source: %s") % (e,) - except IOError as e: - msg = u"%s" % (e,) + msg = _("Cannot get source: %s") % (e,) + except OSError as e: + msg = f"{e}" except TypeError as e: - if "built-in" in u"%s" % (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.funcprops = None @@ -727,7 +766,7 @@ def set_docstring(self): # 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=False): + 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. @@ -746,7 +785,7 @@ def complete(self, tab=False): self.completers, cursor_offset=self.cursor_offset, line=self.current_line, - locals_=self.interp.locals, + 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, @@ -757,28 +796,33 @@ def complete(self, tab=False): self.matches_iter.clear() return bool(self.funcprops) - self.matches_iter.update( - self.cursor_offset, self.current_line, matches, completer - ) + 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 + 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: - 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.""" @@ -798,7 +842,7 @@ 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: @@ -817,23 +861,29 @@ def line_is_empty(line): indentation = 0 return indentation - def formatforfile(self, session_ouput): + @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 session_ouput.split("\n"): + for line in session_output.split("\n"): if line.startswith(self.ps1): yield line[len(self.ps1) :] elif line.startswith(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.""" @@ -846,56 +896,53 @@ def write2file(self): 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" + 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 os.path.exists(fn): - mode = self.interact.file_prompt( + if path.exists(): + new_mode = self.interact.file_prompt( _( - "%s already exists. Do you " - "want to (c)ancel, " - " (o)verwrite or " - "(a)ppend? " + "%s already exists. Do you want to (c)ancel, (o)verwrite or (a)ppend? " ) - % (fn,) + % (path,) ) - if mode in ("o", "overwrite", _("overwrite")): + if new_mode in ("o", "overwrite", _("overwrite")): mode = "w" - elif mode in ("a", "append", _("append")): + elif new_mode in ("a", "append", _("append")): mode = "a" else: self.interact.notify(_("Save cancelled.")) return - stdout_text = self.formatforfile(self.getstdout()) + stdout_text = self.get_session_formatted_for_file() try: - with open(fn, mode) as f: + with open(path, mode) as f: f.write(stdout_text) - except IOError as e: - self.interact.notify(_("Error writing file '%s': %s") % (fn, e)) + 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 copy2clipboard(self): + def copy2clipboard(self) -> None: """Copy current content to clipboard.""" - if self.clipboard is None: + if not have_pyperclip: self.interact.notify(_("No clipboard available.")) return - content = self.formatforfile(self.getstdout()) + content = self.get_session_formatted_for_file() try: - self.clipboard.copy(content) - except CopyFailed: + 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): + def pastebin(self, s=None) -> str | None: """Upload to a pastebin and display the URL in the status bar.""" if s is None: @@ -905,11 +952,13 @@ def pastebin(self, s=None): _("Pastebin buffer? (y/N) ") ): self.interact.notify(_("Pastebin aborted.")) - return - return self.do_pastebin(s) + 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. " "Removal URL: %s") @@ -923,11 +972,11 @@ def do_pastebin(self, s): paste_url, removal_url = self.paster.paste(s) except PasteFailed as e: self.interact.notify(_("Upload failed: %s") % e) - return + return None self.prev_pastebin_content = s self.prev_pastebin_url = paste_url - self.prev_removal_url = removal_url + self.prev_removal_url = removal_url if removal_url is not None else "" if removal_url is not None: self.interact.notify( @@ -940,32 +989,32 @@ def do_pastebin(self, s): 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): - pythonhist = os.path.expanduser(self.config.hist_file) + def insert_into_history(self, s: str): try: self.rl_history.append_reload_and_write( - s, pythonhist, getpreferredencoding() + s, self.config.hist_file, getpreferredencoding() ) except RuntimeError as e: - self.interact.notify(u"%s" % (e,)) + self.interact.notify(f"{e}") - def prompt_undo(self): + def prompt_undo(self) -> int: """Returns how many lines to undo, 0 means don't undo""" if ( self.config.single_undo_time < 0 @@ -973,14 +1022,18 @@ def prompt_undo(self): ): return 1 est = self.interp.timer.estimate() - n = self.interact.file_prompt( + 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 + try: - if n == "": - n = "1" - n = int(n) + if m == "": + m = "1" + n = int(m) except ValueError: self.interact.notify(_("Undo canceled"), 0.1) return 0 @@ -997,7 +1050,7 @@ def prompt_undo(self): self.interact.notify(message % (n, est), 0.1) return n - def undo(self, n=1): + 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 @@ -1012,12 +1065,16 @@ def undo(self, n=1): 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 @@ -1034,7 +1091,7 @@ 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 @@ -1051,8 +1108,8 @@ def tokenize(self, s, newline=False): 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]: @@ -1060,10 +1117,10 @@ def tokenize(self, s, newline=False): 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() + 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": line += 1 @@ -1086,7 +1143,7 @@ def tokenize(self, s, newline=False): stack.append( (line, len(line_tokens) - 1, line_tokens, value) ) - elif value in itervalues(parens): + elif value in parens.values(): saved_stack = list(stack) try: while True: @@ -1132,65 +1189,56 @@ def tokenize(self, s, newline=False): 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 occurring after a traceback.""" - def send_to_external_editor(self, text): + 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( - prepare_for_exec(self.config.editor, encoding) - ) + editor_args = shlex.split(self.config.editor) with tempfile.NamedTemporaryFile(suffix=".py") as temp: temp.write(text.encode(encoding)) temp.flush() - args = editor_args + [prepare_for_exec(temp.name, encoding)] + args = editor_args + [temp.name] if subprocess.call(args) == 0: with open(temp.name) as f: - if py3: - return f.read() - else: - return f.read().decode(encoding) + return f.read() else: return text def open_in_external_editor(self, filename): - encoding = getpreferredencoding() - editor_args = shlex.split( - prepare_for_exec(self.config.editor, encoding) - ) - args = editor_args + [prepare_for_exec(filename, encoding)] + editor_args = shlex.split(self.config.editor) + args = editor_args + [filename] return subprocess.call(args) == 0 def edit_config(self): - if not os.path.isfile(self.config.config_path): + if self.config.config_path is None: + self.interact.notify(_("No config file specified.")) + return + + if not self.config.config_path.is_file(): if self.interact.confirm( - _( - "Config file does not exist - create " - "new from default? (y/N)" - ) + _("Config file does not exist - create new from default? (y/N)") ): try: default_config = pkgutil.get_data( "bpython", "sample-config" ) - if py3: # py3 files need unicode - default_config = default_config.decode("ascii") - containing_dir = os.path.dirname( - os.path.abspath(self.config.config_path) - ) - if not os.path.exists(containing_dir): - os.makedirs(containing_dir) + # 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 (IOError, OSError) as e: + except OSError as e: self.interact.notify( _("Error writing file '%s': %s") - % (self.config.config.path, e) + % (self.config.config_path, e) ) return False else: @@ -1200,42 +1248,29 @@ def edit_config(self): if self.open_in_external_editor(self.config.config_path): self.interact.notify( _( - "bpython config file edited. Restart " - "bpython for changes to take effect." + "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): +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 + 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(code_string, 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(code_string): - 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: @@ -1270,7 +1305,7 @@ def token_is_any_of(token): 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 index 1f7c8dd65..03127f1b2 100644 --- a/bpython/sample-config +++ b/bpython/sample-config @@ -1,11 +1,13 @@ -# This is a standard python config file -# Valid values can be True, False, integer numbers, strings +# 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 +# --config option on the command line. # -# see http://docs.bpython-interpreter.org/configuration.html -# for all configurable options +# See http://docs.bpython-interpreter.org/configuration.html for all +# configurable options. # General section tag [general] @@ -36,6 +38,10 @@ # 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 @@ -55,6 +61,8 @@ # Enable autoreload feature by default (default: False). # default_autoreload = False +# Enable autocompletion of brackets and quotes (default: False) +# brackets_completion = False [keyboard] @@ -67,6 +75,7 @@ # 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 diff --git a/bpython/simpleeval.py b/bpython/simpleeval.py index 1f87b76c3..6e911590e 100644 --- a/bpython/simpleeval.py +++ b/bpython/simpleeval.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - # The MIT License # # Copyright (c) 2015 the bpython authors. @@ -25,35 +23,23 @@ """simple evaluation of side-effect free code In order to provide fancy completion, some code can be executed safely. - """ -from __future__ import absolute_import - import ast -import inspect -from six import string_types -from six.moves import builtins +import builtins +from typing import Any from . import line as line_properties -from ._py3compat import py3 -from .inspection import is_new_style, AttrCleaner - -_string_type_nodes = (ast.Str, ast.Bytes) if py3 else (ast.Str,) -_numeric_types = (int, float, complex) + (() if py3 else (long,)) +from .inspection import getattr_safe -# added in Python 3.4 -if hasattr(ast, "NameConstant"): - _name_type_nodes = (ast.Name, ast.NameConstant) -else: - _name_type_nodes = (ast.Name,) +_numeric_types = (int, float, complex) class EvaluationError(Exception): """Raised if an exception occurred in safe_eval.""" -def safe_eval(expr, namespace): +def safe_eval(expr: str, namespace: dict[str, Any]) -> Any: """Not all that safe, just catches some errors""" try: return eval(expr, namespace) @@ -65,14 +51,14 @@ def safe_eval(expr, namespace): # This function is under the Python License, Version 2 # This license requires modifications to the code be reported. -# Based on ast.literal_eval in Python 2 and Python 3 +# Based on ast.literal_eval # Modifications: -# * Python 2 and Python 3 versions of the function are combined # * 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 @@ -80,39 +66,61 @@ def simple_eval(node_or_string, namespace=None): The string or node provided may only consist of: * the following Python literal structures: strings, numbers, tuples, - lists, and dicts + 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 the Python 3 (and unlike the Python 2) literal_eval, unary and binary - + and - operations are allowed on all builtin numeric types. + 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 + The optional namespace dict-like ought not to cause side effects on lookup. """ if namespace is None: namespace = {} - if isinstance(node_or_string, string_types): + 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, _string_type_nodes): - return node.s - elif isinstance(node, ast.Num): - return node.n + 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 dict( - (_convert(k), _convert(v)) - for k, v in zip(node.keys, node.values) - ) + 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, _name_type_nodes): + elif isinstance(node, ast.Name): try: return namespace[node.id] except KeyError: @@ -136,11 +144,14 @@ def _convert(node): elif isinstance(node, ast.BinOp) and isinstance( node.op, (ast.Add, ast.Sub) ): - # ast.literal_eval does ast typechecks here, we use type checks + # 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 ( - type(left) in _numeric_types and type(right) in _numeric_types + isinstance(left, _numeric_types) + and isinstance(right, _numeric_types) ): raise ValueError("binary + and - only allowed on builtin nums") if isinstance(node.op, ast.Add): @@ -150,30 +161,31 @@ def _convert(node): # this is a deviation from literal_eval: we allow indexing elif isinstance(node, ast.Subscript) and isinstance( - node.slice, ast.Index + node.slice, (ast.Constant, ast.Name) ): obj = _convert(node.value) - index = _convert(node.slice.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 safe_get_attribute(obj, attr) + return getattr_safe(obj, attr) - raise ValueError("malformed string") + raise ValueError(f"malformed node or string: {node!r}") return _convert(node_or_string) def safe_getitem(obj, index): - if type(obj) in (list, tuple, dict, bytes) + string_types: + """Safely tries to access obj[index]""" + if type(obj) in (list, tuple, dict, bytes, str): try: return obj[index] except (KeyError, IndexError): - raise EvaluationError("can't lookup key %r on %r" % (index, obj)) - raise ValueError("unsafe to lookup on object of type %s" % (type(obj),)) + 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): @@ -185,7 +197,9 @@ def find_attribute_with_name(node, name): return r -def evaluate_current_expression(cursor_offset, line, namespace=None): +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. @@ -195,9 +209,6 @@ def evaluate_current_expression(cursor_offset, line, namespace=None): # Find the biggest valid ast. # Once our attribute access is found, return its .value subtree - if namespace is None: - namespace = {} - # in case attribute is blank, e.g. foo.| -> foo.xxx| temp_line = line[:cursor_offset] + "xxx" + line[cursor_offset:] temp_cursor = cursor_offset + 3 @@ -244,48 +255,4 @@ def evaluate_current_attribute(cursor_offset, line, namespace=None): try: return getattr(obj, attr.word) except AttributeError: - raise EvaluationError( - "can't lookup attribute %s on %r" % (attr.word, obj) - ) - - -def safe_get_attribute(obj, attr): - """Gets attributes without triggering descriptors on new-style classes""" - if is_new_style(obj): - with AttrCleaner(obj): - result = safe_get_attribute_new_style(obj, attr) - if isinstance(result, member_descriptor): - # will either be the same slot descriptor or the value - return getattr(obj, attr) - return result - return getattr(obj, attr) - - -class _ClassWithSlots(object): - __slots__ = ["a"] - - -member_descriptor = type(_ClassWithSlots.a) - - -def safe_get_attribute_new_style(obj, attr): - """Returns approximately the attribute returned by getattr(obj, attr) - - The object returned ought to be callable if getattr(obj, attr) was. - Fake callable objects may be returned instead, in order to avoid executing - arbitrary code in descriptors. - - If the object is an instance of a class that uses __slots__, will return - the member_descriptor object instead of the value. - """ - if not is_new_style(obj): - raise ValueError("%r is not a new-style class or object" % obj) - to_look_through = ( - obj.__mro__ if inspect.isclass(obj) else (obj,) + type(obj).__mro__ - ) - - for cls in to_look_through: - if hasattr(cls, "__dict__") and attr in cls.__dict__: - return cls.__dict__[attr] - - raise 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 7ff693c0a..4618eca4d 100644 --- a/bpython/test/__init__.py +++ b/bpython/test/__init__.py @@ -1,19 +1,9 @@ -# -*- coding: utf-8 -*- - -try: - import unittest2 as unittest -except ImportError: - import unittest - -try: - from unittest import mock -except ImportError: - import mock +import unittest +import unittest.mock +import os +from pathlib import Path from bpython.translations import init -from bpython._py3compat import py3 -from six.moves import builtins -import os class FixLanguageTestCase(unittest.TestCase): @@ -22,17 +12,8 @@ def setUpClass(cls): init(languages=["en"]) -class MagicIterMock(mock.MagicMock): - - if py3: - __next__ = mock.Mock(return_value=None) - else: - next = mock.Mock(return_value=None) - - -def builtin_target(obj): - """Returns mock target string of a builtin""" - return "%s.%s" % (builtins.__name__, obj.__name__) +class MagicIterMock(unittest.mock.MagicMock): + __next__ = unittest.mock.Mock(return_value=None) -TEST_CONFIG = os.path.join(os.path.dirname(__file__), "test.config") +TEST_CONFIG = Path(__file__).parent / "test.config" diff --git a/bpython/test/fodder/original.py b/bpython/test/fodder/original.py index 1afd64129..d644c16dd 100644 --- a/bpython/test/fodder/original.py +++ b/bpython/test/fodder/original.py @@ -1,7 +1,7 @@ # careful: whitespace is very important in this file # also, this code runs - so everything should be a noop -class BlankLineBetweenMethods(object): +class BlankLineBetweenMethods: def method1(self): pass diff --git a/bpython/test/fodder/processed.py b/bpython/test/fodder/processed.py index 6b6331662..9f944ee66 100644 --- a/bpython/test/fodder/processed.py +++ b/bpython/test/fodder/processed.py @@ -1,6 +1,6 @@ #careful! Whitespace is very important in this file -class BlankLineBetweenMethods(object): +class BlankLineBetweenMethods: def method1(self): pass diff --git a/bpython/test/test_args.py b/bpython/test/test_args.py index f473a172f..36f233ebc 100644 --- a/bpython/test/test_args.py +++ b/bpython/test/test_args.py @@ -1,26 +1,68 @@ -# encoding: utf-8 - +import errno +import os +import pty import re +import select import subprocess import sys import tempfile -from textwrap import dedent +import unittest +from textwrap import dedent from bpython import args -from bpython.test import FixLanguageTestCase as TestCase, unittest +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) -try: - from nose.plugins.attrib import attr -except ImportError: + 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() - def attr(*args, **kwargs): - def identity(func): - return func + if p.returncode: + raise RuntimeError(f"Subprocess exited with {p.returncode}") - return identity + return ( + result[master_stdout].decode(getpreferredencoding()), + result[master_stderr].decode(getpreferredencoding()), + ) -@attr(speed="slow") class TestExecArgs(unittest.TestCase): def test_exec_dunder_file(self): with tempfile.NamedTemporaryFile(mode="w") as f: @@ -33,15 +75,9 @@ def test_exec_dunder_file(self): ) ) f.flush() - p = subprocess.Popen( - [sys.executable] - + (["-W", "ignore"] if sys.version_info[:2] == (2, 6) else []) - + ["-m", "bpython.curtsies", f.name], - stderr=subprocess.PIPE, - universal_newlines=True, + _, stderr = run_with_tty( + [sys.executable] + ["-m", "bpython.curtsies", f.name] ) - (_, stderr) = p.communicate() - self.assertEqual(stderr.strip(), f.name) def test_exec_nonascii_file(self): @@ -49,40 +85,31 @@ def test_exec_nonascii_file(self): f.write( dedent( """\ - #!/usr/bin/env python # coding: utf-8 "你好 # nonascii" """ ) ) f.flush() - try: - subprocess.check_call( - [sys.executable, "-m", "bpython.curtsies", f.name] - ) - except subprocess.CalledProcessError: - self.fail("Error running module with nonascii characters") + _, 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( """\ - #!/usr/bin/env python - # coding: utf-8 1/0 """ ) ) f.flush() - p = subprocess.Popen( + _, stderr = run_with_tty( [sys.executable, "-m", "bpython.curtsies", f.name], - stderr=subprocess.PIPE, - universal_newlines=True, ) - (_, stderr) = p.communicate() - - self.assertIn("line 3", clean_colors(stderr)) + self.assertIn("line 1", clean_colors(stderr)) def clean_colors(s): diff --git a/bpython/test/test_autocomplete.py b/bpython/test/test_autocomplete.py index 0492428ec..da32fbb8c 100644 --- a/bpython/test/test_autocomplete.py +++ b/bpython/test/test_autocomplete.py @@ -1,14 +1,8 @@ -# encoding: utf-8 - -from collections import namedtuple import inspect import keyword -import sys - -try: - import unittest2 as unittest -except ImportError: - import unittest +import unittest +from collections import namedtuple +from unittest import mock try: import jedi @@ -17,14 +11,10 @@ except ImportError: has_jedi = False -from bpython import autocomplete -from bpython._py3compat import py3 -from bpython.test import mock +from bpython import autocomplete, inspection +from bpython.line import LinePart -if py3: - glob_function = "glob.iglob" -else: - glob_function = "glob.glob" +glob_function = "glob.iglob" class TestSafeEval(unittest.TestCase): @@ -45,7 +35,7 @@ def test_filename(self): self.assertEqual(last_part_of_filename("ab.c/e.f.g/"), "e.f.g/") def test_attribute(self): - self.assertEqual(autocomplete.after_last_dot("abc.edf"), "edf") + self.assertEqual(autocomplete._after_last_dot("abc.edf"), "edf") def completer(matches): @@ -91,9 +81,7 @@ def test_first_completer_returns_None(self): class TestCumulativeCompleter(unittest.TestCase): - def completer( - self, matches, - ): + def completer(self, matches): mock_completer = autocomplete.BaseCompletionType() mock_completer.matches = mock.Mock(return_value=matches) return mock_completer @@ -116,7 +104,16 @@ 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"), set(["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), + ) class TestFilenameCompletion(unittest.TestCase): @@ -127,7 +124,9 @@ def test_locate_fails_when_not_in_string(self): self.assertEqual(self.completer.locate(4, "abcd"), None) def test_locate_succeeds_when_in_string(self): - self.assertEqual(self.completer.locate(4, "a'bc'd"), (2, 4, "bc")) + self.assertEqual( + self.completer.locate(4, "a'bc'd"), LinePart(2, 4, "bc") + ) def test_issue_491(self): self.assertNotEqual(self.completer.matches(9, '"a[a.l-1]'), None) @@ -177,7 +176,7 @@ def test_formatting_takes_just_last_part(self): self.assertEqual(self.completer.format("/hello/there"), "there") -class MockNumPy(object): +class MockNumPy: """This is a mock numpy object that raises an error when there is an attempt to convert it to a boolean.""" @@ -193,7 +192,7 @@ 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), set(["'ab']", "'cd']"]) + com.matches(2, "d[", locals_=local), {"'ab']", "'cd']"} ) def test_none_returned_when_eval_error(self): @@ -217,7 +216,7 @@ def test_obj_that_does_not_allow_conversion_to_bool(self): self.assertEqual(com.matches(7, "mNumPy[", locals_=local), None) -class Foo(object): +class Foo: a = 10 def __init__(self): @@ -227,31 +226,21 @@ def method(self, x): pass -class OldStyleFoo: - a = 10 - - def __init__(self): - self.b = 20 - - def method(self, x): - pass - - -skip_old_style = unittest.skipIf( - py3, "In Python 3 there are no old style classes" -) - - class Properties(Foo): @property def asserts_when_called(self): raise AssertionError("getter method called") -class Slots(object): +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): @@ -260,50 +249,28 @@ def setUpClass(cls): def test_att_matches_found_on_instance(self): self.assertSetEqual( self.com.matches(2, "a.", locals_={"a": Foo()}), - set(["a.method", "a.a", "a.b"]), + {"a.method", "a.a", "a.b"}, ) - @skip_old_style - def test_att_matches_found_on_old_style_instance(self): + def test_descriptor_attributes_not_run(self): + com = autocomplete.AttrCompletion() self.assertSetEqual( - self.com.matches(2, "a.", locals_={"a": OldStyleFoo()}), - set(["a.method", "a.a", "a.b"]), - ) - self.assertIn( - u"a.__dict__", - self.com.matches(4, "a.__", locals_={"a": OldStyleFoo()}), - ) - - @skip_old_style - def test_att_matches_found_on_old_style_class_object(self): - self.assertIn( - u"A.__dict__", - self.com.matches(4, "A.__", locals_={"A": OldStyleFoo}), - ) - - @skip_old_style - def test_issue536(self): - class OldStyleWithBrokenGetAttr: - def __getattr__(self, attr): - raise Exception() - - locals_ = {"a": OldStyleWithBrokenGetAttr()} - self.assertIn( - u"a.__module__", self.com.matches(4, "a.__", locals_=locals_) + com.matches(2, "a.", locals_={"a": Properties()}), + {"a.b", "a.a", "a.method", "a.asserts_when_called"}, ) - def test_descriptor_attributes_not_run(self): + def test_custom_get_attribute_not_invoked(self): com = autocomplete.AttrCompletion() self.assertSetEqual( - com.matches(2, "a.", locals_={"a": Properties()}), - set(["a.b", "a.a", "a.method", "a.asserts_when_called"]), + 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}), - set(["A.b", "A.a", "A.mro"]), + {"A.b", "A.a"}, ) @@ -315,18 +282,11 @@ def setUpClass(cls): def test_att_matches_found_on_instance(self): self.assertSetEqual( self.com.matches(5, "a[0].", locals_={"a": [Foo()]}), - set(["method", "a", "b"]), - ) - - @skip_old_style - def test_att_matches_found_on_old_style_instance(self): - self.assertSetEqual( - self.com.matches(5, "a[0].", locals_={"a": [OldStyleFoo()]}), - set(["method", "a", "b"]), + {"method", "a", "b"}, ) def test_other_getitem_methods_not_called(self): - class FakeList(object): + class FakeList: def __getitem__(inner_self, i): self.fail("possibly side-effecting __getitem_ method called") @@ -335,7 +295,7 @@ def __getitem__(inner_self, i): def test_tuples_complete(self): self.assertSetEqual( self.com.matches(5, "a[0].", locals_={"a": (Foo(),)}), - set(["method", "a", "b"]), + {"method", "a", "b"}, ) @unittest.skip("TODO, subclasses do not complete yet") @@ -345,7 +305,7 @@ class ListSubclass(list): self.assertSetEqual( self.com.matches(5, "a[0].", locals_={"a": ListSubclass([Foo()])}), - set(["method", "a", "b"]), + {"method", "a", "b"}, ) def test_getitem_not_called_in_list_subclasses_overriding_getitem(self): @@ -358,13 +318,13 @@ def __getitem__(inner_self, i): def test_literals_complete(self): self.assertSetEqual( self.com.matches(10, "[a][0][0].", locals_={"a": (Foo(),)}), - set(["method", "a", "b"]), + {"method", "a", "b"}, ) def test_dictionaries_complete(self): self.assertSetEqual( self.com.matches(7, 'a["b"].', locals_={"a": {"b": Foo()}}), - set(["method", "a", "b"]), + {"method", "a", "b"}, ) @@ -373,12 +333,17 @@ 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), + com.matches( + 10, + " def __", + current_block=block, + complete_magic_methods=True, + ), set(autocomplete.MAGIC_METHODS), ) -Comp = namedtuple("Completion", ["name", "complete"]) +Completion = namedtuple("Completion", ["name", "complete"]) @unittest.skipUnless(has_jedi, "jedi required") @@ -403,7 +368,7 @@ def matches_from_completions( ): with mock.patch("bpython.autocomplete.jedi.Script") as Script: script = Script.return_value - script.completions.return_value = completions + script.complete.return_value = completions com = autocomplete.MultilineJediCompletion() return com.matches( cursor, line, current_block=block, history=history @@ -415,7 +380,7 @@ def test_completions_starting_with_different_letters(self): " a", "class Foo:\n a", ["adsf"], - [Comp("Abc", "bc"), Comp("Cbc", "bc")], + [Completion("Abc", "bc"), Completion("Cbc", "bc")], ) self.assertEqual(matches, None) @@ -425,11 +390,10 @@ def test_completions_starting_with_different_cases(self): " a", "class Foo:\n a", ["adsf"], - [Comp("Abc", "bc"), Comp("ade", "de")], + [Completion("Abc", "bc"), Completion("ade", "de")], ) - self.assertSetEqual(matches, set(["ade"])) + self.assertSetEqual(matches, {"ade"}) - @unittest.skipUnless(py3, "asyncio required") def test_issue_544(self): com = autocomplete.MultilineJediCompletion() code = "@asyncio.coroutine\ndef" @@ -447,16 +411,12 @@ def function(): self.assertEqual( self.com.matches(8, "function", locals_={"function": function}), - set(("function(",)), + {"function("}, ) def test_completions_are_unicode(self): for m in self.com.matches(1, "a", locals_={"abc": 10}): - self.assertIsInstance(m, type(u"")) - - @unittest.skipIf(py3, "in Python 3 invalid identifiers are passed through") - def test_ignores_nonascii_encodable(self): - self.assertEqual(self.com.matches(3, "abc", locals_={"abcß": 10}), None) + self.assertIsInstance(m, str) def test_mock_kwlist(self): with mock.patch.object(keyword, "kwlist", new=["abcd"]): @@ -472,19 +432,19 @@ def test_set_of_params_returns_when_matches_found(self): def func(apple, apricot, banana, carrot): pass - if py3: - argspec = list(inspect.getfullargspec(func)) - else: - argspec = list(inspect.getargspec(func)) - - argspec = ["func", argspec, False] + argspec = inspection.ArgSpec(*inspect.getfullargspec(func)) + funcspec = inspection.FuncProps("func", argspec, False) com = autocomplete.ParameterNameCompletion() self.assertSetEqual( - com.matches(1, "a", argspec=argspec), set(["apple=", "apricot="]) + com.matches(1, "a", funcprops=funcspec), {"apple=", "apricot="} + ) + self.assertSetEqual( + com.matches(2, "ba", funcprops=funcspec), {"banana="} ) self.assertSetEqual( - com.matches(2, "ba", argspec=argspec), set(["banana="]) + com.matches(3, "car", funcprops=funcspec), {"carrot="} ) self.assertSetEqual( - com.matches(3, "car", argspec=argspec), set(["carrot="]) + 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 e8307ea1a..c34f2dac6 100644 --- a/bpython/test/test_config.py +++ b/bpython/test/test_config.py @@ -1,43 +1,34 @@ -# encoding: utf-8 - import os import tempfile import textwrap +import unittest +from pathlib import Path -from bpython.test import unittest 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, struct=None): + def load_temp_config(self, content): """Write config to a temporary file and load it.""" - if struct is None: - struct = config.Struct() - with tempfile.NamedTemporaryFile() as f: f.write(content.encode("utf8")) f.flush() - config.loadini(struct, f.name) - - return struct + 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.assertEqual(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.assertEqual(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("") diff --git a/bpython/test/test_crashers.py b/bpython/test/test_crashers.py index 9b6382640..051e5b691 100644 --- a/bpython/test/test_crashers.py +++ b/bpython/test/test_crashers.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - import fcntl import os import pty @@ -7,8 +5,9 @@ import sys import termios import textwrap +import unittest -from bpython.test import unittest, TEST_CONFIG +from bpython.test import TEST_CONFIG from bpython.config import getpreferredencoding try: @@ -18,10 +17,10 @@ from twisted.trial.unittest import TestCase as TrialTestCase except ImportError: - class TrialTestCase(object): + class TrialTestCase: # type: ignore [no-redef] pass - reactor = None + reactor = None # type: ignore try: import urwid @@ -30,23 +29,13 @@ class TrialTestCase(object): except ImportError: have_urwid = False -try: - from nose.plugins.attrib import attr -except ImportError: - - def attr(*args, **kwargs): - def identity(func): - return func - - return identity - def set_win_size(fd, rows, columns): 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): @@ -83,6 +72,10 @@ def next(self): 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: @@ -102,16 +95,19 @@ def processExited(self, reason): ( sys.executable, "-m", - "bpython." + self.backend, + f"bpython.{self.backend}", "--config", - TEST_CONFIG, + str(TEST_CONFIG), + "-q", # prevents version greeting ), - env=dict(TERM="vt100", LANG=os.environ.get("LANG", "")), + env={ + "TERM": "vt100", + "LANG": os.environ.get("LANG", "C.UTF-8"), + }, usePTY=(master, slave, os.ttyname(slave)), ) return result - @attr(speed="slow") def test_issue108(self): input = textwrap.dedent( """\ @@ -123,7 +119,6 @@ def spam(): deferred = self.run_bpython(input) return deferred.addCallback(self.check_no_traceback) - @attr(speed="slow") def test_issue133(self): input = textwrap.dedent( """\ @@ -138,6 +133,11 @@ def check_no_traceback(self, data): 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" diff --git a/bpython/test/test_curtsies.py b/bpython/test/test_curtsies.py index b337350d1..fde2b1037 100644 --- a/bpython/test/test_curtsies.py +++ b/bpython/test/test_curtsies.py @@ -1,10 +1,8 @@ -# coding: utf-8 -from __future__ import unicode_literals +import unittest from collections import namedtuple - from bpython.curtsies import combined_events -from bpython.test import FixLanguageTestCase as TestCase, unittest +from bpython.test import FixLanguageTestCase as TestCase import curtsies.events @@ -12,7 +10,7 @@ ScheduledEvent = namedtuple("ScheduledEvent", ["when", "event"]) -class EventGenerator(object): +class EventGenerator: def __init__(self, initial_events=(), scheduled_events=()): self._events = [] self._current_tick = 0 diff --git a/bpython/test/test_curtsies_coderunner.py b/bpython/test/test_curtsies_coderunner.py index 25844af59..bb5cec423 100644 --- a/bpython/test/test_curtsies_coderunner.py +++ b/bpython/test/test_curtsies_coderunner.py @@ -1,8 +1,7 @@ -# encoding: utf-8 - import sys +import unittest -from bpython.test import mock, unittest +from unittest import mock from bpython.curtsiesfrontend.coderunner import CodeRunner, FakeOutput @@ -20,8 +19,8 @@ def test_simple(self): request_refresh=lambda: self.orig_stdout.flush() or self.orig_stderr.flush() ) - stdout = FakeOutput(c, lambda *args, **kwargs: None) - stderr = FakeOutput(c, lambda *args, **kwargs: None) + 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") @@ -38,8 +37,8 @@ def test_exception(self): def ctrlc(): raise KeyboardInterrupt() - stdout = FakeOutput(c, lambda x: ctrlc()) - stderr = FakeOutput(c, lambda *args, **kwargs: None) + 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") @@ -48,8 +47,8 @@ def ctrlc(): class TestFakeOutput(unittest.TestCase): def assert_unicode(self, s): - self.assertIsInstance(s, type(u"")) + self.assertIsInstance(s, str) def test_bytes(self): - out = FakeOutput(mock.Mock(), self.assert_unicode) + 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 2c68b6092..fdb9dcad4 100644 --- a/bpython/test/test_curtsies_painting.py +++ b/bpython/test/test_curtsies_painting.py @@ -1,18 +1,21 @@ -# coding: utf8 - -from __future__ import unicode_literals import itertools import os import pydoc import string import sys -from contextlib import contextmanager -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 bpython.curtsiesfrontend.events import RefreshRequestEvent -from bpython.test import mock from bpython import config, inspection from bpython.curtsiesfrontend.repl import BaseRepl from bpython.curtsiesfrontend import replpainter @@ -24,8 +27,7 @@ def setup_config(): - config_struct = config.Struct() - config.loadini(config_struct, TEST_CONFIG) + config_struct = config.Config(TEST_CONFIG) config_struct.cli_suggestion_width = 1 return config_struct @@ -33,7 +35,14 @@ def setup_config(): class ClearEnviron(TestCase): @classmethod def setUpClass(cls): - cls.mock_environ = mock.patch.dict("os.environ", {}, clear=True) + 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() @@ -43,13 +52,13 @@ def tearDownClass(cls): TestCase.tearDownClass() -class CurtsiesPaintingTest(FormatStringTest, ClearEnviron): +class CurtsiesPaintingTest(ClearEnviron): def setUp(self): class TestRepl(BaseRepl): def _request_refresh(inner_self): pass - self.repl = TestRepl(config=setup_config()) + self.repl = TestRepl(setup_config(), cast(CursorAwareWindow, None)) self.repl.height, self.repl.width = (5, 10) @property @@ -58,17 +67,29 @@ def locals(self): 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=None, **paint_kwargs ): array, cursor_pos = self.repl.paint(**paint_kwargs) - self.assertFSArraysEqualIgnoringFormatting(array, screen) + 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): @@ -77,7 +98,7 @@ def test_history_is_cleared(self): 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): @@ -92,18 +113,18 @@ def test_enter_text(self): + cyan(" ") + green("1") ), - cyan("Welcome to"), - ] + ], + 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(insert_into_history=False) - screen = fsarray([">>> 1 + 1", "2", "Welcome to"]) + 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 @@ -112,22 +133,14 @@ def test_completion(self): self.repl.height, self.repl.width = (5, 32) self.repl.current_line = "an" self.cursor_offset = 2 - if config.supports_box_chars(): - screen = [ + screen = self.process_box_characters( + [ ">>> an", "┌──────────────────────────────┐", - "│ and any( │", + "│ and anext( any( │", "└──────────────────────────────┘", - "Welcome to bpython! Press f", - ] - else: - screen = [ - ">>> an", - "+------------------------------+", - "| and any( |", - "+------------------------------+", - "Welcome to bpython! Press f", ] + ) self.assert_paint_ignoring_formatting(screen, (0, 4)) def test_argspec(self): @@ -153,7 +166,7 @@ def foo(x, y, z=10): + bold(cyan("10")) + yellow(")") ] - self.assertFSArraysEqual(fsarray(array), fsarray(screen)) + assertFSArraysEqual(fsarray(array), fsarray(screen)) def test_formatted_docstring(self): actual = replpainter.formatted_docstring( @@ -162,7 +175,7 @@ def test_formatted_docstring(self): config=setup_config(), ) expected = fsarray(["Returns the results", "", "Also has side effects"]) - self.assertFSArraysEqualIgnoringFormatting(actual, expected) + assertFSArraysEqualIgnoringFormatting(actual, expected) def test_unicode_docstrings(self): "A bit of a special case in Python 2" @@ -175,7 +188,7 @@ def foo(): foo.__doc__, 40, config=setup_config() ) expected = fsarray(["åß∂ƒ"]) - self.assertFSArraysEqualIgnoringFormatting(actual, expected) + assertFSArraysEqualIgnoringFormatting(actual, expected) def test_nonsense_docstrings(self): for docstring in [ @@ -188,7 +201,7 @@ def test_nonsense_docstrings(self): docstring, 40, config=setup_config() ) except Exception: - self.fail("bad docstring caused crash: {!r}".format(docstring)) + self.fail(f"bad docstring caused crash: {docstring!r}") def test_weird_boto_docstrings(self): # Boto does something like this. @@ -205,7 +218,7 @@ def foo(): wd = pydoc.getdoc(foo) actual = replpainter.formatted_docstring(wd, 40, config=setup_config()) expected = fsarray(["asdfåß∂ƒ"]) - self.assertFSArraysEqualIgnoringFormatting(actual, expected) + assertFSArraysEqualIgnoringFormatting(actual, expected) def test_paint_lasts_events(self): actual = replpainter.paint_last_events( @@ -216,7 +229,7 @@ def test_paint_lasts_events(self): else: expected = fsarray(["+-+", "|c|", "|b|", "+-+"]) - self.assertFSArraysEqualIgnoringFormatting(actual, expected) + assertFSArraysEqualIgnoringFormatting(actual, expected) @contextmanager @@ -248,7 +261,7 @@ def enter(self, line=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(insert_into_history=False) + self.repl.on_enter(new_code=False) self.assertEqual(self.repl.rl_history.entries, [""]) self.send_refreshes() @@ -264,7 +277,9 @@ class TestRepl(BaseRepl): def _request_refresh(inner_self): self.refresh() - self.repl = TestRepl(banner="", config=setup_config()) + self.repl = TestRepl( + setup_config(), cast(CursorAwareWindow, None), banner="" + ) self.repl.height, self.repl.width = (5, 32) def send_key(self, key): @@ -272,6 +287,35 @@ def send_key(self, 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" @@ -592,7 +636,7 @@ def test_cursor_stays_at_bottom_of_screen(self): self.repl.width = 50 self.repl.current_line = "__import__('random').__name__" with output_to_repl(self.repl): - self.repl.on_enter(insert_into_history=False) + self.repl.on_enter(new_code=False) screen = [">>> __import__('random').__name__", "'random'"] self.assert_paint_ignoring_formatting(screen) @@ -609,6 +653,7 @@ def test_cursor_stays_at_bottom_of_screen(self): 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, [""]) @@ -625,7 +670,8 @@ def test_unhighlight_paren_bugs(self): [ cyan(">>> ") + on_magenta(bold(red("("))), green("... ") + on_magenta(bold(red(")"))), - ] + ], + width=32, ) self.assert_paint(screen, (1, 5)) @@ -635,7 +681,8 @@ def test_unhighlight_paren_bugs(self): [ cyan(">>> ") + yellow("("), green("... ") + yellow(")") + bold(cyan(" ")), - ] + ], + width=32, ) self.assert_paint(screen, (1, 6)) @@ -662,7 +709,7 @@ def test_472(self): def completion_target(num_names, chars_in_first_name=1): - class Class(object): + class Class: pass if chars_in_first_name < 1: @@ -708,14 +755,16 @@ def test_simple(self): self.repl.current_line = "abc" self.repl.cursor_offset = 3 self.repl.process_event(".") - screen = [ - ">>> abc.", - "+------------------+", - "| aaaaaaaaaaaaaaaa |", - "| b |", - "| c |", - "+------------------+", - ] + screen = self.process_box_characters( + [ + ">>> abc.", + "┌──────────────────┐", + "│ aaaaaaaaaaaaaaaa │", + "│ b │", + "│ c │", + "└──────────────────┘", + ] + ) self.assert_paint_ignoring_formatting(screen, (0, 8)) def test_fill_screen(self): @@ -724,23 +773,25 @@ def test_fill_screen(self): self.repl.current_line = "abc" self.repl.cursor_offset = 3 self.repl.process_event(".") - screen = [ - ">>> abc.", - "+------------------+", - "| aaaaaaaaaaaaaaaa |", - "| b |", - "| c |", - "| d |", - "| e |", - "| f |", - "| g |", - "| h |", - "| i |", - "| j |", - "| k |", - "| l |", - "+------------------+", - ] + 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): @@ -750,37 +801,41 @@ def test_lower_on_screen(self): self.repl.current_line = "abc" self.repl.cursor_offset = 3 self.repl.process_event(".") - screen = [ - ">>> abc.", - "+------------------+", - "| aaaaaaaaaaaaaaaa |", - "| b |", - "| c |", - "| d |", - "| e |", - "| f |", - "| g |", - "| h |", - "| i |", - "| j |", - "| k |", - "| l |", - "+------------------+", - ] + 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 = [ - ">>> abc.", - "+------------------+", - "| aaaaaaaaaaaaaaaa |", - "| b |", - "| c |", - "+------------------+", - ] + screen = self.process_box_characters( + [ + ">>> abc.", + "┌──────────────────┐", + "│ aaaaaaaaaaaaaaaa │", + "│ b │", + "│ c │", + "└──────────────────┘", + ] + ) self.assert_paint_ignoring_formatting(screen) def test_at_bottom_of_screen(self): @@ -790,35 +845,39 @@ def test_at_bottom_of_screen(self): self.repl.current_line = "abc" self.repl.cursor_offset = 3 self.repl.process_event(".") - screen = [ - ">>> abc.", - "+------------------+", - "| aaaaaaaaaaaaaaaa |", - "| b |", - "| c |", - "| d |", - "| e |", - "| f |", - "| g |", - "| h |", - "| i |", - "| j |", - "| k |", - "| l |", - "+------------------+", - ] + 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 = [ - ">>> abc.", - "+------------------+", - "| aaaaaaaaaaaaaaaa |", - "| b |", - "| c |", - "+------------------+", - ] + 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 index c0a20fcc0..ede87460d 100644 --- a/bpython/test/test_curtsies_parser.py +++ b/bpython/test/test_curtsies_parser.py @@ -1,7 +1,3 @@ -# encoding: utf-8 - -from __future__ import unicode_literals - from bpython.test import unittest from bpython.curtsiesfrontend import parse from curtsies.fmtfuncs import yellow, cyan, green, bold diff --git a/bpython/test/test_curtsies_repl.py b/bpython/test/test_curtsies_repl.py index 23f4b25b9..59102f9e1 100644 --- a/bpython/test/test_curtsies_repl.py +++ b/bpython/test/test_curtsies_repl.py @@ -1,46 +1,38 @@ -# coding: utf-8 -from __future__ import unicode_literals - import code import os import sys import tempfile import io -from functools import partial +from typing import cast +import unittest + from contextlib import contextmanager -from six.moves import StringIO +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._py3compat import py3 from bpython.test import ( FixLanguageTestCase as TestCase, MagicIterMock, - mock, - unittest, TEST_CONFIG, ) from curtsies import events - -if py3: - from importlib import invalidate_caches -else: - - def invalidate_caches(): - """Does not exist before Python 3.3""" +from curtsies.window import CursorAwareWindow +from importlib import invalidate_caches def setup_config(conf): - config_struct = config.Struct() - config.loadini(config_struct, TEST_CONFIG) + config_struct = config.Config(TEST_CONFIG) for key, value in conf.items(): if not hasattr(config_struct, key): - raise ValueError("%r is not a valid config attribute" % (key,)) + raise ValueError(f"{key!r} is not a valid config attribute") setattr(config_struct, key, value) return config_struct @@ -78,6 +70,8 @@ def test_external_communication(self): 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): @@ -94,8 +88,7 @@ def test_last_word(self): self.assertEqual(curtsiesrepl._last_word("a"), "a") self.assertEqual(curtsiesrepl._last_word("a b"), "b") - # this is the behavior of bash - not currently implemented - @unittest.skip + @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") @@ -110,10 +103,7 @@ def test_get_last_word_with_prev_line(self): def mock_next(obj, return_value): - if py3: - obj.__next__.return_value = return_value - else: - obj.next.return_value = return_value + obj.__next__.return_value = return_value class TestCurtsiesReplTab(TestCase): @@ -232,7 +222,7 @@ def test_list_win_not_visible_and_match_selected_if_one_option(self): # from http://stackoverflow.com/a/17981937/398212 - thanks @rkennedy @contextmanager def captured_output(): - new_out, new_err = StringIO(), StringIO() + 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 @@ -243,7 +233,9 @@ def captured_output(): def create_repl(**kwargs): config = setup_config({"editor": "true"}) - repl = curtsiesrepl.BaseRepl(config=config, **kwargs) + repl = curtsiesrepl.BaseRepl( + config, cast(CursorAwareWindow, None), **kwargs + ) os.environ["PAGER"] = "true" os.environ.pop("PYTHONSTARTUP", None) repl.width = 50 @@ -255,7 +247,6 @@ class TestFutureImports(TestCase): def test_repl(self): repl = create_repl() with captured_output() as (out, err): - repl.push("from __future__ import division") repl.push("1 / 2") self.assertEqual(out.getvalue(), "0.5\n") @@ -263,7 +254,6 @@ 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("from __future__ import division\n") f.write("print(1/2)\n") f.flush() args.exec_code(interp, [f.name]) @@ -313,7 +303,7 @@ def test_simple(self): self.assertEqual(self.repl.predicted_indent("def asdf():"), 4) self.assertEqual(self.repl.predicted_indent("def asdf(): return 7"), 0) - @unittest.skip + @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) @@ -418,7 +408,7 @@ def setUp(self): self.repl.pager = self.assert_pager_gets_unicode def assert_pager_gets_unicode(self, text): - self.assertIsInstance(text, type("")) + self.assertIsInstance(text, str) def test_help(self): self.repl.pager(self.repl.help_text()) @@ -445,11 +435,10 @@ def setUp(self): self.repl = create_repl() def write_startup_file(self, fname, encoding): - with io.open(fname, mode="wt", encoding=encoding) as f: + with open(fname, mode="w", encoding=encoding) as f: f.write("# coding: ") f.write(encoding) f.write("\n") - f.write("from __future__ import unicode_literals\n") f.write('a = "äöü"\n') def test_startup_event_utf8(self): diff --git a/bpython/test/test_filewatch.py b/bpython/test/test_filewatch.py index 2a3b6f0de..67b29f943 100644 --- a/bpython/test/test_filewatch.py +++ b/bpython/test/test_filewatch.py @@ -1,6 +1,5 @@ -# encoding: utf-8 - import os +import unittest try: import watchdog @@ -10,7 +9,7 @@ except ImportError: has_watchdog = False -from bpython.test import mock, unittest +from unittest import mock @unittest.skipUnless(has_watchdog, "watchdog required") diff --git a/bpython/test/test_history.py b/bpython/test/test_history.py index f095104e3..d810cf6be 100644 --- a/bpython/test/test_history.py +++ b/bpython/test/test_history.py @@ -1,18 +1,14 @@ -# encoding: utf-8 - -import io -import os - -from six.moves import range +import tempfile +import unittest +from pathlib import Path from bpython.config import getpreferredencoding from bpython.history import History -from bpython.test import unittest class TestHistory(unittest.TestCase): def setUp(self): - self.history = History("#%d" % x for x in range(1000)) + self.history = History(f"#{x}" for x in range(1000)) def test_is_at_start(self): self.history.first() @@ -89,10 +85,11 @@ def test_reset(self): class TestHistoryFileAccess(unittest.TestCase): def setUp(self): - self.filename = "history_temp_file" + self.tempdir = tempfile.TemporaryDirectory() + self.filename = Path(self.tempdir.name) / "history_temp_file" self.encoding = getpreferredencoding() - with io.open( + with open( self.filename, "w", encoding=self.encoding, errors="ignore" ) as f: f.write(b"#1\n#2\n".decode()) @@ -114,21 +111,17 @@ def test_append_reload_and_write(self): def test_save(self): history = History() - history.entries = [] - for line in ["#1", "#2", "#3", "#4"]: + 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) - # empty the list of entries and load again from the file - history.entries = [""] + # load again from the file + history = History() history.load(self.filename, self.encoding) self.assertEqual(history.entries, ["#3", "#4"]) def tearDown(self): - try: - os.remove(self.filename) - except OSError: - pass + self.tempdir = None diff --git a/bpython/test/test_importcompletion.py b/bpython/test/test_importcompletion.py index 0de09e799..814d3c312 100644 --- a/bpython/test/test_importcompletion.py +++ b/bpython/test/test_importcompletion.py @@ -1,61 +1,223 @@ -# encoding: utf-8 +import os +import tempfile +import unittest -from __future__ import unicode_literals - -from bpython import importcompletion -from bpython.test 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 = [ + self.module_gatherer = ModuleGatherer() + self.module_gatherer.modules = [ "zzabc", "zzabd", "zzefg", "zzabc.e", "zzabc.f", + "zzefg.a1", + "zzefg.a2", ] - def tearDown(self): - importcompletion.modules = self.original_modules - def test_simple_completion(self): self.assertSetEqual( - importcompletion.complete(10, "import zza"), set(["zzabc", "zzabd"]) + 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_package_completion(self): + 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( - importcompletion.complete(13, "import zzabc."), - set(["zzabc.e", "zzabc.f"]), + 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): - @classmethod - def setUpClass(cls): - for _ in importcompletion.find_iterator: + def setUp(self): + self.module_gatherer = ModuleGatherer() + while self.module_gatherer.find_coroutine(): pass __import__("sys") __import__("os") - @classmethod - def tearDownClass(cls): - importcompletion.find_iterator = importcompletion.find_all_modules() - importcompletion.modules = set() - def test_from_attribute(self): self.assertSetEqual( - importcompletion.complete(19, "from sys import arg"), set(["argv"]) + self.module_gatherer.complete(19, "from sys import arg"), {"argv"} ) def test_from_attr_module(self): self.assertSetEqual( - importcompletion.complete(9, "from os.p"), set(["os.path"]) + self.module_gatherer.complete(9, "from os.p"), {"os.path"} ) def test_from_package(self): self.assertSetEqual( - importcompletion.complete(17, "from xml import d"), set(["dom"]) + 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 096ae4cb0..30e911021 100644 --- a/bpython/test/test_inspection.py +++ b/bpython/test/test_inspection.py @@ -1,41 +1,40 @@ -# -*- coding: utf-8 -*- - +import inspect import os +import sys +import unittest +from collections.abc import Sequence +from typing import List -from bpython._py3compat import py3 from bpython import inspection -from bpython.test import unittest from bpython.test.fodder import encoding_ascii from bpython.test.fodder import encoding_latin1 from bpython.test.fodder import encoding_utf8 +pypy = "PyPy" in sys.version + +try: + import numpy +except ImportError: + numpy = None # type: ignore + -foo_ascii_only = u'''def foo(): +foo_ascii_only = '''def foo(): """Test""" pass ''' -foo_non_ascii = u'''def foo(): +foo_non_ascii = '''def foo(): """Test äöü""" pass ''' -class OldCallable: +class Callable: def __call__(self): pass -class Callable(object): - def __call__(self): - pass - - -class OldNoncallable: - pass - - -class Noncallable(object): +class Noncallable: pass @@ -43,63 +42,28 @@ def spam(): pass -class CallableMethod(object): +class CallableMethod: def method(self): pass class TestInspection(unittest.TestCase): - def test_is_callable(self): - 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)) - self.assertTrue(inspection.is_callable(CallableMethod().method)) - - @unittest.skipIf(py3, "old-style classes only exist in Python 2") - def test_is_new_style_py2(self): - self.assertTrue(inspection.is_new_style(spam)) - self.assertTrue(inspection.is_new_style(Noncallable)) - self.assertFalse(inspection.is_new_style(OldNoncallable)) - self.assertTrue(inspection.is_new_style(Noncallable())) - self.assertFalse(inspection.is_new_style(OldNoncallable())) - self.assertTrue(inspection.is_new_style(None)) - - @unittest.skipUnless(py3, "only in Python 3 are all classes new-style") - def test_is_new_style_py3(self): - self.assertTrue(inspection.is_new_style(spam)) - self.assertTrue(inspection.is_new_style(Noncallable)) - self.assertTrue(inspection.is_new_style(OldNoncallable)) - self.assertTrue(inspection.is_new_style(Noncallable())) - self.assertTrue(inspection.is_new_style(OldNoncallable())) - self.assertTrue(inspection.is_new_style(None)) - def test_parsekeywordpairs(self): # See issue #109 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.getfuncprops("fails", fails) + self.assertIsNotNone(argspec) defaults = argspec.argspec.defaults - self.assertEqual(str(defaults[0]), default_arg_repr) + self.assertEqual(str(defaults[0]), '["-a", "-b"]') def test_pasekeywordpairs_string(self): def spam(eggs="foo, bar"): pass defaults = inspection.getfuncprops("spam", spam).argspec.defaults - self.assertEqual(repr(defaults[0]), "'foo, bar'") + self.assertEqual(repr(defaults[0]), '"foo, bar"') def test_parsekeywordpairs_multiple_keywords(self): def spam(eggs=23, foobar="yay"): @@ -107,7 +71,14 @@ def spam(eggs=23, foobar="yay"): 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") @@ -122,24 +93,16 @@ def test_get_encoding_utf8(self): self.assertEqual(inspection.get_encoding(encoding_utf8.foo), "utf-8") def test_get_source_ascii(self): - self.assertEqual( - inspection.get_source_unicode(encoding_ascii.foo), foo_ascii_only - ) + self.assertEqual(inspect.getsource(encoding_ascii.foo), foo_ascii_only) def test_get_source_utf8(self): - self.assertEqual( - inspection.get_source_unicode(encoding_utf8.foo), foo_non_ascii - ) + self.assertEqual(inspect.getsource(encoding_utf8.foo), foo_non_ascii) def test_get_source_latin1(self): - self.assertEqual( - inspection.get_source_unicode(encoding_latin1.foo), foo_non_ascii - ) + self.assertEqual(inspect.getsource(encoding_latin1.foo), foo_non_ascii) def test_get_source_file(self): - path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "fodder" - ) + path = os.path.join(os.path.dirname(__file__), "fodder") encoding = inspection.get_encoding_file( os.path.join(path, "encoding_ascii.py") @@ -154,6 +117,245 @@ def test_get_source_file(self): ) 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__": unittest.main() diff --git a/bpython/test/test_interpreter.py b/bpython/test/test_interpreter.py index 4bdb67551..e5bc08956 100644 --- a/bpython/test/test_interpreter.py +++ b/bpython/test/test_interpreter.py @@ -1,153 +1,89 @@ -# -*- coding: utf-8 -*- - -from __future__ import unicode_literals - import sys -import re -from textwrap import dedent +import unittest from curtsies.fmtfuncs import bold, green, magenta, cyan, red, plain from bpython.curtsiesfrontend import interpreter -from bpython._py3compat import py3 -from bpython.test import mock, unittest pypy = "PyPy" in sys.version -def remove_ansi(s): - return re.sub(r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]".encode("ascii"), b"", s) +class Interpreter(interpreter.Interp): + def __init__(self): + super().__init__() + self.a = [] + self.write = self.a.append class TestInterpreter(unittest.TestCase): - def interp_errlog(self): - i = interpreter.Interp() - a = [] - i.write = a.append - return i, a - - def err_lineno(self, a): - strings = [x.__unicode__() for x in a] - for line in reversed(strings): - clean_line = remove_ansi(line) - m = re.search(r"line (\d+)[,]", clean_line) - if m: - return int(m.group(1)) - return None - def test_syntaxerror(self): - i, a = self.interp_errlog() + i = Interpreter() i.runsource("1.1.1.1") - if sys.version_info[:2] >= (3, 8): - expected = ( - " File " - + green('""') - + ", line " - + bold(magenta("1")) - + "\n 1.1.1.1\n ^\n" - + bold(red("SyntaxError")) - + ": " - + cyan("invalid syntax") - + "\n" - ) - elif pypy: - expected = ( - " File " - + green('""') - + ", line " - + bold(magenta("1")) - + "\n 1.1.1.1\n ^\n" - + bold(red("SyntaxError")) - + ": " - + cyan("invalid syntax") - + "\n" - ) - else: - expected = ( - " File " - + green('""') - + ", line " - + bold(magenta("1")) - + "\n 1.1.1.1\n ^\n" - + bold(red("SyntaxError")) - + ": " - + cyan("invalid syntax") - + "\n" - ) - - self.assertMultiLineEqual(str(plain("").join(a)), str(expected)) - self.assertEqual(plain("").join(a), expected) - - def test_traceback(self): - i, a = self.interp_errlog() - - def f(): - return 1 / 0 - - def gfunc(): - return f() - - i.runsource("gfunc()") - - if pypy and not py3: - global_not_found = "global name 'gfunc' is not defined" - else: - global_not_found = "name 'gfunc' is not defined" - expected = ( - "Traceback (most recent call last):\n File " + " File " + green('""') + ", line " + bold(magenta("1")) - + ", in " - + cyan("") - + "\n gfunc()\n" - + bold(red("NameError")) + + "\n 1.1.1.1\n ^^\n" + + bold(red("SyntaxError")) + ": " - + cyan(global_not_found) + + cyan("invalid syntax") + "\n" ) + a = i.a self.assertMultiLineEqual(str(plain("").join(a)), str(expected)) self.assertEqual(plain("").join(a), expected) - @unittest.skipIf(py3, "runsource() accepts only unicode in Python 3") - def test_runsource_bytes(self): - i = interpreter.Interp(encoding=b"latin-1") - - i.runsource("a = b'\xfe'".encode("latin-1"), encode=False) - self.assertIsInstance(i.locals["a"], str) - self.assertEqual(i.locals["a"], b"\xfe") - - i.runsource("b = u'\xfe'".encode("latin-1"), encode=False) - self.assertIsInstance(i.locals["b"], unicode) - self.assertEqual(i.locals["b"], "\xfe") + def test_traceback(self): + i = Interpreter() - @unittest.skipUnless(py3, "Only a syntax error in Python 3") - def test_runsource_bytes_over_128_syntax_error_py3(self): - i = interpreter.Interp(encoding=b"latin-1") - i.showsyntaxerror = mock.Mock(return_value=None) + def f(): + return 1 / 0 - i.runsource("a = b'\xfe'") - i.showsyntaxerror.assert_called_with(mock.ANY) + def gfunc(): + return f() - @unittest.skipIf(py3, "encode is Python 2 only") - def test_runsource_bytes_over_128_syntax_error_py2(self): - i = interpreter.Interp(encoding=b"latin-1") + i.runsource("gfunc()") - i.runsource(b"a = b'\xfe'") - self.assertIsInstance(i.locals["a"], type(b"")) - self.assertEqual(i.locals["a"], b"\xfe") + global_not_found = "name 'gfunc' is not defined" - @unittest.skipIf(py3, "encode is Python 2 only") - def test_runsource_unicode(self): - i = interpreter.Interp(encoding=b"latin-1") + 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" + ) - i.runsource("a = u'\xfe'") - self.assertIsInstance(i.locals["a"], type("")) - self.assertEqual(i.locals["a"], "\xfe") + 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" @@ -157,155 +93,3 @@ def test_getsource_works_on_interactively_defined_functions(self): inspected_source = inspect.getsource(i.locals["foo"]) self.assertEqual(inspected_source, source) - - @unittest.skipIf(py3, "encode only does anything in Python 2") - def test_runsource_unicode_autoencode_and_noencode(self): - """error line numbers should be fixed""" - - # Since correct behavior for unicode is the same - # for auto and False, run the same tests - for encode in ["auto", False]: - i, a = self.interp_errlog() - i.runsource("[1 + 1,\nabcd]", encode=encode) - self.assertEqual(self.err_lineno(a), 2) - - i, a = self.interp_errlog() - i.runsource("[1 + 1,\nabcd]", encode=encode) - self.assertEqual(self.err_lineno(a), 2) - - i, a = self.interp_errlog() - i.runsource("#encoding: utf-8\nabcd", encode=encode) - self.assertEqual(self.err_lineno(a), 2) - - i, a = self.interp_errlog() - i.runsource( - "#encoding: utf-8\nabcd", filename="x.py", encode=encode - ) - self.assertIn( - "SyntaxError:", - "".join("".join(remove_ansi(x.__unicode__()) for x in a)), - ) - - @unittest.skipIf(py3, "encode only does anything in Python 2") - def test_runsource_unicode_encode(self): - i, _ = self.interp_errlog() - with self.assertRaises(ValueError): - i.runsource("1 + 1", encode=True) - - i, _ = self.interp_errlog() - with self.assertRaises(ValueError): - i.runsource("1 + 1", filename="x.py", encode=True) - - @unittest.skipIf(py3, "encode only does anything in Python 2") - def test_runsource_bytestring_noencode(self): - i, a = self.interp_errlog() - i.runsource(b"[1 + 1,\nabcd]", encode=False) - self.assertEqual(self.err_lineno(a), 2) - - i, a = self.interp_errlog() - i.runsource(b"[1 + 1,\nabcd]", filename="x.py", encode=False) - self.assertEqual(self.err_lineno(a), 2) - - i, a = self.interp_errlog() - i.runsource( - dedent( - b"""\ - #encoding: utf-8 - - ["%s", - abcd]""" - % ("åß∂ƒ".encode("utf8"),) - ), - encode=False, - ) - self.assertEqual(self.err_lineno(a), 4) - - i, a = self.interp_errlog() - i.runsource( - dedent( - b"""\ - #encoding: utf-8 - - ["%s", - abcd]""" - % ("åß∂ƒ".encode("utf8"),) - ), - filename="x.py", - encode=False, - ) - self.assertEqual(self.err_lineno(a), 4) - - @unittest.skipIf(py3, "encode only does anything in Python 2") - def test_runsource_bytestring_encode(self): - i, a = self.interp_errlog() - i.runsource(b"[1 + 1,\nabcd]", encode=True) - self.assertEqual(self.err_lineno(a), 2) - - i, a = self.interp_errlog() - with self.assertRaises(ValueError): - i.runsource(b"[1 + 1,\nabcd]", filename="x.py", encode=True) - - i, a = self.interp_errlog() - i.runsource( - dedent( - b"""\ - #encoding: utf-8 - - [u"%s", - abcd]""" - % ("åß∂ƒ".encode("utf8"),) - ), - encode=True, - ) - self.assertEqual(self.err_lineno(a), 4) - - i, a = self.interp_errlog() - with self.assertRaises(ValueError): - i.runsource( - dedent( - b"""\ - #encoding: utf-8 - - [u"%s", - abcd]""" - % ("åß∂ƒ".encode("utf8"),) - ), - filename="x.py", - encode=True, - ) - - @unittest.skipIf(py3, "encode only does anything in Python 2") - def test_runsource_bytestring_autoencode(self): - i, a = self.interp_errlog() - i.runsource(b"[1 + 1,\n abcd]") - self.assertEqual(self.err_lineno(a), 2) - - i, a = self.interp_errlog() - i.runsource(b"[1 + 1,\nabcd]", filename="x.py") - self.assertEqual(self.err_lineno(a), 2) - - i, a = self.interp_errlog() - i.runsource( - dedent( - b"""\ - #encoding: utf-8 - - [u"%s", - abcd]""" - % ("åß∂ƒ".encode("utf8"),) - ) - ) - self.assertEqual(self.err_lineno(a), 4) - - i, a = self.interp_errlog() - i.runsource( - dedent( - b"""\ - #encoding: utf-8 - - [u"%s", - abcd]""" - % ("åß∂ƒ".encode("utf8"),) - ) - ) - self.assertEqual(self.err_lineno(a), 4) diff --git a/bpython/test/test_keys.py b/bpython/test/test_keys.py index 75f62353c..23e8798cc 100644 --- a/bpython/test/test_keys.py +++ b/bpython/test/test_keys.py @@ -1,7 +1,6 @@ -# encoding: utf-8 +import unittest from bpython import keys -from bpython.test import unittest class TestCLIKeys(unittest.TestCase): diff --git a/bpython/test/test_line_properties.py b/bpython/test/test_line_properties.py index 35169c6ac..017978277 100644 --- a/bpython/test/test_line_properties.py +++ b/bpython/test/test_line_properties.py @@ -1,9 +1,9 @@ -# encoding: utf-8 - import re +from typing import Optional, Tuple +import unittest -from bpython.test import unittest from bpython.line import ( + LinePart, current_word, current_dict_key, current_dict, @@ -27,7 +27,7 @@ def cursor(s): 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: @@ -43,16 +43,16 @@ def decode(s): 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[">"]]) + return (d["|"], s), LinePart(d["<"], d[">"], s[d["<"] : d[">"]]) else: return (d["|"], s), None -def line_with_cursor(cursor_offset, line): +def line_with_cursor(cursor_offset: int, line: str) -> str: 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 @@ -60,7 +60,9 @@ 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:] @@ -109,19 +111,25 @@ def test_I(self): 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") @@ -180,10 +188,24 @@ def test_simple(self): 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): diff --git a/bpython/test/test_manual_readline.py b/bpython/test/test_manual_readline.py index aedfddfe2..445e78b30 100644 --- a/bpython/test/test_manual_readline.py +++ b/bpython/test/test_manual_readline.py @@ -1,4 +1,4 @@ -# encoding: utf-8 +import unittest from bpython.curtsiesfrontend.manual_readline import ( left_arrow, @@ -18,7 +18,6 @@ UnconfiguredEdits, delete_word_from_cursor_back, ) -from bpython.test import unittest class TestManualReadline(unittest.TestCase): @@ -324,7 +323,7 @@ def g(cursor_offset, line): self.edits.add_config_attr("att", f) self.assertNotIn("att", self.edits) - class config(object): + class config: att = "c" key_dispatch = {"c": "c"} diff --git a/bpython/test/test_preprocess.py b/bpython/test/test_preprocess.py index 7fb782989..8e8a36304 100644 --- a/bpython/test/test_preprocess.py +++ b/bpython/test/test_preprocess.py @@ -1,35 +1,34 @@ -# encoding: utf-8 - -from code import compile_command as compiler -from functools import partial 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 import unittest from bpython.test.fodder import original, processed -skip = unittest.skip -preproc = partial(preprocess, compiler=compiler) +preproc = partial(preprocess, compiler=CommandCompiler()) def get_fodder_source(test_name): - pattern = r"#StartTest-%s\n(.*?)#EndTest" % (test_name,) - orig, xformed = [ + 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( - "Can't locate test %s in original fodder file" % (test_name,) + f"Can't locate test {test_name} in original fodder file" ) if not xformed: raise ValueError( - "Can't locate test %s in processed fodder file" % (test_name,) + f"Can't locate test {test_name} in processed fodder file" ) return orig.group(1), xformed.group(1) @@ -87,14 +86,14 @@ def test_empty_line_within_class(self): def test_blank_lines_in_for_loop(self): self.assertIndented("blank_lines_in_for_loop") - @skip( + @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") - @skip( + @unittest.skip( "More advanced technique required: need to try compiling and " "backtracking" ) diff --git a/bpython/test/test_repl.py b/bpython/test/test_repl.py index d76dadc60..a32ef90e8 100644 --- a/bpython/test/test_repl.py +++ b/bpython/test/test_repl.py @@ -1,27 +1,30 @@ -# encoding: utf-8 - -from itertools import islice -from six.moves import range import collections import inspect import os -import shutil import socket import sys import tempfile +import unittest -from bpython._py3compat import py3 -from bpython import config, repl, cli, autocomplete -from bpython.test import MagicIterMock, mock, FixLanguageTestCase as TestCase -from bpython.test import unittest, TEST_CONFIG +from typing import List, Tuple +from itertools import islice +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, +) pypy = "PyPy" in sys.version def setup_config(conf): - config_struct = config.Struct() - config.loadini(config_struct, TEST_CONFIG) + 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 @@ -37,16 +40,32 @@ def reset(self): class FakeRepl(repl.Repl): def __init__(self, conf=None): - repl.Repl.__init__(self, repl.Interpreter(), setup_config(conf)) - self.current_line = "" - self.cursor_offset = 0 + super().__init__(repl.Interpreter(), setup_config(conf)) + self._current_line = "" + self._cursor_offset = 0 + def _get_current_line(self) -> str: + return self._current_line -class FakeCliRepl(cli.CLIRepl, FakeRepl): - def __init__(self): - self.s = "" - self.cpos = 0 - self.rl_history = FakeHistory() + def _set_current_line(self, val: str) -> None: + self._current_line = val + + def _get_cursor_offset(self) -> int: + return self._cursor_offset + + def _set_cursor_offset(self, val: int) -> None: + self._cursor_offset = val + + def getstdout(self) -> str: + raise NotImplementedError + + def reprint_line( + self, lineno: int, tokens: list[tuple[repl._TokenType, str]] + ) -> None: + raise NotImplementedError + + def reevaluate(self): + raise NotImplementedError class TestMatchesIterator(unittest.TestCase): @@ -102,7 +121,7 @@ def test_update(self): newmatches = ["string", "str", "set"] completer = mock.Mock() - completer.locate.return_value = (0, 1, "s") + completer.locate.return_value = LinePart(0, 1, "s") self.matches_iterator.update(1, "s", newmatches, completer) newslice = islice(newmatches, 0, 3) @@ -111,7 +130,7 @@ def test_update(self): def test_cur_line(self): completer = mock.Mock() - completer.locate.return_value = ( + completer.locate.return_value = LinePart( 0, self.matches_iterator.orig_cursor_offset, self.matches_iterator.orig_line, @@ -158,9 +177,10 @@ def set_input_line(self, line): self.repl.cursor_offset = len(line) def test_func_name(self): - for (line, expected_name) in [ + for line, expected_name in [ ("spam(", "spam"), - ("spam(map([]", "map"), + # map pydoc has no signature in pypy + ("spam(any([]", "any") if pypy else ("spam(map([]", "map"), ("spam((), ", "spam"), ]: self.set_input_line(line) @@ -168,9 +188,10 @@ def test_func_name(self): self.assertEqual(self.repl.current_func.__name__, expected_name) def test_func_name_method_issue_479(self): - for (line, expected_name) in [ + for line, expected_name in [ ("o.spam(", "spam"), - ("o.spam(map([]", "map"), + # 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) @@ -203,6 +224,7 @@ def test_lambda_position(self): # Argument position self.assertEqual(self.repl.arg_pos, 1) + @unittest.skipIf(pypy, "range pydoc has no signature in pypy") def test_issue127(self): self.set_input_line("x=range(") self.assertTrue(self.repl.get_args()) @@ -285,7 +307,7 @@ def assert_get_source_error_for_current_function(self, func, msg): try: self.repl.get_source_of_current_name() except repl.SourceNotFound as e: - self.assertEqual(e.args[0], msg) + self.assertEqual(msg, e.args[0]) else: self.fail("Should have raised SourceNotFound") @@ -310,9 +332,14 @@ def test_current_function_cpython(self): self.assert_get_source_error_for_current_function( collections.defaultdict.copy, "No source code found for INPUTLINE" ) - self.assert_get_source_error_for_current_function( - collections.defaultdict, "could not find class definition" - ) + 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 @@ -331,15 +358,11 @@ def setUp(self): self.repl.config.editor = "true" def test_create_config(self): - tmp_dir = tempfile.mkdtemp() - try: - config_path = os.path.join(tmp_dir, "newdir", "config") + 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(os.path.exists(config_path)) - finally: - shutil.rmtree(tmp_dir) - self.assertFalse(os.path.exists(config_path)) + self.assertTrue(config_path.exists()) class TestRepl(unittest.TestCase): @@ -368,7 +391,9 @@ def test_push(self): # COMPLETE TESTS # 1. Global tests def test_simple_global_complete(self): - self.repl = FakeRepl({"autocomplete_mode": autocomplete.SIMPLE}) + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.SIMPLE} + ) self.set_input_line("d") self.assertTrue(self.repl.complete()) @@ -379,7 +404,9 @@ def test_simple_global_complete(self): ) def test_substring_global_complete(self): - self.repl = FakeRepl({"autocomplete_mode": autocomplete.SUBSTRING}) + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.SUBSTRING} + ) self.set_input_line("time") self.assertTrue(self.repl.complete()) @@ -389,21 +416,23 @@ def test_substring_global_complete(self): ) def test_fuzzy_global_complete(self): - self.repl = FakeRepl({"autocomplete_mode": autocomplete.FUZZY}) + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.FUZZY} + ) self.set_input_line("doc") self.assertTrue(self.repl.complete()) self.assertTrue(hasattr(self.repl.matches_iter, "matches")) self.assertEqual( self.repl.matches_iter.matches, - ["UnboundLocalError(", "__doc__"] - if not py3 - else ["ChildProcessError(", "UnboundLocalError(", "__doc__"], + ["ChildProcessError(", "UnboundLocalError(", "__doc__"], ) # 2. Attribute tests def test_simple_attribute_complete(self): - self.repl = FakeRepl({"autocomplete_mode": autocomplete.SIMPLE}) + 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" @@ -415,7 +444,9 @@ def test_simple_attribute_complete(self): self.assertEqual(self.repl.matches_iter.matches, ["Foo.bar"]) def test_substring_attribute_complete(self): - self.repl = FakeRepl({"autocomplete_mode": autocomplete.SUBSTRING}) + 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" @@ -427,7 +458,9 @@ def test_substring_attribute_complete(self): self.assertEqual(self.repl.matches_iter.matches, ["Foo.baz"]) def test_fuzzy_attribute_complete(self): - self.repl = FakeRepl({"autocomplete_mode": autocomplete.FUZZY}) + 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" @@ -440,7 +473,9 @@ def test_fuzzy_attribute_complete(self): # 3. Edge cases def test_updating_namespace_complete(self): - self.repl = FakeRepl({"autocomplete_mode": autocomplete.SIMPLE}) + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.SIMPLE} + ) self.set_input_line("foo") self.repl.push("foobar = 2") @@ -449,7 +484,9 @@ def test_updating_namespace_complete(self): 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.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.SIMPLE} + ) self.set_input_line("_") self.assertTrue(self.repl.complete()) self.assertTrue(hasattr(self.repl.matches_iter, "matches")) @@ -457,7 +494,9 @@ def test_file_should_not_appear_in_complete(self): # 4. Parameter names def test_paremeter_name_completion(self): - self.repl = FakeRepl({"autocomplete_mode": autocomplete.SIMPLE}) + self.repl = FakeRepl( + {"autocomplete_mode": autocomplete.AutocompleteModes.SIMPLE} + ) self.set_input_line("foo(ab") code = "def foo(abc=1, abd=2, xyz=3):\n\tpass\n" @@ -470,141 +509,39 @@ def test_paremeter_name_completion(self): 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) -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.Mock(True) - - self.repl.s = "foo" - self.repl.addstr("bar") - self.assertEqual(self.repl.s, "foobar") - - self.repl.cpos = 3 - self.repl.addstr("buzz") - self.assertEqual(self.repl.s, "foobuzzbar") - - -class TestCliReplTab(unittest.TestCase): - def setUp(self): - self.repl = FakeCliRepl() - - # 3 Types of tab complete - def test_simple_tab_complete(self): - self.repl.matches_iter = MagicIterMock() - if py3: - self.repl.matches_iter.__bool__.return_value = False - else: - self.repl.matches_iter.__nonzero__.return_value = False - self.repl.complete = mock.Mock() - self.repl.print_line = mock.Mock() - self.repl.matches_iter.is_cseq.return_value = False - self.repl.show_list = mock.Mock() - self.repl.funcprops = mock.Mock() - self.repl.arg_pos = mock.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") - - @unittest.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") - - @unittest.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.Mock() - self.repl.config.tab_length = 4 - self.repl.complete = mock.Mock() - self.repl.print_line = mock.Mock() - self.repl.tab() - self.assertEqual(self.repl.s, " ") - - def test_back_parameter(self): - self.repl.matches_iter = mock.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.Mock() - self.repl.funcprops = mock.Mock() - self.repl.arg_pos = mock.Mock() - self.repl.matches_iter.cur_line.return_value = (None, "previtem") - self.repl.print_line = mock.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 - @unittest.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") - - @unittest.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.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.Mock() - self.repl.tab() - self.assertEqual(self.repl.s, "foo") - - @unittest.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") - - @unittest.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__": diff --git a/bpython/test/test_simpleeval.py b/bpython/test/test_simpleeval.py index 9065f75cc..8bdb19296 100644 --- a/bpython/test/test_simpleeval.py +++ b/bpython/test/test_simpleeval.py @@ -1,17 +1,13 @@ -# -*- coding: utf-8 -*- - import ast import numbers +import sys +import unittest from bpython.simpleeval import ( simple_eval, evaluate_current_expression, EvaluationError, - safe_get_attribute, - safe_get_attribute_new_style, ) -from bpython.test import unittest -from bpython._py3compat import py3 class TestSimpleEval(unittest.TestCase): @@ -22,24 +18,27 @@ 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) - self.assertEqual(simple_eval("a", {"a": 1}), 1) def test_name_lookup(self): - """Names can be lookup up in a namespace""" + """Names can be looked up in a namespace""" self.assertEqual(simple_eval("a", {"a": 1}), 1) self.assertEqual(simple_eval("map"), map) - self.assertEqual(simple_eval("a[b]", {"a": {"c": 1}, "b": "c"}), 1) - def test_allow_name_lookup(self): - """Names can be lookup up in a namespace""" - self.assertEqual(simple_eval("a", {"a": 1}), 1) + 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(object): + class FakeDict: pass with self.assertRaises(ValueError): @@ -92,7 +91,7 @@ def test_nonexistant_names_raise(self): simple_eval("a") def test_attribute_access(self): - class Foo(object): + class Foo: abc = 1 self.assertEqual(simple_eval("foo.abc", {"foo": Foo()}), 1) @@ -135,125 +134,5 @@ def test_with_namespace(self): self.assertCannotEval("a[1].a|bc", {}) -class A(object): - a = "a" - - -class B(A): - b = "b" - - -class Property(object): - @property - def prop(self): - raise AssertionError("Property __get__ executed") - - -class Slots(object): - __slots__ = ["s1", "s2", "s3"] - - if not py3: - - @property - def s3(self): - raise AssertionError("Property __get__ executed") - - -class SlotsSubclass(Slots): - @property - def s4(self): - raise AssertionError("Property __get__ executed") - - -class OverriddenGetattr(object): - def __getattr__(self, attr): - raise AssertionError("custom __getattr__ executed") - - a = 1 - - -class OverriddenGetattribute(object): - def __getattribute__(self, attr): - raise AssertionError("custom __getattribute__ executed") - - a = 1 - - -class OverriddenMRO(object): - def __mro__(self): - raise AssertionError("custom mro executed") - - a = 1 - - -member_descriptor = type(Slots.s1) - - -class TestSafeGetAttribute(unittest.TestCase): - def test_lookup_on_object(self): - a = A() - a.x = 1 - self.assertEqual(safe_get_attribute_new_style(a, "x"), 1) - self.assertEqual(safe_get_attribute_new_style(a, "a"), "a") - b = B() - b.y = 2 - self.assertEqual(safe_get_attribute_new_style(b, "y"), 2) - self.assertEqual(safe_get_attribute_new_style(b, "a"), "a") - self.assertEqual(safe_get_attribute_new_style(b, "b"), "b") - - def test_avoid_running_properties(self): - p = Property() - self.assertEqual(safe_get_attribute_new_style(p, "prop"), Property.prop) - - @unittest.skipIf(py3, "Old-style classes not in Python 3") - def test_raises_on_old_style_class(self): - class Old: - pass - - with self.assertRaises(ValueError): - safe_get_attribute_new_style(Old, "asdf") - - def test_lookup_with_slots(self): - s = Slots() - s.s1 = "s1" - self.assertEqual(safe_get_attribute(s, "s1"), "s1") - self.assertIsInstance( - safe_get_attribute_new_style(s, "s1"), member_descriptor - ) - with self.assertRaises(AttributeError): - safe_get_attribute(s, "s2") - self.assertIsInstance( - safe_get_attribute_new_style(s, "s2"), member_descriptor - ) - - def test_lookup_on_slots_classes(self): - sga = safe_get_attribute - 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) - - @unittest.skipIf(py3, "Py 3 doesn't allow slots and prop in same class") - def test_lookup_with_property_and_slots(self): - sga = safe_get_attribute - s = SlotsSubclass() - self.assertIsInstance(sga(Slots, "s3"), property) - self.assertEqual(safe_get_attribute(s, "s3"), Slots.__dict__["s3"]) - self.assertIsInstance(sga(SlotsSubclass, "s3"), property) - - def test_lookup_on_overridden_methods(self): - sga = safe_get_attribute - 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") - - if __name__ == "__main__": unittest.main() diff --git a/bpython/translations/__init__.py b/bpython/translations/__init__.py index 2c45ac5ce..069f34653 100644 --- a/bpython/translations/__init__.py +++ b/bpython/translations/__init__.py @@ -1,36 +1,25 @@ -# encoding: utf-8 - -from __future__ import absolute_import - import gettext import locale import os.path import sys +from typing import Optional, cast, List from .. import package_dir -from .._py3compat import py3 - -translator = None - -if py3: - - def _(message): - return translator.gettext(message) - def ngettext(singular, plural, n): - return translator.ngettext(singular, plural, n) +translator: gettext.NullTranslations = cast(gettext.NullTranslations, None) -else: +def _(message) -> str: + return translator.gettext(message) - def _(message): - return translator.ugettext(message) - def ngettext(singular, plural, n): - return translator.ungettext(singular, plural, n) +def ngettext(singular, plural, n): + return translator.ngettext(singular, plural, n) -def init(locale_dir=None, languages=None): +def init( + locale_dir: str | None = None, languages: list[str] | None = None +) -> None: try: locale.setlocale(locale.LC_ALL, "") except locale.Error: diff --git a/bpython/translations/bpython.pot b/bpython/translations/bpython.pot index 99f58d7e2..9237869da 100644 --- a/bpython/translations/bpython.pot +++ b/bpython/translations/bpython.pot @@ -1,14 +1,14 @@ # Translations template for bpython. -# Copyright (C) 2020 ORGANIZATION +# Copyright (C) 2021 ORGANIZATION # This file is distributed under the same license as the bpython project. -# FIRST AUTHOR , 2020. +# FIRST AUTHOR , 2021. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: bpython 0.19.dev37\n" -"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2020-01-05 13:08+0000\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" @@ -17,306 +17,331 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.8.0\n" -#: bpython/args.py:66 +#: 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:81 +#: bpython/args.py:127 msgid "Use CONFIG instead of default config file." msgstr "" -#: bpython/args.py:87 +#: bpython/args.py:133 msgid "Drop to bpython shell after running file instead of exiting." msgstr "" -#: bpython/args.py:95 +#: bpython/args.py:139 msgid "Don't flush the output to stdout." msgstr "" -#: bpython/args.py:101 +#: bpython/args.py:145 msgid "Print version and exit." msgstr "" -#: bpython/cli.py:324 bpython/urwid.py:561 -msgid "y" +#: bpython/args.py:152 +msgid "Set log level for logging" msgstr "" -#: bpython/cli.py:324 bpython/urwid.py:561 -msgid "yes" +#: bpython/args.py:157 +msgid "Log output file" msgstr "" -#: bpython/cli.py:1743 -msgid "Rewind" +#: bpython/args.py:168 +msgid "File to execute and additional arguments passed on to the executed script." msgstr "" -#: bpython/cli.py:1744 -msgid "Save" -msgstr "" - -#: bpython/cli.py:1745 -msgid "Pastebin" -msgstr "" - -#: bpython/cli.py:1746 -msgid "Pager" -msgstr "" - -#: bpython/cli.py:1747 -msgid "Show Source" +#: bpython/curtsiesfrontend/interaction.py:107 +#: bpython/urwid.py:539 +msgid "y" msgstr "" -#: bpython/cli.py:1994 -msgid "" -"WARNING: You are using `bpython-cli`, the curses backend for `bpython`. " -"This backend has been deprecated in version 0.19 and might disappear in a" -" future version." +#: bpython/urwid.py:539 +msgid "yes" msgstr "" -#: bpython/cli.py:2003 bpython/curtsies.py:208 bpython/urwid.py:1405 -msgid "" -"WARNING: You are using `bpython` on Python 2. Support for Python 2 has " -"been deprecated in version 0.19 and might disappear in a future version." +#: bpython/curtsies.py:201 +msgid "start by pasting lines of a file into session" msgstr "" -#: bpython/curtsies.py:152 -msgid "log debug messages to bpython.log" +#: bpython/curtsies.py:207 +msgid "curtsies arguments" msgstr "" -#: bpython/curtsies.py:158 -msgid "start by pasting lines of a file into session" +#: bpython/curtsies.py:208 +msgid "Additional arguments specific to the curtsies-based REPL." msgstr "" -#: bpython/history.py:231 +#: bpython/history.py:250 #, python-format msgid "Error occurred while writing to file %s (%s)" msgstr "" -#: bpython/paste.py:95 +#: bpython/paste.py:85 msgid "Helper program not found." msgstr "" -#: bpython/paste.py:97 +#: bpython/paste.py:87 msgid "Helper program could not be run." msgstr "" -#: bpython/paste.py:103 +#: bpython/paste.py:93 #, python-format msgid "Helper program returned non-zero exit status %d." msgstr "" -#: bpython/paste.py:108 +#: bpython/paste.py:98 msgid "No output from helper program." msgstr "" -#: bpython/paste.py:115 +#: bpython/paste.py:105 msgid "Failed to recognize the helper program's output as an URL." msgstr "" -#: bpython/repl.py:690 +#: bpython/repl.py:644 msgid "Nothing to get source of" msgstr "" -#: bpython/repl.py:695 +#: bpython/repl.py:649 #, python-format msgid "Cannot get source: %s" msgstr "" -#: bpython/repl.py:700 +#: bpython/repl.py:654 #, python-format msgid "Cannot access source of %r" msgstr "" -#: bpython/repl.py:702 +#: bpython/repl.py:656 #, python-format msgid "No source code found for %s" msgstr "" -#: bpython/repl.py:841 +#: bpython/repl.py:801 msgid "Save to file (Esc to cancel): " msgstr "" -#: bpython/repl.py:843 bpython/repl.py:846 bpython/repl.py:870 +#: bpython/repl.py:803 bpython/repl.py:806 bpython/repl.py:830 msgid "Save cancelled." msgstr "" -#: bpython/repl.py:857 +#: 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:865 +#: bpython/repl.py:825 msgid "overwrite" msgstr "" -#: bpython/repl.py:867 +#: bpython/repl.py:827 msgid "append" msgstr "" -#: bpython/repl.py:879 bpython/repl.py:1192 +#: bpython/repl.py:839 bpython/repl.py:1143 #, python-format msgid "Error writing file '%s': %s" msgstr "" -#: bpython/repl.py:881 +#: bpython/repl.py:841 #, python-format msgid "Saved to %s." msgstr "" -#: bpython/repl.py:887 +#: bpython/repl.py:847 msgid "No clipboard available." msgstr "" -#: bpython/repl.py:894 +#: bpython/repl.py:854 msgid "Could not copy to clipboard." msgstr "" -#: bpython/repl.py:896 +#: bpython/repl.py:856 msgid "Copied content to clipboard." msgstr "" -#: bpython/repl.py:905 +#: bpython/repl.py:865 msgid "Pastebin buffer? (y/N) " msgstr "" -#: bpython/repl.py:907 +#: bpython/repl.py:867 msgid "Pastebin aborted." msgstr "" -#: bpython/repl.py:915 +#: bpython/repl.py:875 #, python-format msgid "Duplicate pastebin. Previous URL: %s. Removal URL: %s" msgstr "" -#: bpython/repl.py:921 +#: bpython/repl.py:881 msgid "Posting data to pastebin..." msgstr "" -#: bpython/repl.py:925 +#: bpython/repl.py:885 #, python-format msgid "Upload failed: %s" msgstr "" -#: bpython/repl.py:934 +#: bpython/repl.py:894 #, python-format msgid "Pastebin URL: %s - Removal URL: %s" msgstr "" -#: bpython/repl.py:939 +#: bpython/repl.py:899 #, python-format msgid "Pastebin URL: %s" msgstr "" -#: bpython/repl.py:977 +#: bpython/repl.py:937 #, python-format msgid "Undo how many lines? (Undo will take up to ~%.1f seconds) [1]" msgstr "" -#: bpython/repl.py:985 bpython/repl.py:989 +#: bpython/repl.py:945 bpython/repl.py:949 msgid "Undo canceled" msgstr "" -#: bpython/repl.py:992 +#: 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:1172 +#: bpython/repl.py:1128 msgid "Config file does not exist - create new from default? (y/N)" msgstr "" -#: bpython/repl.py:1202 +#: bpython/repl.py:1153 msgid "bpython config file edited. Restart bpython for changes to take effect." msgstr "" -#: bpython/repl.py:1208 +#: bpython/repl.py:1158 #, python-format msgid "Error editing config file: %s" msgstr "" -#: bpython/urwid.py:628 +#: bpython/urwid.py:606 #, python-format msgid " <%s> Rewind <%s> Save <%s> Pastebin <%s> Pager <%s> Show Source " msgstr "" -#: bpython/urwid.py:1177 +#: bpython/urwid.py:1116 msgid "Run twisted reactor." msgstr "" -#: bpython/urwid.py:1182 +#: bpython/urwid.py:1121 msgid "Select specific reactor (see --help-reactors). Implies --twisted." msgstr "" -#: bpython/urwid.py:1190 +#: bpython/urwid.py:1129 msgid "List available reactors for -r." msgstr "" -#: bpython/urwid.py:1195 +#: 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:1204 +#: bpython/urwid.py:1143 msgid "Port to run an eval server on (forces Twisted)." msgstr "" -#: bpython/urwid.py:1396 +#: 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:350 +#: bpython/curtsiesfrontend/repl.py:339 msgid "Welcome to bpython!" msgstr "" -#: bpython/curtsiesfrontend/repl.py:352 +#: bpython/curtsiesfrontend/repl.py:341 #, python-format msgid "Press <%s> for help." msgstr "" -#: bpython/curtsiesfrontend/repl.py:673 +#: bpython/curtsiesfrontend/repl.py:681 #, python-format msgid "Executing PYTHONSTARTUP failed: %s" msgstr "" -#: bpython/curtsiesfrontend/repl.py:691 +#: bpython/curtsiesfrontend/repl.py:698 #, python-format msgid "Reloaded at %s because %s modified." msgstr "" -#: bpython/curtsiesfrontend/repl.py:993 +#: bpython/curtsiesfrontend/repl.py:1008 msgid "Session not reevaluated because it was not edited" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1006 +#: bpython/curtsiesfrontend/repl.py:1023 msgid "Session not reevaluated because saved file was blank" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1016 +#: bpython/curtsiesfrontend/repl.py:1033 msgid "Session edited and reevaluated" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1027 +#: bpython/curtsiesfrontend/repl.py:1044 #, python-format msgid "Reloaded at %s by user." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1033 +#: bpython/curtsiesfrontend/repl.py:1050 msgid "Auto-reloading deactivated." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1038 +#: bpython/curtsiesfrontend/repl.py:1055 msgid "Auto-reloading active, watching for file changes..." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1044 +#: 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 56ba969c6..feb534f7f 100644 --- a/bpython/translations/de/LC_MESSAGES/bpython.po +++ b/bpython/translations/de/LC_MESSAGES/bpython.po @@ -1,14 +1,14 @@ # 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: 2020-01-05 13:08+0000\n" -"PO-Revision-Date: 2020-01-06 12:17+0100\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" @@ -18,252 +18,253 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.8.0\n" -#: bpython/args.py:66 +#: 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 [Optionen] [Datei [Argumente]]\n" +"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:81 +#: bpython/args.py:127 msgid "Use CONFIG instead of default config file." msgstr "Verwende CONFIG antatt der standardmäßigen Konfigurationsdatei." -#: bpython/args.py:87 +#: 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:95 +#: 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:101 +#: bpython/args.py:145 msgid "Print version and exit." msgstr "Zeige Versionsinformationen an und beende." -#: bpython/cli.py:324 bpython/urwid.py:561 +#: 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:324 bpython/urwid.py:561 +#: bpython/urwid.py:539 msgid "yes" msgstr "ja" -#: bpython/cli.py:1743 -msgid "Rewind" -msgstr "Rückgängig" - -#: bpython/cli.py:1744 -msgid "Save" -msgstr "Speichern" - -#: bpython/cli.py:1745 -msgid "Pastebin" +#: bpython/curtsies.py:201 +msgid "start by pasting lines of a file into session" msgstr "" -#: bpython/cli.py:1746 -msgid "Pager" -msgstr "" +#: bpython/curtsies.py:207 +msgid "curtsies arguments" +msgstr "Argumente für curtsies" -#: bpython/cli.py:1747 -msgid "Show Source" -msgstr "Quellcode anzeigen" +#: 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:1994 -msgid "" -"WARNING: You are using `bpython-cli`, the curses backend for `bpython`. " -"This backend has been deprecated in version 0.19 and might disappear in a" -" future version." -msgstr "ACHTUNG: `bpython-cli` 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/cli.py:2003 bpython/curtsies.py:208 bpython/urwid.py:1405 -msgid "" -"WARNING: You are using `bpython` on Python 2. Support for Python 2 has " -"been deprecated in version 0.19 and might disappear in a future version." -msgstr "ACHTUNG: `bpython` wird mit Python 2 verwendet. Diese Pythonversion wird seit Version 0.19 nicht mehr aktiv unterstützt und Unterstützung dafür wird in einer zukünftigen Version entfernt werden." - -#: bpython/curtsies.py:152 -msgid "log debug messages to bpython.log" -msgstr "" - -#: bpython/curtsies.py:158 -msgid "start by pasting lines of a file into session" -msgstr "" - -#: bpython/history.py:231 +#: bpython/history.py:250 #, python-format msgid "Error occurred while writing to file %s (%s)" msgstr "Fehler beim Schreiben in Datei %s aufgetreten (%s)" -#: bpython/paste.py:95 +#: bpython/paste.py:85 msgid "Helper program not found." msgstr "Hilfsprogramm konnte nicht gefunden werden." -#: bpython/paste.py:97 +#: bpython/paste.py:87 msgid "Helper program could not be run." msgstr "Hilfsprogramm konnte nicht ausgeführt werden." -#: bpython/paste.py:103 +#: bpython/paste.py:93 #, python-format msgid "Helper program returned non-zero exit status %d." msgstr "Hilfsprogramm beendete mit Status %d." -#: bpython/paste.py:108 +#: bpython/paste.py:98 msgid "No output from helper program." msgstr "Keine Ausgabe von Hilfsprogramm vorhanden." -#: bpython/paste.py:115 +#: bpython/paste.py:105 msgid "Failed to recognize the helper program's output as an URL." msgstr "Konnte Ausgabe von Hilfsprogramm nicht verarbeiten." -#: bpython/repl.py:690 +#: bpython/repl.py:644 msgid "Nothing to get source of" -msgstr "" +msgstr "Nichts um Quellcode abzurufen" -#: bpython/repl.py:695 +#: bpython/repl.py:649 #, python-format msgid "Cannot get source: %s" msgstr "Kann Quellcode nicht finden: %s" -#: bpython/repl.py:700 +#: bpython/repl.py:654 #, python-format msgid "Cannot access source of %r" msgstr "Kann auf Quellcode nicht zugreifen: %r" -#: bpython/repl.py:702 +#: bpython/repl.py:656 #, python-format msgid "No source code found for %s" msgstr "Quellcode für %s nicht gefunden" -#: bpython/repl.py:841 +#: bpython/repl.py:801 msgid "Save to file (Esc to cancel): " msgstr "In Datei speichern (Esc um abzubrechen): " -#: bpython/repl.py:843 bpython/repl.py:846 bpython/repl.py:870 +#: bpython/repl.py:803 bpython/repl.py:806 bpython/repl.py:830 msgid "Save cancelled." msgstr "Speichern abgebrochen." -#: bpython/repl.py:857 +#: 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?" +msgstr "%s existiert bereit. (C) abbrechen, (o) überschrieben oder (a) anhängen? " -#: bpython/repl.py:865 +#: bpython/repl.py:825 msgid "overwrite" msgstr "überschreiben" -#: bpython/repl.py:867 +#: bpython/repl.py:827 msgid "append" msgstr "anhängen" -#: bpython/repl.py:879 bpython/repl.py:1192 +#: bpython/repl.py:839 bpython/repl.py:1143 #, python-format msgid "Error writing file '%s': %s" msgstr "Fehler beim Schreiben in Datei '%s': %s" -#: bpython/repl.py:881 +#: bpython/repl.py:841 #, python-format msgid "Saved to %s." msgstr "Nach %s gespeichert." -#: bpython/repl.py:887 +#: bpython/repl.py:847 msgid "No clipboard available." msgstr "Zwischenablage ist nicht verfügbar." -#: bpython/repl.py:894 +#: bpython/repl.py:854 msgid "Could not copy to clipboard." msgstr "Konnte nicht in Zwischenablage kopieren." -#: bpython/repl.py:896 +#: bpython/repl.py:856 msgid "Copied content to clipboard." msgstr "Inhalt wurde in Zwischenablage kopiert." -#: bpython/repl.py:905 +#: bpython/repl.py:865 msgid "Pastebin buffer? (y/N) " -msgstr "" +msgstr "Buffer bei Pastebin hochladen? (j/N)" -#: bpython/repl.py:907 +#: bpython/repl.py:867 msgid "Pastebin aborted." -msgstr "" +msgstr "Hochladen zu Pastebin abgebrochen." -#: bpython/repl.py:915 +#: 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:921 +#: bpython/repl.py:881 msgid "Posting data to pastebin..." -msgstr "Lade Daten hoch..." +msgstr "Lade Daten hoch zu Pastebin..." -#: bpython/repl.py:925 +#: bpython/repl.py:885 #, python-format msgid "Upload failed: %s" msgstr "Hochladen ist fehlgeschlagen: %s" -#: bpython/repl.py:934 +#: bpython/repl.py:894 #, python-format msgid "Pastebin URL: %s - Removal URL: %s" -msgstr "" +msgstr "Pastebin URL: %s - URL zum Löschen: %s" -#: bpython/repl.py:939 +#: bpython/repl.py:899 #, python-format msgid "Pastebin URL: %s" -msgstr "" +msgstr "Pastebin URL: %s" -#: bpython/repl.py:977 +#: 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:985 bpython/repl.py:989 +#: bpython/repl.py:945 bpython/repl.py:949 msgid "Undo canceled" msgstr "Rückgängigmachen abgebrochen" -#: bpython/repl.py:992 +#: 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] "" +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:1172 +#: 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:1202 +#: 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:1208 +#: bpython/repl.py:1158 #, python-format msgid "Error editing config file: %s" msgstr "Fehler beim Bearbeiten der Konfigurationsdatei: %s" -#: bpython/urwid.py:628 +#: 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:1177 +#: bpython/urwid.py:1116 msgid "Run twisted reactor." msgstr "Führe twisted reactor aus." -#: bpython/urwid.py:1182 +#: bpython/urwid.py:1121 msgid "Select specific reactor (see --help-reactors). Implies --twisted." msgstr "Wähle reactor aus (siehe --help-reactors). Impliziert --twisted." -#: bpython/urwid.py:1190 +#: bpython/urwid.py:1129 msgid "List available reactors for -r." msgstr "Liste verfügbare reactors für -r auf." -#: bpython/urwid.py:1195 +#: bpython/urwid.py:1134 msgid "" "twistd plugin to run (use twistd for a list). Use \"--\" to pass further " "options to the plugin." @@ -271,64 +272,99 @@ msgstr "" "Auszuführendes twistd Plugin (starte twistd für eine Liste). Verwende " "\"--\" um Optionen an das Plugin zu übergeben." -#: bpython/urwid.py:1204 +#: bpython/urwid.py:1143 msgid "Port to run an eval server on (forces Twisted)." msgstr "" -#: bpython/urwid.py:1396 +#: 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." +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:350 +#: bpython/curtsiesfrontend/repl.py:339 msgid "Welcome to bpython!" msgstr "Willkommen by bpython!" -#: bpython/curtsiesfrontend/repl.py:352 +#: bpython/curtsiesfrontend/repl.py:341 #, python-format msgid "Press <%s> for help." msgstr "Drücke <%s> für Hilfe." -#: bpython/curtsiesfrontend/repl.py:673 +#: bpython/curtsiesfrontend/repl.py:681 #, python-format msgid "Executing PYTHONSTARTUP failed: %s" msgstr "Fehler beim Ausführen von PYTHONSTARTUP: %s" -#: bpython/curtsiesfrontend/repl.py:691 +#: 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:993 +#: 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:1006 +#: 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:1016 +#: bpython/curtsiesfrontend/repl.py:1033 msgid "Session edited and reevaluated" msgstr "Sitzung bearbeitet und neu ausgeführt" -#: bpython/curtsiesfrontend/repl.py:1027 +#: bpython/curtsiesfrontend/repl.py:1044 #, python-format msgid "Reloaded at %s by user." msgstr "Bei %s vom Benutzer neu geladen." -#: bpython/curtsiesfrontend/repl.py:1033 +#: bpython/curtsiesfrontend/repl.py:1050 msgid "Auto-reloading deactivated." msgstr "Automatisches Neuladen deaktiviert." -#: bpython/curtsiesfrontend/repl.py:1038 +#: 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:1044 +#: 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 331c672ac..d34872816 100644 --- a/bpython/translations/es_ES/LC_MESSAGES/bpython.po +++ b/bpython/translations/es_ES/LC_MESSAGES/bpython.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: bpython 0.9.7\n" "Report-Msgid-Bugs-To: http://github.com/bpython/bpython/issues\n" -"POT-Creation-Date: 2020-01-05 13:08+0000\n" -"PO-Revision-Date: 2015-02-02 00:34+0100\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" @@ -18,311 +18,333 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.8.0\n" -#: bpython/args.py:66 +#: 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:81 +#: bpython/args.py:127 msgid "Use CONFIG instead of default config file." msgstr "" -#: bpython/args.py:87 +#: bpython/args.py:133 msgid "Drop to bpython shell after running file instead of exiting." msgstr "" -#: bpython/args.py:95 +#: bpython/args.py:139 msgid "Don't flush the output to stdout." msgstr "" -#: bpython/args.py:101 +#: bpython/args.py:145 msgid "Print version and exit." msgstr "" -#: bpython/cli.py:324 bpython/urwid.py:561 -msgid "y" -msgstr "s" - -#: bpython/cli.py:324 bpython/urwid.py:561 -msgid "yes" -msgstr "si" - -#: bpython/cli.py:1743 -msgid "Rewind" +#: bpython/args.py:152 +msgid "Set log level for logging" msgstr "" -#: bpython/cli.py:1744 -msgid "Save" +#: bpython/args.py:157 +msgid "Log output file" msgstr "" -#: bpython/cli.py:1745 -msgid "Pastebin" +#: bpython/args.py:168 +msgid "File to execute and additional arguments passed on to the executed script." msgstr "" -#: bpython/cli.py:1746 -msgid "Pager" -msgstr "" - -#: bpython/cli.py:1747 -msgid "Show Source" -msgstr "" +#: bpython/curtsiesfrontend/interaction.py:107 +#: bpython/urwid.py:539 +msgid "y" +msgstr "s" -#: bpython/cli.py:1994 -msgid "" -"WARNING: You are using `bpython-cli`, the curses backend for `bpython`. " -"This backend has been deprecated in version 0.19 and might disappear in a" -" future version." -msgstr "" +#: bpython/urwid.py:539 +msgid "yes" +msgstr "si" -#: bpython/cli.py:2003 bpython/curtsies.py:208 bpython/urwid.py:1405 -msgid "" -"WARNING: You are using `bpython` on Python 2. Support for Python 2 has " -"been deprecated in version 0.19 and might disappear in a future version." +#: bpython/curtsies.py:201 +msgid "start by pasting lines of a file into session" msgstr "" -#: bpython/curtsies.py:152 -msgid "log debug messages to bpython.log" +#: bpython/curtsies.py:207 +msgid "curtsies arguments" msgstr "" -#: bpython/curtsies.py:158 -msgid "start by pasting lines of a file into session" +#: bpython/curtsies.py:208 +msgid "Additional arguments specific to the curtsies-based REPL." msgstr "" -#: bpython/history.py:231 +#: bpython/history.py:250 #, python-format msgid "Error occurred while writing to file %s (%s)" msgstr "" -#: bpython/paste.py:95 +#: bpython/paste.py:85 msgid "Helper program not found." msgstr "" -#: bpython/paste.py:97 +#: bpython/paste.py:87 msgid "Helper program could not be run." msgstr "" -#: bpython/paste.py:103 +#: bpython/paste.py:93 #, python-format msgid "Helper program returned non-zero exit status %d." msgstr "" -#: bpython/paste.py:108 +#: bpython/paste.py:98 msgid "No output from helper program." msgstr "" -#: bpython/paste.py:115 +#: bpython/paste.py:105 msgid "Failed to recognize the helper program's output as an URL." msgstr "" -#: bpython/repl.py:690 +#: bpython/repl.py:644 msgid "Nothing to get source of" msgstr "" -#: bpython/repl.py:695 +#: bpython/repl.py:649 #, python-format msgid "Cannot get source: %s" msgstr "" -#: bpython/repl.py:700 +#: bpython/repl.py:654 #, python-format msgid "Cannot access source of %r" msgstr "" -#: bpython/repl.py:702 +#: bpython/repl.py:656 #, python-format msgid "No source code found for %s" msgstr "" -#: bpython/repl.py:841 +#: bpython/repl.py:801 msgid "Save to file (Esc to cancel): " msgstr "" -#: bpython/repl.py:843 bpython/repl.py:846 bpython/repl.py:870 +#: bpython/repl.py:803 bpython/repl.py:806 bpython/repl.py:830 msgid "Save cancelled." msgstr "" -#: bpython/repl.py:857 +#: 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:865 +#: bpython/repl.py:825 msgid "overwrite" msgstr "" -#: bpython/repl.py:867 +#: bpython/repl.py:827 msgid "append" msgstr "" -#: bpython/repl.py:879 bpython/repl.py:1192 +#: bpython/repl.py:839 bpython/repl.py:1143 #, python-format msgid "Error writing file '%s': %s" msgstr "" -#: bpython/repl.py:881 +#: bpython/repl.py:841 #, python-format msgid "Saved to %s." msgstr "" -#: bpython/repl.py:887 +#: bpython/repl.py:847 msgid "No clipboard available." msgstr "" -#: bpython/repl.py:894 +#: bpython/repl.py:854 msgid "Could not copy to clipboard." msgstr "" -#: bpython/repl.py:896 +#: bpython/repl.py:856 msgid "Copied content to clipboard." msgstr "" -#: bpython/repl.py:905 +#: bpython/repl.py:865 msgid "Pastebin buffer? (y/N) " msgstr "" -#: bpython/repl.py:907 +#: bpython/repl.py:867 msgid "Pastebin aborted." msgstr "" -#: bpython/repl.py:915 +#: bpython/repl.py:875 #, python-format msgid "Duplicate pastebin. Previous URL: %s. Removal URL: %s" msgstr "" -#: bpython/repl.py:921 +#: bpython/repl.py:881 msgid "Posting data to pastebin..." msgstr "" -#: bpython/repl.py:925 +#: bpython/repl.py:885 #, python-format msgid "Upload failed: %s" msgstr "" -#: bpython/repl.py:934 +#: bpython/repl.py:894 #, python-format msgid "Pastebin URL: %s - Removal URL: %s" msgstr "" -#: bpython/repl.py:939 +#: bpython/repl.py:899 #, python-format msgid "Pastebin URL: %s" msgstr "" -#: bpython/repl.py:977 +#: bpython/repl.py:937 #, python-format msgid "Undo how many lines? (Undo will take up to ~%.1f seconds) [1]" msgstr "" -#: bpython/repl.py:985 bpython/repl.py:989 +#: bpython/repl.py:945 bpython/repl.py:949 msgid "Undo canceled" msgstr "" -#: bpython/repl.py:992 +#: 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:1172 +#: bpython/repl.py:1128 msgid "Config file does not exist - create new from default? (y/N)" msgstr "" -#: bpython/repl.py:1202 +#: bpython/repl.py:1153 msgid "bpython config file edited. Restart bpython for changes to take effect." msgstr "" -#: bpython/repl.py:1208 +#: bpython/repl.py:1158 #, python-format msgid "Error editing config file: %s" msgstr "" -#: bpython/urwid.py:628 +#: 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:1177 +#: bpython/urwid.py:1116 msgid "Run twisted reactor." msgstr "" -#: bpython/urwid.py:1182 +#: bpython/urwid.py:1121 msgid "Select specific reactor (see --help-reactors). Implies --twisted." msgstr "" -#: bpython/urwid.py:1190 +#: bpython/urwid.py:1129 msgid "List available reactors for -r." msgstr "" -#: bpython/urwid.py:1195 +#: 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:1204 +#: bpython/urwid.py:1143 msgid "Port to run an eval server on (forces Twisted)." msgstr "" -#: bpython/urwid.py:1396 +#: 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:350 +#: bpython/curtsiesfrontend/repl.py:339 msgid "Welcome to bpython!" msgstr "" -#: bpython/curtsiesfrontend/repl.py:352 +#: bpython/curtsiesfrontend/repl.py:341 #, python-format msgid "Press <%s> for help." msgstr "" -#: bpython/curtsiesfrontend/repl.py:673 +#: bpython/curtsiesfrontend/repl.py:681 #, python-format msgid "Executing PYTHONSTARTUP failed: %s" msgstr "" -#: bpython/curtsiesfrontend/repl.py:691 +#: bpython/curtsiesfrontend/repl.py:698 #, python-format msgid "Reloaded at %s because %s modified." msgstr "" -#: bpython/curtsiesfrontend/repl.py:993 +#: bpython/curtsiesfrontend/repl.py:1008 msgid "Session not reevaluated because it was not edited" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1006 +#: bpython/curtsiesfrontend/repl.py:1023 msgid "Session not reevaluated because saved file was blank" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1016 +#: bpython/curtsiesfrontend/repl.py:1033 msgid "Session edited and reevaluated" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1027 +#: bpython/curtsiesfrontend/repl.py:1044 #, python-format msgid "Reloaded at %s by user." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1033 +#: bpython/curtsiesfrontend/repl.py:1050 msgid "Auto-reloading deactivated." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1038 +#: bpython/curtsiesfrontend/repl.py:1055 msgid "Auto-reloading active, watching for file changes..." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1044 +#: bpython/curtsiesfrontend/repl.py:1061 msgid "Auto-reloading not available because watchdog not installed." msgstr "" -#~ msgid "Error editing config file." -#~ 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 index bc03d38d5..ba1205048 100644 --- a/bpython/translations/fr_FR/LC_MESSAGES/bpython.po +++ b/bpython/translations/fr_FR/LC_MESSAGES/bpython.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: bpython 0.13-442\n" "Report-Msgid-Bugs-To: http://github.com/bpython/bpython/issues\n" -"POT-Creation-Date: 2020-01-05 13:08+0000\n" -"PO-Revision-Date: 2019-09-22 22:58+0200\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" @@ -17,252 +17,245 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.8.0\n" -#: bpython/args.py:66 +#: 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 "" -"Utilisation: %prog [options] [fichier [arguments]]\n" +"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:81 +#: 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:87 +#: 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:95 +#: bpython/args.py:139 msgid "Don't flush the output to stdout." msgstr "Ne pas purger la sortie vers stdout." -#: bpython/args.py:101 +#: bpython/args.py:145 msgid "Print version and exit." msgstr "Afficher la version et quitter." -#: bpython/cli.py:324 bpython/urwid.py:561 -msgid "y" -msgstr "o" - -#: bpython/cli.py:324 bpython/urwid.py:561 -msgid "yes" -msgstr "oui" - -#: bpython/cli.py:1743 -msgid "Rewind" -msgstr "Rembobiner" - -#: bpython/cli.py:1744 -msgid "Save" -msgstr "Sauvegarder" +#: bpython/args.py:152 +msgid "Set log level for logging" +msgstr "" -#: bpython/cli.py:1745 -msgid "Pastebin" +#: bpython/args.py:157 +msgid "Log output file" msgstr "" -#: bpython/cli.py:1746 -msgid "Pager" +#: bpython/args.py:168 +msgid "File to execute and additional arguments passed on to the executed script." msgstr "" -#: bpython/cli.py:1747 -msgid "Show Source" -msgstr "Montrer le code source" +#: bpython/curtsiesfrontend/interaction.py:107 +#: bpython/urwid.py:539 +msgid "y" +msgstr "o" -#: bpython/cli.py:1994 -msgid "" -"WARNING: You are using `bpython-cli`, the curses backend for `bpython`. " -"This backend has been deprecated in version 0.19 and might disappear in a" -" future version." -msgstr "" +#: bpython/urwid.py:539 +msgid "yes" +msgstr "oui" -#: bpython/cli.py:2003 bpython/curtsies.py:208 bpython/urwid.py:1405 -msgid "" -"WARNING: You are using `bpython` on Python 2. Support for Python 2 has " -"been deprecated in version 0.19 and might disappear in a future version." +#: bpython/curtsies.py:201 +msgid "start by pasting lines of a file into session" msgstr "" -#: bpython/curtsies.py:152 -msgid "log debug messages to bpython.log" -msgstr "logger les messages de debug dans bpython.log" +#: bpython/curtsies.py:207 +msgid "curtsies arguments" +msgstr "" -#: bpython/curtsies.py:158 -msgid "start by pasting lines of a file into session" +#: bpython/curtsies.py:208 +msgid "Additional arguments specific to the curtsies-based REPL." msgstr "" -#: bpython/history.py:231 +#: 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:95 +#: bpython/paste.py:85 msgid "Helper program not found." msgstr "programme externe non trouvé." -#: bpython/paste.py:97 +#: bpython/paste.py:87 msgid "Helper program could not be run." msgstr "impossible de lancer le programme externe." -#: bpython/paste.py:103 +#: 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:108 +#: bpython/paste.py:98 msgid "No output from helper program." msgstr "pas de sortie du programme externe." -#: bpython/paste.py:115 +#: 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:690 +#: bpython/repl.py:644 msgid "Nothing to get source of" msgstr "" -#: bpython/repl.py:695 +#: bpython/repl.py:649 #, python-format msgid "Cannot get source: %s" msgstr "Impossible de récupérer le source: %s" -#: bpython/repl.py:700 +#: bpython/repl.py:654 #, python-format msgid "Cannot access source of %r" msgstr "Impossible d'accéder au source de %r" -#: bpython/repl.py:702 +#: bpython/repl.py:656 #, python-format msgid "No source code found for %s" msgstr "Pas de code source trouvé pour %s" -#: bpython/repl.py:841 +#: bpython/repl.py:801 msgid "Save to file (Esc to cancel): " msgstr "" -#: bpython/repl.py:843 bpython/repl.py:846 bpython/repl.py:870 +#: bpython/repl.py:803 bpython/repl.py:806 bpython/repl.py:830 msgid "Save cancelled." msgstr "" -#: bpython/repl.py:857 +#: 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:865 +#: bpython/repl.py:825 msgid "overwrite" msgstr "" -#: bpython/repl.py:867 +#: bpython/repl.py:827 msgid "append" msgstr "" -#: bpython/repl.py:879 bpython/repl.py:1192 +#: 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:881 +#: bpython/repl.py:841 #, python-format msgid "Saved to %s." msgstr "" -#: bpython/repl.py:887 +#: bpython/repl.py:847 msgid "No clipboard available." msgstr "Pas de presse-papier disponible." -#: bpython/repl.py:894 +#: bpython/repl.py:854 msgid "Could not copy to clipboard." msgstr "Impossible de copier vers le presse-papier." -#: bpython/repl.py:896 +#: bpython/repl.py:856 msgid "Copied content to clipboard." msgstr "Contenu copié vers le presse-papier." -#: bpython/repl.py:905 +#: bpython/repl.py:865 msgid "Pastebin buffer? (y/N) " msgstr "Tampon Pastebin ? (o/N) " -#: bpython/repl.py:907 +#: bpython/repl.py:867 msgid "Pastebin aborted." msgstr "Pastebin abandonné." -#: bpython/repl.py:915 +#: 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:921 +#: bpython/repl.py:881 msgid "Posting data to pastebin..." msgstr "Envoi des donnés à pastebin..." -#: bpython/repl.py:925 +#: bpython/repl.py:885 #, python-format msgid "Upload failed: %s" msgstr "Echec du téléchargement: %s" -#: bpython/repl.py:934 +#: bpython/repl.py:894 #, python-format msgid "Pastebin URL: %s - Removal URL: %s" msgstr "URL Pastebin: %s - URL de suppression: %s" -#: bpython/repl.py:939 +#: bpython/repl.py:899 #, python-format msgid "Pastebin URL: %s" msgstr "URL Pastebin: %s" -#: bpython/repl.py:977 +#: bpython/repl.py:937 #, python-format msgid "Undo how many lines? (Undo will take up to ~%.1f seconds) [1]" msgstr "" -#: bpython/repl.py:985 bpython/repl.py:989 +#: bpython/repl.py:945 bpython/repl.py:949 msgid "Undo canceled" msgstr "" -#: bpython/repl.py:992 +#: 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:1172 +#: 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:1202 +#: bpython/repl.py:1153 msgid "bpython config file edited. Restart bpython for changes to take effect." msgstr "" -#: bpython/repl.py:1208 +#: bpython/repl.py:1158 #, python-format msgid "Error editing config file: %s" msgstr "" -#: bpython/urwid.py:628 +#: 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:1177 +#: bpython/urwid.py:1116 msgid "Run twisted reactor." msgstr "Lancer le reactor twisted." -#: bpython/urwid.py:1182 +#: 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:1190 +#: bpython/urwid.py:1129 msgid "List available reactors for -r." msgstr "Lister les reactors disponibles pour -r." -#: bpython/urwid.py:1195 +#: bpython/urwid.py:1134 msgid "" "twistd plugin to run (use twistd for a list). Use \"--\" to pass further " "options to the plugin." @@ -270,62 +263,94 @@ msgstr "" "plugin twistd à lancer (utiliser twistd pour une list). Utiliser \"--\" " "pour donner plus d'options au plugin." -#: bpython/urwid.py:1204 +#: 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:1396 +#: 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:350 +#: bpython/curtsiesfrontend/repl.py:339 msgid "Welcome to bpython!" msgstr "Bienvenue dans bpython!" -#: bpython/curtsiesfrontend/repl.py:352 +#: bpython/curtsiesfrontend/repl.py:341 #, python-format msgid "Press <%s> for help." msgstr "Appuyer sur <%s> pour de l'aide." -#: bpython/curtsiesfrontend/repl.py:673 +#: 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:691 +#: bpython/curtsiesfrontend/repl.py:698 #, python-format msgid "Reloaded at %s because %s modified." msgstr "" -#: bpython/curtsiesfrontend/repl.py:993 +#: bpython/curtsiesfrontend/repl.py:1008 msgid "Session not reevaluated because it was not edited" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1006 +#: bpython/curtsiesfrontend/repl.py:1023 msgid "Session not reevaluated because saved file was blank" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1016 +#: bpython/curtsiesfrontend/repl.py:1033 msgid "Session edited and reevaluated" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1027 +#: bpython/curtsiesfrontend/repl.py:1044 #, python-format msgid "Reloaded at %s by user." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1033 +#: bpython/curtsiesfrontend/repl.py:1050 msgid "Auto-reloading deactivated." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1038 +#: bpython/curtsiesfrontend/repl.py:1055 msgid "Auto-reloading active, watching for file changes..." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1044 +#: 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 f9a471d9d..46488bc3c 100644 --- a/bpython/translations/it_IT/LC_MESSAGES/bpython.po +++ b/bpython/translations/it_IT/LC_MESSAGES/bpython.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: bpython 0.9.7\n" "Report-Msgid-Bugs-To: http://github.com/bpython/bpython/issues\n" -"POT-Creation-Date: 2020-01-05 13:08+0000\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" @@ -18,309 +18,334 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.8.0\n" -#: bpython/args.py:66 +#: 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:81 +#: bpython/args.py:127 msgid "Use CONFIG instead of default config file." msgstr "" -#: bpython/args.py:87 +#: bpython/args.py:133 msgid "Drop to bpython shell after running file instead of exiting." msgstr "" -#: bpython/args.py:95 +#: bpython/args.py:139 msgid "Don't flush the output to stdout." msgstr "" -#: bpython/args.py:101 +#: bpython/args.py:145 msgid "Print version and exit." msgstr "" -#: bpython/cli.py:324 bpython/urwid.py:561 -msgid "y" -msgstr "s" - -#: bpython/cli.py:324 bpython/urwid.py:561 -msgid "yes" -msgstr "si" - -#: bpython/cli.py:1743 -msgid "Rewind" +#: bpython/args.py:152 +msgid "Set log level for logging" msgstr "" -#: bpython/cli.py:1744 -msgid "Save" +#: bpython/args.py:157 +msgid "Log output file" msgstr "" -#: bpython/cli.py:1745 -msgid "Pastebin" +#: bpython/args.py:168 +msgid "File to execute and additional arguments passed on to the executed script." msgstr "" -#: bpython/cli.py:1746 -msgid "Pager" -msgstr "" - -#: bpython/cli.py:1747 -msgid "Show Source" -msgstr "" +#: bpython/curtsiesfrontend/interaction.py:107 +#: bpython/urwid.py:539 +msgid "y" +msgstr "s" -#: bpython/cli.py:1994 -msgid "" -"WARNING: You are using `bpython-cli`, the curses backend for `bpython`. " -"This backend has been deprecated in version 0.19 and might disappear in a" -" future version." -msgstr "" +#: bpython/urwid.py:539 +msgid "yes" +msgstr "si" -#: bpython/cli.py:2003 bpython/curtsies.py:208 bpython/urwid.py:1405 -msgid "" -"WARNING: You are using `bpython` on Python 2. Support for Python 2 has " -"been deprecated in version 0.19 and might disappear in a future version." +#: bpython/curtsies.py:201 +msgid "start by pasting lines of a file into session" msgstr "" -#: bpython/curtsies.py:152 -msgid "log debug messages to bpython.log" +#: bpython/curtsies.py:207 +msgid "curtsies arguments" msgstr "" -#: bpython/curtsies.py:158 -msgid "start by pasting lines of a file into session" +#: bpython/curtsies.py:208 +msgid "Additional arguments specific to the curtsies-based REPL." msgstr "" -#: bpython/history.py:231 +#: bpython/history.py:250 #, python-format msgid "Error occurred while writing to file %s (%s)" msgstr "" -#: bpython/paste.py:95 +#: bpython/paste.py:85 msgid "Helper program not found." msgstr "" -#: bpython/paste.py:97 +#: bpython/paste.py:87 msgid "Helper program could not be run." msgstr "" -#: bpython/paste.py:103 +#: bpython/paste.py:93 #, python-format msgid "Helper program returned non-zero exit status %d." msgstr "" -#: bpython/paste.py:108 +#: bpython/paste.py:98 msgid "No output from helper program." msgstr "" -#: bpython/paste.py:115 +#: bpython/paste.py:105 msgid "Failed to recognize the helper program's output as an URL." msgstr "" -#: bpython/repl.py:690 +#: bpython/repl.py:644 msgid "Nothing to get source of" msgstr "" -#: bpython/repl.py:695 +#: bpython/repl.py:649 #, python-format msgid "Cannot get source: %s" msgstr "" -#: bpython/repl.py:700 +#: bpython/repl.py:654 #, python-format msgid "Cannot access source of %r" msgstr "" -#: bpython/repl.py:702 +#: bpython/repl.py:656 #, python-format msgid "No source code found for %s" msgstr "" -#: bpython/repl.py:841 +#: bpython/repl.py:801 msgid "Save to file (Esc to cancel): " msgstr "" -#: bpython/repl.py:843 bpython/repl.py:846 bpython/repl.py:870 +#: bpython/repl.py:803 bpython/repl.py:806 bpython/repl.py:830 msgid "Save cancelled." msgstr "" -#: bpython/repl.py:857 +#: 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:865 +#: bpython/repl.py:825 msgid "overwrite" msgstr "" -#: bpython/repl.py:867 +#: bpython/repl.py:827 msgid "append" msgstr "" -#: bpython/repl.py:879 bpython/repl.py:1192 +#: bpython/repl.py:839 bpython/repl.py:1143 #, python-format msgid "Error writing file '%s': %s" msgstr "" -#: bpython/repl.py:881 +#: bpython/repl.py:841 #, python-format msgid "Saved to %s." msgstr "" -#: bpython/repl.py:887 +#: bpython/repl.py:847 msgid "No clipboard available." msgstr "" -#: bpython/repl.py:894 +#: bpython/repl.py:854 msgid "Could not copy to clipboard." msgstr "" -#: bpython/repl.py:896 +#: bpython/repl.py:856 msgid "Copied content to clipboard." msgstr "" -#: bpython/repl.py:905 +#: bpython/repl.py:865 msgid "Pastebin buffer? (y/N) " msgstr "" -#: bpython/repl.py:907 +#: bpython/repl.py:867 msgid "Pastebin aborted." msgstr "" -#: bpython/repl.py:915 +#: bpython/repl.py:875 #, python-format msgid "Duplicate pastebin. Previous URL: %s. Removal URL: %s" msgstr "" -#: bpython/repl.py:921 +#: bpython/repl.py:881 msgid "Posting data to pastebin..." msgstr "" -#: bpython/repl.py:925 +#: bpython/repl.py:885 #, python-format msgid "Upload failed: %s" msgstr "" -#: bpython/repl.py:934 +#: bpython/repl.py:894 #, python-format msgid "Pastebin URL: %s - Removal URL: %s" msgstr "" -#: bpython/repl.py:939 +#: bpython/repl.py:899 #, python-format msgid "Pastebin URL: %s" msgstr "" -#: bpython/repl.py:977 +#: bpython/repl.py:937 #, python-format msgid "Undo how many lines? (Undo will take up to ~%.1f seconds) [1]" msgstr "" -#: bpython/repl.py:985 bpython/repl.py:989 +#: bpython/repl.py:945 bpython/repl.py:949 msgid "Undo canceled" msgstr "" -#: bpython/repl.py:992 +#: 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:1172 +#: bpython/repl.py:1128 msgid "Config file does not exist - create new from default? (y/N)" msgstr "" -#: bpython/repl.py:1202 +#: bpython/repl.py:1153 msgid "bpython config file edited. Restart bpython for changes to take effect." msgstr "" -#: bpython/repl.py:1208 +#: bpython/repl.py:1158 #, python-format msgid "Error editing config file: %s" msgstr "" -#: bpython/urwid.py:628 +#: 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:1177 +#: bpython/urwid.py:1116 msgid "Run twisted reactor." msgstr "" -#: bpython/urwid.py:1182 +#: bpython/urwid.py:1121 msgid "Select specific reactor (see --help-reactors). Implies --twisted." msgstr "" -#: bpython/urwid.py:1190 +#: bpython/urwid.py:1129 msgid "List available reactors for -r." msgstr "" -#: bpython/urwid.py:1195 +#: 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:1204 +#: bpython/urwid.py:1143 msgid "Port to run an eval server on (forces Twisted)." msgstr "" -#: bpython/urwid.py:1396 +#: 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:350 +#: bpython/curtsiesfrontend/repl.py:339 msgid "Welcome to bpython!" msgstr "" -#: bpython/curtsiesfrontend/repl.py:352 +#: bpython/curtsiesfrontend/repl.py:341 #, python-format msgid "Press <%s> for help." msgstr "" -#: bpython/curtsiesfrontend/repl.py:673 +#: bpython/curtsiesfrontend/repl.py:681 #, python-format msgid "Executing PYTHONSTARTUP failed: %s" msgstr "" -#: bpython/curtsiesfrontend/repl.py:691 +#: bpython/curtsiesfrontend/repl.py:698 #, python-format msgid "Reloaded at %s because %s modified." msgstr "" -#: bpython/curtsiesfrontend/repl.py:993 +#: bpython/curtsiesfrontend/repl.py:1008 msgid "Session not reevaluated because it was not edited" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1006 +#: bpython/curtsiesfrontend/repl.py:1023 msgid "Session not reevaluated because saved file was blank" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1016 +#: bpython/curtsiesfrontend/repl.py:1033 msgid "Session edited and reevaluated" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1027 +#: bpython/curtsiesfrontend/repl.py:1044 #, python-format msgid "Reloaded at %s by user." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1033 +#: bpython/curtsiesfrontend/repl.py:1050 msgid "Auto-reloading deactivated." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1038 +#: bpython/curtsiesfrontend/repl.py:1055 msgid "Auto-reloading active, watching for file changes..." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1044 +#: 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 fabdfd591..375f4f32e 100644 --- a/bpython/translations/nl_NL/LC_MESSAGES/bpython.po +++ b/bpython/translations/nl_NL/LC_MESSAGES/bpython.po @@ -7,8 +7,8 @@ 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: 2020-01-05 13:08+0000\n" -"PO-Revision-Date: 2015-02-02 00:34+0100\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" @@ -18,309 +18,331 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.8.0\n" -#: bpython/args.py:66 +#: 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:81 +#: bpython/args.py:127 msgid "Use CONFIG instead of default config file." msgstr "" -#: bpython/args.py:87 +#: bpython/args.py:133 msgid "Drop to bpython shell after running file instead of exiting." msgstr "" -#: bpython/args.py:95 +#: bpython/args.py:139 msgid "Don't flush the output to stdout." msgstr "" -#: bpython/args.py:101 +#: bpython/args.py:145 msgid "Print version and exit." msgstr "" -#: bpython/cli.py:324 bpython/urwid.py:561 -msgid "y" -msgstr "j" - -#: bpython/cli.py:324 bpython/urwid.py:561 -msgid "yes" -msgstr "ja" - -#: bpython/cli.py:1743 -msgid "Rewind" +#: bpython/args.py:152 +msgid "Set log level for logging" msgstr "" -#: bpython/cli.py:1744 -msgid "Save" +#: bpython/args.py:157 +msgid "Log output file" msgstr "" -#: bpython/cli.py:1745 -msgid "Pastebin" +#: bpython/args.py:168 +msgid "File to execute and additional arguments passed on to the executed script." msgstr "" -#: bpython/cli.py:1746 -msgid "Pager" -msgstr "" - -#: bpython/cli.py:1747 -msgid "Show Source" -msgstr "" +#: bpython/curtsiesfrontend/interaction.py:107 +#: bpython/urwid.py:539 +msgid "y" +msgstr "j" -#: bpython/cli.py:1994 -msgid "" -"WARNING: You are using `bpython-cli`, the curses backend for `bpython`. " -"This backend has been deprecated in version 0.19 and might disappear in a" -" future version." -msgstr "" +#: bpython/urwid.py:539 +msgid "yes" +msgstr "ja" -#: bpython/cli.py:2003 bpython/curtsies.py:208 bpython/urwid.py:1405 -msgid "" -"WARNING: You are using `bpython` on Python 2. Support for Python 2 has " -"been deprecated in version 0.19 and might disappear in a future version." +#: bpython/curtsies.py:201 +msgid "start by pasting lines of a file into session" msgstr "" -#: bpython/curtsies.py:152 -msgid "log debug messages to bpython.log" +#: bpython/curtsies.py:207 +msgid "curtsies arguments" msgstr "" -#: bpython/curtsies.py:158 -msgid "start by pasting lines of a file into session" +#: bpython/curtsies.py:208 +msgid "Additional arguments specific to the curtsies-based REPL." msgstr "" -#: bpython/history.py:231 +#: bpython/history.py:250 #, python-format msgid "Error occurred while writing to file %s (%s)" msgstr "" -#: bpython/paste.py:95 +#: bpython/paste.py:85 msgid "Helper program not found." msgstr "" -#: bpython/paste.py:97 +#: bpython/paste.py:87 msgid "Helper program could not be run." msgstr "" -#: bpython/paste.py:103 +#: bpython/paste.py:93 #, python-format msgid "Helper program returned non-zero exit status %d." msgstr "" -#: bpython/paste.py:108 +#: bpython/paste.py:98 msgid "No output from helper program." msgstr "" -#: bpython/paste.py:115 +#: bpython/paste.py:105 msgid "Failed to recognize the helper program's output as an URL." msgstr "" -#: bpython/repl.py:690 +#: bpython/repl.py:644 msgid "Nothing to get source of" msgstr "" -#: bpython/repl.py:695 +#: bpython/repl.py:649 #, python-format msgid "Cannot get source: %s" msgstr "" -#: bpython/repl.py:700 +#: bpython/repl.py:654 #, python-format msgid "Cannot access source of %r" msgstr "" -#: bpython/repl.py:702 +#: bpython/repl.py:656 #, python-format msgid "No source code found for %s" msgstr "" -#: bpython/repl.py:841 +#: bpython/repl.py:801 msgid "Save to file (Esc to cancel): " msgstr "" -#: bpython/repl.py:843 bpython/repl.py:846 bpython/repl.py:870 +#: bpython/repl.py:803 bpython/repl.py:806 bpython/repl.py:830 msgid "Save cancelled." msgstr "" -#: bpython/repl.py:857 +#: 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:865 +#: bpython/repl.py:825 msgid "overwrite" msgstr "" -#: bpython/repl.py:867 +#: bpython/repl.py:827 msgid "append" msgstr "" -#: bpython/repl.py:879 bpython/repl.py:1192 +#: bpython/repl.py:839 bpython/repl.py:1143 #, python-format msgid "Error writing file '%s': %s" msgstr "" -#: bpython/repl.py:881 +#: bpython/repl.py:841 #, python-format msgid "Saved to %s." msgstr "" -#: bpython/repl.py:887 +#: bpython/repl.py:847 msgid "No clipboard available." msgstr "" -#: bpython/repl.py:894 +#: bpython/repl.py:854 msgid "Could not copy to clipboard." msgstr "" -#: bpython/repl.py:896 +#: bpython/repl.py:856 msgid "Copied content to clipboard." msgstr "" -#: bpython/repl.py:905 +#: bpython/repl.py:865 msgid "Pastebin buffer? (y/N) " msgstr "" -#: bpython/repl.py:907 +#: bpython/repl.py:867 msgid "Pastebin aborted." msgstr "" -#: bpython/repl.py:915 +#: bpython/repl.py:875 #, python-format msgid "Duplicate pastebin. Previous URL: %s. Removal URL: %s" msgstr "" -#: bpython/repl.py:921 +#: bpython/repl.py:881 msgid "Posting data to pastebin..." msgstr "" -#: bpython/repl.py:925 +#: bpython/repl.py:885 #, python-format msgid "Upload failed: %s" msgstr "" -#: bpython/repl.py:934 +#: bpython/repl.py:894 #, python-format msgid "Pastebin URL: %s - Removal URL: %s" msgstr "" -#: bpython/repl.py:939 +#: bpython/repl.py:899 #, python-format msgid "Pastebin URL: %s" msgstr "" -#: bpython/repl.py:977 +#: bpython/repl.py:937 #, python-format msgid "Undo how many lines? (Undo will take up to ~%.1f seconds) [1]" msgstr "" -#: bpython/repl.py:985 bpython/repl.py:989 +#: bpython/repl.py:945 bpython/repl.py:949 msgid "Undo canceled" msgstr "" -#: bpython/repl.py:992 +#: 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:1172 +#: bpython/repl.py:1128 msgid "Config file does not exist - create new from default? (y/N)" msgstr "" -#: bpython/repl.py:1202 +#: bpython/repl.py:1153 msgid "bpython config file edited. Restart bpython for changes to take effect." msgstr "" -#: bpython/repl.py:1208 +#: bpython/repl.py:1158 #, python-format msgid "Error editing config file: %s" msgstr "" -#: bpython/urwid.py:628 +#: 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:1177 +#: bpython/urwid.py:1116 msgid "Run twisted reactor." msgstr "" -#: bpython/urwid.py:1182 +#: bpython/urwid.py:1121 msgid "Select specific reactor (see --help-reactors). Implies --twisted." msgstr "" -#: bpython/urwid.py:1190 +#: bpython/urwid.py:1129 msgid "List available reactors for -r." msgstr "" -#: bpython/urwid.py:1195 +#: 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:1204 +#: bpython/urwid.py:1143 msgid "Port to run an eval server on (forces Twisted)." msgstr "" -#: bpython/urwid.py:1396 +#: 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:350 +#: bpython/curtsiesfrontend/repl.py:339 msgid "Welcome to bpython!" msgstr "" -#: bpython/curtsiesfrontend/repl.py:352 +#: bpython/curtsiesfrontend/repl.py:341 #, python-format msgid "Press <%s> for help." msgstr "" -#: bpython/curtsiesfrontend/repl.py:673 +#: bpython/curtsiesfrontend/repl.py:681 #, python-format msgid "Executing PYTHONSTARTUP failed: %s" msgstr "" -#: bpython/curtsiesfrontend/repl.py:691 +#: bpython/curtsiesfrontend/repl.py:698 #, python-format msgid "Reloaded at %s because %s modified." msgstr "" -#: bpython/curtsiesfrontend/repl.py:993 +#: bpython/curtsiesfrontend/repl.py:1008 msgid "Session not reevaluated because it was not edited" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1006 +#: bpython/curtsiesfrontend/repl.py:1023 msgid "Session not reevaluated because saved file was blank" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1016 +#: bpython/curtsiesfrontend/repl.py:1033 msgid "Session edited and reevaluated" msgstr "" -#: bpython/curtsiesfrontend/repl.py:1027 +#: bpython/curtsiesfrontend/repl.py:1044 #, python-format msgid "Reloaded at %s by user." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1033 +#: bpython/curtsiesfrontend/repl.py:1050 msgid "Auto-reloading deactivated." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1038 +#: bpython/curtsiesfrontend/repl.py:1055 msgid "Auto-reloading active, watching for file changes..." msgstr "" -#: bpython/curtsiesfrontend/repl.py:1044 +#: bpython/curtsiesfrontend/repl.py:1061 msgid "Auto-reloading not available because watchdog not installed." msgstr "" -#~ msgid "Error editing config file." -#~ 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 eb91147af..d4899332d 100644 --- a/bpython/urwid.py +++ b/bpython/urwid.py @@ -1,6 +1,3 @@ -# encoding: utf-8 - -# # The MIT License # # Copyright (c) 2010-2011 Marien Zwart @@ -23,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. @@ -33,36 +32,18 @@ This is still *VERY* rough. """ - -from __future__ import absolute_import, division, print_function - import sys import os import time import locale import signal -from optparse import Option -from six.moves import range -from six import iteritems, string_types - -from pygments.token import Token +import urwid from . import args as bpargs, repl, translations -from ._py3compat import py3 -from .config import getpreferredencoding from .formatter import theme_map -from .importcompletion import find_coroutine from .translations import _ - from .keys import urwid_key_dispatch as key_dispatch -import urwid - -if not py3: - import inspect - -Parenthesis = Token.Punctuation.Parenthesis - # Urwid colors are: # 'black', 'dark red', 'dark green', 'brown', 'dark blue', # 'dark magenta', 'dark cyan', 'light gray', 'dark gray', @@ -92,13 +73,12 @@ else: class EvalProtocol(basic.LineOnlyReceiver): - 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) @@ -115,41 +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. - 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): @@ -162,7 +108,7 @@ class StatusbarEdit(urwid.Edit): 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: @@ -170,14 +116,13 @@ def keypress(self, size, 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") -class Statusbar(object): - +class Statusbar: """Statusbar object, ripped off from bpython.cli. This class provides the status bar at the bottom of the screen. @@ -252,7 +197,7 @@ 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() @@ -280,17 +225,11 @@ def _on_prompt_enter(self, edit, new_text): 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, string_types): - 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): @@ -305,7 +244,6 @@ def format_tokens(tokensource): class BPythonEdit(urwid.Edit): - """Customized editor *very* tightly interwoven with URWIDRepl. Changes include: @@ -337,10 +275,10 @@ def __init__(self, config, *args, **kwargs): 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): @@ -381,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 @@ -389,21 +327,21 @@ 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 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 @@ -440,10 +378,10 @@ def keypress(self, size, key): if not (cpos or len(line) % self.tab_length or line.strip()): 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 @@ -451,7 +389,7 @@ 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 @@ -462,13 +400,12 @@ class BPythonListBox(urwid.ListBox): """ 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. @@ -480,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 @@ -549,7 +489,8 @@ def render(self, size, focus=False): 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) self.callback = None @@ -586,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): +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 @@ -603,7 +546,7 @@ 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 = "" # native str (bytes in Py2, unicode in Py3) + self.stdout_hist = "" # native str (unicode in Py3) self.frame = urwid.Frame(self.overlay) @@ -780,16 +723,15 @@ def _populate_completion(self): if self.complete(): if self.funcprops: # This is mostly just stolen from the cli module. - func_name, args, is_bound = self.funcprops + func_name = self.funcprops.func + args = self.funcprops.argspec.args + is_bound = self.funcprops.is_bound_method in_arg = self.arg_pos - args, varargs, varkw, defaults = args[:4] - if py3: - kwonly = self.funcprops.argspec.kwonly - kwonly_defaults = ( - self.funcprops.argspec.kwonly_defaults or {} - ) - else: - kwonly, kwonly_defaults = [], {} + 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 @@ -813,13 +755,7 @@ def _populate_completion(self): if k == in_arg or i == in_arg: 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)]) if k != len(args) - 1: @@ -908,7 +844,6 @@ def reevaluate(self): self.f_string = "" self.buffer = [] self.scr.erase() - self.s_hist = [] # Set cursor position to -1 to prevent paren matching self.cpos = -1 @@ -916,14 +851,8 @@ 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") @@ -955,16 +884,12 @@ def write(self, s): 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 @@ -974,7 +899,7 @@ 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) + return super().push(s, insert_into_history) except SystemExit as e: self.exit_value = e.args raise urwid.ExitMainLoop() @@ -1013,23 +938,16 @@ 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. if not more: caption = ("prompt", self.ps1) - if py3: - self.stdout_hist += self.ps1 - else: - self.stdout_hist += self.ps1.encode(getpreferredencoding()) + self.stdout_hist += self.ps1 else: caption = ("prompt_more", self.ps2) - if py3: - self.stdout_hist += self.ps2 - else: - self.stdout_hist += self.ps2.encode(getpreferredencoding()) + self.stdout_hist += self.ps2 self.edit = BPythonEdit(self.config, caption=caption) urwid.connect_signal(self.edit, "change", self.on_input_change) @@ -1074,11 +992,7 @@ def handle_input(self, event): inp = self.edit.get_edit_text() self.history.append(inp) self.edit.make_readonly() - # XXX what is this s_hist thing? - if py3: - self.stdout_hist += inp - else: - self.stdout_hist += inp.encode(locale.getpreferredencoding()) + self.stdout_hist += inp self.stdout_hist += "\n" self.edit = None # This may take a while, so force a redraw first: @@ -1163,47 +1077,48 @@ def tab(self, back=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)."), - ), - ], + options_callback, ), ) @@ -1213,7 +1128,7 @@ def main(args=None, locals_=None, banner=None): # 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 " @@ -1228,7 +1143,7 @@ def main(args=None, locals_=None, banner=None): "default", "bold" if color.isupper() else "default", ) - for name, color in iteritems(config.color_scheme) + for name, color in config.color_scheme.items() ] palette.extend( [ @@ -1255,7 +1170,7 @@ 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: @@ -1290,7 +1205,7 @@ def main(args=None, locals_=None, banner=None): 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) @@ -1298,7 +1213,7 @@ def main(args=None, locals_=None, banner=None): 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) @@ -1379,13 +1294,8 @@ def start(main_loop, user_data): # this is CLIRepl.startup inlined. 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) @@ -1399,26 +1309,18 @@ def start(main_loop, user_data): ) myrepl.write("\n") - if sys.version_info[0] == 2: - # XXX these deprecation warnings need to go at some point - myrepl.write( - _( - "WARNING: You are using `bpython` on Python 2. Support for Python 2 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()) diff --git a/data/org.bpython-interpreter.bpython.appdata.xml b/data/org.bpython-interpreter.bpython.metainfo.xml similarity index 100% rename from data/org.bpython-interpreter.bpython.appdata.xml rename to data/org.bpython-interpreter.bpython.metainfo.xml diff --git a/doc/sphinx/source/authors.rst b/doc/sphinx/source/authors.rst index c83e6aefb..d475229e0 100644 --- a/doc/sphinx/source/authors.rst +++ b/doc/sphinx/source/authors.rst @@ -5,4 +5,4 @@ Authors If you contributed to bpython and want to be on this list please find us (:ref:`community`) and let us know! -.. include:: ../../../AUTHORS +.. include:: ../../../AUTHORS.rst diff --git a/doc/sphinx/source/changelog.rst b/doc/sphinx/source/changelog.rst deleted file mode 120000 index b6b15a7d0..000000000 --- a/doc/sphinx/source/changelog.rst +++ /dev/null @@ -1 +0,0 @@ -../../../CHANGELOG \ No newline at end of file diff --git a/doc/sphinx/source/changelog.rst b/doc/sphinx/source/changelog.rst new file mode 100644 index 000000000..29e651cab --- /dev/null +++ b/doc/sphinx/source/changelog.rst @@ -0,0 +1,3 @@ +.. _changelog: + +.. include:: ../../../CHANGELOG.rst diff --git a/doc/sphinx/source/community.rst b/doc/sphinx/source/community.rst index 00b8c4093..911bbc4f6 100644 --- a/doc/sphinx/source/community.rst +++ b/doc/sphinx/source/community.rst @@ -10,8 +10,8 @@ These are the places where you can find us. IRC --- -You can find us in `#bpython `_ on the `Freenode -`_ network. Don't worry when you get no response (this does +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. diff --git a/doc/sphinx/source/conf.py b/doc/sphinx/source/conf.py index 99f63344e..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. @@ -16,7 +15,7 @@ # 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,20 +24,20 @@ 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-2015 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 @@ -46,172 +45,180 @@ # # The short X.Y version. -version_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), - '../../../bpython/_version.py') +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('\'', '') + version = vf.read().strip().split("=")[-1].replace("'", "") # The full version, including alpha/beta/rc tags. 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 = [ +# 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 {curtsies, curses, 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 c51eb7efd..1521542ed 100644 --- a/doc/sphinx/source/configuration-options.rst +++ b/doc/sphinx/source/configuration-options.rst @@ -15,14 +15,20 @@ 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). - -As of version 0.14 this option has no effect, but is reserved for later use. +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 +brackets_completion +^^^^^^^^^^^^^^^^^^^ +Whether opening character of the pairs ``()``, ``[]``, ``""``, and ``''`` should be auto-closed +(default: False). + +.. versionadded:: 0.23 + .. _configuration_color_scheme: color_scheme @@ -91,10 +97,9 @@ 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 @@ -105,59 +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 -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 (default: https://bpaste.net/show/$paste_id/). - -pastebin_removal_url -^^^^^^^^^^^^^^^^^^^^ -The url under which a paste can be removed. ``$removal_id`` will be replaced by -the removal ID of the paste (default: https://bpaste.net/remova/$removal_id/). - -.. versionadded:: 0.14 - pastebin_url ^^^^^^^^^^^^ The pastebin url to post to (without a trailing slash). This pastebin has to be -a pastebin which uses provides a similar interface to ``bpaste.net``'s JSON -interface (default: https://bpaste.net/json/new). +a pastebin which provides a similar interface to ``bpaste.net``'s JSON +interface (default: https://bpaste.net). save_append_py ^^^^^^^^^^^^^^ @@ -183,10 +180,16 @@ Soft tab size (default 4, see PEP-8). unicode_box ^^^^^^^^^^^ -Whether to use Unicode characters to draw boxes. +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 -------- This section refers to the ``[keyboard]`` section in your @@ -309,7 +312,7 @@ Brings up sincerely cheerful description of bpython features and current key bin .. versionadded:: 0.14 incremental_search -^^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^ Default: M-s Perform incremental search on all stored lines in the history. @@ -409,7 +412,7 @@ yank_from_buffer ^^^^^^^^^^^^^^^^ Default: C-y -Pastes the current line from the buffer (the one you previously cutted) +Pastes the current line from the buffer (the one you previously cut) CLI --- diff --git a/doc/sphinx/source/configuration.rst b/doc/sphinx/source/configuration.rst index 6b07f8a6e..1f559e15f 100644 --- a/doc/sphinx/source/configuration.rst +++ b/doc/sphinx/source/configuration.rst @@ -4,7 +4,7 @@ Configuration ============= 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/.config/bpython/config`` [#f1]_. +saved to ``$XDG_CONFIG_HOME/bpython/config`` [#f1]_. .. include:: configuration-options.rst diff --git a/doc/sphinx/source/contributing.rst b/doc/sphinx/source/contributing.rst index 430aabc6e..3b93089df 100644 --- a/doc/sphinx/source/contributing.rst +++ b/doc/sphinx/source/contributing.rst @@ -10,15 +10,15 @@ 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, +`#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 ------------------------------------------- -bpython supports Python 2.7, 3.4 and newer. The code is compatible with all -supported versions without the need to run post processing like `2to3`. +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 @@ -30,7 +30,10 @@ environment with # necessary every time you work on bpython $ source bpython-dev/bin/activate -Fork bpython in the GitHub web interface, then clone the repo: +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. + +Then, clone the forked repo: .. code-block:: bash @@ -47,7 +50,7 @@ Next install your development copy of bpython and its dependencies: # install optional dependencies $ pip install watchdog urwid # development dependencies - $ pip install sphinx mock nose + $ pip install sphinx pytest # this runs your modified copy of bpython! $ bpython @@ -60,14 +63,12 @@ Next install your development copy of bpython and its dependencies: .. code-block:: bash - $ sudp apt-get install python-greenlet python-pygments python-requests - $ sudo apt-get install python-watchdog python-urwid - $ sudo apt-get install python-sphinx python-mock python-nose + $ sudo apt install python3-greenlet python3-pygments python3-requests + $ sudo apt install python3-watchdog python3-urwid + $ sudo apt install python3-sphinx python3-pytest - Remember to replace ``python`` with ``python3`` in every package name if - you intend to develop with Python 3. You also need to run `virtualenv` with - `--system-site-packages` packages, if you want to use the packages provided - by your distribution. + You also need to run `virtualenv` with `--system-site-packages` packages, if + you want to use the packages provided by your distribution. .. note:: @@ -76,7 +77,7 @@ Next install your development copy of bpython and its dependencies: .. code-block:: bash - $ sudo apt-get install gcc python-dev + $ sudo apt install gcc python3-dev As a first dev task, I recommend getting `bpython` to print your name every time you hit a specific key. @@ -85,14 +86,8 @@ To run tests from the bpython directory: .. code-block:: bash - $ nosetests - -If you want to skip test cases that are known to be slow, run `nosetests` in -the following way: - -.. code-block:: bash + $ pytest - $ nosetests -A "speed != 'slow'" Building the documentation -------------------------- diff --git a/doc/sphinx/source/django.rst b/doc/sphinx/source/django.rst index 443649505..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 its 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 3322f2c6c..f209d2971 100644 --- a/doc/sphinx/source/index.rst +++ b/doc/sphinx/source/index.rst @@ -23,3 +23,4 @@ Contents: bpaste tips bpdb + simplerepl diff --git a/doc/sphinx/source/man-bpython.rst b/doc/sphinx/source/man-bpython.rst index 951300c6c..fe37a25fe 100644 --- a/doc/sphinx/source/man-bpython.rst +++ b/doc/sphinx/source/man-bpython.rst @@ -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. @@ -57,12 +57,12 @@ The following options are supported by all frontends: 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: --L, --log Write debugging messages to the file bpython.log. Use - -LL for more verbose logging. -p file, --paste=file Paste in the contents of a file at startup. In addition to the common options, :program:`bpython-urwid` also supports the diff --git a/doc/sphinx/source/releases.rst b/doc/sphinx/source/releases.rst index 36a4a9bc8..7d789f166 100644 --- a/doc/sphinx/source/releases.rst +++ b/doc/sphinx/source/releases.rst @@ -45,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.7, 3.4, 3.5 and 3.6. +* Runs under Python 3.9 - 3.13 * Save * Rewind * Pastebin @@ -59,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/bpython/simplerepl.py b/doc/sphinx/source/simplerepl.py similarity index 85% rename from bpython/simplerepl.py rename to doc/sphinx/source/simplerepl.py index 068f91b8a..8496f0dd6 100644 --- a/bpython/simplerepl.py +++ b/doc/sphinx/source/simplerepl.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - # The MIT License # # Copyright (c) 2015 the bpython authors. @@ -25,15 +23,15 @@ the methods of bpython.curtsiesrepl.repl.BaseRepl that must be overridden. """ -from __future__ import unicode_literals, print_function, absolute_import import time import logging -from .curtsiesfrontend.repl import BaseRepl -from .curtsiesfrontend import events as bpythonevents -from . import translations -from . import importcompletion +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 @@ -42,9 +40,9 @@ class SimpleRepl(BaseRepl): - def __init__(self): + def __init__(self, config): self.requested_events = [] - BaseRepl.__init__(self) + BaseRepl.__init__(self, config, window=None) def _request_refresh(self): self.requested_events.append(bpythonevents.RefreshRequestEvent()) @@ -54,7 +52,7 @@ def _schedule_refresh(self, when="now"): self.request_refresh() else: dt = round(when - time.time(), 1) - self.out("please refresh in {} seconds".format(dt)) + self.out(f"please refresh in {dt} seconds") def _request_reload(self, files_modified=("?",)): e = bpythonevents.ReloadEvent() @@ -67,7 +65,7 @@ def request_undo(self, n=1): def out(self, msg): if hasattr(self, "orig_stdout"): - self.orig_stdout.write((msg + "\n").encode("utf8")) + self.orig_stdout.write(f"{msg}\n") self.orig_stdout.flush() else: print(msg) @@ -92,7 +90,7 @@ def print_padded(s): print_padded("") self.out("X``" + ("`" * (self.width + 2)) + "``X") for line in arr: - self.out("X```" + unicode(line.ljust(self.width)) + "```X") + self.out("X```" + line.ljust(self.width) + "```X") logger.debug("line:") logger.debug(repr(line)) self.out("X``" + ("`" * (self.width + 2)) + "``X") @@ -119,9 +117,11 @@ def get_input(self): def main(args=None, locals_=None, banner=None): translations.init() - while importcompletion.find_coroutine(): + config = Config(default_config_path()) + module_gatherer = ModuleGatherer() + while module_gatherer.find_coroutine(): pass - with SimpleRepl() as r: + with SimpleRepl(config) as r: r.width = 50 r.height = 10 while True: 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/tips.rst b/doc/sphinx/source/tips.rst index 925097a87..0745e3bfa 100644 --- a/doc/sphinx/source/tips.rst +++ b/doc/sphinx/source/tips.rst @@ -16,13 +16,12 @@ equivalent file. .. code-block:: bash - alias bpython3.5='PYTHONPATH=~/python/bpython python3.5 -m bpython.cli' + 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 of course add multiple aliases, so you can run bpython with 2.7 and the -3 series. +You can of course add multiple aliases. .. note:: diff --git a/doc/sphinx/source/windows.rst b/doc/sphinx/source/windows.rst index 6d2c05a0b..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 curses frontend like you would on a Linux -system (e.g. by typing `bpython-curses` on your prompt). diff --git a/pyproject.toml b/pyproject.toml index ad3cc4caf..40efff3e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,10 @@ +[build-system] +requires = ["setuptools >= 62.4.0"] +build-backend = "setuptools.build_meta" + [tool.black] line-length = 80 -target_version = ["py27"] +target_version = ["py311"] include = '\.pyi?$' exclude = ''' /( @@ -15,5 +19,6 @@ exclude = ''' | build | dist | bpython/test/fodder + | doc )/ ''' diff --git a/requirements.txt b/requirements.txt index 78619ff24..cc8fbff84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ Pygments -curtsies >=0.1.18 +curtsies >=0.4.0 +cwcwidth greenlet +pyxdg requests -setuptools -six >=1.5 +setuptools>=62.4.0 diff --git a/setup.cfg b/setup.cfg index 2a9acf13d..e17199211 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,84 @@ -[bdist_wheel] -universal = 1 +[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 24ec50055..de10eaf44 100755 --- a/setup.py +++ b/setup.py @@ -1,22 +1,15 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - +#!/usr/bin/env python3 import os import platform import re import subprocess -import sys -from distutils.command.build import build -from setuptools import setup -from setuptools.command.install import install as _install +from setuptools import setup, Command +from setuptools.command.build import build 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 + from babel.messages import frontend as babel using_translations = True except ImportError: @@ -24,15 +17,212 @@ try: import sphinx - from sphinx.setup_command import BuildDoc - # Sphinx 1.1.2 is buggy and building bpython with that version fails. - # See #241. - using_sphinx = sphinx.__version__ >= "1.1.3" + # Sphinx 1.5 and newer support Python 3.6 + using_sphinx = sphinx.__version__ >= "1.5" except ImportError: using_sphinx = False +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 @@ -86,13 +276,12 @@ def git_describe_to_python_version(version): try: # get version from git describe proc = subprocess.Popen( - ["git", "describe", "--tags"], + ["git", "describe", "--tags", "--first-parent"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) stdout = proc.communicate()[0].strip() - if sys.version_info[0] > 2: - stdout = stdout.decode("ascii") + stdout = stdout.decode("ascii") if proc.returncode == 0: version = git_describe_to_python_version(stdout) @@ -103,92 +292,59 @@ def git_describe_to_python_version(version): try: # get version from existing version file with open(version_file) as vf: - version = vf.read().strip().split("=")[-1].replace("'", "") + version = ( + vf.read() + .strip() + .split("=")[-1] + .replace("'", "") + .replace('"', "") + ) version = version.strip() - except IOError: + 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("__version__ = '%s'\n" % (version,)) + vf.write(f'__version__ = "{version}"\n') -class install(_install): - """Force install to run build target.""" - +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 = {"build": build, "install": install} +cmdclass = {"build": custom_build} -from bpython import package_dir -translations_dir = os.path.join(package_dir, "translations") +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: + cmdclass["build_sphinx"] = BuildDoc + cmdclass["build_sphinx_man"] = BuildDoc - 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"]: + if platform.system() in ("FreeBSD", "OpenBSD"): man_dir = "man" else: man_dir = "share/man" @@ -209,67 +365,14 @@ def initialize_options(self): ), # AppData ( - os.path.join("share", "appinfo"), - ["data/org.bpython-interpreter.bpython.appdata.xml"], + 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) -classifiers = [ - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 3", -] - -install_requires = [ - "pygments", - "requests", - "curtsies >=0.1.18", - "greenlet", - "six >=1.5", -] - -extras_require = { - "urwid": ["urwid"], - "watch": ["watchdog"], - "jedi": ["jedi"], - # need requests[security] for SNI support (only before 2.7.7) - ':python_full_version == "2.7.0" or ' - 'python_full_version == "2.7.1" or ' - 'python_full_version == "2.7.2" or ' - 'python_full_version == "2.7.3" or ' - 'python_full_version == "2.7.4" or ' - 'python_full_version == "2.7.5" or ' - 'python_full_version == "2.7.6"': [ - "pyOpenSSL", - "pyasn1", - "ndg-httpsclient", - ], -} - -packages = [ - "bpython", - "bpython.curtsiesfrontend", - "bpython.test", - "bpython.test.fodder", - "bpython.translations", - "bpdb", -] - -entry_points = { - "console_scripts": [ - "bpython = bpython.curtsies:main", - "bpython-curses = bpython.cli:main", - "bpython-urwid = bpython.urwid:main [urwid]", - "bpdb = bpdb:main", - ] -} - -tests_require = [] -if sys.version_info[0] == 2: - tests_require.append("mock") - # translations mo_files = [] for language in os.listdir(translations_dir): @@ -277,30 +380,18 @@ def initialize_options(self): 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="https://www.bpython-interpreter.org/", - long_description="""bpython is a fancy interface to the Python - interpreter for Unix-like operating systems.""", - classifiers=classifiers, - install_requires=install_requires, - extras_require=extras_require, - tests_require=tests_require, - packages=packages, data_files=data_files, package_data={ "bpython": ["sample-config"], "bpython.translations": mo_files, "bpython.test": ["test.config", "test.theme"], }, - entry_points=entry_points, 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/stubs/watchdog/__init__.pyi b/stubs/watchdog/__init__.pyi new file mode 100644 index 000000000..e69de29bb 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 100% rename from light.theme rename to theme/light.theme diff --git a/sample.theme b/theme/sample.theme similarity index 100% rename from sample.theme rename to theme/sample.theme diff --git a/windows.theme b/theme/windows.theme similarity index 100% rename from windows.theme rename to theme/windows.theme