diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..bb3aaa6f --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[run] +source = . +branch = true +parallel = 1 + +[report] +show_missing = true +include = pynvim/*,test/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..8b8b0457 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,101 @@ +name: ci +on: + push: + pull_request: + branches: + - 'master' + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.repository_owner == 'neovim' && github.sha || github.ref_name }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + cache: 'pip' + python-version: 3.11 + - name: install dependencies + run: python3 -m pip install tox tox-gh-actions + - name: checkqa + run: tox run -e checkqa,docs + + test: + strategy: + fail-fast: false + matrix: + config: [ + # NOTE: don't forget updating tox.ini + { python-version: '3.13', neovim-version: 'nightly' }, + { python-version: '3.12', neovim-version: 'nightly' }, + { python-version: '3.12', neovim-version: 'stable' }, + { python-version: '3.11' }, + { python-version: '3.10' }, + # for python 3.7~3.9, use older version of OS (ubuntu-20.04 and macos-13) + { python-version: '3.9', ubuntu: '20.04', macos: '13' }, + { python-version: '3.8', ubuntu: '20.04', macos: '13' }, + { python-version: '3.7', ubuntu: '20.04', macos: '13' }, + ] + os: ['ubuntu', 'macos', 'windows'] + + name: + test (python ${{ matrix.config.python-version }}, + ${{ matrix.config.neovim-version || 'nightly' }}, + ${{ matrix.os }}-${{ matrix.config[matrix.os] || 'latest' }}) + runs-on: ${{ matrix.os }}-${{ matrix.config[matrix.os] || 'latest' }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + cache: 'pip' + python-version: ${{ matrix.config.python-version }} + + - name: install neovim (Linux/macOS) + if: runner.os != 'Windows' + run: | + set -eu -o pipefail + + NVIM_OS="$(echo "$RUNNER_OS" | tr '[:upper:]' '[:lower:]')" + if ! [ "$NVIM_OS" = "linux" ] && ! [ "$NVIM_OS" = "macos" ]; then + echo "RUNNER_OS=${RUNNER_OS} not supported"; exit 1; + fi + + NVIM_NAME="nvim-${NVIM_OS}-x86_64" + curl -LO "https://github.com/neovim/neovim/releases/download/${{ matrix.config.neovim-version || 'nightly' }}/${NVIM_NAME}.tar.gz" + tar xzf "${NVIM_NAME}.tar.gz" + echo "RUNNER_OS = $RUNNER_OS" + "$NVIM_NAME/bin/nvim" --version + + # update $PATH for later steps + echo "$(pwd)/$NVIM_NAME/bin" >> "$GITHUB_PATH" + + - name: install neovim (Windows) + if: runner.os == 'Windows' + run: | + curl -LO "https://github.com/neovim/neovim/releases/download/${{ matrix.config.neovim-version || 'nightly' }}/nvim-win64.zip" + unzip nvim-win64.zip + nvim-win64/bin/nvim --version + + # update $PATH for later steps + echo "$(pwd)/nvim-win64/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: install dependencies + run: | + python3 -m pip install -U pip + python3 -m pip install tox tox-gh-actions + + - name: check neovim + run: | + python3 -m pip install -e . # install pynvim + nvim --headless --clean -c 'checkhealth | %+print | q' + + - name: test with tox + run: | + echo $PATH + which nvim + which -a python3 + python3 --version + tox run diff --git a/.gitignore b/.gitignore index 6ea0120a..de238dd2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,13 @@ neovim/ui/screen.c neovim/ui/screen.so build/ dist/ +venv *.pyc +.cache +.eggs +.tox +.pytest_cache +.coverage* + +# Sphinx documentation +docs/_build/ diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 00000000..db303bdf --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,24 @@ +# readthedocs config file for Sphinx projects +# https://docs.readthedocs.io/en/stable/config-file/v2.html + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + # Fail on all warnings. + fail_on_warning: true + +python: + install: + - method: pip + path: . + extra_requirements: + - docs # pip install .[docs] diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index ab17321e..00000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,134 +0,0 @@ -checks: - python: - imports_relative_import: true - imports_wildcard_import: true - classes_no_self_argument: true - classes_bad_mcs_method_argument: true - classes_bad_classmethod_argument: true - code_rating: true - duplicate_code: true - variables_unused_variable: true - variables_unused_import: true - variables_unused_wildcard_import: true - variables_unused_argument: true - variables_global_variable_not_assigned: true - typecheck_redundant_keyword_arg: true - imports_import_self: true - format_superfluous_parens: true - exceptions_pointless_except: true - design_interface_not_implemented: true - design_abstract_class_not_used: true - basic_useless_else_on_loop: true - basic_unreachable: true - basic_unnecessary_pass: true - basic_unnecessary_lambda: true - basic_pointless_string_statement: true - basic_pointless_statement: true - basic_expression_not_assigned: true - variables_redefined_outer_name: true - variables_redefined_builtin: true - variables_redefine_in_handler: true - newstyle_bad_super_call: true - logging_not_lazy: true - exceptions_broad_except: true - exceptions_bare_except: true - classes_super_init_not_called: true - classes_protected_access: true - classes_non_parent_init_called: true - classes_bad_mcs_classmethod_argument: true - classes_attribute_defined_outside_init: true - classes_method_hidden: true - basic_lost_exception: true - basic_function_redefined: true - basic_exec_used: true - basic_eval_used: true - basic_dangerous_default_value: true - design_abstract_class_little_used: true - imports_deprecated_module: true - format_old_ne_operator: true - format_backtick: true - basic_old_raise_syntax: true - variables_used_before_assignment: true - variables_unpacking_non_sequence: true - variables_undefined_variable: true - variables_undefined_loop_variable: true - variables_undefined_all_variable: true - variables_unbalanced_tuple_unpacking: true - variables_no_name_in_module: true - variables_invalid_all_object: true - variables_global_variable_undefined: true - typecheck_unexpected_keyword_arg: true - typecheck_not_callable: true - typecheck_no_value_for_parameter: true - typecheck_no_member: true - typecheck_too_many_function_args: true - typecheck_missing_kwoa: true - typecheck_maybe_no_member: true - typecheck_duplicate_keyword_arg: true - typecheck_assignment_from_none: true - typecheck_assignment_from_no_return: true - string_unused_format_string_key: true - string_truncated_format_string: true - string_too_many_format_args: true - string_too_few_format_args: true - string_mixed_format_string: true - string_missing_format_string_key: true - string_format_needs_mapping: true - string_constant_anomalous_unicode_escape_in_string: true - string_constant_anomalous_backslash_in_string: true - string_bad_str_strip_call: true - string_bad_format_string_key: true - string_bad_format_character: true - open_mode_bad_open_mode: true - logging_unsupported_format: true - logging_too_many_args: true - logging_too_few_args: true - logging_format_truncated: true - imports_reimported: true - imports_import_error: true - imports_cyclic_import: true - exceptions_raising_string: true - exceptions_raising_non_exception: true - exceptions_raising_bad_type: true - exceptions_notimplemented_raised: true - exceptions_catching_non_exception: true - exceptions_bad_except_order: true - classes_valid_slots: true - classes_signature_differs: true - classes_non_iterator_returned: true - classes_no_method_argument: true - classes_missing_interface_method: true - classes_interface_is_not_class: true - classes_bad_staticmethod_argument: true - classes_bad_context_manager: true - classes_arguments_differ: true - classes_access_member_before_definition: true - classes_abstract_method: true - basic_yield_outside_function: true - basic_return_outside_function: true - basic_return_in_init: true - basic_return_arg_in_generator: true - basic_not_in_loop: true - basic_nonexistent_operator: true - basic_missing_reversed_argument: true - basic_missing_module_attribute: true - basic_init_is_generator: true - basic_duplicate_key: true - basic_duplicate_argument_name: true - basic_bad_reversed_sequence: true - basic_assert_on_tuple: true - basic_abstract_class_instantiated: true - format_lowercase_l_suffix: true - classes_no_self_use: true - classes_no_init: true - exceptions_binary_op_exception: true - variables_global_statement: true - -filter: - excluded_paths: - - test/* - -tools: - external_code_coverage: - timeout: 1200 - runs: 6 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6a731c3c..00000000 --- a/.travis.yml +++ /dev/null @@ -1,44 +0,0 @@ -sudo: false -language: python -env: - global: - - NOSE_NOLOGCAPTURE=true - - NOSE_VERBOSE=2 - - NOSE_WITH_COVERAGE=true - - NOSE_COVER_PACKAGE=neovim - matrix: - - CI_TARGET=tests -matrix: - include: - - python: 2.7 - env: CI_TARGET=flake - - python: 3.4 - env: CI_TARGET=flake -python: - # If the build matrix gets bigger, also update the number of runs - # at the bottom of .scrutinizer.yml. - - 2.6 - - 2.7 - - 3.3 - - 3.4 - - 3.5 - - pypy -before_install: - - if [ $CI_TARGET = tests ]; then - eval "$(curl -Ss https://raw.githubusercontent.com/neovim/bot-ci/master/scripts/travis-setup.sh) nightly-x64"; - pip install -q scrutinizer-ocular; - else - pip install -q flake8 flake8-import-order flake8-docstrings pep8-naming; - fi -install: - - pip install . -script: - - if [ $CI_TARGET = tests ]; then - nosetests; - else - flake8 neovim; - fi -after_script: - - if [ $CI_TARGET = tests ]; then - ocular; - fi diff --git a/MANIFEST.in b/MANIFEST.in index 64ad321d..e7edcd7c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ include README.md LICENSE +recursive-include test *.py diff --git a/README.md b/README.md index ce07b076..a05dca7f 100644 --- a/README.md +++ b/README.md @@ -1,175 +1,113 @@ -### Python client to [Neovim](https://github.com/neovim/neovim) +Pynvim: Python client to [Neovim](https://github.com/neovim/neovim) +=================================================================== -[![Build Status](https://travis-ci.org/neovim/python-client.svg?branch=master)](https://travis-ci.org/neovim/python-client) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/neovim/python-client/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/neovim/python-client/?branch=master) -[![Code Coverage](https://scrutinizer-ci.com/g/neovim/python-client/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/neovim/python-client/?branch=master) +[![Documentation Status](https://readthedocs.org/projects/pynvim/badge/?version=latest)](https://readthedocs.org/projects/pynvim/builds/) +[![Code coverage](https://codecov.io/gh/neovim/pynvim/branch/master/graph/badge.svg)](https://codecov.io/gh/neovim/pynvim) -Implements support for python plugins in Nvim. Also works as a library for +Pynvim implements support for python plugins in Nvim. It also works as a library for connecting to and scripting Nvim processes through its msgpack-rpc API. -#### Installation +Install +------- -```sh -pip2 install neovim -pip3 install neovim -``` +Supports python 3.7 or later. -If you only use one of python2 or python3, it is enough to install that -version. You can install the package without being root by adding the `--user` -flag. +- Installation option #1: install using uv (recommended): -If you follow Neovim master, make sure to upgrade the python-client when you -upgrade neovim: -```sh -pip2 install --upgrade neovim -pip3 install --upgrade neovim -``` + - Install uv (https://docs.astral.sh/uv/). -Alternatively, the master version could be installed by executing the following -in the root of this repository: -```sh -pip2 install . -pip3 install . -``` + - Install pynvim (the `--upgrade` switch ensures installation of the latest + version): -#### Python Plugin API + uv tool install --upgrade pynvim -Neovim has a new mechanism for defining plugins, as well as a number of -extensions to the python API. The API extensions are accessible no matter if the -traditional `:python` interface or the new mechanism is used, as discussed -below. + - Anytime you upgrade Neovim, make sure to upgrade pynvim as well by + re-running the above command. -* `vim.funcs` exposes vimscript functions (both builtin and global user defined - functions) as a python namespace. For instance to set the value of the value - of a register +- Installation option #2: install using pipx: - `vim.funcs.setreg('0', ["some", "text"], 'l')` + - Install pipx (https://pipx.pypa.io/stable/). -* The API is not thread-safe in general. However, `vim.async_call` allows a - spawned thread to schedule code to be executed on the main thread. This method - could also be called from `:python` or a synchronous request handler, to defer - some execution that shouldn't block nvim. + - Install pynvim (the `--upgrade` switch ensures installation of the latest + version): - `:python vim.async_call(myfunc, args...)` + pipx install --upgrade pynvim - Note that this code will still block the plugin host if it does long-running - computations. Intensive computations should be done in a separate thread (or - process), and `vim.async_call` can be used to send results back to nvim. + - Anytime you upgrade Neovim, make sure to upgrade pynvim as well by + re-running the above command. -* Some methods accept an extra keyword-only argument `async`: `vim.eval`, - `vim.command` as well as the `vim.funcs` wrappers. The python host will not - wait for nvim to complete the request, which also means that the return value - is unavailable. +- Other installation options: -#### Remote (new-style) plugins + - See [pynvim installation + documentation](https://pynvim.readthedocs.io/en/latest/installation.html) + for additional installation options and information. -Neovim allows python plugins to be defined by placing python files or packages -in `rplugin/python3/` (in a runtimepath folder). These follow the structure of -this example: +Python Plugin API +----------------- -```python -import neovim +Pynvim supports python _remote plugins_ (via the language-agnostic Nvim rplugin +interface), as well as _Vim plugins_ (via the `:python3` interface). Thus when +pynvim is installed Neovim will report support for the `+python3` Vim feature. -@neovim.plugin -class TestPlugin(object): +The rplugin interface allows plugins to handle vimL function calls as well as +defining commands and autocommands, and such plugins can operate asynchronously +without blocking nvim. For details on the new rplugin interface, +see the [Remote Plugin](http://pynvim.readthedocs.io/en/latest/usage/remote-plugins.html) documentation. - def __init__(self, nvim): - self.nvim = nvim +Pynvim defines some extensions over the vim python API: - @neovim.function("TestFunction", sync=True) - def testfunction(self, args): - return 3 +* Builtin and plugin vimL functions are available as `nvim.funcs` +* API functions are available as `vim.api` and for objects such as `buffer.api` +* Lua functions can be defined using `vim.exec_lua` and called with `vim.lua` +* Support for thread-safety and async requests. - @neovim.command("TestCommand", range='', nargs='*') - def testcommand(self, args, range): - self.nvim.current.line = ('Command with args: {}, range: {}' - .format(args, range)) +See the [Python Plugin API](http://pynvim.readthedocs.io/en/latest/usage/python-plugin-api.html) documentation for usage of this new functionality. - @neovim.autocmd('BufEnter', pattern='*.py', eval='expand("")', sync=True) - def on_bufenter(self, filename): - self.nvim.out_write("testplugin is in " + filename + "\n") -``` +### Known Issues +- Vim evaluates `'v:'` to ``, whereas neovim evaluates to ``. This is expected behaviour due to the way booleans are implemented in python as explained [here](https://github.com/neovim/pynvim/issues/523#issuecomment-1495502011). -If `sync=True` is supplied nvim will wait for the handler to finish (this is -required for function return values), but by default handlers are executed -asynchronously. +Development +----------- -You need to run `:UpdateRemotePlugins` in nvim for changes in the specifications -to have effect. For details see `:help remote-plugin` in nvim. +Use (and activate) a local virtualenv, for example: -#### Development + python3 -m virtualenv venv + source venv/bin/activate -If you change the code, you need to run -```sh -pip2 install . -pip3 install . -``` -for the changes to have effect. Alternatively you could execute neovim -with the `$PYTHONPATH` environment variable -``` -PYTHONPATH=/path/to/python-client nvim -``` -But note this is not completely reliable as installed packages can appear before -`$PYTHONPATH` in the python search path. +If you change the code, you must reinstall for the changes to take effect: -You need to rerun this command if you have changed the code, in order for nvim -to use it for the plugin host. + pip install . -To run the tests execute +Use `pytest` to run the tests. Invoking with `python -m` prepends the current +directory to `sys.path` (otherwise `pytest` might find other versions!): -```sh -nosetests -``` + python -m pytest -This will run the tests in an embedded instance of nvim. -If you want to test a different version than `nvim` in `$PATH` use -```sh -NVIM_CHILD_ARGV='["/path/to/nvim", "-u", "NONE", "--embed"]' nosetests -``` - -Alternatively, if you want to see the state of nvim, you could use - -```sh -export NVIM_LISTEN_ADDRESS=/tmp/nvimtest -xterm -e "nvim -u NONE"& -nosetests -``` - -But note you need to restart nvim every time you run the tests! Substitute your -favorite terminal emulator for `xterm`. +For details about testing and troubleshooting, see the +[development](http://pynvim.readthedocs.io/en/latest/development.html) +documentation. -#### Troubleshooting - -You can run the plugin host in nvim with logging enabled to debug errors: -``` -NVIM_PYTHON_LOG_FILE=logfile NVIM_PYTHON_LOG_LEVEL=DEBUG nvim -``` -As more than one python host process might be started, the log filenames take -the pattern `logfile_PID` where `PID` is the process id. - -If the host cannot start at all, the error could be found in `~/.nvimlog` if -`nvim` was compiled with logging. - -#### Usage through the python REPL +### Usage from the Python REPL A number of different transports are supported, but the simplest way to get -started is with the python REPL. First, start Nvim with a known address (or use -the `$NVIM_LISTEN_ADDRESS` of a running instance): +started is with the python REPL. First, start Nvim with a known address: ```sh -$ NVIM_LISTEN_ADDRESS=/tmp/nvim nvim +$ nvim --listen /tmp/nvim.sock ``` +Or alternatively, note the `v:servername` address of a running Nvim instance. + In another terminal, connect a python REPL to Nvim (note that the API is similar to the one exposed by the [python-vim bridge](http://vimdoc.sourceforge.net/htmldoc/if_pyth.html#python-vim)): ```python ->>> from neovim import attach -# Create a python API session attached to unix domain socket created above: ->>> nvim = attach('socket', path='/tmp/nvim') -# Now do some work. ->>> buffer = nvim.buffers[0] # Get the first buffer +>>> import pynvim +# Create a session attached to Nvim's address (`v:servername`). +>>> nvim = pynvim.attach('socket', path='/tmp/nvim.sock') +# Now do some work. +>>> buffer = nvim.current.buffer # Get the current buffer >>> buffer[0] = 'replace first line' >>> buffer[:] = ['replace whole buffer'] >>> nvim.command('vsplit') @@ -179,12 +117,73 @@ bridge](http://vimdoc.sourceforge.net/htmldoc/if_pyth.html#python-vim)): [1, 2, 3] ``` -You can embed neovim into your python application instead of binding to a -running neovim instance. +You can embed Neovim into your python application instead of connecting to +a running Neovim instance. ```python ->>> from neovim import attach ->>> nvim = attach('child', argv=["/bin/env", "nvim", "--embed"]) +>>> import pynvim +>>> nvim = pynvim.attach('child', argv=["/usr/bin/env", "nvim", "--embed", "--headless"]) ``` -The tests can be consulted for more examples. +- The `--headless` argument tells `nvim` not to wait for a UI to connect. +- Alternatively, use `--embed` _without_ `--headless` if your client is a UI + and you want `nvim` to wait for your client to `nvim_ui_attach` before + continuing startup. + +See the [tests](https://github.com/neovim/pynvim/tree/master/test) for more examples. + +Release +------- + +1. Create a release commit with title `Pynvim x.y.z` + - list significant changes in the commit message + - bump the version in `pynvim/_version.py` +2. Push to `master`. + ``` + git push + ``` +3. Make a release on GitHub with the same commit/version tag and copy the message. +4. Run `scripts/disable_log_statements.sh` +5. Run `pipx run build` +6. (Validation) Diff the release tarball `dist/pynvim-x.y.z.tar.gz` against the previous one. + - Fetch the previous tar.gz from https://pypi.org/manage/project/pynvim/releases/ + - Unzip both. + - Unzip both. + - Diff them with `:DiffTool old/ new/` (plugin: https://github.com/deathbeam/difftool.nvim) +7. Run `pipx run twine upload -r pypi dist/*` + - Assumes you have a pypi account with permissions. +8. Run `scripts/enable_log_statements.sh` or `git reset --hard` to restore the working dir. +9. Bump up to the next development version in `pynvim/_version.py`, with `prerelease` suffix `dev0`. + +### Releasing with bump-my-version + +`bump-my-version` automates the process of updating version strings, creating git commits, and tagging releases. + +1. **Install `bump-my-version`:** + If you haven't already, install the development dependencies: + ```bash + pip install .[dev] + ``` + +2. **Bump the version:** + To increment the version, use one of the following commands: + * **Patch release:** `bump-my-version bump patch` (e.g., `0.6.1` -> `0.6.2`) + * **Minor release:** `bump-my-version bump minor` (e.g., `0.6.1` -> `0.7.0`) + * **Major release:** `bump-my-version bump major` (e.g., `0.6.1` -> `1.0.0`) + + This command will: + * Update the `version` in `pyproject.toml`. + * Update the `VERSION` in `pynvim/_version.py`. + * Create a git commit with a message like "Bump version: 0.6.1 → 0.6.2". + * Create a git tag (e.g., `v0.6.2`). + +3. **Push changes and tags:** + After bumping the version, push the commit and the new tag to your remote repository: + ```bash + git push --follow-tags + ``` + +License +------- + +[Apache License 2.0](https://github.com/neovim/pynvim/blob/master/LICENSE) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..39bbacc0 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,6 @@ +coverage: + status: + project: true + patch: true + changes: true +comment: false diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..3792b341 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = Neovim +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/api/buffer.rst b/docs/api/buffer.rst new file mode 100644 index 00000000..1b8ca715 --- /dev/null +++ b/docs/api/buffer.rst @@ -0,0 +1,5 @@ +Buffer Class +============ + +.. autoclass:: pynvim.api.Buffer + :members: diff --git a/docs/api/nvim.rst b/docs/api/nvim.rst new file mode 100644 index 00000000..28dfa7e7 --- /dev/null +++ b/docs/api/nvim.rst @@ -0,0 +1,7 @@ +Nvim Class +========== + +An instance of this class is used by remote plugins. + +.. autoclass:: pynvim.api.Nvim + :members: diff --git a/docs/api/tabpage.rst b/docs/api/tabpage.rst new file mode 100644 index 00000000..caf4666c --- /dev/null +++ b/docs/api/tabpage.rst @@ -0,0 +1,5 @@ +Tabpage Class +============= + +.. autoclass:: pynvim.api.Tabpage + :members: diff --git a/docs/api/window.rst b/docs/api/window.rst new file mode 100644 index 00000000..5ea146f8 --- /dev/null +++ b/docs/api/window.rst @@ -0,0 +1,5 @@ +Window Class +============ + +.. autoclass:: pynvim.api.Window + :members: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..ce10fb60 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Neovim documentation build configuration file, created by +# sphinx-quickstart on Sat Feb 3 12:15:22 2018. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# 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. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('./')) +import datetime + + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ['sphinx.ext.autodoc', + 'sphinx.ext.viewcode'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'Neovim Python Client' +copyright = '2014 - {year}, Neovim'.format( + year=datetime.datetime.now().year +) +author = 'Neovim' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '' +# The full version, including alpha/beta/rc tags. +release = '' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = 'en' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# 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 = {} + +# 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 = [] + + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Neovimdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'Neovim.tex', 'Neovim Documentation', + 'Neovim', 'manual'), +] + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'neovim', 'Neovim Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'Neovim', 'Neovim Documentation', + author, 'Neovim', 'One line description of project.', + 'Miscellaneous'), +] + + + diff --git a/docs/development.rst b/docs/development.rst new file mode 100644 index 00000000..de08e769 --- /dev/null +++ b/docs/development.rst @@ -0,0 +1,109 @@ +Development +=========== + +If you change the code, you need to run:: + + pip3 install . + +for the changes to have effect. + +Alternatively you could execute Nvim with the ``$PYTHONPATH`` environment variable:: + + PYTHONPATH=/path/to/pynvim nvim + +But note this is not completely reliable, +as installed packages can appear before ``$PYTHONPATH`` in the python search path. + +You need to rerun this command if you have changed the code, +in order for Nvim to use it for the plugin host. + +To run the tests execute:: + + python -m pytest + +This will run the tests in an embedded instance of Nvim, with the current +directory added to ``sys.path``. + +If you want to test a different version than ``nvim`` in ``$PATH`` use:: + + NVIM_CHILD_ARGV='["/path/to/nvim", "--clean", "--embed", "--headless"]' pytest + +Alternatively, if you want to see the state of nvim, you could use:: + + export NVIM=/tmp/nvimtest + xterm -e "nvim --listen $NVIM -u NONE" & + python -m pytest + +But note you need to restart Nvim every time you run the tests! +Substitute your favorite terminal emulator for ``xterm``. + +Contributing +------------ + +Before submitting any pull requests, please run linters and tests if possible. + +In the CI we run `flake8` and `mypy`: + + flake8 pynvim test + mypy pynvim test + +If you have `tox`_, you can test with multiple python versions locally: + + tox run # run on all available python environments + tox run -e py311,checkqa # run on python3.11, and linters + tox run --parallell # run everything in parallel + +.. _`tox`: https://tox.wiki/ + +Troubleshooting +--------------- + +You can run the plugin host in Nvim with logging enabled to debug errors:: + + NVIM_PYTHON_LOG_FILE=logfile NVIM_PYTHON_LOG_LEVEL=DEBUG nvim + +As more than one Python host process might be started, +the log filenames take the pattern ``logfile_py3_KIND`` +where ``KIND`` is either "rplugin" or "script" (for the ``:python3`` script +interface). + +If the host cannot start at all, +the error could be found in ``~/.nvimlog`` if ``nvim`` was compiled with logging. + +Usage through the Python REPL +----------------------------- + +A number of different transports are supported, +but the simplest way to get started is with the python REPL. +First, start Nvim with a known address (or use the ``v:servername`` of a running instance):: + + nvim --listen /tmp/nvim.sock + +In another terminal, +connect a python REPL to Nvim (note that the API is similar to the one exposed by the `python-vim bridge`_): + +.. code-block:: python + + >>> from pynvim import attach + # Create a session attached to Nvim's address (`v:servername`). + >>> nvim = attach('socket', path='/tmp/nvim.sock') + # Now do some work. + >>> buffer = nvim.current.buffer # Get the current buffer + >>> buffer[0] = 'replace first line' + >>> buffer[:] = ['replace whole buffer'] + >>> nvim.command('vsplit') + >>> nvim.windows[1].width = 10 + >>> nvim.vars['global_var'] = [1, 2, 3] + >>> nvim.eval('g:global_var') + [1, 2, 3] + +.. _`python-vim bridge`: http://vimdoc.sourceforge.net/htmldoc/if_pyth.html#python-vim + +You can embed Nvim into your python application instead of binding to a running neovim instance: + +.. code-block:: python + + >>> from pynvim import attach + >>> nvim = attach('child', argv=["/bin/env", "nvim", "--embed", "--headless"]) + +The tests can be consulted for more examples. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..285b9048 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,36 @@ +Neovim Python Client +==================== + +Implements support for Python plugins in `Neovim`_. +Also works as a library for connecting to and scripting Neovim processes through its msgpack-rpc API. + +.. _`Neovim`: http://neovim.io/ + +.. toctree:: + :caption: Getting started + :maxdepth: 2 + + installation + +.. toctree:: + :caption: Usage + :maxdepth: 2 + + usage/python-plugin-api + usage/remote-plugins + +.. toctree:: + :caption: API documentation + :maxdepth: 2 + + plugin-decorators + api/nvim + api/buffer + api/window + api/tabpage + +.. toctree:: + :caption: Development + :maxdepth: 2 + + development diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 00000000..9f2fc25e --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,138 @@ +Installation +============ + +The Neovim Python client supports Python 3.7 or later. + +Using uv or pipx +---------------- + +For automatic detection by Neovim, pynvim should be installed in a dedicated +Python virtual environment and the ``pynvim-python`` executable should be placed +on the ``PATH``. The recommended approach for this is to use a tool like uv +(https://docs.astral.sh/uv/) or pipx (https://pipx.pypa.io/stable/); the +``--upgrade`` switch ensures installation of the latest version: + +- Install using uv (recommended):: + + uv tool install --upgrade pynvim + +- Install using pipx:: + + pipx install --upgrade pynvim + +**NOTE** For Neovim before v0.12.0, set the variable ``python3_host_prog`` in +``init.vim`` to point to ``pynvim-python``:: + + let g:python3_host_prog = 'pynvim-python' + +Using manually created Python virtual environment +------------------------------------------------- + +Alternatively, you may manually create a Python virtual environment +(https://docs.python.org/3.13/library/venv.html):: + + python3 -m venv pynvim-venv + +Then install pynvim into the virtual environment; the +``--upgrade`` switch ensures installation of the latest version:: + +- For Unix:: + + pynvim-venv/bin/python -m pip install --upgrade pynvim + +- For Windows:: + + pynvim-venv\Scripts\python -m pip install --upgrade pynvim + +Then copy the ``pynvim-python`` executable somewhere on the ``PATH``: + +- For Unix:: + + # Assuming `~/.local/bin` is on `PATH`: + cp pynvim-venv/bin/pynvim-python ~/.local/bin/pynvim-python + +- For Windows:: + + REM Assuming `C:\apps` is on `PATH`: + copy pynvim-venv\Scripts\pynvim-python.exe C:\apps\pynvim-python.exe + +**NOTE** For Neovim before v0.12.0, set the variable ``python3_host_prog`` in +``init.vim`` to point to ``pynvim-python``:: + + let g:python3_host_prog = 'pynvim-python' + +Install from source +------------------- + +Clone the repository somewhere on your disk and enter to the repository:: + + git clone https://github.com/neovim/pynvim.git + cd pynvim + +Now you can install it following the instructions above, using ``.`` instead of +``pynvim``; the ``--upgrade`` switch ensures installation of the latest version: + +- Install from source using uv:: + + uv tool install --upgrade . + +- Install from source using pipx:: + + pipx install --upgrade . + +- Install from source using manually created Python virtual environment: + + - Create ``pynvim-venv`` as above. + + - Install: + + - For Unix:: + + pynvim-venv/bin/python -m pip install --upgrade . + + - For Windows:: + + pynvim-venv\Scripts\python -m pip install --upgrade . + + - Copy ``pynvim-python`` executable as above. + +**NOTE** For Neovim before v0.12.0, set the variable ``python3_host_prog`` in +``init.vim`` to point to ``pynvim-python``:: + + let g:python3_host_prog = 'pynvim-python' + +Upgrade pynvim when upgrading Neovim +------------------------------------ + +Make sure to upgrade ``pynvim`` when you upgrade Neovim. Follow the previous +instructions; the ``--upgrade`` switch will ensure installation of the latest +version. + +Explicitly choosing pynvim virtual environment +---------------------------------------------- + +As an alternative to exposing ``pynvim-python`` on ``PATH``, you may configure +Neovim to use a specific Python interpreter that has pynvim installed; this may +be useful when working on pynvim itself. + +After installing into a virtual environment named ``pynvim-venv``, add the +following into Neovim's ``init.vim`` file: + +- For Unix:: + + let g:python3_host_prog = '/path/to/pynvim-venv/bin/python' + +- For Windows:: + + let g:python3_host_prog = 'c:\path\to\pynvim-venv\bin\python.exe' + +Installing outside of a virtual environment is deprecated +--------------------------------------------------------- + +Installing into the per-user Python site package area is a deprecated practice +with recent Python versions. For example, the following command fails on Ubuntu +24.04 with the error message ``error: externally-managed-environment``:: + + pip install --user pynvim + +Instead, always install into a virtual environment. diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..9f606e2e --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,36 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build +set SPHINXPROJ=Neovim + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/docs/plugin-decorators.rst b/docs/plugin-decorators.rst new file mode 100644 index 00000000..efcc61bf --- /dev/null +++ b/docs/plugin-decorators.rst @@ -0,0 +1,26 @@ +Plugin Decorators +================= + +.. module:: pynvim.plugin + +Plugin decorators. + +Plugin +------ + +.. autofunction:: plugin + +Command +------- + +.. autofunction:: command + +Autocmd +------- + +.. autofunction:: autocmd + +Function +-------- + +.. autofunction:: function diff --git a/docs/usage/python-plugin-api.rst b/docs/usage/python-plugin-api.rst new file mode 100644 index 00000000..de21af0c --- /dev/null +++ b/docs/usage/python-plugin-api.rst @@ -0,0 +1,123 @@ +Python Plugin API +================= + +Neovim has a new mechanism for defining plugins, +as well as a number of extensions to the python API. +The API extensions are accessible no matter if the traditional ``:python`` interface or the new mechanism is used, +as discussed on :doc:`remote-plugins`. + +Nvim API methods: ``vim.api`` +----------------------------- + +Exposes Neovim API methods. +For instance to call ``nvim_strwidth``: + +.. code-block:: python + + result = vim.api.strwidth("some text") + +Note the initial ``nvim_`` is not included. +Also, object methods can be called directly on their object: + +.. code-block:: python + + buf = vim.current.buffer + length = buf.api.line_count() + +calls ``nvim_buf_line_count``. +Alternatively msgpack requests can be invoked directly: + +.. code-block:: python + + result = vim.request("nvim_strwith", "some text") + length = vim.request("nvim_buf_line_count", buf) + +Both ``vim.api`` and ``vim.request`` can take an ``async_=True`` keyword argument +to instead send a msgpack notification. Nvim will execute the API method the +same way, but python will not wait for it to finish, so the return value is +unavailable. + +Vimscript functions: ``vim.funcs`` +---------------------------------- + +Exposes vimscript functions (both builtin and global user defined functions) as a python namespace. +For instance to set the value of a register: + +.. code-block:: python + + vim.funcs.setreg('0', ["some", "text"], 'l') + +These functions can also take the ``async_=True`` keyword argument, just like API +methods. + +Lua integration +--------------- + +Python plugins can define and invoke lua code in Nvim's in-process lua +interpreter. This is especially useful in asynchronous contexts, where an async +event handler can schedule a complex operation with many api calls to be +executed by nvim without interleaved processing of user input or other event +sources (unless requested). + +The recommended usage is the following pattern. First use ``vim.exec_lua(code)`` +to define a module with lua functions: + +.. code-block:: python + + vim.exec_lua(""" + local a = vim.api + local function add(a,b) + return a+b + end + + local function buffer_ticks() + local ticks = {} + for _, buf in ipairs(a.nvim_list_bufs()) do + ticks[#ticks+1] = a.nvim_buf_get_changedtick(buf) + end + return ticks + end + + _testplugin = {add=add, buffer_ticks=buffer_ticks} + """) + +Alternatively, place the code in ``/lua/testplugin.lua`` under your plugin repo +root, and use ``vim.exec_lua("_testplugin = require('testplugin')")``. +In both cases, replace ``testplugin`` with a unique string based on your plugin +name. + +Then, the module can be accessed as ``vim.lua._testplugin``. + +.. code-block:: python + + mod = vim.lua._testplugin + mod.add(2,3) # => 5 + mod.buffer_ticks() # => list of ticks + +These functions can also take the ``async_=True`` keyword argument, just like API +methods. + +It is also possible to pass arguments directly to a code block. Using +``vim.exec_lua(code, args...)``, the arguments will be available in lua as ``...``. + +Async calls +----------- + +The API is not thread-safe in general. +However, ``vim.async_call`` allows a spawned thread to schedule code to be executed on the main thread. +This method could also be called from ``:python`` or a synchronous request handler, +to defer some execution that shouldn't block Neovim: + +.. code-block:: vim + + :python vim.async_call(myfunc, args...) + +Note that this code will still block the plugin host if it does long-running computations. +Intensive computations should be done in a separate thread (or process), +and ``vim.async_call`` can be used to send results back to Neovim. + +Some methods accept an ``async_`` keyword argument: ``vim.eval``, +``vim.command``, ``vim.request`` as well as the ``vim.funcs``, ``vim.api` and +``vim.lua``` wrappers. When ``async_=True`` is passed the client will not wait +for Neovim to complete the request (which also means that the return value is +unavailable). diff --git a/docs/usage/remote-plugins.rst b/docs/usage/remote-plugins.rst new file mode 100644 index 00000000..8ab66454 --- /dev/null +++ b/docs/usage/remote-plugins.rst @@ -0,0 +1,99 @@ +.. _remote-plugins: + +Remote (new-style) plugins +========================== + +Neovim allows Python 3 plugins to be defined by placing python files or packages in ``rplugin/python3/`` (in a ``runtimepath`` folder). +Python 2 rplugins are also supported and placed in ``rplugin/python/``, +but are considered deprecated. +Further added library features will only be available on Python 3. +Rplugins follow the structure of this example: + +.. code-block:: python + + import pynvim + + @pynvim.plugin + class TestPlugin(object): + + def __init__(self, nvim): + self.nvim = nvim + + @pynvim.function('TestFunction', sync=True) + def testfunction(self, args): + return 3 + + @pynvim.command('TestCommand', nargs='*', range='') + def testcommand(self, args, range): + self.nvim.current.line = ('Command with args: {}, range: {}' + .format(args, range)) + + @pynvim.autocmd('BufEnter', pattern='*.py', eval='expand("")', sync=True) + def on_bufenter(self, filename): + self.nvim.out_write('testplugin is in ' + filename + '\n') + +If ``sync=True`` is supplied Neovim will wait for the handler to finish +(this is required for function return values), +but by default handlers are executed asynchronously. + +Normally async handlers (``sync=False``, the default) +are blocked while a synchronous handler is running. +This ensures that async handlers can call requests without Neovim confusing these requests with requests from a synchronous handler. +To execute an asynchronous handler even when other handlers are running, +add ``allow_nested=True`` to the decorator. +This handler must then not make synchronous Neovim requests, +but it can make asynchronous requests, i.e. passing ``async_=True``. + +.. note:: + + Plugin objects are constructed the first time any request of the class is + invoked. Any error in ``__init__`` will be reported as an error from this + first request. A well-behaved rplugin will not start executing until its + functionality is requested by the user. Initialize the plugin when user + invokes a command, or use an appropriate autocommand, e.g. FileType if it + makes sense to automatically start the plugin for a given filetype. Plugins + must not invoke API methods (or really do anything with non-trivial + side-effects) in global module scope, as the module might be loaded as part + of executing `UpdateRemotePlugins`. + +You need to run ``:UpdateRemotePlugins`` in Neovim for changes in the specifications to have effect. +For details see ``:help remote-plugin`` in Neovim. + +For local plugin development, it's a good idea to use an isolated vimrc: + +.. code-block:: console + + cat vimrc + let &runtimepath.=','.escape(expand(':p:h'), '\,') + +That appends the current directory to the Nvim runtime path so Nvim can +find your plugin. You can now invoke Neovim: + +.. code-block:: console + + nvim -u ./vimrc + +Then run ``:UpdateRemotePlugins`` and your plugin should be activated. + +In case you run into some issues, you can list your loaded plugins from inside +Neovim by running ``:scriptnames`` like so.: + +.. code-block:: vim + + :scriptnames + 1: ~/path/to/your/plugin-git-repo/vimrc + 2: /usr/share/nvim/runtime/filetype.vim + ... + 25: /usr/share/nvim/runtime/plugin/zipPlugin.vim + 26: ~/path/to/your/plugin-git-repo/plugin/lucid.vim + +You can also inspect the ``&runtimepath`` like this: + +.. code-block:: vim + + :set runtimepath + runtimepath=~/.config/nvim,/etc/xdg/nvim,~/.local/share/nvim/site,..., + ,~/g/path/to/your/plugin-git-repo + + " Or alternatively + :echo &rtp diff --git a/neovim/__init__.py b/neovim/__init__.py index 15c7b16f..31ce7c8f 100644 --- a/neovim/__init__.py +++ b/neovim/__init__.py @@ -1,128 +1,8 @@ """Python client for Nvim. -Client library for talking with Nvim processes via it's msgpack-rpc API. +This is a transition package. New projects should instead import pynvim package. """ -import logging -import os -import sys +import pynvim +from pynvim import * -from .api import Nvim -from .compat import IS_PYTHON3 -from .msgpack_rpc import (ErrorResponse, child_session, socket_session, - stdio_session, tcp_session) -from .plugin import (Host, autocmd, command, decode, encoding, function, - plugin, rpc_export, shutdown_hook) - - -__all__ = ('tcp_session', 'socket_session', 'stdio_session', 'child_session', - 'start_host', 'autocmd', 'command', 'encoding', 'decode', - 'function', 'plugin', 'rpc_export', 'Host', 'Nvim', - 'shutdown_hook', 'attach', 'setup_logging', 'ErrorResponse') - - -def start_host(session=None): - """Promote the current process into python plugin host for Nvim. - - Start msgpack-rpc event loop for `session`, listening for Nvim requests - and notifications. It registers Nvim commands for loading/unloading - python plugins. - - The sys.stdout and sys.stderr streams are redirected to Nvim through - `session`. That means print statements probably won't work as expected - while this function doesn't return. - - This function is normally called at program startup and could have been - defined as a separate executable. It is exposed as a library function for - testing purposes only. - """ - plugins = [] - for arg in sys.argv: - _, ext = os.path.splitext(arg) - if ext == '.py': - plugins.append(arg) - elif os.path.isdir(arg): - init = os.path.join(arg, '__init__.py') - if os.path.isfile(init): - plugins.append(arg) - - # This is a special case to support the old workaround of - # adding an empty .py file to make a package directory - # visible, and it should be removed soon. - for path in list(plugins): - dup = path + ".py" - if os.path.isdir(path) and dup in plugins: - plugins.remove(dup) - - if not plugins: - sys.exit('must specify at least one plugin as argument') - - setup_logging() - - if not session: - session = stdio_session() - host = Host(Nvim.from_session(session)) - host.start(plugins) - - -def attach(session_type, address=None, port=None, - path=None, argv=None, decode=None): - """Provide a nicer interface to create python api sessions. - - Previous machinery to create python api sessions is still there. This only - creates a facade function to make things easier for the most usual cases. - Thus, instead of: - from neovim import socket_session, Nvim - session = tcp_session(address=
, port=) - nvim = Nvim.from_session(session) - You can now do: - from neovim import attach - nvim = attach('tcp', address=
, port=) - And also: - nvim = attach('socket', path=) - nvim = attach('child', argv=) - nvim = attach('stdio') - """ - session = (tcp_session(address, port) if session_type == 'tcp' else - socket_session(path) if session_type == 'socket' else - stdio_session() if session_type == 'stdio' else - child_session(argv) if session_type == 'child' else - None) - - if not session: - raise Exception('Unknown session type "%s"' % session_type) - - if decode is None: - decode = IS_PYTHON3 - - return Nvim.from_session(session).with_decode(decode) - - -def setup_logging(): - """Setup logging according to environment variables.""" - logger = logging.getLogger(__name__) - if 'NVIM_PYTHON_LOG_FILE' in os.environ: - logfile = (os.environ['NVIM_PYTHON_LOG_FILE'].strip() + - '_' + str(os.getpid())) - handler = logging.FileHandler(logfile, 'w') - handler.formatter = logging.Formatter( - '%(asctime)s [%(levelname)s @ ' - '%(filename)s:%(funcName)s:%(lineno)s] %(process)s - %(message)s') - logging.root.addHandler(handler) - level = logging.INFO - if 'NVIM_PYTHON_LOG_LEVEL' in os.environ: - l = getattr(logging, - os.environ['NVIM_PYTHON_LOG_LEVEL'].strip(), - level) - if isinstance(l, int): - level = l - logger.setLevel(level) - - -# Required for python 2.6 -class NullHandler(logging.Handler): - def emit(self, record): - pass - - -if not logging.root.handlers: - logging.root.addHandler(NullHandler()) +__all__ = pynvim.__all__ diff --git a/neovim/api/__init__.py b/neovim/api/__init__.py index 7feb889f..3f7a8da2 100644 --- a/neovim/api/__init__.py +++ b/neovim/api/__init__.py @@ -1,15 +1,8 @@ """Nvim API subpackage. -This package implements a higher-level API that wraps msgpack-rpc `Session` -instances. +This is a transition package. New projects should instead import pynvim.api. """ +from pynvim import api +from pynvim.api import * -from .buffer import Buffer -from .common import decode_if_bytes, walk -from .nvim import Nvim, NvimError -from .tabpage import Tabpage -from .window import Window - - -__all__ = ('Nvim', 'Buffer', 'Window', 'Tabpage', 'NvimError', - 'decode_if_bytes', 'walk') +__all__ = api.__all__ diff --git a/neovim/api/buffer.py b/neovim/api/buffer.py deleted file mode 100644 index ab90b745..00000000 --- a/neovim/api/buffer.py +++ /dev/null @@ -1,204 +0,0 @@ -"""API for working with Nvim buffers.""" -from .common import Remote -from ..compat import IS_PYTHON3 - - -__all__ = ('Buffer') - - -if IS_PYTHON3: - basestring = str - - -class Buffer(Remote): - - """A remote Nvim buffer.""" - - _api_prefix = "buffer_" - - def __len__(self): - """Return the number of lines contained in a Buffer.""" - return self.request('buffer_line_count') - - def __getitem__(self, idx): - """Get a buffer line or slice by integer index. - - Indexes may be negative to specify positions from the end of the - buffer. For example, -1 is the last line, -2 is the line before that - and so on. - - When retrieving slices, omiting indexes(eg: `buffer[:]`) will bring - the whole buffer. - """ - if not isinstance(idx, slice): - return self._session.request('buffer_get_line', self, idx) - include_end = False - start = idx.start - end = idx.stop - if start is None: - start = 0 - if end is None: - end = -1 - include_end = True - return self._session.request('buffer_get_line_slice', self, start, end, - True, include_end) - - def __setitem__(self, idx, lines): - """Replace a buffer line or slice by integer index. - - Like with `__getitem__`, indexes may be negative. - - When replacing slices, omiting indexes(eg: `buffer[:]`) will replace - the whole buffer. - """ - if not isinstance(idx, slice): - if lines is None: - return self._session.request('buffer_del_line', self, idx) - else: - return self._session.request('buffer_set_line', self, idx, - lines) - if lines is None: - lines = [] - include_end = False - start = idx.start - end = idx.stop - if start is None: - start = 0 - if end is None: - end = -1 - include_end = True - return self._session.request('buffer_set_line_slice', self, start, end, - True, include_end, lines) - - def __iter__(self): - """Iterate lines of a buffer. - - This will retrieve all lines locally before iteration starts. This - approach is used because for most cases, the gain is much greater by - minimizing the number of API calls by transfering all data needed to - work. - """ - lines = self[:] - for line in lines: - yield line - - def __delitem__(self, idx): - """Delete line or slice of lines from the buffer. - - This is the same as __setitem__(idx, []) - """ - if not isinstance(idx, slice): - self.__setitem__(idx, None) - else: - self.__setitem__(idx, []) - - def get_line_slice(self, start, stop, start_incl, end_incl): - """More flexible wrapper for retrieving slices.""" - return self._session.request('buffer_get_line_slice', self, start, - stop, start_incl, end_incl) - - def set_line_slice(self, start, stop, start_incl, end_incl, lines): - """More flexible wrapper for replacing slices.""" - return self._session.request('buffer_set_line_slice', self, start, - stop, start_incl, end_incl, lines) - - def append(self, lines, index=-1): - """Append a string or list of lines to the buffer.""" - if isinstance(lines, basestring): - lines = [lines] - return self._session.request('buffer_insert', self, index, lines) - - def mark(self, name): - """Return (row, col) tuple for a named mark.""" - return self.request('buffer_get_mark', name) - - def range(self, start, end): - """Return a `Range` object, which represents part of the Buffer.""" - return Range(self, start, end) - - def add_highlight(self, hl_group, line, col_start=0, - col_end=-1, src_id=-1, async=None): - """Add a highlight to the buffer.""" - if async is None: - async = (src_id != 0) - return self.request('buffer_add_highlight', src_id, hl_group, - line, col_start, col_end, async=async) - - def clear_highlight(self, src_id, line_start=0, line_end=-1, async=True): - """clear highlights from the buffer.""" - self.request('buffer_clear_highlight', src_id, - line_start, line_end, async=async) - - @property - def name(self): - """Get the buffer name.""" - return self.request('buffer_get_name') - - @name.setter - def name(self, value): - """Set the buffer name. BufFilePre/BufFilePost are triggered.""" - return self.request('buffer_set_name', value) - - @property - def valid(self): - """Return True if the buffer still exists.""" - return self.request('buffer_is_valid') - - @property - def number(self): - """Get the buffer number.""" - return self.request('buffer_get_number') - - -class Range(object): - def __init__(self, buffer, start, end): - self._buffer = buffer - self.start = start - 1 - self.end = end - 1 - - def __len__(self): - return self.end - self.start + 1 - - def __getitem__(self, idx): - if not isinstance(idx, slice): - return self._buffer[self._normalize_index(idx)] - start = self._normalize_index(idx.start) - end = self._normalize_index(idx.stop) - if start is None: - start = self.start - if end is None: - end = self.end + 1 - return self._buffer[start:end] - - def __setitem__(self, idx, lines): - if not isinstance(idx, slice): - self._buffer[self._normalize_index(idx)] = lines - return - start = self._normalize_index(idx.start) - end = self._normalize_index(idx.stop) - if start is None: - start = self.start - if end is None: - end = self.end + 1 - self._buffer[start:end] = lines - - def __iter__(self): - for i in range(self.start, self.end + 1): - yield self._buffer[i] - - def append(self, lines, i=None): - i = self._normalize_index(i) - if i is None: - i = self.end + 1 - self._buffer.append(lines, i) - - def _normalize_index(self, index): - if index is None: - return None - if index < 0: - index = self.end - else: - index += self.start - if index > self.end: - index = self.end - return index diff --git a/neovim/api/common.py b/neovim/api/common.py deleted file mode 100644 index eb7da905..00000000 --- a/neovim/api/common.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Code shared between the API classes.""" -import functools - - -class Remote(object): - - """Base class for Nvim objects(buffer/window/tabpage). - - Each type of object has it's own specialized class with API wrappers around - the msgpack-rpc session. This implements equality which takes the remote - object handle into consideration. - """ - - def __init__(self, session, code_data): - """Initialize from session and code_data immutable object. - - The `code_data` contains serialization information required for - msgpack-rpc calls. It must be immutable for Buffer equality to work. - """ - self._session = session - self.code_data = code_data - self.api = RemoteApi(self, self._api_prefix) - self.vars = RemoteMap(self, self._api_prefix + 'get_var', - self._api_prefix + 'set_var') - self.options = RemoteMap(self, self._api_prefix + 'get_option', - self._api_prefix + 'set_option') - - def __eq__(self, other): - """Return True if `self` and `other` are the same object.""" - return (hasattr(other, 'code_data') and - other.code_data == self.code_data) - - def __hash__(self): - """Return hash based on remote object id.""" - return self.code_data.__hash__() - - def request(self, name, *args, **kwargs): - """Wrapper for nvim.request.""" - return self._session.request(name, self, *args, **kwargs) - - -class RemoteApi(object): - - """Wrapper to allow api methods to be called like python methods.""" - - def __init__(self, obj, api_prefix): - """Initialize a RemoteApi with object and api prefix.""" - self._obj = obj - self._api_prefix = api_prefix - - def __getattr__(self, name): - """Return wrapper to named api method.""" - return functools.partial(self._obj.request, self._api_prefix + name) - - -class RemoteMap(object): - - """Represents a string->object map stored in Nvim. - - This is the dict counterpart to the `RemoteSequence` class, but it is used - as a generic way of retrieving values from the various map-like data - structures present in Nvim. - - It is used to provide a dict-like API to vim variables and options. - """ - - def __init__(self, obj, get_method, set_method=None, self_obj=None): - """Initialize a RemoteMap with session, getter/setter and self_obj.""" - self._get = functools.partial(obj.request, get_method) - self._set = None - if set_method: - self._set = functools.partial(obj.request, set_method) - - def __getitem__(self, key): - """Return a map value by key.""" - return self._get(key) - - def __setitem__(self, key, value): - """Set a map value by key(if the setter was provided).""" - if not self._set: - raise TypeError('This dict is read-only') - self._set(key, value) - - def __delitem__(self, key): - """Delete a map value by associating None with the key.""" - if not self._set: - raise TypeError('This dict is read-only') - return self._set(key, None) - - def __contains__(self, key): - """Check if key is present in the map.""" - try: - self._get(key) - return True - except Exception: - return False - - def get(self, key, default=None): - """Return value for key if present, else a default value.""" - try: - return self._get(key) - except Exception: - return default - - -class RemoteSequence(object): - - """Represents a sequence of objects stored in Nvim. - - This class is used to wrap msgapck-rpc functions that work on Nvim - sequences(of lines, buffers, windows and tabpages) with an API that - is similar to the one provided by the python-vim interface. - - For example, the 'buffers' property of the `Nvim class is a RemoteSequence - sequence instance, and the expression `nvim.buffers[0]` is translated to - session.request('vim_get_buffers')[0]. - - It can also receive an optional self_obj that will be passed as first - argument of the request. For example, `tabpage.windows[0]` is translated - to: session.request('tabpage_get_windows', tabpage_instance)[0]. - - One important detail about this class is that all methods will fetch the - sequence into a list and perform the necessary manipulation - locally(iteration, indexing, counting, etc). - """ - - def __init__(self, session, method): - """Initialize a RemoteSequence with session, method and self_obj.""" - self._fetch = functools.partial(session.request, method) - - def __len__(self): - """Return the length of the remote sequence.""" - return len(self._fetch()) - - def __getitem__(self, idx): - """Return a sequence item by index.""" - if not isinstance(idx, slice): - return self._fetch()[idx] - return self._fetch()[idx.start:idx.stop] - - def __iter__(self): - """Return an iterator for the sequence.""" - items = self._fetch() - for item in items: - yield item - - def __contains__(self, item): - """Check if an item is present in the sequence.""" - return item in self._fetch() - - -def _identity(obj, session, method, kind): - return obj - - -def decode_if_bytes(obj, mode=True): - """Decode obj if it is bytes.""" - if mode is True: - mode = "strict" - if isinstance(obj, bytes): - return obj.decode("utf-8", errors=mode) - return obj - - -def walk(fn, obj, *args, **kwargs): - """Recursively walk an object graph applying `fn`/`args` to objects.""" - if type(obj) in [list, tuple]: - return list(walk(fn, o, *args) for o in obj) - if type(obj) is dict: - return dict((walk(fn, k, *args), walk(fn, v, *args)) for k, v in - obj.items()) - return fn(obj, *args, **kwargs) diff --git a/neovim/api/nvim.py b/neovim/api/nvim.py deleted file mode 100644 index 21728884..00000000 --- a/neovim/api/nvim.py +++ /dev/null @@ -1,386 +0,0 @@ -"""Main Nvim interface.""" -import functools -import os -import sys - -from traceback import format_exc, format_stack - -from msgpack import ExtType - -from .buffer import Buffer -from .common import (Remote, RemoteApi, RemoteMap, RemoteSequence, - decode_if_bytes, walk) -from .tabpage import Tabpage -from .window import Window -from ..compat import IS_PYTHON3 - - -__all__ = ('Nvim') - - -os_chdir = os.chdir - - -class Nvim(object): - - """Class that represents a remote Nvim instance. - - This class is main entry point to Nvim remote API, it is a wrapper - around Session instances. - - The constructor of this class must not be called directly. Instead, the - `from_session` class method should be used to create the first instance - from a raw `Session` instance. - - Subsequent instances for the same session can be created by calling the - `with_decode` instance method to change the decoding behavior or - `SubClass.from_nvim(nvim)` where `SubClass` is a subclass of `Nvim`, which - is useful for having multiple `Nvim` objects that behave differently - without one affecting the other. - """ - - @classmethod - def from_session(cls, session): - """Create a new Nvim instance for a Session instance. - - This method must be called to create the first Nvim instance, since it - queries Nvim metadata for type information and sets a SessionHook for - creating specialized objects from Nvim remote handles. - """ - session.error_wrapper = lambda e: NvimError(e[1]) - channel_id, metadata = session.request(b'vim_get_api_info') - - if IS_PYTHON3: - # decode all metadata strings for python3 - metadata = walk(decode_if_bytes, metadata) - - types = { - metadata['types']['Buffer']['id']: Buffer, - metadata['types']['Window']['id']: Window, - metadata['types']['Tabpage']['id']: Tabpage, - } - - return cls(session, channel_id, metadata, types) - - @classmethod - def from_nvim(cls, nvim): - """Create a new Nvim instance from an existing instance.""" - return cls(nvim._session, nvim.channel_id, nvim.metadata, - nvim.types, nvim._decode, nvim._err_cb) - - def __init__(self, session, channel_id, metadata, types, - decode=False, err_cb=None): - """Initialize a new Nvim instance. This method is module-private.""" - self._session = session - self.channel_id = channel_id - self.metadata = metadata - self.types = types - self.api = RemoteApi(self, 'vim_') - self.vars = RemoteMap(self, 'vim_get_var', 'vim_set_var') - self.vvars = RemoteMap(self, 'vim_get_vvar', None) - self.options = RemoteMap(self, 'vim_get_option', 'vim_set_option') - self.buffers = RemoteSequence(self, 'vim_get_buffers') - self.windows = RemoteSequence(self, 'vim_get_windows') - self.tabpages = RemoteSequence(self, 'vim_get_tabpages') - self.current = Current(self) - self.funcs = Funcs(self) - self.error = NvimError - self._decode = decode - self._err_cb = err_cb - - def _from_nvim(self, obj, decode=None): - if decode is None: - decode = self._decode - if type(obj) is ExtType: - cls = self.types[obj.code] - return cls(self, (obj.code, obj.data)) - if decode: - obj = decode_if_bytes(obj, decode) - return obj - - def _to_nvim(self, obj): - if isinstance(obj, Remote): - return ExtType(*obj.code_data) - return obj - - def request(self, name, *args, **kwargs): - r"""Send an API request or notification to nvim. - - It is rarely needed to call this function directly, as most API - functions have python wrapper functions. The `api` object can - be also be used to call API functions as methods: - - vim.api.err_write('ERROR\n', async=True) - vim.current.buffer.api.get_mark('.') - - is equivalent to - - vim.request('vim_err_write', 'ERROR\n', async=True) - vim.request('buffer_get_mark', vim.current.buffer, '.') - - - Normally a blocking request will be sent. If the `async` flag is - present and True, a asynchronous notification is sent instead. This - will never block, and the return value or error is ignored. - """ - decode = kwargs.pop('decode', self._decode) - args = walk(self._to_nvim, args) - res = self._session.request(name, *args, **kwargs) - return walk(self._from_nvim, res, decode=decode) - - def next_message(self): - """Block until a message(request or notification) is available. - - If any messages were previously enqueued, return the first in queue. - If not, run the event loop until one is received. - """ - msg = self._session.next_message() - if msg: - return walk(self._from_nvim, msg) - - def run_loop(self, request_cb, notification_cb, - setup_cb=None, err_cb=None): - """Run the event loop to receive requests and notifications from Nvim. - - This should not be called from a plugin running in the host, which - already runs the loop and dispatches events to plugins. - """ - def filter_request_cb(name, args): - args = walk(self._from_nvim, args) - result = request_cb(self._from_nvim(name), args) - return walk(self._to_nvim, result) - - def filter_notification_cb(name, args): - notification_cb(self._from_nvim(name), walk(self._from_nvim, args)) - - if err_cb is None: - err_cb = sys.stderr.write - self._err_cb = err_cb - - self._session.run(filter_request_cb, filter_notification_cb, setup_cb) - - def stop_loop(self): - """Stop the event loop being started with `run_loop`.""" - self._session.stop() - - def with_decode(self, decode=True): - """Initialize a new Nvim instance.""" - return Nvim(self._session, self.channel_id, - self.metadata, self.types, decode, self._err_cb) - - def ui_attach(self, width, height, rgb): - """Register as a remote UI. - - After this method is called, the client will receive redraw - notifications. - """ - return self.request('ui_attach', width, height, rgb) - - def ui_detach(self): - """Unregister as a remote UI.""" - return self.request('ui_detach') - - def ui_try_resize(self, width, height): - """Notify nvim that the client window has resized. - - If possible, nvim will send a redraw request to resize. - """ - return self.request('ui_try_resize', width, height) - - def subscribe(self, event): - """Subscribe to a Nvim event.""" - return self.request('vim_subscribe', event) - - def unsubscribe(self, event): - """Unsubscribe to a Nvim event.""" - return self.request('vim_unsubscribe', event) - - def command(self, string, **kwargs): - """Execute a single ex command.""" - return self.request('vim_command', string, **kwargs) - - def command_output(self, string): - """Execute a single ex command and return the output.""" - return self.request('vim_command_output', string) - - def eval(self, string, **kwargs): - """Evaluate a vimscript expression.""" - return self.request('vim_eval', string, **kwargs) - - def call(self, name, *args, **kwargs): - """Call a vimscript function.""" - return self.request('vim_call_function', name, args, **kwargs) - - def strwidth(self, string): - """Return the number of display cells `string` occupies. - - Tab is counted as one cell. - """ - return self.request('vim_strwidth', string) - - def list_runtime_paths(self): - """Return a list of paths contained in the 'runtimepath' option.""" - return self.request('vim_list_runtime_paths') - - def foreach_rtp(self, cb): - """Invoke `cb` for each path in 'runtimepath'. - - Call the given callable for each path in 'runtimepath' until either - callable returns something but None, the exception is raised or there - are no longer paths. If stopped in case callable returned non-None, - vim.foreach_rtp function returns the value returned by callable. - """ - for path in self.request('vim_list_runtime_paths'): - try: - if cb(path) is not None: - break - except Exception: - break - - def chdir(self, dir_path): - """Run os.chdir, then all appropriate vim stuff.""" - os_chdir(dir_path) - return self.request('vim_change_directory', dir_path) - - def feedkeys(self, keys, options='', escape_csi=True): - """Push `keys` to Nvim user input buffer. - - Options can be a string with the following character flags: - - 'm': Remap keys. This is default. - - 'n': Do not remap keys. - - 't': Handle keys as if typed; otherwise they are handled as if coming - from a mapping. This matters for undo, opening folds, etc. - """ - return self.request('vim_feedkeys', keys, options, escape_csi) - - def input(self, bytes): - """Push `bytes` to Nvim low level input buffer. - - Unlike `feedkeys()`, this uses the lowest level input buffer and the - call is not deferred. It returns the number of bytes actually - written(which can be less than what was requested if the buffer is - full). - """ - return self.request('vim_input', bytes) - - def replace_termcodes(self, string, from_part=False, do_lt=True, - special=True): - r"""Replace any terminal code strings by byte sequences. - - The returned sequences are Nvim's internal representation of keys, - for example: - - -> '\x1b' - -> '\r' - -> '\x0c' - -> '\x80ku' - - The returned sequences can be used as input to `feedkeys`. - """ - return self.request('vim_replace_termcodes', string, - from_part, do_lt, special) - - def out_write(self, msg): - """Print `msg` as a normal message.""" - return self.request('vim_out_write', msg) - - def err_write(self, msg, **kwargs): - """Print `msg` as an error message.""" - return self.request('vim_err_write', msg, **kwargs) - - def quit(self, quit_command='qa!'): - """Send a quit command to Nvim. - - By default, the quit command is 'qa!' which will make Nvim quit without - saving anything. - """ - try: - self.command(quit_command) - except IOError: - # sending a quit command will raise an IOError because the - # connection is closed before a response is received. Safe to - # ignore it. - pass - - def new_highlight_source(self): - """Return new src_id for use with Buffer.add_highlight.""" - return self.current.buffer.add_highlight("", 0, src_id=0) - - def async_call(self, fn, *args, **kwargs): - """Schedule `fn` to be called by the event loop soon. - - This function is thread-safe, and is the only way code not - on the main thread could interact with nvim api objects. - - This function can also be called in a synchronous - event handler, just before it returns, to defer execution - that shouldn't block neovim. - """ - call_point = ''.join(format_stack(None, 5)[:-1]) - - def handler(): - try: - fn(*args, **kwargs) - except Exception as err: - msg = ("error caught while executing async callback:\n" - "{0!r}\n{1}\n \nthe call was requested at\n{2}" - .format(err, format_exc(5), call_point)) - self._err_cb(msg) - raise - self._session.threadsafe_call(handler) - - -class Current(object): - - """Helper class for emulating vim.current from python-vim.""" - - def __init__(self, session): - self._session = session - self.range = None - - @property - def line(self): - return self._session.request('vim_get_current_line') - - @line.setter - def line(self, line): - return self._session.request('vim_set_current_line', line) - - @property - def buffer(self): - return self._session.request('vim_get_current_buffer') - - @buffer.setter - def buffer(self, buffer): - return self._session.request('vim_set_current_buffer', buffer) - - @property - def window(self): - return self._session.request('vim_get_current_window') - - @window.setter - def window(self, window): - return self._session.request('vim_set_current_window', window) - - @property - def tabpage(self): - return self._session.request('vim_get_current_tabpage') - - @tabpage.setter - def tabpage(self, tabpage): - return self._session.request('vim_set_current_tabpage', tabpage) - - -class Funcs(object): - - """Helper class for functional vimscript interface.""" - - def __init__(self, nvim): - self._nvim = nvim - - def __getattr__(self, name): - return functools.partial(self._nvim.call, name) - - -class NvimError(Exception): - pass diff --git a/neovim/api/tabpage.py b/neovim/api/tabpage.py deleted file mode 100644 index 7742ba39..00000000 --- a/neovim/api/tabpage.py +++ /dev/null @@ -1,30 +0,0 @@ -"""API for working with Nvim tabpages.""" -from .common import Remote, RemoteSequence - - -__all__ = ('Tabpage') - - -class Tabpage(Remote): - """A remote Nvim tabpage.""" - - _api_prefix = "tabpage_" - - def __init__(self, *args): - """Initialize from session and code_data immutable object. - - The `code_data` contains serialization information required for - msgpack-rpc calls. It must be immutable for Buffer equality to work. - """ - super(Tabpage, self).__init__(*args) - self.windows = RemoteSequence(self, 'tabpage_get_windows') - - @property - def window(self): - """Get the `Window` currently focused on the tabpage.""" - return self.request('tabpage_get_window') - - @property - def valid(self): - """Return True if the tabpage still exists.""" - return self.request('tabpage_is_valid') diff --git a/neovim/api/window.py b/neovim/api/window.py deleted file mode 100644 index b02ebe97..00000000 --- a/neovim/api/window.py +++ /dev/null @@ -1,67 +0,0 @@ -"""API for working with Nvim windows.""" -from .common import Remote - - -__all__ = ('Window') - - -class Window(Remote): - - """A remote Nvim window.""" - - _api_prefix = "window_" - - @property - def buffer(self): - """Get the `Buffer` currently being displayed by the window.""" - return self.request('window_get_buffer') - - @property - def cursor(self): - """Get the (row, col) tuple with the current cursor position.""" - return self.request('window_get_cursor') - - @cursor.setter - def cursor(self, pos): - """Set the (row, col) tuple as the new cursor position.""" - return self.request('window_set_cursor', pos) - - @property - def height(self): - """Get the window height in rows.""" - return self.request('window_get_height') - - @height.setter - def height(self, height): - """Set the window height in rows.""" - return self.request('window_set_height', height) - - @property - def width(self): - """Get the window width in rows.""" - return self.request('window_get_width') - - @width.setter - def width(self, width): - """Set the window height in rows.""" - return self.request('window_set_width', width) - - @property - def row(self): - """0-indexed, on-screen window position(row) in display cells.""" - return self.request('window_get_position')[0] - - @property - def col(self): - """0-indexed, on-screen window position(col) in display cells.""" - return self.request('window_get_position')[1] - - @property - def tabpage(self): - """Get the `Tabpage` that contains the window.""" - return self.request('window_get_tabpage') - - @property - def valid(self): - """Return True if the window still exists.""" - return self.request('window_is_valid') diff --git a/neovim/compat.py b/neovim/compat.py deleted file mode 100644 index 3a33faff..00000000 --- a/neovim/compat.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Code for supporting compatibility across python versions.""" - -import sys -from imp import find_module as original_find_module - - -IS_PYTHON3 = sys.version_info >= (3, 0) - - -if IS_PYTHON3: - def find_module(fullname, path): - """Compatibility wrapper for imp.find_module. - - Automatically decodes arguments of find_module, in Python3 - they must be Unicode - """ - if isinstance(fullname, bytes): - fullname = fullname.decode() - if isinstance(path, bytes): - path = path.decode() - elif isinstance(path, list): - newpath = [] - for element in path: - if isinstance(element, bytes): - newpath.append(element.decode()) - else: - newpath.append(element) - path = newpath - return original_find_module(fullname, path) - - # There is no 'long' type in Python3 just int - long = int -else: - find_module = original_find_module - -NUM_TYPES = (int, long, float) diff --git a/neovim/msgpack_rpc/event_loop/__init__.py b/neovim/msgpack_rpc/event_loop/__init__.py deleted file mode 100644 index 2e83807a..00000000 --- a/neovim/msgpack_rpc/event_loop/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Event loop abstraction subpackage. - -Tries to use pyuv as a backend, falling back to the asyncio implementation. -""" -try: - # libuv is fully implemented in C, use it when available - from .uv import UvEventLoop - EventLoop = UvEventLoop -except ImportError: - # asyncio(trollius on python 2) is pure python and should be more portable - # across python implementations - from .asyncio import AsyncioEventLoop - EventLoop = AsyncioEventLoop - - -__all__ = ('EventLoop') diff --git a/neovim/msgpack_rpc/event_loop/asyncio.py b/neovim/msgpack_rpc/event_loop/asyncio.py deleted file mode 100644 index 87ce79a4..00000000 --- a/neovim/msgpack_rpc/event_loop/asyncio.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Event loop implementation that uses the `asyncio` standard module. - -The `asyncio` module was added to python standard library on 3.4, and it -provides a pure python implementation of an event loop library. It is used -as a fallback in case pyuv is not available(on python implementations other -than CPython). - -Earlier python versions are supported through the `trollius` package, which -is a backport of `asyncio` that works on Python 2.6+. -""" -from __future__ import absolute_import - -import os -import sys -from collections import deque - -try: - # For python 3.4+, use the standard library module - import asyncio -except (ImportError, SyntaxError): - # Fallback to trollius - import trollius as asyncio - -from .base import BaseEventLoop - - -loop_cls = asyncio.SelectorEventLoop -if os.name == 'nt': - # On windows use ProactorEventLoop which support pipes and is backed by the - # more powerful IOCP facility - loop_cls = asyncio.ProactorEventLoop - - -class AsyncioEventLoop(BaseEventLoop, asyncio.Protocol, - asyncio.SubprocessProtocol): - - """`BaseEventLoop` subclass that uses `asyncio` as a backend.""" - - def connection_made(self, transport): - """Used to signal `asyncio.Protocol` of a successful connection.""" - self._transport = transport - if isinstance(transport, asyncio.SubprocessTransport): - self._transport = transport.get_pipe_transport(0) - - def connection_lost(self, exc): - """Used to signal `asyncio.Protocol` of a lost connection.""" - self._on_error(exc.args[0] if exc else 'EOF') - - def data_received(self, data): - """Used to signal `asyncio.Protocol` of incoming data.""" - if self._on_data: - self._on_data(data) - return - self._queued_data.append(data) - - def pipe_connection_lost(self, fd, exc): - """Used to signal `asyncio.SubprocessProtocol` of a lost connection.""" - self._on_error(exc.args[0] if exc else 'EOF') - - def pipe_data_received(self, fd, data): - """Used to signal `asyncio.SubprocessProtocol` of incoming data.""" - if fd == sys.stderr.fileno(): - self._on_stderr(data) - elif self._on_data: - self._on_data(data) - else: - self._queued_data.append(data) - - def process_exited(self): - """Used to signal `asyncio.SubprocessProtocol` when the child exits.""" - self._on_error('EOF') - - def _init(self): - self._loop = loop_cls() - self._queued_data = deque() - self._fact = lambda: self - - def _connect_tcp(self, address, port): - coroutine = self._loop.create_connection(self._fact, address, port) - self._loop.run_until_complete(coroutine) - - def _connect_socket(self, path): - if os.name == 'nt': - coroutine = self._loop.create_pipe_connection(self._fact, path) - else: - coroutine = self._loop.create_unix_connection(self._fact, path) - self._loop.run_until_complete(coroutine) - - def _connect_stdio(self): - coroutine = self._loop.connect_read_pipe(self._fact, sys.stdin) - self._loop.run_until_complete(coroutine) - coroutine = self._loop.connect_write_pipe(self._fact, sys.stdout) - self._loop.run_until_complete(coroutine) - - def _connect_child(self, argv): - coroutine = self._loop.subprocess_exec(self._fact, *argv) - self._loop.run_until_complete(coroutine) - - def _start_reading(self): - pass - - def _send(self, data): - self._transport.write(data) - - def _run(self): - while self._queued_data: - self._on_data(self._queued_data.popleft()) - self._loop.run_forever() - - def _stop(self): - self._loop.stop() - - def _threadsafe_call(self, fn): - self._loop.call_soon_threadsafe(fn) - - def _setup_signals(self, signals): - if os.name == 'nt': - # add_signal_handler is not supported in win32 - self._signals = [] - return - - self._signals = list(signals) - for signum in self._signals: - self._loop.add_signal_handler(signum, self._on_signal, signum) - - def _teardown_signals(self): - for signum in self._signals: - self._loop.remove_signal_handler(signum) diff --git a/neovim/msgpack_rpc/event_loop/base.py b/neovim/msgpack_rpc/event_loop/base.py deleted file mode 100644 index a05299e9..00000000 --- a/neovim/msgpack_rpc/event_loop/base.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Common code for event loop implementations.""" -import logging -import signal -import threading - - -logger = logging.getLogger(__name__) -debug, info, warn = (logger.debug, logger.info, logger.warning,) - - -# When signals are restored, the event loop library may reset SIGINT to SIG_DFL -# which exits the program. To be able to restore the python interpreter to it's -# default state, we keep a reference to the default handler -default_int_handler = signal.getsignal(signal.SIGINT) -main_thread = threading.current_thread() - - -class BaseEventLoop(object): - - """Abstract base class for all event loops. - - Event loops act as the bottom layer for Nvim sessions created by this - library. They hide system/transport details behind a simple interface for - reading/writing bytes to the connected Nvim instance. - - This class exposes public methods for interacting with the underlying - event loop and delegates implementation-specific work to the following - methods, which subclasses are expected to implement: - - - `_init()`: Implementation-specific initialization - - `_connect_tcp(address, port)`: connect to Nvim using tcp/ip - - `_connect_socket(path)`: Same as tcp, but use a UNIX domain socket or - or named pipe. - - `_connect_stdio()`: Use stdin/stdout as the connection to Nvim - - `_connect_child(argv)`: Use the argument vector `argv` to spawn an - embedded Nvim that has it's stdin/stdout connected to the event loop. - - `_start_reading()`: Called after any of _connect_* methods. Can be used - to perform any post-connection setup or validation. - - `_send(data)`: Send `data`(byte array) to Nvim. The data is only - - `_run()`: Runs the event loop until stopped or the connection is closed. - calling the following methods when some event happens: - actually sent when the event loop is running. - - `_on_data(data)`: When Nvim sends some data. - - `_on_signal(signum)`: When a signal is received. - - `_on_error(message)`: When a non-recoverable error occurs(eg: - connection lost) - - `_stop()`: Stop the event loop - - `_interrupt(data)`: Like `stop()`, but may be called from other threads - this. - - `_setup_signals(signals)`: Add implementation-specific listeners for - for `signals`, which is a list of OS-specific signal numbers. - - `_teardown_signals()`: Removes signal listeners set by `_setup_signals` - """ - - def __init__(self, transport_type, *args): - """Initialize and connect the event loop instance. - - The only arguments are the transport type and transport-specific - configuration, like this: - - >>> BaseEventLoop('tcp', '127.0.0.1', 7450) - Traceback (most recent call last): - ... - AttributeError: 'BaseEventLoop' object has no attribute '_init' - >>> BaseEventLoop('socket', '/tmp/nvim-socket') - Traceback (most recent call last): - ... - AttributeError: 'BaseEventLoop' object has no attribute '_init' - >>> BaseEventLoop('stdio') - Traceback (most recent call last): - ... - AttributeError: 'BaseEventLoop' object has no attribute '_init' - >>> BaseEventLoop('child', ['nvim', '--embed', '-u', 'NONE']) - Traceback (most recent call last): - ... - AttributeError: 'BaseEventLoop' object has no attribute '_init' - - This calls the implementation-specific initialization - `_init`, one of the `_connect_*` methods(based on `transport_type`) - and `_start_reading()` - """ - self._transport_type = transport_type - self._signames = dict((k, v) for v, k in signal.__dict__.items() - if v.startswith('SIG')) - self._on_data = None - self._error = None - self._init() - getattr(self, '_connect_{0}'.format(transport_type))(*args) - self._start_reading() - - def connect_tcp(self, address, port): - """Connect to tcp/ip `address`:`port`. Delegated to `_connect_tcp`.""" - info('Connecting to TCP address: %s:%d', address, port) - self._connect_tcp(address, port) - - def connect_socket(self, path): - """Connect to socket at `path`. Delegated to `_connect_socket`.""" - info('Connecting to %s', path) - self._connect_socket(path) - - def connect_stdio(self): - """Connect using stdin/stdout. Delegated to `_connect_stdio`.""" - info('Preparing stdin/stdout for streaming data') - self._connect_stdio() - - def connect_child(self, argv): - """Connect a new Nvim instance. Delegated to `_connect_child`.""" - info('Spawning a new nvim instance') - self._connect_child(argv) - - def send(self, data): - """Queue `data` for sending to Nvim.""" - debug("Sending '%s'", data) - self._send(data) - - def threadsafe_call(self, fn): - """Call a function in the event loop thread. - - This is the only safe way to interact with a session from other - threads. - """ - self._threadsafe_call(fn) - - def run(self, data_cb): - """Run the event loop.""" - if self._error: - err = self._error - if isinstance(self._error, KeyboardInterrupt): - # KeyboardInterrupt is not destructive(it may be used in - # the REPL). - # After throwing KeyboardInterrupt, cleanup the _error field - # so the loop may be started again - self._error = None - raise err - self._on_data = data_cb - if threading.current_thread() == main_thread: - self._setup_signals([signal.SIGINT, signal.SIGTERM]) - debug('Entering event loop') - self._run() - debug('Exited event loop') - if threading.current_thread() == main_thread: - self._teardown_signals() - signal.signal(signal.SIGINT, default_int_handler) - self._on_data = None - - def stop(self): - """Stop the event loop.""" - self._stop() - debug('Stopped event loop') - - def _on_signal(self, signum): - msg = 'Received {0}'.format(self._signames[signum]) - debug(msg) - if signum == signal.SIGINT and self._transport_type == 'stdio': - # When the transport is stdio, we are probably running as a Nvim - # child process. In that case, we don't want to be killed by - # ctrl+C - return - cls = Exception - if signum == signal.SIGINT: - cls = KeyboardInterrupt - self._error = cls(msg) - self.stop() - - def _on_error(self, error): - debug(error) - self._error = IOError(error) - self.stop() - - def _on_interrupt(self): - self.stop() diff --git a/neovim/msgpack_rpc/event_loop/uv.py b/neovim/msgpack_rpc/event_loop/uv.py deleted file mode 100644 index 73daab42..00000000 --- a/neovim/msgpack_rpc/event_loop/uv.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Event loop implementation that uses pyuv(libuv-python bindings).""" -import sys -from collections import deque - -import pyuv - -from .base import BaseEventLoop - - -class UvEventLoop(BaseEventLoop): - - """`BaseEventLoop` subclass that uses `pvuv` as a backend.""" - - def _init(self): - self._loop = pyuv.Loop() - self._async = pyuv.Async(self._loop, self._on_async) - self._connection_error = None - self._error_stream = None - self._callbacks = deque() - - def _on_connect(self, stream, error): - self.stop() - if error: - msg = 'Cannot connect to {0}: {1}'.format( - self._connect_address, pyuv.errno.strerror(error)) - self._connection_error = IOError(msg) - return - self._read_stream = self._write_stream = stream - - def _on_read(self, handle, data, error): - if error or not data: - msg = pyuv.errno.strerror(error) if error else 'EOF' - self._on_error(msg) - return - if handle == self._error_stream: - return - self._on_data(data) - - def _on_write(self, handle, error): - if error: - msg = pyuv.errno.strerror(error) - self._on_error(msg) - - def _on_exit(self, handle, exit_status, term_signal): - self._on_error('EOF') - - def _disconnected(self, *args): - raise IOError('Not connected to Nvim') - - def _connect_tcp(self, address, port): - stream = pyuv.TCP(self._loop) - self._connect_address = '{0}:{1}'.format(address, port) - stream.connect((address, port), self._on_connect) - - def _connect_socket(self, path): - stream = pyuv.Pipe(self._loop) - self._connect_address = path - stream.connect(path, self._on_connect) - - def _connect_stdio(self): - self._read_stream = pyuv.Pipe(self._loop) - self._read_stream.open(sys.stdin.fileno()) - self._write_stream = pyuv.Pipe(self._loop) - self._write_stream.open(sys.stdout.fileno()) - - def _connect_child(self, argv): - self._write_stream = pyuv.Pipe(self._loop) - self._read_stream = pyuv.Pipe(self._loop) - self._error_stream = pyuv.Pipe(self._loop) - stdin = pyuv.StdIO(self._write_stream, - flags=pyuv.UV_CREATE_PIPE + pyuv.UV_READABLE_PIPE) - stdout = pyuv.StdIO(self._read_stream, - flags=pyuv.UV_CREATE_PIPE + pyuv.UV_WRITABLE_PIPE) - stderr = pyuv.StdIO(self._error_stream, - flags=pyuv.UV_CREATE_PIPE + pyuv.UV_WRITABLE_PIPE) - self._process = pyuv.Process(self._loop) - self._process.spawn(file=argv[0], - exit_callback=self._on_exit, - args=argv[1:], - flags=pyuv.UV_PROCESS_WINDOWS_HIDE, - stdio=(stdin, stdout, stderr,)) - self._error_stream.start_read(self._on_read) - - def _start_reading(self): - if self._transport_type in ['tcp', 'socket']: - self._loop.run() - if self._connection_error: - self.run = self.send = self._disconnected - raise self._connection_error - self._read_stream.start_read(self._on_read) - - def _send(self, data): - self._write_stream.write(data, self._on_write) - - def _run(self): - self._loop.run(pyuv.UV_RUN_DEFAULT) - - def _stop(self): - self._loop.stop() - - def _threadsafe_call(self, fn): - self._callbacks.append(fn) - self._async.send() - - def _on_async(self, handle): - while self._callbacks: - self._callbacks.popleft()() - - def _setup_signals(self, signals): - self._signal_handles = [] - - def handler(h, signum): - self._on_signal(signum) - - for signum in signals: - handle = pyuv.Signal(self._loop) - handle.start(handler, signum) - self._signal_handles.append(handle) - - def _teardown_signals(self): - for handle in self._signal_handles: - handle.stop() diff --git a/neovim/plugin/__init__.py b/neovim/plugin/__init__.py deleted file mode 100644 index 672a3008..00000000 --- a/neovim/plugin/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Nvim plugin/host subpackage.""" - -from .decorators import (autocmd, command, decode, encoding, function, - plugin, rpc_export, shutdown_hook) -from .host import Host - - -__all__ = ('Host', 'plugin', 'rpc_export', 'command', 'autocmd', - 'function', 'encoding', 'decode', 'shutdown_hook') diff --git a/neovim/plugin/decorators.py b/neovim/plugin/decorators.py deleted file mode 100644 index 9e4fe4a3..00000000 --- a/neovim/plugin/decorators.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Decorators used by python host plugin system.""" - -import inspect -import logging - -from ..compat import IS_PYTHON3 - -logger = logging.getLogger(__name__) -debug, info, warn = (logger.debug, logger.info, logger.warning,) -__all__ = ('plugin', 'rpc_export', 'command', 'autocmd', 'function', - 'encoding', 'decode', 'shutdown_hook') - - -def plugin(cls): - """Tag a class as a plugin. - - This decorator is required to make the a class methods discoverable by the - plugin_load method of the host. - """ - cls._nvim_plugin = True - # the _nvim_bind attribute is set to True by default, meaning that - # decorated functions have a bound Nvim instance as first argument. - # For methods in a plugin-decorated class this is not required, because - # the class initializer will already receive the nvim object. - predicate = lambda fn: hasattr(fn, '_nvim_bind') - for _, fn in inspect.getmembers(cls, predicate): - if IS_PYTHON3: - fn._nvim_bind = False - else: - fn.im_func._nvim_bind = False - return cls - - -def rpc_export(rpc_method_name, sync=False): - """Export a function or plugin method as a msgpack-rpc request handler.""" - def dec(f): - f._nvim_rpc_method_name = rpc_method_name - f._nvim_rpc_sync = sync - f._nvim_bind = True - f._nvim_prefix_plugin_path = False - return f - return dec - - -def command(name, nargs=0, complete=None, range=None, count=None, bang=False, - register=False, sync=False, eval=None): - """Tag a function or plugin method as a Nvim command handler.""" - def dec(f): - f._nvim_rpc_method_name = 'command:{0}'.format(name) - f._nvim_rpc_sync = sync - f._nvim_bind = True - f._nvim_prefix_plugin_path = True - - opts = {} - - if range is not None: - opts['range'] = '' if range is True else str(range) - elif count: - opts['count'] = count - - if bang: - opts['bang'] = True - - if register: - opts['register'] = True - - if nargs: - opts['nargs'] = nargs - - if complete: - opts['complete'] = complete - - if eval: - opts['eval'] = eval - - f._nvim_rpc_spec = { - 'type': 'command', - 'name': name, - 'sync': sync, - 'opts': opts - } - return f - return dec - - -def autocmd(name, pattern='*', sync=False, eval=None): - """Tag a function or plugin method as a Nvim autocommand handler.""" - def dec(f): - f._nvim_rpc_method_name = 'autocmd:{0}:{1}'.format(name, pattern) - f._nvim_rpc_sync = sync - f._nvim_bind = True - f._nvim_prefix_plugin_path = True - - opts = { - 'pattern': pattern - } - - if eval: - opts['eval'] = eval - - f._nvim_rpc_spec = { - 'type': 'autocmd', - 'name': name, - 'sync': sync, - 'opts': opts - } - return f - return dec - - -def function(name, range=False, sync=False, eval=None): - """Tag a function or plugin method as a Nvim function handler.""" - def dec(f): - f._nvim_rpc_method_name = 'function:{0}'.format(name) - f._nvim_rpc_sync = sync - f._nvim_bind = True - f._nvim_prefix_plugin_path = True - - opts = {} - - if range: - opts['range'] = '' if range is True else str(range) - - if eval: - opts['eval'] = eval - - f._nvim_rpc_spec = { - 'type': 'function', - 'name': name, - 'sync': sync, - 'opts': opts - } - return f - return dec - - -def shutdown_hook(f): - """Tag a function or method as a shutdown hook.""" - f._nvim_shutdown_hook = True - f._nvim_bind = True - return f - - -def decode(mode='strict'): - """Configure automatic encoding/decoding of strings.""" - def dec(f): - f._nvim_decode = mode - return f - return dec - - -def encoding(encoding=True): - """DEPRECATED: use neovim.decode().""" - if isinstance(encoding, str): - encoding = True - - def dec(f): - f._nvim_decode = encoding - return f - return dec diff --git a/neovim/plugin/host.py b/neovim/plugin/host.py deleted file mode 100644 index 78703a68..00000000 --- a/neovim/plugin/host.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Implements a Nvim host for python plugins.""" -import functools -import imp -import inspect -import logging -import os -import os.path -import re - -from traceback import format_exc - -from . import script_host -from ..api import decode_if_bytes, walk -from ..compat import IS_PYTHON3, find_module -from ..msgpack_rpc import ErrorResponse - -__all__ = ('Host') - -logger = logging.getLogger(__name__) -error, debug, info, warn = (logger.error, logger.debug, logger.info, - logger.warning,) - - -class Host(object): - - """Nvim host for python plugins. - - Takes care of loading/unloading plugins and routing msgpack-rpc - requests/notifications to the appropriate handlers. - """ - - def __init__(self, nvim): - """Set handlers for plugin_load/plugin_unload.""" - self.nvim = nvim - self._specs = {} - self._loaded = {} - self._load_errors = {} - self._notification_handlers = {} - self._request_handlers = { - 'poll': lambda: 'ok', - 'specs': self._on_specs_request, - 'shutdown': self.shutdown - } - - # Decode per default for Python3 - self._decode_default = IS_PYTHON3 - - def _on_async_err(self, msg): - self.nvim.err_write(msg, async=True) - - def start(self, plugins): - """Start listening for msgpack-rpc requests and notifications.""" - self.nvim.run_loop(self._on_request, - self._on_notification, - lambda: self._load(plugins), - err_cb=self._on_async_err) - - def shutdown(self): - """Shutdown the host.""" - self._unload() - self.nvim.stop_loop() - - def _on_request(self, name, args): - """Handle a msgpack-rpc request.""" - if IS_PYTHON3: - name = decode_if_bytes(name) - handler = self._request_handlers.get(name, None) - if not handler: - msg = self._missing_handler_error(name, 'request') - error(msg) - raise ErrorResponse(msg) - - debug('calling request handler for "%s", args: "%s"', name, args) - rv = handler(*args) - debug("request handler for '%s %s' returns: %s", name, args, rv) - return rv - - def _on_notification(self, name, args): - """Handle a msgpack-rpc notification.""" - if IS_PYTHON3: - name = decode_if_bytes(name) - handler = self._notification_handlers.get(name, None) - if not handler: - msg = self._missing_handler_error(name, 'notification') - error(msg) - self._on_async_err(msg + "\n") - return - - debug('calling notification handler for "%s", args: "%s"', name, args) - try: - handler(*args) - except Exception as err: - msg = ("error caught in async handler '{} {}':\n{!r}\n{}\n" - .format(name, args, err, format_exc(5))) - self._on_async_err(msg + "\n") - raise - - def _missing_handler_error(self, name, kind): - msg = 'no {} handler registered for "{}"'.format(kind, name) - pathmatch = re.match(r'(.+):[^:]+:[^:]+', name) - if pathmatch: - loader_error = self._load_errors.get(pathmatch.group(1)) - if loader_error is not None: - msg = msg + "\n" + loader_error - return msg - - def _load(self, plugins): - for path in plugins: - err = None - if path in self._loaded: - error('{0} is already loaded'.format(path)) - continue - try: - if path == "script_host.py": - module = script_host - else: - directory, name = os.path.split(os.path.splitext(path)[0]) - file, pathname, descr = find_module(name, [directory]) - module = imp.load_module(name, file, pathname, descr) - handlers = [] - self._discover_classes(module, handlers, path) - self._discover_functions(module, handlers, path) - if not handlers: - error('{0} exports no handlers'.format(path)) - continue - self._loaded[path] = {'handlers': handlers, 'module': module} - except Exception as e: - err = ('Encountered {} loading plugin at {}: {}\n{}' - .format(type(e).__name__, path, e, format_exc(5))) - error(err) - self._load_errors[path] = err - - def _unload(self): - for path, plugin in self._loaded.items(): - handlers = plugin['handlers'] - for handler in handlers: - method_name = handler._nvim_rpc_method_name - if hasattr(handler, '_nvim_shutdown_hook'): - handler() - elif handler._nvim_rpc_sync: - del self._request_handlers[method_name] - else: - del self._notification_handlers[method_name] - self._specs = {} - self._loaded = {} - - def _discover_classes(self, module, handlers, plugin_path): - for _, cls in inspect.getmembers(module, inspect.isclass): - if getattr(cls, '_nvim_plugin', False): - # create an instance of the plugin and pass the nvim object - plugin = cls(self._configure_nvim_for(cls)) - # discover handlers in the plugin instance - self._discover_functions(plugin, handlers, plugin_path) - - def _discover_functions(self, obj, handlers, plugin_path): - def predicate(o): - return hasattr(o, '_nvim_rpc_method_name') - - def decoder(fn, decode, *args): - return fn(*walk(decode_if_bytes, args, decode)) - specs = [] - objdecode = getattr(obj, '_nvim_decode', self._decode_default) - for _, fn in inspect.getmembers(obj, predicate): - decode = getattr(fn, '_nvim_decode', objdecode) - if fn._nvim_bind: - # bind a nvim instance to the handler - fn2 = functools.partial(fn, self._configure_nvim_for(fn)) - # copy _nvim_* attributes from the original function - self._copy_attributes(fn, fn2) - fn = fn2 - if decode: - fn2 = functools.partial(decoder, fn, decode) - self._copy_attributes(fn, fn2) - fn = fn2 - - # register in the rpc handler dict - method = fn._nvim_rpc_method_name - if fn._nvim_prefix_plugin_path: - method = '{0}:{1}'.format(plugin_path, method) - if fn._nvim_rpc_sync: - if method in self._request_handlers: - raise Exception(('Request handler for "{0}" is ' + - 'already registered').format(method)) - self._request_handlers[method] = fn - else: - if method in self._notification_handlers: - raise Exception(('Notification handler for "{0}" is ' + - 'already registered').format(method)) - self._notification_handlers[method] = fn - if hasattr(fn, '_nvim_rpc_spec'): - specs.append(fn._nvim_rpc_spec) - handlers.append(fn) - if specs: - self._specs[plugin_path] = specs - - def _copy_attributes(self, fn, fn2): - # Copy _nvim_* attributes from the original function - for attr in dir(fn): - if attr.startswith('_nvim_'): - setattr(fn2, attr, getattr(fn, attr)) - - def _on_specs_request(self, path): - if IS_PYTHON3: - path = decode_if_bytes(path) - if path in self._load_errors: - self.nvim.out_write(self._load_errors[path] + '\n') - return self._specs.get(path, 0) - - def _configure_nvim_for(self, obj): - # Configure a nvim instance for obj (checks encoding configuration) - nvim = self.nvim - decode = getattr(obj, '_nvim_decode', self._decode_default) - if decode: - nvim = nvim.with_decode(decode) - return nvim diff --git a/pynvim/__init__.py b/pynvim/__init__.py new file mode 100644 index 00000000..daefc1ec --- /dev/null +++ b/pynvim/__init__.py @@ -0,0 +1,175 @@ +"""Python client for Nvim. + +Client library for talking with Nvim processes via its msgpack-rpc API. +""" +import logging +import os +import sys +from types import SimpleNamespace as Version +from typing import List, Optional, cast, overload + +from pynvim._version import VERSION, __version__ +from pynvim.api import Nvim, NvimError +from pynvim.msgpack_rpc import (ErrorResponse, Session, TTransportType, + child_session, socket_session, stdio_session, + tcp_session) +from pynvim.plugin import (Host, autocmd, command, decode, encoding, function, + plugin, rpc_export, shutdown_hook) + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + + +__all__ = ('tcp_session', 'socket_session', 'stdio_session', 'child_session', + 'start_host', 'autocmd', 'command', 'encoding', 'decode', + 'function', 'plugin', 'rpc_export', 'Host', 'Nvim', 'NvimError', + 'Version', 'VERSION', '__version__', + 'shutdown_hook', 'attach', 'setup_logging', 'ErrorResponse', + ) + + +def start_host(session: Optional[Session] = None) -> None: + """Promote the current process into python plugin host for Nvim. + + Start msgpack-rpc event loop for `session`, listening for Nvim requests + and notifications. It registers Nvim commands for loading/unloading + python plugins. + + The sys.stdout and sys.stderr streams are redirected to Nvim through + `session`. That means print statements probably won't work as expected + while this function doesn't return. + + This function is normally called at program startup and could have been + defined as a separate executable. It is exposed as a library function for + testing purposes only. + """ + plugins = [] + for arg in sys.argv: + _, ext = os.path.splitext(arg) + if ext == '.py': + plugins.append(arg) + elif os.path.isdir(arg): + init = os.path.join(arg, '__init__.py') + if os.path.isfile(init): + plugins.append(arg) + + # This is a special case to support the old workaround of + # adding an empty .py file to make a package directory + # visible, and it should be removed soon. + for path in list(plugins): + dup = path + ".py" + if os.path.isdir(path) and dup in plugins: + plugins.remove(dup) + + # Special case: the legacy scripthost receives a single relative filename + # while the rplugin host will receive absolute paths. + if plugins == ["script_host.py"]: + name = "script" + else: + name = "rplugin" + + setup_logging(name) + + if not session: + session = stdio_session() + nvim = Nvim.from_session(session) + + if nvim.version.api_level < 1: + sys.stderr.write("This version of pynvim " + "requires nvim 0.1.6 or later") + sys.exit(1) + + host = Host(nvim) + host.start(plugins) + + +@overload +def attach(session_type: Literal['tcp'], address: str, port: int = 7450) -> Nvim: ... + + +@overload +def attach(session_type: Literal['socket'], *, path: str) -> Nvim: ... + + +@overload +def attach(session_type: Literal['child'], *, argv: List[str]) -> Nvim: ... + + +@overload +def attach(session_type: Literal['stdio']) -> Nvim: ... + + +def attach( + session_type: TTransportType, + address: Optional[str] = None, + port: int = 7450, + path: Optional[str] = None, + argv: Optional[List[str]] = None, + decode: Literal[True] = True +) -> Nvim: + """Provide a nicer interface to create python api sessions. + + Previous machinery to create python api sessions is still there. This only + creates a facade function to make things easier for the most usual cases. + Thus, instead of: + from pynvim import socket_session, Nvim + session = tcp_session(address=
, port=) + nvim = Nvim.from_session(session) + You can now do: + from pynvim import attach + nvim = attach('tcp', address=
, port=) + And also: + nvim = attach('socket', path=) + nvim = attach('child', argv=) + nvim = attach('stdio') + + When the session is not needed anymore, it is recommended to explicitly + close it: + nvim.close() + It is also possible to use the session as a context manager: + with attach('socket', path=thepath) as nvim: + print(nvim.funcs.getpid()) + print(nvim.current.line) + This will automatically close the session when you're done with it, or + when an error occurred. + + + """ + session = ( + tcp_session(cast(str, address), port) if session_type == 'tcp' else + socket_session(cast(str, path)) if session_type == 'socket' else + stdio_session() if session_type == 'stdio' else + child_session(cast(List[str], argv)) if session_type == 'child' else + None + ) + + if not session: + raise Exception('Unknown session type "%s"' % session_type) + + return Nvim.from_session(session).with_decode(decode) + + +def setup_logging(name: str) -> None: + """Setup logging according to environment variables.""" + logger = logging.getLogger(__name__) + if 'NVIM_PYTHON_LOG_FILE' in os.environ: + prefix = os.environ['NVIM_PYTHON_LOG_FILE'].strip() + major_version = sys.version_info[0] + logfile = '{}_py{}_{}'.format(prefix, major_version, name) + handler = logging.FileHandler(logfile, 'w', 'utf-8') + handler.formatter = logging.Formatter( + '%(asctime)s [%(levelname)s @ ' + '%(filename)s:%(funcName)s:%(lineno)s] %(process)s - %(message)s') + logging.root.addHandler(handler) + level = logging.INFO + env_log_level = os.environ.get('NVIM_PYTHON_LOG_LEVEL', None) + if env_log_level is not None: + lvl = getattr(logging, env_log_level.strip(), None) + if isinstance(lvl, int): + level = lvl + else: + logger.warning('Invalid NVIM_PYTHON_LOG_LEVEL: %r, using INFO.', + env_log_level) + logger.setLevel(level) diff --git a/pynvim/_version.py b/pynvim/_version.py new file mode 100644 index 00000000..cea1b03a --- /dev/null +++ b/pynvim/_version.py @@ -0,0 +1,13 @@ +"""Specifies pynvim version.""" +# pylint: disable=consider-using-f-string + +from types import SimpleNamespace + +# see also setup.py +VERSION = SimpleNamespace(major=0, minor=6, patch=1, prerelease="dev0") + +# e.g. "0.5.0", "0.5.0.dev0" (PEP-440) +__version__ = '{major}.{minor}.{patch}'.format(**vars(VERSION)) + +if VERSION.prerelease: + __version__ += '.' + VERSION.prerelease diff --git a/pynvim/api/__init__.py b/pynvim/api/__init__.py new file mode 100644 index 00000000..31050f9a --- /dev/null +++ b/pynvim/api/__init__.py @@ -0,0 +1,15 @@ +"""Nvim API subpackage. + +This package implements a higher-level API that wraps msgpack-rpc `Session` +instances. +""" + +from pynvim.api.buffer import Buffer +from pynvim.api.common import decode_if_bytes, walk +from pynvim.api.nvim import Nvim, NvimError +from pynvim.api.tabpage import Tabpage +from pynvim.api.window import Window + + +__all__ = ('Nvim', 'Buffer', 'Window', 'Tabpage', 'NvimError', + 'decode_if_bytes', 'walk') diff --git a/pynvim/api/buffer.py b/pynvim/api/buffer.py new file mode 100644 index 00000000..b9fb3bc6 --- /dev/null +++ b/pynvim/api/buffer.py @@ -0,0 +1,332 @@ +"""API for working with a Nvim Buffer.""" + +from __future__ import annotations + +from typing import (Any, Iterator, List, Optional, TYPE_CHECKING, Tuple, Union, cast, + overload) + +from pynvim.api.common import Remote +from pynvim.compat import check_async + +if TYPE_CHECKING: + from pynvim.api import Nvim + + +__all__ = ('Buffer',) + + +@overload +def adjust_index(idx: int, default: Optional[int] = None) -> int: + ... + + +@overload +def adjust_index(idx: Optional[int], default: int) -> int: + ... + + +@overload +def adjust_index(idx: Optional[int], default: Optional[int] = None) -> Optional[int]: + ... + + +def adjust_index(idx: Optional[int], default: Optional[int] = None) -> Optional[int]: + """Convert from python indexing convention to nvim indexing convention.""" + if idx is None: + return default + elif idx < 0: + return idx - 1 + else: + return idx + + +class Buffer(Remote): + + """A remote Nvim buffer.""" + + _api_prefix = "nvim_buf_" + _session: "Nvim" + + def __init__(self, session: Nvim, code_data: Tuple[int, Any]): + """Initialize from Nvim and code_data immutable object.""" + super().__init__(session, code_data) + + def __len__(self) -> int: + """Return the number of lines contained in a Buffer.""" + return self.request('nvim_buf_line_count') + + @overload + def __getitem__(self, idx: int) -> str: # noqa: D105 + ... + + @overload + def __getitem__(self, idx: slice) -> List[str]: # noqa: D105 + ... + + def __getitem__(self, idx: Union[int, slice]) -> Union[str, List[str]]: + """Get a buffer line or slice by integer index. + + Indexes may be negative to specify positions from the end of the + buffer. For example, -1 is the last line, -2 is the line before that + and so on. + + When retrieving slices, omitting indexes(eg: `buffer[:]`) will bring + the whole buffer. + """ + if not isinstance(idx, slice): + i = adjust_index(idx) + return self.request('nvim_buf_get_lines', i, i + 1, True)[0] + start = adjust_index(idx.start, 0) + end = adjust_index(idx.stop, -1) + return self.request('nvim_buf_get_lines', start, end, False) + + @overload + def __setitem__(self, idx: int, item: Optional[str]) -> None: # noqa: D105 + ... + + @overload + def __setitem__( # noqa: D105 + self, idx: slice, item: Optional[Union[List[str], str]] + ) -> None: + ... + + def __setitem__( + self, idx: Union[int, slice], item: Union[None, str, List[str]] + ) -> None: + """Replace a buffer line or slice by integer index. + + Like with `__getitem__`, indexes may be negative. + + When replacing slices, omitting indexes(eg: `buffer[:]`) will replace + the whole buffer. + """ + if not isinstance(idx, slice): + assert not isinstance(item, list) + i = adjust_index(idx) + lines = [item] if item is not None else [] + return self.request('nvim_buf_set_lines', i, i + 1, True, lines) + if item is None: + lines = [] + elif isinstance(item, str): + lines = [item] + else: + lines = item + start = adjust_index(idx.start, 0) + end = adjust_index(idx.stop, -1) + return self.request('nvim_buf_set_lines', start, end, False, lines) + + def __iter__(self) -> Iterator[str]: + """Iterate lines of a buffer. + + This will retrieve all lines locally before iteration starts. This + approach is used because for most cases, the gain is much greater by + minimizing the number of API calls by transferring all data needed to + work. + """ + lines = self[:] + for line in lines: + yield line + + def __delitem__(self, idx: Union[int, slice]) -> None: + """Delete line or slice of lines from the buffer. + + This is the same as __setitem__(idx, []) + """ + self.__setitem__(idx, None) + + def __ne__(self, other: Any) -> bool: + """Test inequality of Buffers. + + Necessary for Python 2 compatibility. + """ + return not self.__eq__(other) + + def append( + self, lines: Union[str, bytes, List[Union[str, bytes]]], index: int = -1 + ) -> None: + """Append a string or list of lines to the buffer.""" + if isinstance(lines, (str, bytes)): + lines = [lines] + return self.request('nvim_buf_set_lines', index, index, True, lines) + + def mark(self, name: str) -> Tuple[int, int]: + """Return (row, col) tuple for a named mark.""" + return cast(Tuple[int, int], tuple(self.request('nvim_buf_get_mark', name))) + + def range(self, start: int, end: int) -> Range: + """Return a `Range` object, which represents part of the Buffer.""" + return Range(self, start, end) + + def add_highlight( + self, + hl_group: str, + line: int, + col_start: int = 0, + col_end: int = -1, + src_id: int = -1, + async_: Optional[bool] = None, + **kwargs: Any + ) -> int: + """Add a highlight to the buffer.""" + async_ = check_async(async_, kwargs, src_id != 0) + return self.request( + "nvim_buf_add_highlight", + src_id, + hl_group, + line, + col_start, + col_end, + async_=async_, + ) + + def clear_highlight( + self, + src_id: int, + line_start: int = 0, + line_end: int = -1, + async_: Optional[bool] = None, + **kwargs: Any + ) -> None: + """Clear highlights from the buffer.""" + async_ = check_async(async_, kwargs, True) + self.request( + "nvim_buf_clear_highlight", src_id, line_start, line_end, async_=async_ + ) + + def update_highlights( + self, + src_id: int, + hls: List[Union[Tuple[str, int], Tuple[str, int, int, int]]], + clear_start: Optional[int] = None, + clear_end: int = -1, + clear: bool = False, + async_: bool = True, + ) -> None: + """Add or update highlights in batch to avoid unnecessary redraws. + + A `src_id` must have been allocated prior to use of this function. Use + for instance `nvim.new_highlight_source()` to get a src_id for your + plugin. + + `hls` should be a list of highlight items. Each item should be a list + or tuple on the form `("GroupName", linenr, col_start, col_end)` or + `("GroupName", linenr)` to highlight an entire line. + + By default existing highlights are preserved. Specify a line range with + clear_start and clear_end to replace highlights in this range. As a + shorthand, use clear=True to clear the entire buffer before adding the + new highlights. + """ + if clear and clear_start is None: + clear_start = 0 + lua = self._session._get_lua_private() + lua.update_highlights(self, src_id, hls, clear_start, clear_end, async_=async_) + + @property + def name(self) -> str: + """Get the buffer name.""" + return self.request('nvim_buf_get_name') + + @name.setter + def name(self, value: str) -> None: + """Set the buffer name. BufFilePre/BufFilePost are triggered.""" + return self.request('nvim_buf_set_name', value) + + @property + def valid(self) -> bool: + """Return True if the buffer still exists.""" + return self.request('nvim_buf_is_valid') + + @property + def loaded(self) -> bool: + """Return True if the buffer is valid and loaded.""" + return self.request('nvim_buf_is_loaded') + + @property + def number(self) -> int: + """Get the buffer number.""" + return self.handle + + +class Range: + + def __init__(self, buffer: Buffer, start: int, end: int): + self._buffer = buffer + self.start = start - 1 + self.end = end - 1 + + def __len__(self) -> int: + return self.end - self.start + 1 + + @overload + def __getitem__(self, idx: int) -> str: + ... + + @overload + def __getitem__(self, idx: slice) -> List[str]: + ... + + def __getitem__(self, idx: Union[int, slice]) -> Union[str, List[str]]: + if not isinstance(idx, slice): + return self._buffer[self._normalize_index(idx)] + start = self._normalize_index(idx.start) + end = self._normalize_index(idx.stop) + if start is None: + start = self.start + if end is None: + end = self.end + 1 + return self._buffer[start:end] + + @overload + def __setitem__(self, idx: int, lines: Optional[str]) -> None: + ... + + @overload + def __setitem__(self, idx: slice, lines: Optional[List[str]]) -> None: + ... + + def __setitem__( + self, idx: Union[int, slice], lines: Union[None, str, List[str]] + ) -> None: + if not isinstance(idx, slice): + assert not isinstance(lines, list) + self._buffer[self._normalize_index(idx)] = lines + return + start = self._normalize_index(idx.start) + end = self._normalize_index(idx.stop) + if start is None: + start = self.start + if end is None: + end = self.end + self._buffer[start:end + 1] = lines + + def __iter__(self) -> Iterator[str]: + for i in range(self.start, self.end + 1): + yield self._buffer[i] + + def append( + self, lines: Union[str, bytes, List[Union[str, bytes]]], i: Optional[int] = None + ) -> None: + i = self._normalize_index(i) + if i is None: + i = self.end + 1 + self._buffer.append(lines, i) + + @overload + def _normalize_index(self, index: int) -> int: + ... + + @overload + def _normalize_index(self, index: None) -> None: + ... + + def _normalize_index(self, index: Optional[int]) -> Optional[int]: + if index is None: + return None + if index < 0: + index = self.end + else: + index += self.start + if index > self.end: + index = self.end + return index diff --git a/pynvim/api/common.py b/pynvim/api/common.py new file mode 100644 index 00000000..6a291e66 --- /dev/null +++ b/pynvim/api/common.py @@ -0,0 +1,255 @@ +"""Code shared between the API classes.""" +import functools +import sys +from abc import ABC, abstractmethod +from typing import (Any, Callable, Generic, Iterator, List, Optional, Tuple, TypeVar, + Union, overload) + +from msgpack import unpackb +if sys.version_info < (3, 8): + from typing_extensions import Literal, Protocol +else: + from typing import Literal, Protocol + +from pynvim.compat import unicode_errors_default + +__all__ = () + + +T = TypeVar('T') +TDecodeMode = Union[Literal[True], str] + + +class NvimError(Exception): + pass + + +class IRemote(Protocol): + def request(self, name: str, *args: Any, **kwargs: Any) -> Any: + raise NotImplementedError + + +class Remote(ABC): + + """Base class for Nvim objects(buffer/window/tabpage). + + Each type of object has it's own specialized class with API wrappers around + the msgpack-rpc session. This implements equality which takes the remote + object handle into consideration. + """ + + def __init__(self, session: IRemote, code_data: Tuple[int, Any]): + """Initialize from session and code_data immutable object. + + The `code_data` contains serialization information required for + msgpack-rpc calls. It must be immutable for Buffer equality to work. + """ + self._session = session + self.code_data = code_data + self.handle = unpackb(code_data[1]) + self.api = RemoteApi(self, self._api_prefix) + self.vars = RemoteMap(self, self._api_prefix + 'get_var', + self._api_prefix + 'set_var', + self._api_prefix + 'del_var') + self.options = RemoteMap(self, self._api_prefix + 'get_option', + self._api_prefix + 'set_option') + + @property + @abstractmethod + def _api_prefix(self) -> str: + raise NotImplementedError() + + def __repr__(self) -> str: + """Get text representation of the object.""" + return '<%s(handle=%r)>' % ( + self.__class__.__name__, + self.handle, + ) + + def __eq__(self, other: Any) -> bool: + """Return True if `self` and `other` are the same object.""" + return (hasattr(other, 'code_data') + and other.code_data == self.code_data) + + def __hash__(self) -> int: + """Return hash based on remote object id.""" + return self.code_data.__hash__() + + def request(self, name: str, *args: Any, **kwargs: Any) -> Any: + """Wrapper for nvim.request.""" + return self._session.request(name, self, *args, **kwargs) + + +class RemoteApi: + """Wrapper to allow api methods to be called like python methods.""" + + def __init__(self, obj: IRemote, api_prefix: str): + """Initialize a RemoteApi with object and api prefix.""" + self._obj = obj + self._api_prefix = api_prefix + + def __getattr__(self, name: str) -> Callable[..., Any]: + """Return wrapper to named api method.""" + return functools.partial(self._obj.request, self._api_prefix + name) + + +E = TypeVar('E', bound=Exception) + + +def transform_keyerror(exc: E) -> Union[E, KeyError]: + if isinstance(exc, NvimError): + if exc.args[0].startswith('Key not found:'): + return KeyError(exc.args[0]) + if exc.args[0].startswith('Invalid option name:'): + return KeyError(exc.args[0]) + return exc + + +class RemoteMap: + """Represents a string->object map stored in Nvim. + + This is the dict counterpart to the `RemoteSequence` class, but it is used + as a generic way of retrieving values from the various map-like data + structures present in Nvim. + + It is used to provide a dict-like API to vim variables and options. + """ + + _set = None + _del = None + + def __init__( + self, + obj: IRemote, + get_method: str, + set_method: Optional[str] = None, + del_method: Optional[str] = None + ): + """Initialize a RemoteMap with session, getter/setter.""" + self._get = functools.partial(obj.request, get_method) + if set_method: + self._set = functools.partial(obj.request, set_method) + if del_method: + self._del = functools.partial(obj.request, del_method) + + def __getitem__(self, key: str) -> Any: + """Return a map value by key.""" + try: + return self._get(key) + except NvimError as exc: + raise transform_keyerror(exc) + + def __setitem__(self, key: str, value: Any) -> None: + """Set a map value by key(if the setter was provided).""" + if not self._set: + raise TypeError('This dict is read-only') + self._set(key, value) + + def __delitem__(self, key: str) -> None: + """Delete a map value by associating None with the key.""" + if not self._del: + raise TypeError('This dict is read-only') + try: + return self._del(key) + except NvimError as exc: + raise transform_keyerror(exc) + + def __contains__(self, key: str) -> bool: + """Check if key is present in the map.""" + try: + self._get(key) + return True + except Exception: + return False + + @overload + def get(self, key: str, default: T) -> T: ... + + @overload + def get(self, key: str, default: Optional[T] = None) -> Optional[T]: ... + + def get(self, key: str, default: Optional[T] = None) -> Optional[T]: + """Return value for key if present, else a default value.""" + try: + return self.__getitem__(key) + except KeyError: + return default + + +class RemoteSequence(Generic[T]): + + """Represents a sequence of objects stored in Nvim. + + This class is used to wrap msgpack-rpc functions that work on Nvim + sequences(of lines, buffers, windows and tabpages) with an API that + is similar to the one provided by the python-vim interface. + + For example, the 'windows' property of the `Nvim` class is a RemoteSequence + sequence instance, and the expression `nvim.windows[0]` is translated to + session.request('nvim_list_wins')[0]. + + One important detail about this class is that all methods will fetch the + sequence into a list and perform the necessary manipulation + locally(iteration, indexing, counting, etc). + """ + + def __init__(self, session: IRemote, method: str): + """Initialize a RemoteSequence with session, method.""" + self._fetch = functools.partial(session.request, method) + + def __len__(self) -> int: + """Return the length of the remote sequence.""" + return len(self._fetch()) + + @overload + def __getitem__(self, idx: int) -> T: ... + + @overload + def __getitem__(self, idx: slice) -> List[T]: ... + + def __getitem__(self, idx: Union[slice, int]) -> Union[T, List[T]]: + """Return a sequence item by index.""" + if not isinstance(idx, slice): + return self._fetch()[idx] + return self._fetch()[idx.start:idx.stop] + + def __iter__(self) -> Iterator[T]: + """Return an iterator for the sequence.""" + items = self._fetch() + for item in items: + yield item + + def __contains__(self, item: T) -> bool: + """Check if an item is present in the sequence.""" + return item in self._fetch() + + +@overload +def decode_if_bytes(obj: bytes, mode: TDecodeMode = True) -> str: ... + + +@overload +def decode_if_bytes(obj: T, mode: TDecodeMode = True) -> Union[T, str]: ... + + +def decode_if_bytes(obj: T, mode: TDecodeMode = True) -> Union[T, str]: + """Decode obj if it is bytes.""" + if mode is True: + mode = unicode_errors_default + if isinstance(obj, bytes): + return obj.decode("utf-8", errors=mode) + return obj + + +def walk(fn: Callable[[Any], Any], obj: Any) -> Any: + """Recursively walk an object graph applying `fn` to objects.""" + + # Note: this function is very hot, so it is worth being careful + # about performance. + type_ = type(obj) + + if type_ is list or type_ is tuple: + return [walk(fn, o) for o in obj] + if type_ is dict: + return {walk(fn, k): walk(fn, v) for k, v in obj.items()} + return fn(obj) diff --git a/pynvim/api/nvim.py b/pynvim/api/nvim.py new file mode 100644 index 00000000..a9cc3d44 --- /dev/null +++ b/pynvim/api/nvim.py @@ -0,0 +1,609 @@ +"""Main Nvim interface.""" + +from __future__ import annotations + +import asyncio +import os +import sys +import threading +from functools import partial +from traceback import format_stack +from types import SimpleNamespace +from typing import (Any, AnyStr, Callable, Dict, Iterator, List, Optional, + TYPE_CHECKING, Union) + +from msgpack import ExtType + +from pynvim.api.buffer import Buffer +from pynvim.api.common import (NvimError, Remote, RemoteApi, RemoteMap, RemoteSequence, + TDecodeMode, decode_if_bytes, walk) +from pynvim.api.tabpage import Tabpage +from pynvim.api.window import Window +from pynvim.util import format_exc_skip + +if TYPE_CHECKING: + from pynvim.msgpack_rpc import Session + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +__all__ = ['Nvim'] + + +os_chdir = os.chdir + +lua_module = """ +local a = vim.api +local function update_highlights(buf, src_id, hls, clear_first, clear_end) + if clear_first ~= nil then + a.nvim_buf_clear_highlight(buf, src_id, clear_first, clear_end) + end + for _,hl in pairs(hls) do + local group, line, col_start, col_end = unpack(hl) + if col_start == nil then + col_start = 0 + end + if col_end == nil then + col_end = -1 + end + a.nvim_buf_add_highlight(buf, src_id, group, line, col_start, col_end) + end +end + +local chid = ... +local mod = {update_highlights=update_highlights} +_G["_pynvim_"..chid] = mod +""" + + +class Nvim: + """Class that represents a remote Nvim instance. + + This class is main entry point to Nvim remote API, it is a wrapper + around Session instances. + + The constructor of this class must not be called directly. Instead, the + `from_session` class method should be used to create the first instance + from a raw `Session` instance. + + Subsequent instances for the same session can be created by calling the + `with_decode` instance method to change the decoding behavior or + `SubClass.from_nvim(nvim)` where `SubClass` is a subclass of `Nvim`, which + is useful for having multiple `Nvim` objects that behave differently + without one affecting the other. + + When this library is used on python3.4+, asyncio event loop is guaranteed + to be used. It is available as the "loop" attribute of this class. Note + that asyncio callbacks cannot make blocking requests, which includes + accessing state-dependent attributes. They should instead schedule another + callback using nvim.async_call, which will not have this restriction. + """ + + @classmethod + def from_session(cls, session: Session) -> Nvim: + """Create a new Nvim instance for a Session instance. + + This method must be called to create the first Nvim instance, since it + queries Nvim metadata for type information and sets a SessionHook for + creating specialized objects from Nvim remote handles. + """ + session.error_wrapper = lambda e: NvimError(decode_if_bytes(e[1])) + channel_id, metadata = session.request(b'nvim_get_api_info') + + metadata = walk(decode_if_bytes, metadata) + + types = { + metadata['types']['Buffer']['id']: Buffer, + metadata['types']['Window']['id']: Window, + metadata['types']['Tabpage']['id']: Tabpage, + } + + return cls(session, channel_id, metadata, types) + + @classmethod + def from_nvim(cls, nvim: Nvim) -> Nvim: + """Create a new Nvim instance from an existing instance.""" + return cls(nvim._session, nvim.channel_id, nvim.metadata, + nvim.types, nvim._decode, nvim._err_cb) + + def __init__( + self, + session: Session, + channel_id: int, + metadata: Dict[str, Any], + types: Dict[int, Any], + decode: TDecodeMode = True, + err_cb: Optional[Callable[[str], None]] = None + ): + """Initialize a new Nvim instance. This method is module-private.""" + self._session = session + self.channel_id = channel_id + self.metadata = metadata + version = metadata.get("version", {"api_level": 0}) + self.version = SimpleNamespace(**version) + self.types = types + self.api = RemoteApi(self, 'nvim_') + self.vars = RemoteMap(self, 'nvim_get_var', 'nvim_set_var', 'nvim_del_var') + self.vvars = RemoteMap(self, 'nvim_get_vvar', None, None) + self.options = RemoteMap(self, 'nvim_get_option', 'nvim_set_option') + self.buffers = Buffers(self) + self.windows: RemoteSequence[Window] = RemoteSequence(self, 'nvim_list_wins') + self.tabpages: RemoteSequence[Tabpage] = RemoteSequence( + self, 'nvim_list_tabpages' + ) + self.current = Current(self) + self.session = CompatibilitySession(self) + self.funcs = Funcs(self) + self.lua = LuaFuncs(self) + self.error = NvimError + self._decode = decode + if err_cb is None: + self._err_cb: Callable[[str], Any] = lambda _: None + else: + self._err_cb = err_cb + + @property + def loop(self) -> asyncio.AbstractEventLoop: + """Get the event loop (exposed to rplugins).""" # noqa + + # see #294: for python 3.4+, the only available and guaranteed + # implementation of msgpack_rpc BaseEventLoop is the AsyncioEventLoop. + # The underlying asyncio event loop is exposed to rplugins. + # pylint: disable=protected-access + return self._session.loop._loop # type: ignore + + def _from_nvim(self, obj: Any, decode: Optional[TDecodeMode] = None) -> Any: + if decode is None: + decode = self._decode + if type(obj) is ExtType: + cls = self.types[obj.code] + return cls(self, (obj.code, obj.data)) + if decode: + obj = decode_if_bytes(obj, decode) + return obj + + def _to_nvim(self, obj: Any) -> Any: + if isinstance(obj, Remote): + return ExtType(*obj.code_data) + return obj + + def _get_lua_private(self) -> LuaFuncs: + if not getattr(self._session, "_has_lua", False): + self.exec_lua(lua_module, self.channel_id) + self._session._has_lua = True # type: ignore[attr-defined] + return getattr(self.lua, "_pynvim_{}".format(self.channel_id)) + + def request(self, name: str, *args: Any, **kwargs: Any) -> Any: + r"""Send an API request or notification to nvim. + + It is rarely needed to call this function directly, as most API + functions have python wrapper functions. The `api` object can + be also be used to call API functions as methods: + + vim.api.err_write('ERROR\n', async_=True) + vim.current.buffer.api.get_mark('.') + + is equivalent to + + vim.request('nvim_err_write', 'ERROR\n', async_=True) + vim.request('nvim_buf_get_mark', vim.current.buffer, '.') + + + Normally a blocking request will be sent. If the `async_` flag is + present and True, a asynchronous notification is sent instead. This + will never block, and the return value or error is ignored. + """ + if (self._session._loop_thread is not None + and threading.current_thread() != self._session._loop_thread): + + msg = ("Request from non-main thread.\n" + "Requests from different threads should be wrapped " + "with nvim.async_call(cb, ...) \n{}\n" + .format('\n'.join(format_stack(None, 5)[:-1]))) + + self.async_call(self._err_cb, msg) + raise NvimError("request from non-main thread") + + decode = kwargs.pop('decode', self._decode) + args = walk(self._to_nvim, args) + res = self._session.request(name, *args, **kwargs) + return walk(partial(self._from_nvim, decode=decode), res) + + def next_message(self) -> Any: + """Block until a message(request or notification) is available. + + If any messages were previously enqueued, return the first in queue. + If not, run the event loop until one is received. + """ + msg = self._session.next_message() + if msg: + return walk(self._from_nvim, msg) + + def run_loop( + self, + request_cb: Optional[Callable[[str, List[Any]], Any]], + notification_cb: Optional[Callable[[str, List[Any]], Any]], + setup_cb: Optional[Callable[[], None]] = None, + err_cb: Optional[Callable[[str], Any]] = None + ) -> None: + """Run the event loop to receive requests and notifications from Nvim. + + This should not be called from a plugin running in the host, which + already runs the loop and dispatches events to plugins. + """ + if err_cb is None: + err_cb = sys.stderr.write + self._err_cb = err_cb + + def filter_request_cb(name: str, args: Any) -> Any: + name = self._from_nvim(name) + args = walk(self._from_nvim, args) + try: + result = request_cb(name, args) # type: ignore[misc] + except Exception: + msg = ("error caught in request handler '{} {}'\n{}\n\n" + .format(name, args, format_exc_skip(1))) + self._err_cb(msg) + raise + return walk(self._to_nvim, result) + + def filter_notification_cb(name: str, args: Any) -> None: + name = self._from_nvim(name) + args = walk(self._from_nvim, args) + try: + notification_cb(name, args) # type: ignore[misc] + except Exception: + msg = ("error caught in notification handler '{} {}'\n{}\n\n" + .format(name, args, format_exc_skip(1))) + self._err_cb(msg) + raise + + self._session.run(filter_request_cb, filter_notification_cb, setup_cb) + + def stop_loop(self) -> None: + """Stop the event loop being started with `run_loop`.""" + self._session.stop() + + def close(self) -> None: + """Close the nvim session and release its resources.""" + self._session.close() + + def __enter__(self) -> Nvim: + """Enter nvim session as a context manager.""" + return self + + def __exit__(self, *exc_info: Any) -> None: + """Exit nvim session as a context manager. + + Closes the event loop. + """ + self.close() + + def with_decode(self, decode: Literal[True] = True) -> Nvim: + """Initialize a new Nvim instance.""" + return Nvim(self._session, self.channel_id, + self.metadata, self.types, decode, self._err_cb) + + def ui_attach( + self, width: int, height: int, rgb: Optional[bool] = None, **kwargs: Any + ) -> None: + """Register as a remote UI. + + After this method is called, the client will receive redraw + notifications. + """ + options = kwargs + if rgb is not None: + options['rgb'] = rgb + return self.request('nvim_ui_attach', width, height, options) + + def ui_detach(self) -> None: + """Unregister as a remote UI.""" + return self.request('nvim_ui_detach') + + def ui_try_resize(self, width: int, height: int) -> None: + """Notify nvim that the client window has resized. + + If possible, nvim will send a redraw request to resize. + """ + return self.request('ui_try_resize', width, height) + + def subscribe(self, event: str) -> None: + """Subscribe to a Nvim event.""" + return self.request('nvim_subscribe', event) + + def unsubscribe(self, event: str) -> None: + """Unsubscribe to a Nvim event.""" + return self.request('nvim_unsubscribe', event) + + def command(self, string: str, **kwargs: Any) -> None: + """Execute a single ex command.""" + return self.request('nvim_command', string, **kwargs) + + def command_output(self, string: str) -> str: + """Execute a single ex command and return the output.""" + return self.request('nvim_command_output', string) + + def eval(self, string: str, **kwargs: Any) -> Any: + """Evaluate a vimscript expression.""" + return self.request('nvim_eval', string, **kwargs) + + def call(self, name: str, *args: Any, **kwargs: Any) -> Any: + """Call a vimscript function.""" + return self.request('nvim_call_function', name, args, **kwargs) + + def exec_lua(self, code: str, *args: Any, **kwargs: Any) -> Any: + """Execute lua code. + + Additional parameters are available as `...` inside the lua chunk. + Only statements are executed. To evaluate an expression, prefix it + with `return`: `return my_function(...)` + + There is a shorthand syntax to call lua functions with arguments: + + nvim.lua.func(1,2) + nvim.lua.mymod.myfunction(data, async_=True) + + is equivalent to + + nvim.exec_lua("return func(...)", 1, 2) + nvim.exec_lua("mymod.myfunction(...)", data, async_=True) + + Note that with `async_=True` there is no return value. + """ + return self.request('nvim_execute_lua', code, args, **kwargs) + + def strwidth(self, string: str) -> int: + """Return the number of display cells `string` occupies. + + Tab is counted as one cell. + """ + return self.request('nvim_strwidth', string) + + def list_runtime_paths(self) -> List[str]: + """Return a list of paths contained in the 'runtimepath' option.""" + return self.request('nvim_list_runtime_paths') + + def foreach_rtp(self, cb: Callable[[str], Any]) -> None: + """Invoke `cb` for each path in 'runtimepath'. + + Call the given callable for each path in 'runtimepath' until either + callable returns something but None, the exception is raised or there + are no longer paths. If stopped in case callable returned non-None, + vim.foreach_rtp function returns the value returned by callable. + """ + for path in self.list_runtime_paths(): + try: + if cb(path) is not None: + break + except Exception: + break + + def chdir(self, dir_path: str) -> None: + """Run os.chdir, then all appropriate vim stuff.""" + os_chdir(dir_path) + return self.request('nvim_set_current_dir', dir_path) + + def feedkeys(self, keys: str, options: str = '', escape_csi: bool = True) -> None: + """Push `keys` to Nvim user input buffer.""" + return self.request('nvim_feedkeys', keys, options, escape_csi) + + def input(self, bytes: AnyStr) -> int: + """Push `bytes` to Nvim low level input buffer. + + Unlike `feedkeys()`, this uses the lowest level input buffer and the + call is not deferred. It returns the number of bytes actually + written(which can be less than what was requested if the buffer is + full). + """ + return self.request('nvim_input', bytes) + + def replace_termcodes( + self, + string: str, + from_part: bool = False, + do_lt: bool = True, + special: bool = True + ) -> str: + r"""Replace any terminal code strings by byte sequences. + + The returned sequences are Nvim's internal representation of keys, + for example: + + -> '\x1b' + -> '\r' + -> '\x0c' + -> '\x80ku' + + The returned sequences can be used as input to `feedkeys`. + """ + return self.request('nvim_replace_termcodes', string, + from_part, do_lt, special) + + def out_write(self, msg: str, **kwargs: Any) -> None: + r"""Print `msg` as a normal message. + + The message is buffered (won't display) until linefeed ("\n"). + """ + return self.request('nvim_out_write', msg, **kwargs) + + def err_write(self, msg: str, **kwargs: Any) -> None: + r"""Print `msg` as an error message. + + The message is buffered (won't display) until linefeed ("\n"). + """ + if self._thread_invalid(): + # special case: if a non-main thread writes to stderr + # i.e. due to an uncaught exception, pass it through + # without raising an additional exception. + self.async_call(self.err_write, msg, **kwargs) + return + return self.request('nvim_err_write', msg, **kwargs) + + def _thread_invalid(self) -> bool: + return (self._session._loop_thread is not None + and threading.current_thread() != self._session._loop_thread) + + def quit(self, quit_command: str = 'qa!') -> None: + """Send a quit command to Nvim. + + By default, the quit command is 'qa!' which will make Nvim quit without + saving anything. + """ + try: + self.command(quit_command) + except OSError: + # sending a quit command will raise an IOError because the + # connection is closed before a response is received. Safe to + # ignore it. + pass + + def new_highlight_source(self) -> int: + """Return new src_id for use with Buffer.add_highlight.""" + return self.current.buffer.add_highlight("", 0, src_id=0) + + def async_call(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None: + """Schedule `fn` to be called by the event loop soon. + + This function is thread-safe, and is the only way code not + on the main thread could interact with nvim api objects. + + This function can also be called in a synchronous + event handler, just before it returns, to defer execution + that shouldn't block neovim. + """ + call_point = ''.join(format_stack(None, 5)[:-1]) + + def handler() -> None: + try: + fn(*args, **kwargs) + except Exception as err: + msg = ("error caught while executing async callback:\n" + "{!r}\n{}\n \nthe call was requested at\n{}" + .format(err, format_exc_skip(1), call_point)) + self._err_cb(msg) + raise + self._session.threadsafe_call(handler) + + +class Buffers(object): + + """Remote NVim buffers. + + Currently the interface for interacting with remote NVim buffers is the + `nvim_list_bufs` msgpack-rpc function. Most methods fetch the list of + buffers from NVim. + + Conforms to *python-buffers*. + """ + + def __init__(self, nvim: Nvim): + """Initialize a Buffers object with Nvim object `nvim`.""" + self._fetch_buffers = nvim.api.list_bufs + + def __len__(self) -> int: + """Return the count of buffers.""" + return len(self._fetch_buffers()) + + def __getitem__(self, number: int) -> Buffer: + """Return the Buffer object matching buffer number `number`.""" + for b in self._fetch_buffers(): + if b.number == number: + return b + raise KeyError(number) + + def __contains__(self, b: Buffer) -> bool: + """Return whether Buffer `b` is a known valid buffer.""" + return isinstance(b, Buffer) and b.valid + + def __iter__(self) -> Iterator[Buffer]: + """Return an iterator over the list of buffers.""" + return iter(self._fetch_buffers()) + + +class CompatibilitySession(object): + + """Helper class for API compatibility.""" + + def __init__(self, nvim: Nvim): + self.threadsafe_call = nvim.async_call + + +class Current(object): + + """Helper class for emulating vim.current from python-vim.""" + + def __init__(self, session: Nvim): + self._session = session + self.range = None + + @property + def line(self) -> str: + return self._session.request('nvim_get_current_line') + + @line.setter + def line(self, line: str) -> None: + return self._session.request('nvim_set_current_line', line) + + @line.deleter + def line(self) -> None: + return self._session.request('nvim_del_current_line') + + @property + def buffer(self) -> Buffer: + return self._session.request('nvim_get_current_buf') + + @buffer.setter + def buffer(self, buffer: Union[Buffer, int]) -> None: + return self._session.request('nvim_set_current_buf', buffer) + + @property + def window(self) -> Window: + return self._session.request('nvim_get_current_win') + + @window.setter + def window(self, window: Union[Window, int]) -> None: + return self._session.request('nvim_set_current_win', window) + + @property + def tabpage(self) -> Tabpage: + return self._session.request('nvim_get_current_tabpage') + + @tabpage.setter + def tabpage(self, tabpage: Union[Tabpage, int]) -> None: + return self._session.request('nvim_set_current_tabpage', tabpage) + + +class Funcs: + """Helper class for functional vimscript interface.""" + + def __init__(self, nvim: Nvim): + self._nvim = nvim + + def __getattr__(self, name: str) -> Callable[..., Any]: + return partial(self._nvim.call, name) + + +class LuaFuncs: + """Wrapper to allow lua functions to be called like python methods.""" + + def __init__(self, nvim: Nvim, name: str = ""): + self._nvim = nvim + self.name = name + + def __getattr__(self, name: str) -> LuaFuncs: + """Return wrapper to named api method.""" + prefix = self.name + "." if self.name else "" + return LuaFuncs(self._nvim, prefix + name) + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + # first new function after keyword rename, be a bit noisy + if 'async' in kwargs: + raise ValueError('"async" argument is not allowed. ' + 'Use "async_" instead.') + async_ = kwargs.get('async_', False) + pattern = "return {}(...)" if not async_ else "{}(...)" + code = pattern.format(self.name) + return self._nvim.exec_lua(code, *args, **kwargs) diff --git a/pynvim/api/tabpage.py b/pynvim/api/tabpage.py new file mode 100644 index 00000000..f6ee28ec --- /dev/null +++ b/pynvim/api/tabpage.py @@ -0,0 +1,46 @@ +"""API for working with Nvim tabpages.""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING, Tuple + +from pynvim.api.common import Remote, RemoteSequence +from pynvim.api.window import Window + +if TYPE_CHECKING: + from pynvim.api.nvim import Nvim + + +__all__ = ['Tabpage'] + + +class Tabpage(Remote): + """A remote Nvim tabpage.""" + + _api_prefix = "nvim_tabpage_" + + def __init__(self, session: Nvim, code_data: Tuple[int, Any]): + """Initialize from session and code_data immutable object. + + The `code_data` contains serialization information required for + msgpack-rpc calls. It must be immutable for Buffer equality to work. + """ + super(Tabpage, self).__init__(session, code_data) + self.windows: RemoteSequence[Window] = RemoteSequence( + self, "nvim_tabpage_list_wins" + ) + + @property + def window(self) -> Window: + """Get the `Window` currently focused on the tabpage.""" + return self.request('nvim_tabpage_get_win') + + @property + def valid(self) -> bool: + """Return True if the tabpage still exists.""" + return self.request('nvim_tabpage_is_valid') + + @property + def number(self) -> int: + """Get the tabpage number.""" + return self.request('nvim_tabpage_get_number') diff --git a/pynvim/api/window.py b/pynvim/api/window.py new file mode 100644 index 00000000..8ad26e48 --- /dev/null +++ b/pynvim/api/window.py @@ -0,0 +1,81 @@ +"""API for working with Nvim windows.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Tuple, cast + +from pynvim.api.buffer import Buffer +from pynvim.api.common import Remote + +if TYPE_CHECKING: + from pynvim.api.tabpage import Tabpage + + +__all__ = ['Window'] + + +class Window(Remote): + + """A remote Nvim window.""" + + _api_prefix = "nvim_win_" + + @property + def buffer(self) -> Buffer: + """Get the `Buffer` currently being displayed by the window.""" + return self.request('nvim_win_get_buf') + + @property + def cursor(self) -> Tuple[int, int]: + """Get the (row, col) tuple with the current cursor position.""" + return cast(Tuple[int, int], tuple(self.request('nvim_win_get_cursor'))) + + @cursor.setter + def cursor(self, pos: Tuple[int, int]) -> None: + """Set the (row, col) tuple as the new cursor position.""" + return self.request('nvim_win_set_cursor', pos) + + @property + def height(self) -> int: + """Get the window height in rows.""" + return self.request('nvim_win_get_height') + + @height.setter + def height(self, height: int) -> None: + """Set the window height in rows.""" + return self.request('nvim_win_set_height', height) + + @property + def width(self) -> int: + """Get the window width in rows.""" + return self.request('nvim_win_get_width') + + @width.setter + def width(self, width: int) -> None: + """Set the window height in rows.""" + return self.request('nvim_win_set_width', width) + + @property + def row(self) -> int: + """0-indexed, on-screen window position(row) in display cells.""" + return self.request('nvim_win_get_position')[0] + + @property + def col(self) -> int: + """0-indexed, on-screen window position(col) in display cells.""" + return self.request('nvim_win_get_position')[1] + + @property + def tabpage(self) -> Tabpage: + """Get the `Tabpage` that contains the window.""" + return self.request('nvim_win_get_tabpage') + + @property + def valid(self) -> bool: + """Return True if the window still exists.""" + return self.request('nvim_win_is_valid') + + @property + def number(self) -> int: + """Get the window number.""" + return self.request('nvim_win_get_number') diff --git a/pynvim/compat.py b/pynvim/compat.py new file mode 100644 index 00000000..61cd9a63 --- /dev/null +++ b/pynvim/compat.py @@ -0,0 +1,48 @@ +"""Code for compatibility across Python versions.""" +import warnings +from typing import Any, Dict, Optional + + +def find_module(fullname, path): # type: ignore + """Compatibility wrapper for imp.find_module. + + Automatically decodes arguments of find_module, in Python3 + they must be Unicode + """ + if isinstance(fullname, bytes): + fullname = fullname.decode() + if isinstance(path, bytes): + path = path.decode() + elif isinstance(path, list): + newpath = [] + for element in path: + if isinstance(element, bytes): + newpath.append(element.decode()) + else: + newpath.append(element) + path = newpath + from imp import find_module as original_find_module + return original_find_module(fullname, path) + + +unicode_errors_default = 'surrogateescape' + +NUM_TYPES = (int, float) + + +def check_async(async_: Optional[bool], kwargs: Dict[str, Any], default: bool) -> bool: + """Return a value of 'async' in kwargs or default when async_ is None. + + This helper function exists for backward compatibility (See #274). + It shows a warning message when 'async' in kwargs is used to note users. + """ + if async_ is not None: + return async_ + elif 'async' in kwargs: + warnings.warn( + '"async" attribute is deprecated. Use "async_" instead.', + DeprecationWarning, + ) + return kwargs.pop('async') + else: + return default diff --git a/neovim/msgpack_rpc/__init__.py b/pynvim/msgpack_rpc/__init__.py similarity index 56% rename from neovim/msgpack_rpc/__init__.py rename to pynvim/msgpack_rpc/__init__.py index 162606c1..e4efc706 100644 --- a/neovim/msgpack_rpc/__init__.py +++ b/pynvim/msgpack_rpc/__init__.py @@ -4,39 +4,46 @@ handling some Nvim particularities(server->client requests for example), the code here should work with other msgpack-rpc servers. """ -from .async_session import AsyncSession -from .event_loop import EventLoop -from .msgpack_stream import MsgpackStream -from .session import ErrorResponse, Session +from typing import Any, List + +from pynvim.msgpack_rpc.async_session import AsyncSession +from pynvim.msgpack_rpc.event_loop import EventLoop, TTransportType +from pynvim.msgpack_rpc.msgpack_stream import MsgpackStream +from pynvim.msgpack_rpc.session import ErrorResponse, Session +from pynvim.util import get_client_info __all__ = ('tcp_session', 'socket_session', 'stdio_session', 'child_session', 'ErrorResponse') -def session(transport_type='stdio', *args, **kwargs): +def session( + transport_type: TTransportType = 'stdio', *args: Any, **kwargs: Any +) -> Session: loop = EventLoop(transport_type, *args, **kwargs) msgpack_stream = MsgpackStream(loop) async_session = AsyncSession(msgpack_stream) session = Session(async_session) + session.request(b'nvim_set_client_info', + *get_client_info('client', 'remote', {}), async_=True) return session -def tcp_session(address, port=7450): +def tcp_session(address: str, port: int = 7450) -> Session: """Create a msgpack-rpc session from a tcp address/port.""" return session('tcp', address, port) -def socket_session(path): +def socket_session(path: str) -> Session: """Create a msgpack-rpc session from a unix domain socket.""" return session('socket', path) -def stdio_session(): +def stdio_session() -> Session: """Create a msgpack-rpc session from stdin/stdout.""" return session('stdio') -def child_session(argv): +def child_session(argv: List[str]) -> Session: """Create a msgpack-rpc session from a new Nvim instance.""" return session('child', argv) diff --git a/neovim/msgpack_rpc/async_session.py b/pynvim/msgpack_rpc/async_session.py similarity index 81% rename from neovim/msgpack_rpc/async_session.py rename to pynvim/msgpack_rpc/async_session.py index aa95088c..e7766454 100644 --- a/neovim/msgpack_rpc/async_session.py +++ b/pynvim/msgpack_rpc/async_session.py @@ -1,13 +1,19 @@ """Asynchronous msgpack-rpc handling in the event loop pipeline.""" import logging from traceback import format_exc +from typing import Any, AnyStr, Callable, Dict +from pynvim.msgpack_rpc.msgpack_stream import MsgpackStream logger = logging.getLogger(__name__) debug, info, warn = (logger.debug, logger.info, logger.warning,) -class AsyncSession(object): +# response call back takes two arguments: (err, return_value) +ResponseCallback = Callable[..., None] + + +class AsyncSession: """Asynchronous msgpack-rpc layer that wraps a msgpack stream. @@ -16,23 +22,25 @@ class AsyncSession(object): requests and notifications. """ - def __init__(self, msgpack_stream): + def __init__(self, msgpack_stream: MsgpackStream): """Wrap `msgpack_stream` on a msgpack-rpc interface.""" self._msgpack_stream = msgpack_stream self._next_request_id = 1 - self._pending_requests = {} + self._pending_requests: Dict[int, ResponseCallback] = {} self._request_cb = self._notification_cb = None self._handlers = { 0: self._on_request, 1: self._on_response, 2: self._on_notification } + self.loop = msgpack_stream.loop def threadsafe_call(self, fn): """Wrapper around `MsgpackStream.threadsafe_call`.""" self._msgpack_stream.threadsafe_call(fn) - def request(self, method, args, response_cb): + def request(self, method: AnyStr, args: Any, + response_cb: ResponseCallback) -> None: """Send a msgpack-rpc request to Nvim. A msgpack-rpc with method `method` and argument `args` is sent to @@ -49,14 +57,14 @@ def notify(self, method, args): A msgpack-rpc with method `method` and argument `args` is sent to Nvim. This will have the same effect as a request, but no response - will be recieved + will be received """ self._msgpack_stream.send([2, method, args]) def run(self, request_cb, notification_cb): """Run the event loop to receive requests and notifications from Nvim. - While the event loop is running, `request_cb` and `_notification_cb` + While the event loop is running, `request_cb` and `notification_cb` will be called whenever requests or notifications are respectively available. """ @@ -70,6 +78,10 @@ def stop(self): """Stop the event loop.""" self._msgpack_stream.stop() + def close(self): + """Close the event loop.""" + self._msgpack_stream.close() + def _on_message(self, msg): try: self._handlers.get(msg[0], self._on_invalid_message)(msg) @@ -84,8 +96,9 @@ def _on_request(self, msg): # - msg[2]: method name # - msg[3]: arguments debug('received request: %s, %s', msg[2], msg[3]) - self._request_cb(msg[2], msg[3], Response(self._msgpack_stream, - msg[1])) + assert self._request_cb is not None + self._request_cb(msg[2], msg[3], + Response(self._msgpack_stream, msg[1])) def _on_response(self, msg): # response to a previous request: @@ -100,6 +113,7 @@ def _on_notification(self, msg): # - msg[1]: event name # - msg[2]: arguments debug('received notification: %s, %s', msg[1], msg[2]) + assert self._notification_cb is not None self._notification_cb(msg[1], msg[2]) def _on_invalid_message(self, msg): @@ -108,15 +122,14 @@ def _on_invalid_message(self, msg): self._msgpack_stream.send([1, 0, error, None]) -class Response(object): - +class Response: """Response to a msgpack-rpc request that came from Nvim. When Nvim sends a msgpack-rpc request, an instance of this class is created for remembering state required to send a response. """ - def __init__(self, msgpack_stream, request_id): + def __init__(self, msgpack_stream: MsgpackStream, request_id: int): """Initialize the Response instance.""" self._msgpack_stream = msgpack_stream self._request_id = request_id diff --git a/pynvim/msgpack_rpc/event_loop/__init__.py b/pynvim/msgpack_rpc/event_loop/__init__.py new file mode 100644 index 00000000..1cf40a77 --- /dev/null +++ b/pynvim/msgpack_rpc/event_loop/__init__.py @@ -0,0 +1,10 @@ +"""Event loop abstraction subpackage. + +We use python's built-in asyncio as the backend. +""" + +from pynvim.msgpack_rpc.event_loop.asyncio import AsyncioEventLoop as EventLoop +from pynvim.msgpack_rpc.event_loop.base import TTransportType + + +__all__ = ['EventLoop', 'TTransportType'] diff --git a/pynvim/msgpack_rpc/event_loop/asyncio.py b/pynvim/msgpack_rpc/event_loop/asyncio.py new file mode 100644 index 00000000..a0ed2080 --- /dev/null +++ b/pynvim/msgpack_rpc/event_loop/asyncio.py @@ -0,0 +1,316 @@ +"""Event loop implementation that uses the `asyncio` standard module.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys +from collections import deque +from signal import Signals +from typing import Any, Callable, Deque, List, Optional, cast + +if sys.version_info >= (3, 12): + from typing import Final, override +else: + from typing_extensions import Final, override + +from pynvim.msgpack_rpc.event_loop.base import BaseEventLoop, TTransportType + +logger = logging.getLogger(__name__) +debug, info, warn = (logger.debug, logger.info, logger.warning,) + +loop_cls = asyncio.SelectorEventLoop + +if os.name == 'nt': + import msvcrt # pylint: disable=import-error + from asyncio.windows_utils import PipeHandle # type: ignore[attr-defined] + + # On windows use ProactorEventLoop which support pipes and is backed by the + # more powerful IOCP facility + # NOTE: we override in the stdio case, because it doesn't work. + loop_cls = asyncio.ProactorEventLoop # type: ignore[attr-defined,misc] + + +# pylint: disable=logging-fstring-interpolation + +class Protocol(asyncio.Protocol, asyncio.SubprocessProtocol): + """The protocol class used for asyncio-based RPC communication.""" + + def __init__(self, on_data, on_error): + """Initialize the Protocol object.""" + assert on_data is not None + assert on_error is not None + self._on_data = on_data + self._on_error = on_error + + @override + def connection_made(self, transport): + """Used to signal `asyncio.Protocol` of a successful connection.""" + self._transport = transport + + @override + def connection_lost(self, exc: Optional[Exception]) -> None: + """Used to signal `asyncio.Protocol` of a lost connection.""" + warn(f"connection_lost: exc = {exc}") + + self._on_error(exc if exc else EOFError("connection_lost")) + + @override + def data_received(self, data: bytes) -> None: + """Used to signal `asyncio.Protocol` of incoming data.""" + self._on_data(data) + + @override + def pipe_connection_lost(self, fd: int, exc: Optional[Exception]) -> None: + """Used to signal `asyncio.SubprocessProtocol` of a lost connection.""" + + assert isinstance(self._transport, asyncio.SubprocessTransport) + debug_info = {'fd': fd, 'exc': exc, 'pid': self._transport.get_pid()} + warn(f"pipe_connection_lost {debug_info}") + + if os.name == 'nt' and fd == 2: # stderr + # On windows, ignore piped stderr being closed immediately (#505) + return + + # pipe_connection_lost() *may* be called before process_exited() is + # called, when a Nvim subprocess crashes (SIGABRT). Do not handle + # errors here, as errors will be handled somewhere else + # self._on_error(exc if exc else EOFError("pipe_connection_lost")) + + @override + def pipe_data_received(self, fd, data): + """Used to signal `asyncio.SubprocessProtocol` of incoming data.""" + if fd == 2: # stderr fd number + # Ignore stderr message, log only for debugging + debug("stderr: %s", str(data)) + elif fd == 1: # stdout + self.data_received(data) + + @override + def process_exited(self) -> None: + """Used to signal `asyncio.SubprocessProtocol` when the child exits.""" + assert isinstance(self._transport, asyncio.SubprocessTransport) + pid = self._transport.get_pid() + return_code = self._transport.get_returncode() + + warn("process_exited, pid = %s, return_code = %s", pid, return_code) + err = EOFError(f"process_exited: pid = {pid}, return_code = {return_code}") + self._on_error(err) + + +class AsyncioEventLoop(BaseEventLoop): + """`BaseEventLoop` subclass that uses core `asyncio` as a backend.""" + + _protocol: Optional[Protocol] + _transport: Optional[asyncio.WriteTransport] + _signals: List[Signals] + _data_buffer: Deque[bytes] + if os.name != 'nt': + _child_watcher: Optional[asyncio.AbstractChildWatcher] + + def __init__(self, + transport_type: TTransportType, + *args: Any, **kwargs: Any): + """asyncio-specific initialization. see BaseEventLoop.__init__.""" + + # The underlying asyncio event loop. + self._loop: Final[asyncio.AbstractEventLoop] = loop_cls() + + # Handle messages from nvim that may arrive before run() starts. + self._data_buffer = deque() + + def _on_data(data: bytes) -> None: + if self._on_data is None: + self._data_buffer.append(data) + return + self._on_data(data) + + # pylint: disable-next=unnecessary-lambda + self._protocol_factory = lambda: Protocol( + on_data=_on_data, + on_error=self._on_error, + ) + self._protocol = None + + # The communication channel (endpoint) created by _connect_*() methods, + # where we write request messages to be sent to neovim + self._transport = None + self._to_close: List[asyncio.BaseTransport] = [] + self._child_watcher = None + + super().__init__(transport_type, *args, **kwargs) + + @override + def _connect_tcp(self, address: str, port: int) -> None: + async def connect_tcp(): + transport, protocol = await self._loop.create_connection( + self._protocol_factory, address, port) + debug(f"tcp connection successful: {address}:{port}") + self._transport = transport + self._protocol = protocol + + self._loop.run_until_complete(connect_tcp()) + + @override + def _connect_socket(self, path: str) -> None: + async def connect_socket(): + if os.name == 'nt': + _create_connection = self._loop.create_pipe_connection + else: + _create_connection = self._loop.create_unix_connection + + transport, protocol = await _create_connection( + self._protocol_factory, path) + debug("socket connection successful: %s", self._transport) + self._transport = transport + self._protocol = protocol + + self._loop.run_until_complete(connect_socket()) + + @override + def _connect_stdio(self) -> None: + async def connect_stdin(): + if os.name == 'nt': + pipe = PipeHandle(msvcrt.get_osfhandle(sys.stdin.fileno())) + else: + pipe = sys.stdin + transport, protocol = await self._loop.connect_read_pipe( + self._protocol_factory, pipe) + debug("native stdin connection successful") + self._to_close.append(transport) + del protocol + self._loop.run_until_complete(connect_stdin()) + + # Make sure subprocesses don't clobber stdout, + # send the output to stderr instead. + rename_stdout = os.dup(sys.stdout.fileno()) + os.dup2(sys.stderr.fileno(), sys.stdout.fileno()) + + async def connect_stdout(): + if os.name == 'nt': + pipe = PipeHandle(msvcrt.get_osfhandle(rename_stdout)) + else: + pipe = os.fdopen(rename_stdout, 'wb') + + transport, protocol = await self._loop.connect_write_pipe( + self._protocol_factory, pipe) + debug("native stdout connection successful") + self._transport = transport + self._protocol = protocol + self._loop.run_until_complete(connect_stdout()) + + @override + def _connect_child(self, argv: List[str]) -> None: + def get_child_watcher(): + try: + return asyncio.get_child_watcher() + except AttributeError: # Python 3.14 + return None + + return None + + if os.name != 'nt': + # see #238, #241 + watcher = get_child_watcher() + if watcher is not None: + watcher.attach_loop(self._loop) + self._child_watcher = watcher + + async def create_subprocess(): + transport: asyncio.SubprocessTransport # type: ignore + transport, protocol = await self._loop.subprocess_exec( + self._protocol_factory, *argv) + pid = transport.get_pid() + debug("child subprocess_exec successful, PID = %s", pid) + + self._transport = cast(asyncio.WriteTransport, + transport.get_pipe_transport(0)) # stdin + self._protocol = protocol + + # proactor transport implementations do not close the pipes + # automatically, so make sure they are closed upon shutdown + def _close_later(transport): + if transport is not None: + self._to_close.append(transport) + + _close_later(transport.get_pipe_transport(1)) + _close_later(transport.get_pipe_transport(2)) + _close_later(transport) + + # await until child process have been launched and the transport has + # been established + self._loop.run_until_complete(create_subprocess()) + + @override + def _start_reading(self) -> None: + pass + + @override + def _send(self, data: bytes) -> None: + assert self._transport, "connection has not been established." + self._transport.write(data) + + @override + def _run(self) -> None: + # process the early messages that arrived as soon as the transport + # channels are open and on_data is fully ready to receive messages. + while self._data_buffer: + data: bytes = self._data_buffer.popleft() + if self._on_data is not None: + self._on_data(data) + + self._loop.run_forever() + + @override + def _stop(self) -> None: + self._loop.stop() + + @override + def _close(self) -> None: + def _close_transport(transport): + transport.close() + + # Windows: for ProactorBasePipeTransport, close() doesn't take in + # effect immediately (closing happens asynchronously inside the + # event loop), need to wait a bit for completing graceful shutdown. + if (sys.version_info < (3, 13) and + os.name == 'nt' and hasattr(transport, '_sock')): + async def wait_until_closed(): + # pylint: disable-next=protected-access + while transport._sock is not None: + await asyncio.sleep(0.01) + self._loop.run_until_complete(wait_until_closed()) + + if self._transport: + _close_transport(self._transport) + self._transport = None + for transport in self._to_close: + _close_transport(transport) + self._to_close[:] = [] + + self._loop.close() + + if self._child_watcher is not None: + self._child_watcher.close() + self._child_watcher = None + + @override + def _threadsafe_call(self, fn: Callable[[], Any]) -> None: + self._loop.call_soon_threadsafe(fn) + + @override + def _setup_signals(self, signals: List[Signals]) -> None: + if os.name == 'nt': + # add_signal_handler is not supported in win32 + self._signals = [] + return + + self._signals = list(signals) + for signum in self._signals: + self._loop.add_signal_handler(signum, self._on_signal, signum) + + @override + def _teardown_signals(self) -> None: + for signum in self._signals: + self._loop.remove_signal_handler(signum) diff --git a/pynvim/msgpack_rpc/event_loop/base.py b/pynvim/msgpack_rpc/event_loop/base.py new file mode 100644 index 00000000..430af7a6 --- /dev/null +++ b/pynvim/msgpack_rpc/event_loop/base.py @@ -0,0 +1,258 @@ +"""Common code for event loop implementations.""" +import logging +import signal +import sys +import threading +from abc import ABC, abstractmethod +from typing import Any, Callable, List, Optional, Union + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +logger = logging.getLogger(__name__) +debug, info, warn = (logger.debug, logger.info, logger.warning,) + + +# When signals are restored, the event loop library may reset SIGINT to SIG_DFL +# which exits the program. To be able to restore the python interpreter to it's +# default state, we keep a reference to the default handler +default_int_handler = signal.getsignal(signal.SIGINT) +main_thread = threading.current_thread() + +TTransportType = Union[ + Literal['stdio'], + Literal['socket'], + Literal['tcp'], + Literal['child'] +] + +# TODO: Since pynvim now supports python 3, the only available backend of the +# msgpack_rpc BaseEventLoop is the built-in asyncio (see #294). We will have +# to remove some unnecessary abstractions as well as greenlet. See also #489 + + +class BaseEventLoop(ABC): + """Abstract base class for all event loops. + + Event loops act as the bottom layer for Nvim sessions created by this + library. They hide system/transport details behind a simple interface for + reading/writing bytes to the connected Nvim instance. + + A lifecycle of event loop is as follows: (1. -> [2. -> 3.]* -> 4.) + 1. initialization (__init__): connection to Nvim is established. + 2. run(data_cb): run the event loop (blocks until the loop stops). + Requests are sent to the remote neovim by calling send(), and + responses (messages) from the remote neovim will be passed to the + given `data_cb` callback function while the event loop is running. + Note that run() may be called multiple times. + 3. stop(): stop the event loop. + 4. close(): close the event loop, destroying all the internal resources. + + This class exposes public methods for interacting with the underlying + event loop and delegates implementation-specific work to the following + methods, which subclasses are expected to implement: + + - `_init()`: Implementation-specific initialization + - `_connect_tcp(address, port)`: connect to Nvim using tcp/ip + - `_connect_socket(path)`: Same as tcp, but use a UNIX domain socket or + named pipe. + - `_connect_stdio()`: Use stdin/stdout as the connection to Nvim + - `_connect_child(argv)`: Use the argument vector `argv` to spawn an + embedded Nvim that has its stdin/stdout connected to the event loop. + - `_start_reading()`: Called after any of _connect_* methods. Can be used + to perform any post-connection setup or validation. + - `_send(data)`: Send `data` (byte array) to Nvim (usually RPC request). + - `_run()`: Runs the event loop until stopped or the connection is closed. + The following methods can be called upon some events by the event loop: + - `_on_data(data)`: When Nvim sends some data (usually RPC response). + - `_on_signal(signum)`: When a signal is received. + - `_on_error(exc)`: When a non-recoverable error occurs (e.g: + connection lost, or any other OSError) + Note that these _on_{data,signal,error} methods are not 'final', may be + changed around an execution of run(). The subclasses are expected to + handle any early messages arriving while _on_data is not yet set. + - `_stop()`: Stop the event loop. + - `_interrupt(data)`: Like `stop()`, but may be called from other threads + this. + - `_setup_signals(signals)`: Add implementation-specific listeners for + for `signals`, which is a list of OS-specific signal numbers. + - `_teardown_signals()`: Removes signal listeners set by `_setup_signals` + """ + + def __init__(self, transport_type: TTransportType, *args: Any, **kwargs: Any): + """Initialize and connect the event loop instance. + + The only arguments are the transport type and transport-specific + configuration, like this: + + >>> BaseEventLoop('tcp', '127.0.0.1', 7450) + >>> BaseEventLoop('socket', '/tmp/nvim-socket') + >>> BaseEventLoop('stdio') + >>> BaseEventLoop('child', ['nvim', '--embed', '--headless', '-u', 'NONE']) + + Implementation-specific initialization should be made in the __init__ + constructor of the subclass, which must call the constructor of the + super class (BaseEventLoop), in which one of the `_connect_*` methods + (based on `transport_type`) and then `_start_reading()`. + """ + self._transport_type = transport_type + self._signames = dict((k, v) for v, k in signal.__dict__.items() + if v.startswith('SIG')) + self._on_data: Optional[Callable[[bytes], None]] = None + self._error: Optional[BaseException] = None + try: + getattr(self, '_connect_{}'.format(transport_type))(*args, **kwargs) + except Exception as e: + self.close() + raise e + self._start_reading() + + @abstractmethod + def _start_reading(self) -> None: + raise NotImplementedError() + + @abstractmethod + def _send(self, data: bytes) -> None: + raise NotImplementedError() + + def connect_tcp(self, address: str, port: int) -> None: + """Connect to tcp/ip `address`:`port`. Delegated to `_connect_tcp`.""" + info('Connecting to TCP address: %s:%d', address, port) + self._connect_tcp(address, port) + + @abstractmethod + def _connect_tcp(self, address: str, port: int) -> None: + raise NotImplementedError() + + def connect_socket(self, path: str) -> None: + """Connect to socket at `path`. Delegated to `_connect_socket`.""" + info('Connecting to %s', path) + self._connect_socket(path) + + @abstractmethod + def _connect_socket(self, path: str) -> None: + raise NotImplementedError() + + def connect_stdio(self) -> None: + """Connect using stdin/stdout. Delegated to `_connect_stdio`.""" + info('Preparing stdin/stdout for streaming data') + self._connect_stdio() + + @abstractmethod + def _connect_stdio(self) -> None: + raise NotImplementedError() + + def connect_child(self, argv): + """Connect a new Nvim instance. Delegated to `_connect_child`.""" + info('Spawning a new nvim instance') + self._connect_child(argv) + + @abstractmethod + def _connect_child(self, argv: List[str]) -> None: + raise NotImplementedError() + + def send(self, data: bytes) -> None: + """Queue `data` for sending to Nvim.""" + debug("Sending '%s'", data) + self._send(data) + + def threadsafe_call(self, fn): + """Call a function in the event loop thread. + + This is the only safe way to interact with a session from other + threads. + """ + self._threadsafe_call(fn) + + @abstractmethod + def _threadsafe_call(self, fn: Callable[[], Any]) -> None: + raise NotImplementedError() + + def run(self, data_cb: Callable[[bytes], None]) -> None: + """Run the event loop, and receives response messages to a callback.""" + if self._error: + err = self._error + if isinstance(self._error, KeyboardInterrupt): + # KeyboardInterrupt is not destructive (it may be used in + # the REPL). + # After throwing KeyboardInterrupt, cleanup the _error field + # so the loop may be started again + self._error = None + raise err + + # data_cb: e.g., MsgpackStream._on_data + self._on_data = data_cb + if threading.current_thread() == main_thread: + self._setup_signals([signal.SIGINT, signal.SIGTERM]) + debug('Entering event loop') + self._run() + debug('Exited event loop') + if threading.current_thread() == main_thread: + self._teardown_signals() + signal.signal(signal.SIGINT, default_int_handler) + self._on_data = None + + # eventloop was stopped due to an error, re-raise it + # (e.g. connection lost when subprocess nvim dies) + if self._error: + # Note: traceback is not preserved and attached for some reason, + # should be somewhere from msgpack_rpc.event_loop.asyncio.Protocol + raise self._error + + @abstractmethod + def _run(self) -> None: + raise NotImplementedError() + + def stop(self) -> None: + """Stop the event loop.""" + self._stop() + debug('Stopped event loop') + + @abstractmethod + def _stop(self) -> None: + raise NotImplementedError() + + def close(self) -> None: + """Stop the event loop.""" + self._close() + debug('Closed event loop') + + @abstractmethod + def _close(self) -> None: + raise NotImplementedError() + + def _on_signal(self, signum: signal.Signals) -> None: + # pylint: disable-next=consider-using-f-string + msg = 'Received signal {}'.format(self._signames[signum]) + debug(msg) + + if signum == signal.SIGINT and self._transport_type == 'stdio': + # When the transport is stdio, we are probably running as a Nvim + # child process. In that case, we don't want to be killed by + # ctrl+C + return + + if signum == signal.SIGINT: + self._error = KeyboardInterrupt() + else: + self._error = Exception(msg) + self.stop() + + def _on_error(self, exc: Exception) -> None: + warn('on_error: %s', repr(exc)) + if self._error is None: + # ignore subsequent exceptions, it's enough to raise only + # the first exception arrived + self._error = exc + self.stop() + + def _on_interrupt(self) -> None: + self.stop() + + def _setup_signals(self, signals: List[signal.Signals]) -> None: + pass # no-op by default + + def _teardown_signals(self) -> None: + pass # no-op by default diff --git a/neovim/msgpack_rpc/msgpack_stream.py b/pynvim/msgpack_rpc/msgpack_stream.py similarity index 63% rename from neovim/msgpack_rpc/msgpack_stream.py rename to pynvim/msgpack_rpc/msgpack_stream.py index 61d8f876..f209d849 100644 --- a/neovim/msgpack_rpc/msgpack_stream.py +++ b/pynvim/msgpack_rpc/msgpack_stream.py @@ -3,34 +3,35 @@ from msgpack import Packer, Unpacker +from pynvim.compat import unicode_errors_default +from pynvim.msgpack_rpc.event_loop.base import BaseEventLoop logger = logging.getLogger(__name__) debug, info, warn = (logger.debug, logger.info, logger.warning,) -class MsgpackStream(object): - +class MsgpackStream: """Two-way msgpack stream that wraps a event loop byte stream. This wraps the event loop interface for reading/writing bytes and exposes an interface for reading/writing msgpack documents. """ - def __init__(self, event_loop): + def __init__(self, event_loop: BaseEventLoop) -> None: """Wrap `event_loop` on a msgpack-aware interface.""" - self._event_loop = event_loop - self._packer = Packer(use_bin_type=True) - self._unpacker = Unpacker() + self.loop = event_loop + self._packer = Packer(unicode_errors=unicode_errors_default) + self._unpacker = Unpacker(unicode_errors=unicode_errors_default) self._message_cb = None def threadsafe_call(self, fn): """Wrapper around `BaseEventLoop.threadsafe_call`.""" - self._event_loop.threadsafe_call(fn) + self.loop.threadsafe_call(fn) def send(self, msg): """Queue `msg` for sending to Nvim.""" - debug('sent %s', msg) - self._event_loop.send(self._packer.pack(msg)) + debug('sending %s', msg) + self.loop.send(self._packer.pack(msg)) def run(self, message_cb): """Run the event loop to receive messages from Nvim. @@ -39,21 +40,26 @@ def run(self, message_cb): a message has been successfully parsed from the input stream. """ self._message_cb = message_cb - self._event_loop.run(self._on_data) + self.loop.run(self._on_data) self._message_cb = None def stop(self): """Stop the event loop.""" - self._event_loop.stop() + self.loop.stop() + + def close(self): + """Close the event loop.""" + self.loop.close() - def _on_data(self, data): + def _on_data(self, data: bytes) -> None: self._unpacker.feed(data) while True: try: debug('waiting for message...') msg = next(self._unpacker) debug('received message: %s', msg) - self._message_cb(msg) + assert self._message_cb is not None + self._message_cb(msg) # type: ignore[unreachable] except StopIteration: debug('unpacker needs more data...') break diff --git a/neovim/msgpack_rpc/session.py b/pynvim/msgpack_rpc/session.py similarity index 62% rename from neovim/msgpack_rpc/session.py rename to pynvim/msgpack_rpc/session.py index e2a49946..1c8e6f27 100644 --- a/neovim/msgpack_rpc/session.py +++ b/pynvim/msgpack_rpc/session.py @@ -1,17 +1,49 @@ """Synchronous msgpack-rpc session layer.""" import logging +import sys +import threading from collections import deque - from traceback import format_exc +from typing import (Any, AnyStr, Callable, Deque, List, NamedTuple, Optional, Sequence, + Tuple, Union, cast) import greenlet +from pynvim.compat import check_async +from pynvim.msgpack_rpc.async_session import AsyncSession +from pynvim.msgpack_rpc.event_loop.base import BaseEventLoop + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + logger = logging.getLogger(__name__) error, debug, info, warn = (logger.error, logger.debug, logger.info, logger.warning,) -class Session(object): +class Request(NamedTuple): + """A request from Nvim.""" + + type: Literal['request'] + name: str + args: List[Any] + response: Any + + +class Notification(NamedTuple): + """A notification from Nvim.""" + + type: Literal['notification'] + name: str + args: List[Any] + + +Message = Union[Request, Notification] + + +class Session: """Msgpack-rpc session layer that uses coroutines for a synchronous API. @@ -20,21 +52,32 @@ class Session(object): from Nvim with a synchronous API. """ - def __init__(self, async_session): + def __init__(self, async_session: AsyncSession): """Wrap `async_session` on a synchronous msgpack-rpc interface.""" self._async_session = async_session - self._request_cb = self._notification_cb = None - self._pending_messages = deque() + self._request_cb: Optional[Callable[[str, List[Any]], None]] = None + self._notification_cb: Optional[Callable[[str, List[Any]], None]] = None + self._pending_messages: Deque[Message] = deque() self._is_running = False - self._setup_exception = None - - def threadsafe_call(self, fn, *args, **kwargs): + self._setup_exception: Optional[Exception] = None + self._loop_thread: Optional[threading.Thread] = None + self.error_wrapper: Callable[[Tuple[int, str]], Exception] = \ + lambda e: Exception(e[1]) + + @property + def loop(self) -> BaseEventLoop: + """Get the underlying msgpack EventLoop.""" + return self._async_session.loop + + def threadsafe_call( + self, fn: Callable[..., Any], *args: Any, **kwargs: Any + ) -> None: """Wrapper around `AsyncSession.threadsafe_call`.""" def handler(): try: fn(*args, **kwargs) except Exception: - warn("error caught while excecuting async callback\n%s\n", + warn("error caught while executing async callback\n%s\n", format_exc()) def greenlet_wrapper(): @@ -43,7 +86,7 @@ def greenlet_wrapper(): self._async_session.threadsafe_call(greenlet_wrapper) - def next_message(self): + def next_message(self) -> Optional[Message]: """Block until a message(request or notification) is available. If any messages were previously enqueued, return the first in queue. @@ -57,8 +100,9 @@ def next_message(self): self._enqueue_notification_and_stop) if self._pending_messages: return self._pending_messages.popleft() + return None - def request(self, method, *args, **kwargs): + def request(self, method: AnyStr, *args: Any, **kwargs: Any) -> Any: """Send a msgpack-rpc request and block until as response is received. If the event loop is running, this method must have been called by a @@ -72,17 +116,17 @@ def request(self, method, *args, **kwargs): - Run the loop until the response is available - Put requests/notifications received while waiting into a queue - If the `async` flag is present and True, a asynchronous notification is - sent instead. This will never block, and the return value or error is - ignored. + If the `async_` flag is present and True, a asynchronous notification + is sent instead. This will never block, and the return value or error + is ignored. """ - async = kwargs.pop('async', False) - if async: + async_ = check_async(kwargs.pop('async_', None), kwargs, False) + if async_: self._async_session.notify(method, args) return if kwargs: - raise ValueError("request got unsupported keyword argument(s): {0}" + raise ValueError("request got unsupported keyword argument(s): {}" .format(', '.join(kwargs.keys()))) if self._is_running: @@ -91,14 +135,17 @@ def request(self, method, *args, **kwargs): v = self._blocking_request(method, args) if not v: # EOF - raise IOError('EOF') + raise OSError('EOF') err, rv = v if err: info("'Received error: %s", err) raise self.error_wrapper(err) return rv - def run(self, request_cb, notification_cb, setup_cb=None): + def run(self, + request_cb: Callable[[str, List[Any]], None], + notification_cb: Callable[[str, List[Any]], None], + setup_cb: Optional[Callable[[], None]] = None) -> None: """Run the event loop to receive requests and notifications from Nvim. Like `AsyncSession.run()`, but `request_cb` and `notification_cb` are @@ -108,10 +155,11 @@ def run(self, request_cb, notification_cb, setup_cb=None): self._notification_cb = notification_cb self._is_running = True self._setup_exception = None + self._loop_thread = threading.current_thread() - def on_setup(): + def on_setup() -> None: try: - setup_cb() + setup_cb() # type: ignore[misc] except Exception as e: self._setup_exception = e self.stop() @@ -122,26 +170,35 @@ def on_setup(): gr.switch() if self._setup_exception: - error('Setup error: {0}'.format(self._setup_exception)) + error( # type: ignore[unreachable] + 'Setup error: {}'.format(self._setup_exception) + ) raise self._setup_exception # Process all pending requests and notifications while self._pending_messages: msg = self._pending_messages.popleft() - getattr(self, '_on_{0}'.format(msg[0]))(*msg[1:]) + getattr(self, '_on_{}'.format(msg[0]))(*msg[1:]) self._async_session.run(self._on_request, self._on_notification) self._is_running = False self._request_cb = None self._notification_cb = None + self._loop_thread = None if self._setup_exception: raise self._setup_exception - def stop(self): + def stop(self) -> None: """Stop the event loop.""" self._async_session.stop() - def _yielding_request(self, method, args): + def close(self) -> None: + """Close the event loop.""" + self._async_session.close() + + def _yielding_request( + self, method: AnyStr, args: Sequence[Any] + ) -> Tuple[Tuple[int, str], Any]: gr = greenlet.getcurrent() parent = gr.parent @@ -153,7 +210,9 @@ def response_cb(err, rv): debug('yielding from greenlet %s to wait for response', gr) return parent.switch() - def _blocking_request(self, method, args): + def _blocking_request( + self, method: AnyStr, args: Sequence[Any] + ) -> Tuple[Tuple[int, str], Any]: result = [] def response_cb(err, rv): @@ -163,36 +222,38 @@ def response_cb(err, rv): self._async_session.request(method, args, response_cb) self._async_session.run(self._enqueue_request, self._enqueue_notification) - return result + return cast(Tuple[Tuple[int, str], Any], tuple(result)) - def _enqueue_request_and_stop(self, name, args, response): + def _enqueue_request_and_stop( + self, name: str, args: List[Any], response: Any + ) -> None: self._enqueue_request(name, args, response) self.stop() - def _enqueue_notification_and_stop(self, name, args): + def _enqueue_notification_and_stop(self, name: str, args: List[Any]) -> None: self._enqueue_notification(name, args) self.stop() - def _enqueue_request(self, name, args, response): - self._pending_messages.append(('request', name, args, response,)) + def _enqueue_request(self, name: str, args: List[Any], response: Any) -> None: + self._pending_messages.append(Request('request', name, args, response,)) - def _enqueue_notification(self, name, args): - self._pending_messages.append(('notification', name, args,)) + def _enqueue_notification(self, name: str, args: List[Any]) -> None: + self._pending_messages.append(Notification('notification', name, args,)) def _on_request(self, name, args, response): def handler(): try: rv = self._request_cb(name, args) - debug('greenlet %s finished executing, ' + - 'sending %s as response', gr, rv) + debug('greenlet %s finished executing, ' + + 'sending %s as response', gr, rv) response.send(rv) except ErrorResponse as err: - warn("error response from request '%s %s': %s", name, - args, format_exc()) + debug("error response from request '%s %s': %s", + name, args, format_exc()) response.send(err.args[0], error=True) except Exception as err: - warn("error caught while processing request '%s %s': %s", name, - args, format_exc()) + warn("error caught while processing request '%s %s': %s", + name, args, format_exc()) response.send(repr(err) + "\n" + format_exc(5), error=True) debug('greenlet %s is now dying...', gr) diff --git a/pynvim/plugin/__init__.py b/pynvim/plugin/__init__.py new file mode 100644 index 00000000..cb4ba41e --- /dev/null +++ b/pynvim/plugin/__init__.py @@ -0,0 +1,9 @@ +"""Nvim plugin/host subpackage.""" + +from pynvim.plugin.decorators import (autocmd, command, decode, encoding, function, + plugin, rpc_export, shutdown_hook) +from pynvim.plugin.host import Host # type: ignore[attr-defined] + + +__all__ = ('Host', 'plugin', 'rpc_export', 'command', 'autocmd', + 'function', 'encoding', 'decode', 'shutdown_hook') diff --git a/pynvim/plugin/decorators.py b/pynvim/plugin/decorators.py new file mode 100644 index 00000000..675fc4cc --- /dev/null +++ b/pynvim/plugin/decorators.py @@ -0,0 +1,210 @@ +"""Decorators used by python host plugin system.""" + +import inspect +import logging +import sys +from typing import Any, Callable, Dict, Optional, TypeVar, Union + +from pynvim.compat import unicode_errors_default + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +logger = logging.getLogger(__name__) +debug, info, warn = (logger.debug, logger.info, logger.warning,) +__all__ = ('plugin', 'rpc_export', 'command', 'autocmd', 'function', + 'encoding', 'decode', 'shutdown_hook') + +T = TypeVar('T') +F = TypeVar('F', bound=Callable[..., Any]) + + +def plugin(cls: T) -> T: + """Tag a class as a plugin. + + This decorator is required to make the class methods discoverable by the + plugin_load method of the host. + """ + cls._nvim_plugin = True # type: ignore[attr-defined] + # the _nvim_bind attribute is set to True by default, meaning that + # decorated functions have a bound Nvim instance as first argument. + # For methods in a plugin-decorated class this is not required, because + # the class initializer will already receive the nvim object. + predicate = lambda fn: hasattr(fn, '_nvim_bind') + for _, fn in inspect.getmembers(cls, predicate): + fn._nvim_bind = False + return cls + + +def rpc_export(rpc_method_name: str, sync: bool = False) -> Callable[[F], F]: + """Export a function or plugin method as a msgpack-rpc request handler.""" + def dec(f: F) -> F: + f._nvim_rpc_method_name = rpc_method_name # type: ignore[attr-defined] + f._nvim_rpc_sync = sync # type: ignore[attr-defined] + f._nvim_bind = True # type: ignore[attr-defined] + f._nvim_prefix_plugin_path = False # type: ignore[attr-defined] + return f + return dec + + +def command( + name: str, + nargs: Union[str, int] = 0, + complete: Optional[str] = None, + range: Optional[Union[str, int]] = None, + count: Optional[int] = None, + bang: bool = False, + register: bool = False, + sync: bool = False, + allow_nested: bool = False, + eval: Optional[str] = None +) -> Callable[[F], F]: + """Tag a function or plugin method as a Nvim command handler.""" + def dec(f: F) -> F: + f._nvim_rpc_method_name = ( # type: ignore[attr-defined] + 'command:{}'.format(name) + ) + f._nvim_rpc_sync = sync # type: ignore[attr-defined] + f._nvim_bind = True # type: ignore[attr-defined] + f._nvim_prefix_plugin_path = True # type: ignore[attr-defined] + + opts: Dict[str, Any] = {} + + if range is not None: + opts['range'] = '' if range is True else str(range) + elif count is not None: + opts['count'] = count + + if bang: + opts['bang'] = '' + + if register: + opts['register'] = '' + + if nargs: + opts['nargs'] = nargs + + if complete: + opts['complete'] = complete + + if eval: + opts['eval'] = eval + + if not sync and allow_nested: + rpc_sync: Union[bool, Literal['urgent']] = "urgent" + else: + rpc_sync = sync + + f._nvim_rpc_spec = { # type: ignore[attr-defined] + 'type': 'command', + 'name': name, + 'sync': rpc_sync, + 'opts': opts + } + return f + return dec + + +def autocmd( + name: str, + pattern: str = '*', + sync: bool = False, + allow_nested: bool = False, + eval: Optional[str] = None +) -> Callable[[F], F]: + """Tag a function or plugin method as a Nvim autocommand handler.""" + def dec(f: F) -> F: + f._nvim_rpc_method_name = ( # type: ignore[attr-defined] + 'autocmd:{}:{}'.format(name, pattern) + ) + f._nvim_rpc_sync = sync # type: ignore[attr-defined] + f._nvim_bind = True # type: ignore[attr-defined] + f._nvim_prefix_plugin_path = True # type: ignore[attr-defined] + + opts = { + 'pattern': pattern + } + + if eval: + opts['eval'] = eval + + if not sync and allow_nested: + rpc_sync: Union[bool, Literal['urgent']] = "urgent" + else: + rpc_sync = sync + + f._nvim_rpc_spec = { # type: ignore[attr-defined] + 'type': 'autocmd', + 'name': name, + 'sync': rpc_sync, + 'opts': opts + } + return f + return dec + + +def function( + name: str, + range: Union[bool, str, int] = False, + sync: bool = False, + allow_nested: bool = False, + eval: Optional[str] = None +) -> Callable[[F], F]: + """Tag a function or plugin method as a Nvim function handler.""" + def dec(f: F) -> F: + f._nvim_rpc_method_name = ( # type: ignore[attr-defined] + 'function:{}'.format(name) + ) + f._nvim_rpc_sync = sync # type: ignore[attr-defined] + f._nvim_bind = True # type: ignore[attr-defined] + f._nvim_prefix_plugin_path = True # type: ignore[attr-defined] + + opts = {} + + if range: + opts['range'] = '' if range is True else str(range) + + if eval: + opts['eval'] = eval + + if not sync and allow_nested: + rpc_sync: Union[bool, Literal['urgent']] = "urgent" + else: + rpc_sync = sync + + f._nvim_rpc_spec = { # type: ignore[attr-defined] + 'type': 'function', + 'name': name, + 'sync': rpc_sync, + 'opts': opts + } + return f + return dec + + +def shutdown_hook(f: F) -> F: + """Tag a function or method as a shutdown hook.""" + f._nvim_shutdown_hook = True # type: ignore[attr-defined] + f._nvim_bind = True # type: ignore[attr-defined] + return f + + +def decode(mode: str = unicode_errors_default) -> Callable[[F], F]: + """Configure automatic encoding/decoding of strings.""" + def dec(f: F) -> F: + f._nvim_decode = mode # type: ignore[attr-defined] + return f + return dec + + +def encoding(encoding: Union[bool, str] = True) -> Callable[[F], F]: + """DEPRECATED: use pynvim.decode().""" + if isinstance(encoding, str): + encoding = True + + def dec(f: F) -> F: + f._nvim_decode = encoding # type: ignore[attr-defined] + return f + return dec diff --git a/pynvim/plugin/host.py b/pynvim/plugin/host.py new file mode 100644 index 00000000..b2f8bced --- /dev/null +++ b/pynvim/plugin/host.py @@ -0,0 +1,291 @@ +# type: ignore +"""Implements a Nvim host for python plugins.""" + +import importlib +import inspect +import logging +import os +import os.path +import pathlib +import re +import sys +from functools import partial +from traceback import format_exc +from typing import Any, Sequence + +from pynvim.api import Nvim, decode_if_bytes, walk +from pynvim.msgpack_rpc import ErrorResponse +from pynvim.plugin import script_host +from pynvim.util import format_exc_skip, get_client_info + +__all__ = ('Host',) + +logger = logging.getLogger(__name__) +error, debug, info, warn = (logger.error, logger.debug, logger.info, + logger.warning,) + +host_method_spec = {"poll": {}, "specs": {"nargs": 1}, "shutdown": {}} + + +def _handle_import(path: str, name: str): + """Import python module `name` from a known file path or module directory. + + The path should be the base directory from which the module can be imported. + To support python 3.12, the use of `imp` should be avoided. + @see https://docs.python.org/3.12/whatsnew/3.12.html#imp + """ + if not name: + raise ValueError("Missing module name.") + + sys.path.append(path) + return importlib.import_module(name) + + +class Host(object): + + """Nvim host for python plugins. + + Takes care of loading/unloading plugins and routing msgpack-rpc + requests/notifications to the appropriate handlers. + """ + + def __init__(self, nvim: Nvim): + """Set handlers for plugin_load/plugin_unload.""" + self.nvim = nvim + self._specs = {} + self._loaded = {} + self._load_errors = {} + self._notification_handlers = { + 'nvim_error_event': self._on_error_event + } + self._request_handlers = { + 'poll': lambda: 'ok', + 'specs': self._on_specs_request, + 'shutdown': self.shutdown + } + + self._decode_default = True + + def _on_async_err(self, msg: str) -> None: + # uncaught python exception + self.nvim.err_write(msg, async_=True) + + def _on_error_event(self, kind: Any, msg: str) -> None: + # error from nvim due to async request + # like nvim.command(..., async_=True) + errmsg = "{}: Async request caused an error:\n{}\n".format( + self.name, decode_if_bytes(msg)) + self.nvim.err_write(errmsg, async_=True) + return errmsg + + def start(self, plugins): + """Start listening for msgpack-rpc requests and notifications.""" + self.nvim.run_loop(self._on_request, + self._on_notification, + lambda: self._load(plugins), + err_cb=self._on_async_err) + + def shutdown(self) -> None: + """Shutdown the host.""" + self._unload() + self.nvim.stop_loop() + + def _wrap_delayed_function(self, cls, delayed_handlers, name, sync, + module_handlers, path, *args): + # delete the delayed handlers to be sure + for handler in delayed_handlers: + method_name = handler._nvim_registered_name + if handler._nvim_rpc_sync: + del self._request_handlers[method_name] + else: + del self._notification_handlers[method_name] + # create an instance of the plugin and pass the nvim object + plugin = cls(self._configure_nvim_for(cls)) + + # discover handlers in the plugin instance + self._discover_functions(plugin, module_handlers, path, False) + + if sync: + return self._request_handlers[name](*args) + else: + return self._notification_handlers[name](*args) + + def _wrap_function(self, fn, sync, decode, nvim_bind, name, *args): + if decode: + args = walk(partial(decode_if_bytes, mode=decode), args) + if nvim_bind is not None: + args.insert(0, nvim_bind) + try: + return fn(*args) + except Exception: + if sync: + msg = ("error caught in request handler '{} {}':\n{}" + .format(name, args, format_exc_skip(1))) + raise ErrorResponse(msg) + else: + msg = ("error caught in async handler '{} {}'\n{}\n" + .format(name, args, format_exc_skip(1))) + self._on_async_err(msg + "\n") + + def _on_request(self, name: str, args: Sequence[Any]) -> None: + """Handle a msgpack-rpc request.""" + name = decode_if_bytes(name) + handler = self._request_handlers.get(name, None) + if not handler: + msg = self._missing_handler_error(name, 'request') + error(msg) + raise ErrorResponse(msg) + + debug('calling request handler for "%s", args: "%s"', name, args) + rv = handler(*args) + debug("request handler for '%s %s' returns: %s", name, args, rv) + return rv + + def _on_notification(self, name: str, args: Sequence[Any]) -> None: + """Handle a msgpack-rpc notification.""" + name = decode_if_bytes(name) + handler = self._notification_handlers.get(name, None) + if not handler: + msg = self._missing_handler_error(name, 'notification') + error(msg) + self._on_async_err(msg + "\n") + return + + debug('calling notification handler for "%s", args: "%s"', name, args) + handler(*args) + + def _missing_handler_error(self, name, kind): + msg = 'no {} handler registered for "{}"'.format(kind, name) + pathmatch = re.match(r'(.+):[^:]+:[^:]+', name) + if pathmatch: + loader_error = self._load_errors.get(pathmatch.group(1)) + if loader_error is not None: + msg = msg + "\n" + loader_error + return msg + + def _load(self, plugins: Sequence[str]) -> None: + """Load the remote plugins and register handlers defined in the plugins. + + Args: + plugins: List of plugin paths to rplugin python modules + registered by remote#host#RegisterPlugin('python3', ...) + (see the generated rplugin.vim manifest) + """ + # self.nvim.err_write("host init _load\n", async_=True) + has_script = False + for path in plugins: + path = pathlib.Path(os.path.normpath(path)).as_posix() # normalize path + err = None + if path in self._loaded: + warn('{} is already loaded'.format(path)) + continue + try: + if path == "script_host.py": + module = script_host + has_script = True + else: + directory, name = os.path.split(os.path.splitext(path)[0]) + module = _handle_import(directory, name) + handlers = [] + self._discover_classes(module, handlers, path) + self._discover_functions(module, handlers, path, False) + if not handlers: + error('{} exports no handlers'.format(path)) + continue + self._loaded[path] = {'handlers': handlers, 'module': module} + except Exception as e: + err = ('Encountered {} loading plugin at {}: {}\n{}' + .format(type(e).__name__, path, e, format_exc(5))) + error(err) + self._load_errors[path] = err + + kind = ("script-host" if len(plugins) == 1 and has_script + else "rplugin-host") + info = get_client_info(kind, 'host', host_method_spec) + self.name = info[0] + self.nvim.api.set_client_info(*info, async_=True) + + def _unload(self) -> None: + for path, plugin in self._loaded.items(): + handlers = plugin['handlers'] + for handler in handlers: + method_name = handler._nvim_registered_name + if hasattr(handler, '_nvim_shutdown_hook'): + handler() + elif handler._nvim_rpc_sync: + del self._request_handlers[method_name] + else: + del self._notification_handlers[method_name] + self._specs = {} + self._loaded = {} + + def _discover_classes(self, module, handlers, plugin_path): + for _, cls in inspect.getmembers(module, inspect.isclass): + if getattr(cls, '_nvim_plugin', False): + # discover handlers in the plugin instance + self._discover_functions(cls, handlers, plugin_path, True) + + def _discover_functions(self, obj, handlers, plugin_path, delay): + def predicate(o): + return hasattr(o, '_nvim_rpc_method_name') + + cls_handlers = [] + specs = [] + objdecode = getattr(obj, '_nvim_decode', self._decode_default) + for _, fn in inspect.getmembers(obj, predicate): + method = fn._nvim_rpc_method_name + if fn._nvim_prefix_plugin_path: + method = '{}:{}'.format(plugin_path, method) + sync = fn._nvim_rpc_sync + if delay: + fn_wrapped = partial(self._wrap_delayed_function, obj, + cls_handlers, method, sync, + handlers, plugin_path) + else: + decode = getattr(fn, '_nvim_decode', objdecode) + nvim_bind = None + if fn._nvim_bind: + nvim_bind = self._configure_nvim_for(fn) + + fn_wrapped = partial(self._wrap_function, fn, + sync, decode, nvim_bind, method) + self._copy_attributes(fn, fn_wrapped) + fn_wrapped._nvim_registered_name = method + # register in the rpc handler dict + if sync: + if method in self._request_handlers: + raise Exception(('Request handler for "{}" is ' + + 'already registered').format(method)) + self._request_handlers[method] = fn_wrapped + else: + if method in self._notification_handlers: + raise Exception(('Notification handler for "{}" is ' + + 'already registered').format(method)) + self._notification_handlers[method] = fn_wrapped + if hasattr(fn, '_nvim_rpc_spec'): + specs.append(fn._nvim_rpc_spec) + handlers.append(fn_wrapped) + cls_handlers.append(fn_wrapped) + if specs: + self._specs[plugin_path] = specs + + def _copy_attributes(self, fn, fn2): + # Copy _nvim_* attributes from the original function + for attr in dir(fn): + if attr.startswith('_nvim_'): + setattr(fn2, attr, getattr(fn, attr)) + + def _on_specs_request(self, path): + path = decode_if_bytes(path) + path = pathlib.Path(os.path.normpath(path)).as_posix() # normalize path + if path in self._load_errors: + self.nvim.out_write(self._load_errors[path] + '\n') + return self._specs.get(path, 0) + + def _configure_nvim_for(self, obj): + # Configure a nvim instance for obj (checks encoding configuration) + nvim = self.nvim + decode = getattr(obj, '_nvim_decode', self._decode_default) + if decode: + nvim = nvim.with_decode(decode) + return nvim diff --git a/neovim/plugin/script_host.py b/pynvim/plugin/script_host.py similarity index 65% rename from neovim/plugin/script_host.py rename to pynvim/plugin/script_host.py index d8c55791..89efbadd 100644 --- a/neovim/plugin/script_host.py +++ b/pynvim/plugin/script_host.py @@ -1,26 +1,22 @@ +# type: ignore """Legacy python/python3-vim emulation.""" -import imp import io import logging import os import sys +from importlib.machinery import PathFinder +from types import ModuleType -from .decorators import plugin, rpc_export -from ..api import Nvim +from pynvim.api import Nvim, walk +from pynvim.msgpack_rpc import ErrorResponse +from pynvim.plugin.decorators import plugin, rpc_export +from pynvim.util import format_exc_skip __all__ = ('ScriptHost',) logger = logging.getLogger(__name__) -debug, info, warn = (logger.debug, logger.info, logger.warn,) - -IS_PYTHON3 = sys.version_info >= (3, 0) - -if IS_PYTHON3: - basestring = str - - if sys.version_info >= (3, 4): - from importlib.machinery import PathFinder +debug, info, warn = (logger.debug, logger.info, logger.warning,) @plugin @@ -32,12 +28,25 @@ def __init__(self, nvim): """Initialize the legacy python-vim environment.""" self.setup(nvim) # context where all code will run - self.module = imp.new_module('__main__') + self.module = ModuleType('__main__') nvim.script_context = self.module # it seems some plugins assume 'sys' is already imported, so do it now exec('import sys', self.module.__dict__) self.legacy_vim = LegacyVim.from_nvim(nvim) sys.modules['vim'] = self.legacy_vim + # mimic Vim by importing vim module by default. + exec('import vim', self.module.__dict__) + # Handle DirChanged. #296 + nvim.command( + 'au DirChanged * call rpcnotify({}, "python_chdir", v:event.cwd)' + .format(nvim.channel_id), async_=True) + # XXX: Avoid race condition. + # https://github.com/neovim/pynvim/pull/296#issuecomment-358970531 + # TODO(bfredl): when host initialization has been refactored, + # to make __init__ safe again, the following should work: + # os.chdir(nvim.eval('getcwd()', async_=False)) + nvim.command('call rpcnotify({}, "python_chdir", getcwd())' + .format(nvim.channel_id), async_=True) def setup(self, nvim): """Setup import hooks and global streams. @@ -72,15 +81,31 @@ def teardown(self): def python_execute(self, script, range_start, range_stop): """Handle the `python` ex command.""" self._set_current_range(range_start, range_stop) - exec(script, self.module.__dict__) + + if script.startswith('='): + # Handle ":py= ...". Evaluate as an expression and print. + # (note: a valid python statement can't start with "=") + expr = script[1:] + print(self.python_eval(expr)) + return + + try: + # pylint: disable-next=exec-used + exec(script, self.module.__dict__) + except Exception as exc: + raise ErrorResponse(format_exc_skip(1)) from exc @rpc_export('python_execute_file', sync=True) def python_execute_file(self, file_path, range_start, range_stop): """Handle the `pyfile` ex command.""" self._set_current_range(range_start, range_stop) - with open(file_path) as f: + with open(file_path, 'rb') as f: script = compile(f.read(), file_path, 'exec') - exec(script, self.module.__dict__) + try: + # pylint: disable-next=exec-used + exec(script, self.module.__dict__) + except Exception as exc: + raise ErrorResponse(format_exc_skip(1)) from exc @rpc_export('python_do_range', sync=True) def python_do_range(self, start, stop, code): @@ -88,7 +113,6 @@ def python_do_range(self, start, stop, code): self._set_current_range(start, stop) nvim = self.nvim start -= 1 - stop -= 1 fname = '_vim_pydo' # define the function @@ -96,15 +120,14 @@ def python_do_range(self, start, stop, code): exec(function_def, self.module.__dict__) # get the function function = self.module.__dict__[fname] - while start <= stop: + while start < stop: # Process batches of 5000 to avoid the overhead of making multiple # API calls for every line. Assuming an average line length of 100 # bytes, approximately 488 kilobytes will be transferred per batch, # which can be done very quickly in a single API call. sstart = start sstop = min(start + 5000, stop) - lines = nvim.current.buffer.get_line_slice(sstart, sstop, True, - True) + lines = nvim.current.buffer.api.get_lines(sstart, sstop, True) exception = None newlines = [] @@ -115,26 +138,24 @@ def python_do_range(self, start, stop, code): # Update earlier lines, and skip to the next if newlines: end = sstart + len(newlines) - 1 - nvim.current.buffer.set_line_slice(sstart, end, - True, True, - newlines) + nvim.current.buffer.api.set_lines(sstart, end, + True, newlines) sstart += len(newlines) + 1 newlines = [] pass - elif isinstance(result, basestring): + elif isinstance(result, str): newlines.append(result) else: - exception = TypeError('pydo should return a string ' + - 'or None, found %s instead' + exception = TypeError('pydo should return a string ' + + 'or None, found %s instead' % result.__class__.__name__) break linenr += 1 - start = sstop + 1 + start = sstop if newlines: - end = sstart + len(newlines) - 1 - nvim.current.buffer.set_line_slice(sstart, end, True, True, - newlines) + end = sstart + len(newlines) + nvim.current.buffer.api.set_lines(sstart, end, True, newlines) if exception: raise exception # delete the function @@ -143,7 +164,16 @@ def python_do_range(self, start, stop, code): @rpc_export('python_eval', sync=True) def python_eval(self, expr): """Handle the `pyeval` vim function.""" - return eval(expr, self.module.__dict__) + try: + # pylint: disable-next=eval-used + return eval(expr, self.module.__dict__) + except Exception as exc: + raise ErrorResponse(format_exc_skip(1)) from exc + + @rpc_export('python_chdir', sync=False) + def python_chdir(self, cwd): + """Handle working directory changes.""" + os.chdir(cwd) def _set_current_range(self, start, stop): current = self.legacy_vim.current @@ -161,23 +191,31 @@ def writelines(self, seq): self.redirect_handler('\n'.join(seq)) +num_types = (int, float) + + +def num_to_str(obj): + if isinstance(obj, num_types): + return str(obj) + else: + return obj + + class LegacyVim(Nvim): def eval(self, expr): obj = self.request("vim_eval", expr) - if IS_PYTHON3: - if isinstance(obj, (int, float)): - return str(obj) - elif isinstance(obj, (int, long, float)): - return str(obj) - return obj + return walk(num_to_str, obj) -# This was copied/adapted from nvim-python help +# Copied/adapted from :help if_pyth. def path_hook(nvim): def _get_paths(): + if nvim._thread_invalid(): + return [] return discover_runtime_directories(nvim) def _find_module(fullname, oldtail, path): + import imp idx = oldtail.find('.') if idx > 0: name = oldtail[:idx] @@ -194,8 +232,11 @@ def __init__(self, module): def load_module(self, fullname, path=None): # Check sys.modules, required for reload (see PEP302). - if fullname in sys.modules: + try: return sys.modules[fullname] + except KeyError: + pass + import imp return imp.load_module(fullname, *self.module) class VimPathFinder(object): @@ -209,9 +250,9 @@ def find_module(fullname, path=None): return None @staticmethod - def find_spec(fullname, path=None, target=None): + def find_spec(fullname, target=None): """Method for Python 3.4+.""" - return PathFinder.find_spec(fullname, path or _get_paths(), target) + return PathFinder.find_spec(fullname, _get_paths(), target) def hook(path): if path == nvim.VIM_SPECIAL_PATH: @@ -224,16 +265,11 @@ def hook(path): def discover_runtime_directories(nvim): rv = [] - for path in nvim.list_runtime_paths(): - if not os.path.exists(path): + for rtp in nvim.list_runtime_paths(): + if not os.path.exists(rtp): continue - path1 = os.path.join(path, 'pythonx') - if IS_PYTHON3: - path2 = os.path.join(path, 'python3') - else: - path2 = os.path.join(path, 'python2') - if os.path.exists(path1): - rv.append(path1) - if os.path.exists(path2): - rv.append(path2) + for subdir in ['pythonx', 'python3']: + path = os.path.join(rtp, subdir) + if os.path.exists(path): + rv.append(path) return rv diff --git a/pynvim/python.py b/pynvim/python.py new file mode 100644 index 00000000..75176b17 --- /dev/null +++ b/pynvim/python.py @@ -0,0 +1,28 @@ +"""Wrapper to expose the Python interpreter as `pynvim-python`. + +`setup.py` declares an entry point for the `main()` function below. When +`pynvim` is installed, an executable named `pynvim-python` will be generated +that will invoke `main()` below; that function then simply chains to the +underlying Python interpreter, passing along all command-line arguments. + +The intent is to have `pynvim-python` be on the `PATH` such that an invocation +such as: + + pynvim-python -c 'import pynvim' + +is equivalent to explicitly running the correct Python interpreter where +`pynvim` is installed: + + /path/to/python -c 'import pynvim' + +This allows Neovim to automatically detect the correct Python interpreter for +use with `pynvim`. +""" + +import subprocess +import sys + + +def main() -> None: + """Chain to Python interpreter, passing all command-line args.""" + sys.exit(subprocess.run([sys.executable] + sys.argv[1:]).returncode) diff --git a/pynvim/util.py b/pynvim/util.py new file mode 100644 index 00000000..cb2f207a --- /dev/null +++ b/pynvim/util.py @@ -0,0 +1,29 @@ +"""Shared utility functions.""" + +import sys +from traceback import format_exception +from typing import Any, Dict, Optional, Tuple, TypeVar + +from pynvim._version import VERSION + + +def format_exc_skip(skip: int, limit: Optional[int] = None) -> str: + """Like traceback.format_exc but allow skipping the first frames.""" + etype, val, tb = sys.exc_info() + for _ in range(skip): + if tb is not None: + tb = tb.tb_next + return ("".join(format_exception(etype, val, tb, limit))).rstrip() + + +T1 = TypeVar("T1") +T2 = TypeVar("T2") + + +def get_client_info( + kind: str, type_: T1, method_spec: T2 +) -> Tuple[str, Dict[str, Any], T1, T2, Dict[str, str]]: + """Returns a tuple describing the client.""" + name = "python{}-{}".format(sys.version_info[0], kind) + attributes = {"license": "Apache v2", "website": "github.com/neovim/pynvim"} + return (name, VERSION.__dict__, type_, method_spec, attributes) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..ad4db359 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,84 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pynvim" +version = "0.6.1.dev0" +description = "Python client for Neovim" +readme = "README.md" +license = { text = "Apache-2.0" } +authors = [ + { name = "Neovim Authors" } +] +requires-python = ">=3.7" +dependencies = [ + "msgpack>=1.0.0", + "greenlet>=3.0; python_implementation != 'PyPy'", + "typing-extensions>=4.5; python_version < '3.12'", +] + +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", +] + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-timeout", +] +docs = [ + "sphinx", + "sphinx-rtd-theme", +] +dev = [ + "bump-my-version", +] + +[project.scripts] +pynvim-python = "pynvim.python:main" + +[project.urls] +Homepage = "https://github.com/neovim/pynvim" +Download = "https://github.com/neovim/pynvim/archive/refs/tags/0.6.1.dev0.tar.gz" +Documentation = "https://pynvim.readthedocs.io" + +# Configuration for bumpversion / bump-my-version +[tool.bumpversion] +current_version = "0.6.1.dev0" +commit = true +tag = true + +# Regex that captures major/minor/patch/prerelease (prerelease optional) +parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(?:\\.(?P[a-zA-Z]+\\d*))?" +serialize = [ + "{major}.{minor}.{patch}.{prerelease}", + "{major}.{minor}.{patch}" +] + +# Update the version in pyproject.toml +[[tool.bumpversion.files]] +filename = "pyproject.toml" +search = 'version = "{current_version}"' +replace = 'version = "{new_version}"' + +# Update the version in pynvim/_version.py (non-hardcoded) +[[tool.bumpversion.files]] +filename = "pynvim/_version.py" +search = 'VERSION = SimpleNamespace(major={current_major}, minor={current_minor}, patch={current_patch}, prerelease="{current_prerelease}")' +replace = 'VERSION = SimpleNamespace(major={new_major}, minor={new_minor}, patch={new_patch}, prerelease="{new_prerelease}")' + +# Update the download URL in pyproject.toml +[[tool.bumpversion.files]] +filename = "pyproject.toml" +search = 'Download = "https://github.com/neovim/pynvim/archive/refs/tags/{current_version}.tar.gz"' +replace = 'Download = "https://github.com/neovim/pynvim/archive/refs/tags/{new_version}.tar.gz"' diff --git a/scripts/disable_log_statements.sh b/scripts/disable_log_statements.sh index a519ea53..cc58e6c3 100755 --- a/scripts/disable_log_statements.sh +++ b/scripts/disable_log_statements.sh @@ -1,4 +1,9 @@ -#!/bin/sh -e +#!/bin/bash -cd neovim -find -name '*.py' | xargs -i{} ../scripts/logging_statement_modifier.py {} +set -e + +cd pynvim +for f in $(find . -name '*.py'); do + echo "Processing: $f" + ../scripts/logging_statement_modifier.py "$f" +done diff --git a/scripts/enable_log_statements.sh b/scripts/enable_log_statements.sh index 3f4a2b6b..a5db4bc1 100755 --- a/scripts/enable_log_statements.sh +++ b/scripts/enable_log_statements.sh @@ -1,4 +1,9 @@ -#!/bin/sh -e +#!/bin/bash -cd neovim -find -name '*.py' | xargs -i{} ../scripts/logging_statement_modifier.py --restore {} +set -e + +cd pynvim +for f in $(find . -name '*.py'); do + echo "Processing: $f" + ../scripts/logging_statement_modifier.py --restore "$f" +done diff --git a/scripts/logging_statement_modifier.py b/scripts/logging_statement_modifier.py index b2f1414f..0f3c4920 100755 --- a/scripts/logging_statement_modifier.py +++ b/scripts/logging_statement_modifier.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 """\ Logging Statement Modifier - replace logging calls with pass (or vice versa) @@ -66,7 +66,7 @@ # include the whitespace, the logging method called, and the first argument if # possible RE_LOGGING_START = re.compile(r'^(\s+)' + STR_RE_LOGGING_CALL) -RE_LOGGING_START_IN_COMMENT = re.compile(r'^(\s+)#' + STR_RE_LOGGING_CALL) +RE_LOGGING_START_IN_COMMENT = re.compile(r'^(\s+)# ' + STR_RE_LOGGING_CALL) def main(argv=sys.argv[1:]): """Parses the command line comments.""" @@ -105,7 +105,7 @@ def main(argv=sys.argv[1:]): min_level_value = 0 if options.min_level == 'NONE' else get_level_value(options.min_level) if options.min_level is None: parser.error("min level must be an integer or one of these values: %s" % ', '.join(LEVEL_CHOICES)) - max_level_value = sys.maxint if options.max_level == 'NONE' else get_level_value(options.max_level) + max_level_value = 9000 if options.max_level == 'NONE' else get_level_value(options.max_level) if options.max_level is None: parser.error("max level must be an integer or one of these values: %s" % ', '.join(LEVEL_CHOICES)) @@ -116,7 +116,7 @@ def main(argv=sys.argv[1:]): return modify_logging(input_fn, options.output_file, min_level_value, max_level_value, options.restore, options.force) - except IOError as e: + except OSError as e: logging.error(str(e)) return -1 @@ -128,11 +128,11 @@ def comment_lines(lines): ret = [] for line in lines: ws_prefix, rest, ignore = RE_LINE_SPLITTER_COMMENT.match(line).groups() - ret.append(ws_prefix + '#' + rest) + ret.append(ws_prefix + '# ' + rest) return ''.join(ret) # matches two main groups: 1) leading whitespace and 2) all following text -RE_LINE_SPLITTER_UNCOMMENT = re.compile(r'^(\s*)#((.|\n)*)$') +RE_LINE_SPLITTER_UNCOMMENT = re.compile(r'^(\s*)# ((.|\n)*)$') def uncomment_lines(lines): """Uncomment the given list of lines and return them. The first hash mark following any amount of whitespace will be removed on each line.""" @@ -220,7 +220,7 @@ def split_call(lines, open_paren_line=0): if num_open == num_closed: return (lines[:i+1], lines[i+1:]) - print ''.join(lines) + print(''.join(lines)) raise Exception('parenthesis are mismatched (%d open, %d closed found)' % (num_open, num_closed)) def modify_logging(input_fn, output_fn, min_level_value, max_level_value, restore, force): @@ -264,7 +264,7 @@ def modify_logging(input_fn, output_fn, min_level_value, max_level_value, restor def check_level(logging_stmt, logging_stmt_is_commented_out, min_level_value, max_level_value): """Extracts the level of the logging statement and returns True if the - level falls betwen min and max_level_value. If the level cannot be + level falls between min and max_level_value. If the level cannot be extracted, then a warning is logged.""" level = get_logging_level(logging_stmt, logging_stmt_is_commented_out) if level is None: diff --git a/setup.cfg b/setup.cfg index d9e8df46..de3fce30 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,30 @@ +[aliases] +test = pytest + [flake8] -ignore = D211,E731,F821 +extend-ignore = D211,E731,D401,W503,D202 +max-line-length = 100 +per-file-ignores = + test/*:D1 +application-import-names = pynvim + +[isort] +known_first_party = pynvim + +[tool:pytest] +testpaths = test + +[mypy] +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +ignore_missing_imports = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_unreachable = true +strict_equality = true + +[mypy-pynvim.msgpack_rpc.*] +disallow_untyped_calls = false +disallow_untyped_defs = false diff --git a/setup.py b/setup.py index d1b66533..fa0f12d9 100644 --- a/setup.py +++ b/setup.py @@ -1,29 +1,66 @@ +"""setup.py for pynvim.""" + +import os.path import platform import sys +__PATH__ = os.path.abspath(os.path.dirname(__file__)) + from setuptools import setup install_requires = [ - 'msgpack-python>=0.4.0', + 'msgpack>=1.0.0', + 'greenlet>=3.0; python_implementation != "PyPy"', + 'typing-extensions>=4.5; python_version < "3.12"', +] + +needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) + +setup_requires = [ +] + +tests_require = [ + 'pytest', + 'pytest_timeout', ] -if sys.version_info < (3, 4): - # trollius is just a backport of 3.4 asyncio module - install_requires.append('trollius') - -if platform.python_implementation() != 'PyPy': - # pypy already includes an implementation of the greenlet module - install_requires.append('greenlet') - -setup(name='neovim', - version='0.1.6', - description='Python client to neovim', - url='http://github.com/neovim/python-client', - download_url='https://github.com/neovim/python-client/archive/0.1.6.tar.gz', - author='Thiago de Arruda', - author_email='tpadilha84@gmail.com', +docs_require = [ + 'sphinx', + 'sphinx-rtd-theme', +] + +extras_require = { + 'test': tests_require, + 'docs': docs_require, +} + + +# __version__: see pynvim/_version.py +with open(os.path.join(__PATH__, "pynvim/_version.py"), + "r", encoding="utf-8") as fp: + _version_env = {} + exec(fp.read(), _version_env) # pylint: disable=exec-used + version = _version_env['__version__'] + + +setup(name='pynvim', + version=version, + description='Python client for Neovim', + url='http://github.com/neovim/pynvim', + download_url=f'https://github.com/neovim/pynvim/archive/{version}.tar.gz', + author='Neovim Authors', license='Apache', - packages=['neovim', 'neovim.api', 'neovim.msgpack_rpc', - 'neovim.msgpack_rpc.event_loop', 'neovim.plugin'], + packages=['pynvim', 'pynvim.api', 'pynvim.msgpack_rpc', + 'pynvim.msgpack_rpc.event_loop', 'pynvim.plugin', + 'neovim', 'neovim.api'], + python_requires=">=3.7", install_requires=install_requires, - zip_safe=False) + setup_requires=setup_requires, + tests_require=tests_require, + extras_require=extras_require, + entry_points={ + 'console_scripts': [ + 'pynvim-python=pynvim.python:main', + ], + }, + ) diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 00000000..49ed3305 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,49 @@ +"""Configs for pytest.""" + +import gc +import json +import os +import sys +from typing import Generator + +import pytest + +import pynvim + +pynvim.setup_logging("test") + + +@pytest.fixture +def vim() -> Generator[pynvim.Nvim, None, None]: + """Create an embedded, sub-process Nvim fixture instance.""" + editor: pynvim.Nvim + + child_argv = os.environ.get('NVIM_CHILD_ARGV') + listen_address = os.environ.get('NVIM') + if child_argv is None and listen_address is None: + child_argv = json.dumps([ + "nvim", + "--clean", # no config and plugins (-u NONE), no SHADA + "-n", # no swap file + "--embed", + "--headless", + # Always use the same exact python executable regardless of $PATH + "--cmd", f"let g:python3_host_prog='{sys.executable}'", + ]) + + if child_argv is not None: + editor = pynvim.attach('child', argv=json.loads(child_argv)) + else: + assert listen_address is not None and listen_address != '' + editor = pynvim.attach('socket', path=listen_address) + + try: + yield editor + + finally: + # Ensure all internal resources (pipes, transports, etc.) are always + # closed properly. Otherwise, during GC finalizers (__del__) will raise + # "Event loop is closed" error. + editor.close() + + gc.collect() # force-run GC, to early-detect potential leakages diff --git a/test/fixtures/module_plugin/rplugin/python3/mymodule/__init__.py b/test/fixtures/module_plugin/rplugin/python3/mymodule/__init__.py new file mode 100644 index 00000000..703517cb --- /dev/null +++ b/test/fixtures/module_plugin/rplugin/python3/mymodule/__init__.py @@ -0,0 +1,9 @@ +"""The `mymodule` package for the fixture module plugin.""" +# pylint: disable=all + +# Somehow the plugin might be using relative imports. +from .plugin import MyPlugin as MyPlugin + +# ... or absolute import (assuming this is the root package) +import mymodule.plugin # noqa: I100 +assert mymodule.plugin.MyPlugin is MyPlugin diff --git a/test/fixtures/module_plugin/rplugin/python3/mymodule/plugin.py b/test/fixtures/module_plugin/rplugin/python3/mymodule/plugin.py new file mode 100644 index 00000000..d25b3a41 --- /dev/null +++ b/test/fixtures/module_plugin/rplugin/python3/mymodule/plugin.py @@ -0,0 +1,13 @@ +"""Actual implement lies here.""" +import pynvim as neovim +import pynvim.api + + +@neovim.plugin +class MyPlugin: + def __init__(self, nvim: pynvim.api.Nvim): + self.nvim = nvim + + @neovim.command("ModuleHelloWorld") + def hello_world(self) -> None: + self.nvim.command("echom 'MyPlugin: Hello World!'") diff --git a/test/fixtures/simple_plugin/rplugin/python3/simple_nvim.py b/test/fixtures/simple_plugin/rplugin/python3/simple_nvim.py new file mode 100644 index 00000000..e3db4bd0 --- /dev/null +++ b/test/fixtures/simple_plugin/rplugin/python3/simple_nvim.py @@ -0,0 +1,13 @@ +import neovim + +import pynvim.api + + +@neovim.plugin +class SimplePlugin: + def __init__(self, nvim: pynvim.api.Nvim): + self.nvim = nvim + + @neovim.command("SimpleHelloWorld") + def hello_world(self) -> None: + self.nvim.command("echom 'SimplePlugin: Hello World!'") diff --git a/test/test_attach.py b/test/test_attach.py new file mode 100644 index 00000000..25d08226 --- /dev/null +++ b/test/test_attach.py @@ -0,0 +1,137 @@ +"""Tests other session_types than subprocess Nvim.""" + +import contextlib +import os.path +import socket +import subprocess +import tempfile +import time +from typing import Generator + +import pytest +import pytest_timeout # pylint: disable=unused-import # noqa + +import pynvim +from pynvim.api import Nvim + +# pylint: disable=consider-using-with +# pylint: disable=redefined-outer-name + + +xfail_on_windows = pytest.mark.xfail( + "os.name == 'nt'", reason="Broken in Windows, see #544") + + +@pytest.fixture +def tmp_socket() -> Generator[str, None, None]: + """Get a temporary UNIX socket file.""" + # see cpython#93914 + addr = tempfile.mktemp(prefix="test_python_", suffix='.sock', + dir=os.path.curdir) + try: + yield addr + finally: + if os.path.exists(addr): + with contextlib.suppress(OSError): + os.unlink(addr) + + +@xfail_on_windows +def test_connect_socket(tmp_socket: str) -> None: + """Tests UNIX socket connection.""" + p = subprocess.Popen(["nvim", "--clean", "-n", "--headless", + "--listen", tmp_socket]) + time.sleep(0.2) # wait a bit until nvim starts up + + try: + nvim: Nvim = pynvim.attach('socket', path=tmp_socket) + assert 42 == nvim.eval('42') + assert "?" == nvim.command_output('echo "?"') + finally: + with contextlib.suppress(OSError): + p.terminate() + + +def test_connect_socket_fail() -> None: + """Tests UNIX socket connection, when the sock file is not found.""" + with pytest.raises(FileNotFoundError): + pynvim.attach('socket', path='/tmp/not-exist.socket') + + +def find_free_port() -> int: + """Find a free, available port number.""" + with socket.socket() as sock: + sock.bind(('', 0)) # Bind to a free port provided by the host. + return sock.getsockname()[1] + + +def test_connect_tcp() -> None: + """Tests TCP connection.""" + address = '127.0.0.1' + port = find_free_port() + p = subprocess.Popen(["nvim", "--clean", "-n", "--headless", + "--listen", f"{address}:{port}"]) + time.sleep(0.2) # wait a bit until nvim starts up + + try: + nvim: Nvim = pynvim.attach('tcp', address=address, port=port) + assert 42 == nvim.eval('42') + assert "?" == nvim.command_output('echo "?"') + finally: + with contextlib.suppress(OSError): + p.terminate() + + +@pytest.mark.timeout(5.0) +def test_connect_tcp_no_server() -> None: + """Tests TCP socket connection that fails; connection refused.""" + port = find_free_port() + + with pytest.raises(ConnectionRefusedError): + pynvim.attach('tcp', address='127.0.0.1', port=port) + + +@xfail_on_windows +def test_connect_stdio(vim: Nvim) -> None: + """Tests stdio connection, using jobstart(..., {'rpc': v:true}).""" + + def source(vim: Nvim, code: str) -> None: + """Source a vimscript code in the embedded nvim instance.""" + fd, fname = tempfile.mkstemp() + try: + with os.fdopen(fd, 'w') as f: + f.write(code) + vim.command('source ' + fname) + finally: + os.unlink(fname) + + # A helper function for debugging that captures what pynvim writes to + # stderr (e.g. python stacktrace): used as a |on_stderr| callback + source(vim, """ + function! OutputHandler(j, lines, event_type) + if a:event_type == 'stderr' + for l:line in a:lines + echom l:line + endfor + endif + endfunction + """) + + remote_py_code = '\n'.join([ + 'import pynvim', + 'nvim = pynvim.attach("stdio")', + 'print("rplugins can write to stdout")', # tests #377 (#60) + 'nvim.api.command("let g:success = 42")', + ]) + # see :help jobstart(), *jobstart-options* |msgpack-rpc| + jobid = vim.funcs.jobstart([ + 'python3', '-c', remote_py_code, + ], {'rpc': True, 'on_stderr': 'OutputHandler'}) + assert jobid > 0 + exitcode = vim.funcs.jobwait([jobid], 500)[0] + messages = vim.command_output('messages') + assert exitcode == 0, ("the python process failed, :messages =>\n\n" + + messages) + + assert 42 == vim.eval('g:success') + assert "rplugins can write to stdout" in messages diff --git a/test/test_buffer.py b/test/test_buffer.py index 43a7c9e0..b0435541 100644 --- a/test/test_buffer.py +++ b/test/test_buffer.py @@ -1,161 +1,201 @@ import os -from nose.tools import with_setup, eq_ as eq, ok_ as ok -from test_common import vim, cleanup +import pytest -@with_setup(setup=cleanup) -def test_get_length(): - eq(len(vim.current.buffer), 1) +from pynvim.api import Nvim, NvimError + + +def test_repr(vim: Nvim) -> None: + assert repr(vim.current.buffer) == "" + + +def test_get_length(vim: Nvim) -> None: + assert len(vim.current.buffer) == 1 vim.current.buffer.append('line') - eq(len(vim.current.buffer), 2) + assert len(vim.current.buffer) == 2 vim.current.buffer.append('line') - eq(len(vim.current.buffer), 3) + assert len(vim.current.buffer) == 3 vim.current.buffer[-1] = None - eq(len(vim.current.buffer), 2) + assert len(vim.current.buffer) == 2 vim.current.buffer[-1] = None vim.current.buffer[-1] = None # There's always at least one line - eq(len(vim.current.buffer), 1) + assert len(vim.current.buffer) == 1 -@with_setup(setup=cleanup) -def test_get_set_del_line(): - eq(vim.current.buffer[0], '') +def test_get_set_del_line(vim: Nvim) -> None: + assert vim.current.buffer[0] == '' vim.current.buffer[0] = 'line1' - eq(vim.current.buffer[0], 'line1') + assert vim.current.buffer[0] == 'line1' vim.current.buffer[0] = 'line2' - eq(vim.current.buffer[0], 'line2') + assert vim.current.buffer[0] == 'line2' vim.current.buffer[0] = None - eq(vim.current.buffer[0], '') + assert vim.current.buffer[0] == '' # __delitem__ vim.current.buffer[:] = ['line1', 'line2', 'line3'] - eq(vim.current.buffer[2], 'line3') + assert vim.current.buffer[2] == 'line3' del vim.current.buffer[0] - eq(vim.current.buffer[0], 'line2') - eq(vim.current.buffer[1], 'line3') + assert vim.current.buffer[0] == 'line2' + assert vim.current.buffer[1] == 'line3' del vim.current.buffer[-1] - eq(vim.current.buffer[0], 'line2') - eq(len(vim.current.buffer), 1) + assert vim.current.buffer[0] == 'line2' + assert len(vim.current.buffer) == 1 -@with_setup(setup=cleanup) -def test_get_set_del_slice(): - eq(vim.current.buffer[:], ['']) +def test_get_set_del_slice(vim: Nvim) -> None: + assert vim.current.buffer[:] == [''] # Replace buffer vim.current.buffer[:] = ['a', 'b', 'c'] - eq(vim.current.buffer[:], ['a', 'b', 'c']) - eq(vim.current.buffer[1:], ['b', 'c']) - eq(vim.current.buffer[1:2], ['b']) - eq(vim.current.buffer[1:1], []) - eq(vim.current.buffer[:-1], ['a', 'b']) - eq(vim.current.buffer[1:-1], ['b']) - eq(vim.current.buffer[-2:], ['b', 'c']) + assert vim.current.buffer[:] == ['a', 'b', 'c'] + assert vim.current.buffer[1:] == ['b', 'c'] + assert vim.current.buffer[1:2] == ['b'] + assert vim.current.buffer[1:1] == [] + assert vim.current.buffer[:-1] == ['a', 'b'] + assert vim.current.buffer[1:-1] == ['b'] + assert vim.current.buffer[-2:] == ['b', 'c'] vim.current.buffer[1:2] = ['a', 'b', 'c'] - eq(vim.current.buffer[:], ['a', 'a', 'b', 'c', 'c']) + assert vim.current.buffer[:] == ['a', 'a', 'b', 'c', 'c'] vim.current.buffer[-1:] = ['a', 'b', 'c'] - eq(vim.current.buffer[:], ['a', 'a', 'b', 'c', 'a', 'b', 'c']) + assert vim.current.buffer[:] == ['a', 'a', 'b', 'c', 'a', 'b', 'c'] vim.current.buffer[:-3] = None - eq(vim.current.buffer[:], ['a', 'b', 'c']) + assert vim.current.buffer[:] == ['a', 'b', 'c'] vim.current.buffer[:] = None - eq(vim.current.buffer[:], ['']) + assert vim.current.buffer[:] == [''] # __delitem__ vim.current.buffer[:] = ['a', 'b', 'c'] del vim.current.buffer[:] - eq(vim.current.buffer[:], ['']) + assert vim.current.buffer[:] == [''] vim.current.buffer[:] = ['a', 'b', 'c'] del vim.current.buffer[:1] - eq(vim.current.buffer[:], ['b', 'c']) + assert vim.current.buffer[:] == ['b', 'c'] del vim.current.buffer[:-1] - eq(vim.current.buffer[:], ['c']) + assert vim.current.buffer[:] == ['c'] -@with_setup(setup=cleanup) -def test_vars(): +def test_vars(vim: Nvim) -> None: vim.current.buffer.vars['python'] = [1, 2, {'3': 1}] - eq(vim.current.buffer.vars['python'], [1, 2, {'3': 1}]) - eq(vim.eval('b:python'), [1, 2, {'3': 1}]) + assert vim.current.buffer.vars['python'] == [1, 2, {'3': 1}] + assert vim.eval('b:python') == [1, 2, {'3': 1}] + assert vim.current.buffer.vars.get('python') == [1, 2, {'3': 1}] + + del vim.current.buffer.vars['python'] + with pytest.raises(KeyError): + vim.current.buffer.vars['python'] + assert vim.eval('exists("b:python")') == 0 + with pytest.raises(KeyError): + del vim.current.buffer.vars['python'] -@with_setup(setup=cleanup) -def test_api(): + assert vim.current.buffer.vars.get('python', 'default') == 'default' + + +def test_api(vim: Nvim) -> None: vim.current.buffer.api.set_var('myvar', 'thetext') - eq(vim.current.buffer.api.get_var('myvar'), 'thetext') - eq(vim.eval('b:myvar'), 'thetext') - vim.current.buffer.api.set_line_slice(0,-1,True,True,['alpha', 'beta']) - eq(vim.current.buffer.api.get_line_slice(0,-1,True,True), ['alpha', 'beta']) - eq(vim.current.buffer[:], ['alpha', 'beta']) + assert vim.current.buffer.api.get_var('myvar') == 'thetext' + assert vim.eval('b:myvar') == 'thetext' + vim.current.buffer.api.set_lines(0, -1, True, ['alpha', 'beta']) + assert vim.current.buffer.api.get_lines(0, -1, True) == ['alpha', 'beta'] + assert vim.current.buffer[:] == ['alpha', 'beta'] -@with_setup(setup=cleanup) -def test_options(): - eq(vim.current.buffer.options['shiftwidth'], 8) +def test_options(vim: Nvim) -> None: + assert vim.current.buffer.options['shiftwidth'] == 8 vim.current.buffer.options['shiftwidth'] = 4 - eq(vim.current.buffer.options['shiftwidth'], 4) + assert vim.current.buffer.options['shiftwidth'] == 4 # global-local option + global_define = vim.options['define'] vim.current.buffer.options['define'] = 'test' - eq(vim.current.buffer.options['define'], 'test') + assert vim.current.buffer.options['define'] == 'test' # Doesn't change the global value - eq(vim.options['define'], '^\s*#\s*define') + assert vim.options['define'] == global_define + with pytest.raises(KeyError) as excinfo: + vim.current.buffer.options['doesnotexist'] + assert excinfo.value.args == ("Invalid option name: 'doesnotexist'",) -@with_setup(setup=cleanup) -def test_number(): + +def test_number(vim: Nvim) -> None: curnum = vim.current.buffer.number vim.command('new') - eq(vim.current.buffer.number, curnum + 1) + assert vim.current.buffer.number == curnum + 1 vim.command('new') - eq(vim.current.buffer.number, curnum + 2) + assert vim.current.buffer.number == curnum + 2 -@with_setup(setup=cleanup) -def test_name(): +def test_name(vim: Nvim) -> None: vim.command('new') - eq(vim.current.buffer.name, '') + assert vim.current.buffer.name == '' new_name = vim.eval('resolve(tempname())') vim.current.buffer.name = new_name - eq(vim.current.buffer.name, new_name) + assert vim.current.buffer.name == new_name vim.command('silent w!') - ok(os.path.isfile(new_name)) + assert os.path.isfile(new_name) os.unlink(new_name) -@with_setup(setup=cleanup) -def test_valid(): +def test_valid(vim: Nvim) -> None: vim.command('new') buffer = vim.current.buffer - ok(buffer.valid) + assert buffer.valid vim.command('bw!') - ok(not buffer.valid) + assert not buffer.valid -@with_setup(setup=cleanup) -def test_append(): +def test_append(vim: Nvim) -> None: vim.current.buffer.append('a') - eq(vim.current.buffer[:], ['', 'a']) + assert vim.current.buffer[:] == ['', 'a'] vim.current.buffer.append('b', 0) - eq(vim.current.buffer[:], ['b', '', 'a']) + assert vim.current.buffer[:] == ['b', '', 'a'] vim.current.buffer.append(['c', 'd']) - eq(vim.current.buffer[:], ['b', '', 'a', 'c', 'd']) + assert vim.current.buffer[:] == ['b', '', 'a', 'c', 'd'] vim.current.buffer.append(['c', 'd'], 2) - eq(vim.current.buffer[:], ['b', '', 'c', 'd', 'a', 'c', 'd']) + assert vim.current.buffer[:] == ['b', '', 'c', 'd', 'a', 'c', 'd'] + vim.current.buffer.append(b'bytes') + assert vim.current.buffer[:] == ['b', '', 'c', 'd', 'a', 'c', 'd', 'bytes'] -@with_setup(setup=cleanup) -def test_mark(): +def test_mark(vim: Nvim) -> None: vim.current.buffer.append(['a', 'bit of', 'text']) - vim.current.window.cursor = [3, 4] + vim.current.window.cursor = (3, 4) vim.command('mark V') - eq(vim.current.buffer.mark('V'), [3, 0]) + assert vim.current.buffer.mark('V') == (3, 0) + +def test_invalid_utf8(vim: Nvim) -> None: + vim.command('normal "=printf("%c", 0xFF)\np') + assert vim.eval("char2nr(getline(1))") == 0xFF + assert vim.current.buffer[:] == ['\udcff'] -@with_setup(setup=cleanup) -def test_get_exceptions(): - try: + vim.current.line += 'x' + assert vim.eval("getline(1)", decode=False) == '\udcffx' + assert vim.current.buffer[:] == ['\udcffx'] + + +def test_get_exceptions(vim: Nvim) -> None: + with pytest.raises(KeyError) as excinfo: vim.current.buffer.options['invalid-option'] - ok(False) - except vim.error: - pass -@with_setup(setup=cleanup) -def test_contains(): - ok(vim.current.buffer in vim.buffers) + assert not isinstance(excinfo.value, NvimError) + assert excinfo.value.args == ("Invalid option name: 'invalid-option'",) + + +def test_set_items_for_range(vim: Nvim) -> None: + vim.current.buffer[:] = ['a', 'b', 'c', 'd', 'e'] + r = vim.current.buffer.range(1, 3) + r[1:3] = ['foo'] * 3 + assert vim.current.buffer[:] == ['a', 'foo', 'foo', 'foo', 'd', 'e'] + + +# NB: we can't easily test the effect of this. But at least run the lua +# function sync, so we know it runs without runtime error with simple args. +def test_update_highlights(vim: Nvim) -> None: + vim.current.buffer[:] = ['a', 'b', 'c'] + src_id = vim.new_highlight_source() + vim.current.buffer.update_highlights( + src_id, [("Comment", 0, 0, -1), ("String", 1, 0, 1)], clear=True, async_=False + ) + + +def test_buffer_inequality(vim: Nvim) -> None: + b = vim.current.buffer + assert not (b != vim.current.buffer) diff --git a/test/test_client_rpc.py b/test/test_client_rpc.py index c1607873..9fb19b25 100644 --- a/test/test_client_rpc.py +++ b/test/test_client_rpc.py @@ -1,70 +1,78 @@ # -*- coding: utf-8 -*- -from nose.tools import with_setup, eq_ as eq -from test_common import vim, cleanup +import time +from typing import List -cid = vim.channel_id +from pynvim.api import Nvim -@with_setup(setup=cleanup) -def test_call_and_reply(): - def setup_cb(): +def test_call_and_reply(vim: Nvim) -> None: + cid = vim.channel_id + + def setup_cb() -> None: cmd = 'let g:result = rpcrequest(%d, "client-call", 1, 2, 3)' % cid vim.command(cmd) - eq(vim.vars['result'], [4, 5, 6]) + assert vim.vars['result'] == [4, 5, 6] vim.stop_loop() - def request_cb(name, args): - eq(name, 'client-call') - eq(args, [1, 2, 3]) + def request_cb(name: str, args: List[int]) -> List[int]: + assert name == 'client-call' + assert args == [1, 2, 3] return [4, 5, 6] vim.run_loop(request_cb, None, setup_cb) -@with_setup(setup=cleanup) -def test_call_api_before_reply(): - def setup_cb(): +def test_call_api_before_reply(vim: Nvim) -> None: + cid = vim.channel_id + + def setup_cb() -> None: cmd = 'let g:result = rpcrequest(%d, "client-call2", 1, 2, 3)' % cid vim.command(cmd) - eq(vim.vars['result'], [7, 8, 9]) + assert vim.vars['result'] == [7, 8, 9] vim.stop_loop() - def request_cb(name, args): + def request_cb(name: str, args: List[int]) -> List[int]: vim.command('let g:result2 = [7, 8, 9]') return vim.vars['result2'] vim.run_loop(request_cb, None, setup_cb) -@with_setup(setup=cleanup) -def test_async_call(): - def request_cb(name, args): +def test_async_call(vim: Nvim) -> None: + + def request_cb(name: str, args: List[int]) -> None: if name == "test-event": vim.vars['result'] = 17 vim.stop_loop() # this would have dead-locked if not async - vim.funcs.rpcrequest(vim.channel_id, "test-event", async=True) + vim.funcs.rpcrequest(vim.channel_id, "test-event", async_=True) vim.run_loop(request_cb, None, None) - eq(vim.vars['result'], 17) + # TODO(blueyed): This sleep is required on Travis, where it hangs with + # "Entering event loop" otherwise (asyncio's EpollSelector._epoll.poll). + time.sleep(0.1) + + assert vim.vars['result'] == 17 + + +def test_recursion(vim: Nvim) -> None: + cid = vim.channel_id -@with_setup(setup=cleanup) -def test_recursion(): - def setup_cb(): + def setup_cb() -> None: vim.vars['result1'] = 0 vim.vars['result2'] = 0 vim.vars['result3'] = 0 vim.vars['result4'] = 0 cmd = 'let g:result1 = rpcrequest(%d, "call", %d)' % (cid, 2,) vim.command(cmd) - eq(vim.vars['result1'], 4) - eq(vim.vars['result2'], 8) - eq(vim.vars['result3'], 16) - eq(vim.vars['result4'], 32) + assert vim.vars['result1'] == 4 + assert vim.vars['result2'] == 8 + assert vim.vars['result3'] == 16 + assert vim.vars['result4'] == 32 vim.stop_loop() - def request_cb(name, args): + def request_cb(name: str, args: List[int]) -> int: n = args[0] n *= 2 if n <= 16: diff --git a/test/test_common.py b/test/test_common.py deleted file mode 100644 index 540d9f39..00000000 --- a/test/test_common.py +++ /dev/null @@ -1,67 +0,0 @@ -import json -import os -import sys - -import neovim - -from nose.tools import eq_ as eq - -neovim.setup_logging() - -child_argv = os.environ.get('NVIM_CHILD_ARGV') -listen_address = os.environ.get('NVIM_LISTEN_ADDRESS') -if child_argv is None and listen_address is None: - child_argv = '["nvim", "-u", "NONE", "--embed"]' - -if child_argv is not None: - vim = neovim.attach('child', argv=json.loads(child_argv)) -else: - vim = neovim.attach('socket', path=listen_address) - -cleanup_func = ''':function BeforeEachTest() - set all& - redir => groups - silent augroup - redir END - for group in split(groups) - exe 'augroup '.group - autocmd! - augroup END - endfor - autocmd! - tabnew - let curbufnum = eval(bufnr('%')) - redir => buflist - silent ls! - redir END - let bufnums = [] - for buf in split(buflist, '\\n') - let bufnum = eval(split(buf, '[ u]')[0]) - if bufnum != curbufnum - call add(bufnums, bufnum) - endif - endfor - if len(bufnums) > 0 - exe 'silent bwipeout! '.join(bufnums, ' ') - endif - silent tabonly - for k in keys(g:) - exe 'unlet g:'.k - endfor - filetype plugin indent off - mapclear - mapclear! - abclear - comclear -endfunction -''' - -vim.input(cleanup_func) - - -def cleanup(): - # cleanup nvim - vim.command('call BeforeEachTest()') - eq(len(vim.tabpages), 1) - eq(len(vim.windows), 1) - eq(len(vim.buffers), 1) diff --git a/test/test_concurrency.py b/test/test_concurrency.py index 4619fcc6..a9e711db 100644 --- a/test/test_concurrency.py +++ b/test/test_concurrency.py @@ -1,21 +1,27 @@ -from nose.tools import with_setup, eq_ as eq -from test_common import vim, cleanup from threading import Timer +from typing import List -@with_setup(setup=cleanup) -def test_interrupt_from_another_thread(): +from pynvim.api import Nvim + + +def test_interrupt_from_another_thread(vim: Nvim) -> None: timer = Timer(0.5, lambda: vim.async_call(lambda: vim.stop_loop())) timer.start() - eq(vim.next_message(), None) + assert vim.next_message() is None + -@with_setup(setup=cleanup) -def test_exception_in_threadsafe_call(): +def test_exception_in_threadsafe_call(vim: Nvim) -> None: # an exception in a threadsafe_call shouldn't crash the entire host - msgs = [] - vim.async_call(lambda: [vim.eval("3"), undefined_variable]) + msgs: List[str] = [] + vim.async_call( + lambda: [ + vim.eval("3"), + undefined_variable # type: ignore[name-defined] # noqa: F821 + ] + ) timer = Timer(0.5, lambda: vim.async_call(lambda: vim.stop_loop())) timer.start() vim.run_loop(None, None, err_cb=msgs.append) - eq(len(msgs), 1) + assert len(msgs) == 1 msgs[0].index('NameError') msgs[0].index('undefined_variable') diff --git a/test/test_decorators.py b/test/test_decorators.py new file mode 100644 index 00000000..1f5c857e --- /dev/null +++ b/test/test_decorators.py @@ -0,0 +1,29 @@ +# type: ignore +from pynvim.plugin.decorators import command + + +def test_command_count() -> None: + def function() -> None: + """A dummy function to decorate.""" + return + + # ensure absence with default value of None + decorated = command('test')(function) + assert 'count' not in decorated._nvim_rpc_spec['opts'] + + # ensure absence with explicit value of None + count_value = None + decorated = command('test', count=count_value)(function) + assert 'count' not in decorated._nvim_rpc_spec['opts'] + + # Test precedence with value of 0 + count_value = 0 + decorated = command('test', count=count_value)(function) + assert 'count' in decorated._nvim_rpc_spec['opts'] + assert decorated._nvim_rpc_spec['opts']['count'] == count_value + + # Test presence with value of 1 + count_value = 1 + decorated = command('test', count=count_value)(function) + assert 'count' in decorated._nvim_rpc_spec['opts'] + assert decorated._nvim_rpc_spec['opts']['count'] == count_value diff --git a/test/test_events.py b/test/test_events.py index d273e0a8..ee481d95 100644 --- a/test/test_events.py +++ b/test/test_events.py @@ -1,52 +1,58 @@ -# -*- coding: utf-8 -*- -from nose.tools import with_setup, eq_ as eq -from test_common import vim, cleanup +import pytest +from pynvim.api import Nvim -@with_setup(setup=cleanup) -def test_receiving_events(): + +def test_receiving_events(vim: Nvim) -> None: vim.command('call rpcnotify(%d, "test-event", 1, 2, 3)' % vim.channel_id) event = vim.next_message() - eq(event[1], 'test-event') - eq(event[2], [1, 2, 3]) + assert event[1] == 'test-event' + assert event[2] == [1, 2, 3] vim.command('au FileType python call rpcnotify(%d, "py!", bufnr("$"))' % vim.channel_id) vim.command('set filetype=python') event = vim.next_message() - eq(event[1], 'py!') - eq(event[2], [vim.current.buffer.number]) + assert event[1] == 'py!' + assert event[2] == [vim.current.buffer.number] + -@with_setup(setup=cleanup) -def test_sending_notify(): +def test_sending_notify(vim: Nvim) -> None: # notify after notify - vim.command("let g:test = 3", async=True) + vim.command("let g:test = 3", async_=True) cmd = 'call rpcnotify(%d, "test-event", g:test)' % vim.channel_id - vim.command(cmd, async=True) + vim.command(cmd, async_=True) event = vim.next_message() - eq(event[1], 'test-event') - eq(event[2], [3]) + assert event[1] == 'test-event' + assert event[2] == [3] # request after notify - vim.command("let g:data = 'xyz'", async=True) - eq(vim.eval('g:data'), 'xyz') + vim.command("let g:data = 'xyz'", async_=True) + assert vim.eval('g:data') == 'xyz' + + +def test_async_error(vim: Nvim) -> None: + # Invoke a bogus Ex command via notify (async). + vim.command("lolwut", async_=True) + event = vim.next_message() + assert event[1] == 'nvim_error_event' + +def test_broadcast(vim: Nvim) -> None: + if (vim.version.major, vim.version.minor) < (0, 11): + # see #570, neovim/neovim#28487 + pytest.skip("neovim/neovim#28487") -@with_setup(setup=cleanup) -def test_broadcast(): - vim.subscribe('event2') vim.command('call rpcnotify(0, "event1", 1, 2, 3)') vim.command('call rpcnotify(0, "event2", 4, 5, 6)') vim.command('call rpcnotify(0, "event2", 7, 8, 9)') event = vim.next_message() - eq(event[1], 'event2') - eq(event[2], [4, 5, 6]) + assert event[1] == 'event1' + assert event[2] == [1, 2, 3] event = vim.next_message() - eq(event[1], 'event2') - eq(event[2], [7, 8, 9]) - vim.unsubscribe('event2') - vim.subscribe('event1') + assert event[1] == 'event2' + assert event[2] == [4, 5, 6] vim.command('call rpcnotify(0, "event2", 10, 11, 12)') vim.command('call rpcnotify(0, "event1", 13, 14, 15)') msg = vim.next_message() - eq(msg[1], 'event1') - eq(msg[2], [13, 14, 15]) + assert msg[1] == 'event2' + assert msg[2] == [7, 8, 9] diff --git a/test/test_host.py b/test/test_host.py new file mode 100644 index 00000000..c404fdc3 --- /dev/null +++ b/test/test_host.py @@ -0,0 +1,58 @@ +# type: ignore +# pylint: disable=protected-access +import os +from typing import Sequence + +from pynvim.plugin.host import Host, host_method_spec +from pynvim.plugin.script_host import ScriptHost + +__PATH__ = os.path.abspath(os.path.dirname(__file__)) + + +def test_host_imports(vim): + h = ScriptHost(vim) + try: + assert h.module.__dict__['vim'] + assert h.module.__dict__['vim'] == h.legacy_vim + assert h.module.__dict__['sys'] + finally: + h.teardown() + + +def test_host_import_rplugin_modules(vim): + # Test whether a Host can load and import rplugins (#461). + # See also $VIMRUNTIME/autoload/provider/pythonx.vim. + h = Host(vim) + + plugins: Sequence[str] = [ # plugin paths like real rplugins + os.path.join(__PATH__, "./fixtures/simple_plugin/rplugin/python3/simple_nvim.py"), + os.path.join(__PATH__, "./fixtures/module_plugin/rplugin/python3/mymodule/"), + os.path.join(__PATH__, "./fixtures/module_plugin/rplugin/python3/mymodule"), # duplicate + ] + h._load(plugins) + assert len(h._loaded) == 2 + + # pylint: disable-next=unbalanced-tuple-unpacking + simple_nvim, mymodule = list(h._loaded.values()) + assert simple_nvim['module'].__name__ == 'simple_nvim' + assert mymodule['module'].__name__ == 'mymodule' + + +def test_host_clientinfo(vim): + h = Host(vim) + assert h._request_handlers.keys() == host_method_spec.keys() + assert 'remote' == vim.api.get_chan_info(vim.channel_id)['client']['type'] + h._load([]) + assert 'host' == vim.api.get_chan_info(vim.channel_id)['client']['type'] + + +# Smoke test for Host._on_error_event(). #425 +def test_host_async_error(vim): + h = Host(vim) + h._load([]) + # Invoke a bogus Ex command via notify (async). + vim.command("lolwut", async_=True) + event = vim.next_message() + assert event[1] == 'nvim_error_event' + assert 'rplugin-host: Async request caused an error:\nboom\n' \ + in h._on_error_event(None, 'boom') diff --git a/test/test_logging.py b/test/test_logging.py new file mode 100644 index 00000000..4becf306 --- /dev/null +++ b/test/test_logging.py @@ -0,0 +1,37 @@ +import os +import sys +from typing import Any + + +def test_setup_logging(monkeypatch: Any, tmpdir: str, caplog: Any) -> None: + from pynvim import setup_logging + + major_version = sys.version_info[0] + + setup_logging('name1') + assert caplog.messages == [] + + def get_expected_logfile(prefix: str, name: str) -> str: + return '{}_py{}_{}'.format(prefix, major_version, name) + + prefix = tmpdir.join('testlog1') + monkeypatch.setenv('NVIM_PYTHON_LOG_FILE', str(prefix)) + setup_logging('name2') + assert caplog.messages == [] + logfile = get_expected_logfile(prefix, 'name2') + assert os.path.exists(logfile) + assert open(logfile, 'r').read() == '' + + monkeypatch.setenv('NVIM_PYTHON_LOG_LEVEL', 'invalid') + setup_logging('name3') + assert caplog.record_tuples == [ + ('pynvim', 30, "Invalid NVIM_PYTHON_LOG_LEVEL: 'invalid', using INFO."), + ] + logfile = get_expected_logfile(prefix, 'name2') + assert os.path.exists(logfile) + with open(logfile, 'r') as f: + lines = f.readlines() + assert len(lines) == 1 + assert lines[0].endswith( + "- Invalid NVIM_PYTHON_LOG_LEVEL: 'invalid', using INFO.\n" + ) diff --git a/test/test_tabpage.py b/test/test_tabpage.py index 1ada3764..f700510d 100644 --- a/test/test_tabpage.py +++ b/test/test_tabpage.py @@ -1,30 +1,50 @@ -import os -from nose.tools import with_setup, eq_ as eq, ok_ as ok -from test_common import vim, cleanup +import pytest +from pynvim.api import Nvim -@with_setup(setup=cleanup) -def test_windows(): + +def test_windows(vim: Nvim) -> None: vim.command('tabnew') vim.command('vsplit') - eq(list(vim.tabpages[0].windows), [vim.windows[0]]) - eq(list(vim.tabpages[1].windows), [vim.windows[1], vim.windows[2]]) - eq(vim.tabpages[1].window, vim.windows[1]) + assert list(vim.tabpages[0].windows) == [vim.windows[0]] + assert list(vim.tabpages[1].windows) == [vim.windows[1], vim.windows[2]] + assert vim.tabpages[1].window == vim.windows[1] vim.current.window = vim.windows[2] - eq(vim.tabpages[1].window, vim.windows[2]) + assert vim.tabpages[1].window == vim.windows[2] -@with_setup(setup=cleanup) -def test_vars(): +def test_vars(vim: Nvim) -> None: vim.current.tabpage.vars['python'] = [1, 2, {'3': 1}] - eq(vim.current.tabpage.vars['python'], [1, 2, {'3': 1}]) - eq(vim.eval('t:python'), [1, 2, {'3': 1}]) + assert vim.current.tabpage.vars['python'] == [1, 2, {'3': 1}] + assert vim.eval('t:python') == [1, 2, {'3': 1}] + assert vim.current.tabpage.vars.get('python') == [1, 2, {'3': 1}] + + del vim.current.tabpage.vars['python'] + with pytest.raises(KeyError): + vim.current.tabpage.vars['python'] + assert vim.eval('exists("t:python")') == 0 + + with pytest.raises(KeyError): + del vim.current.tabpage.vars['python'] + assert vim.current.tabpage.vars.get('python', 'default') == 'default' -@with_setup(setup=cleanup) -def test_valid(): + +def test_valid(vim: Nvim) -> None: vim.command('tabnew') tabpage = vim.tabpages[1] - ok(tabpage.valid) + assert tabpage.valid vim.command('tabclose') - ok(not tabpage.valid) + assert not tabpage.valid + + +def test_number(vim: Nvim) -> None: + curnum = vim.current.tabpage.number + vim.command('tabnew') + assert vim.current.tabpage.number == curnum + 1 + vim.command('tabnew') + assert vim.current.tabpage.number == curnum + 2 + + +def test_repr(vim: Nvim) -> None: + assert repr(vim.current.tabpage) == "" diff --git a/test/test_version.py b/test/test_version.py new file mode 100644 index 00000000..d54b593c --- /dev/null +++ b/test/test_version.py @@ -0,0 +1,7 @@ +import pynvim + + +def test_version() -> None: + assert pynvim.__version__ + assert isinstance(pynvim.__version__, str) + print(f"pynvim.__version__ = '{pynvim.__version__}'") diff --git a/test/test_vim.py b/test/test_vim.py index 7a453f0b..8a76f5e6 100644 --- a/test/test_vim.py +++ b/test/test_vim.py @@ -1,147 +1,287 @@ -# -*- coding: utf-8 -*- -import os, tempfile -from nose.tools import with_setup, eq_ as eq, ok_ as ok -from test_common import vim, cleanup +"""Tests interaction with neovim via Nvim API (with child process).""" -def source(code): +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +from pynvim.api import Nvim, NvimError + + +def source(vim: Nvim, code: str) -> None: fd, fname = tempfile.mkstemp() - with os.fdopen(fd,'w') as f: + with os.fdopen(fd, 'w') as f: f.write(code) - vim.command('source '+fname) + vim.command('source ' + fname) os.unlink(fname) -@with_setup(setup=cleanup) -def test_command(): +def test_clientinfo(vim: Nvim) -> None: + assert 'remote' == vim.api.get_chan_info(vim.channel_id)['client']['type'] + + +def test_command(vim: Nvim) -> None: fname = tempfile.mkstemp()[1] vim.command('new') - vim.command('edit %s' % fname) + vim.command('edit {}'.format(fname)) # skip the "press return" state, which does not handle deferred calls vim.input('\r') vim.command('normal itesting\npython\napi') vim.command('w') - ok(os.path.isfile(fname)) - eq(open(fname).read(), 'testing\npython\napi\n') - os.unlink(fname) + assert os.path.isfile(fname) + with open(fname) as f: + assert f.read() == 'testing\npython\napi\n' + try: + os.unlink(fname) + except OSError: + pass # on windows, this can be flaky; ignore it + + +def test_command_output(vim: Nvim) -> None: + assert vim.command_output('echo "test"') == 'test' + # can capture multi-line outputs + vim.command("let g:multiline_string = join(['foo', 'bar'], nr2char(10))") + assert vim.command_output('echo g:multiline_string') == "foo\nbar" -@with_setup -def test_command_output(): - eq(vim.command_output('echo test'), 'test') +def test_command_error(vim: Nvim) -> None: + with pytest.raises(vim.error) as excinfo: + vim.current.window.cursor = -1, -1 + assert excinfo.value.args == ('Cursor position outside buffer',) -@with_setup(setup=cleanup) -def test_eval(): + +def test_eval(vim: Nvim) -> None: vim.command('let g:v1 = "a"') vim.command('let g:v2 = [1, 2, {"v3": 3}]') - eq(vim.eval('g:'), {'v1': 'a', 'v2': [1, 2, {'v3': 3}]}) + g = vim.eval('g:') + assert g['v1'] == 'a' + assert g['v2'] == [1, 2, {'v3': 3}] + -@with_setup(setup=cleanup) -def test_call(): - eq(vim.funcs.join(['first', 'last'], ', '), 'first, last') - source(""" +def test_call(vim: Nvim) -> None: + assert vim.funcs.join(['first', 'last'], ', ') == 'first, last' + source(vim, """ function! Testfun(a,b) return string(a:a).":".a:b endfunction """) - eq(vim.funcs.Testfun(3, 'alpha'), '3:alpha') + assert vim.funcs.Testfun(3, 'alpha') == '3:alpha' -@with_setup(setup=cleanup) -def test_api(): +def test_api(vim: Nvim) -> None: vim.api.command('let g:var = 3') - eq(vim.api.eval('g:var'), 3) + assert vim.api.eval('g:var') == 3 -@with_setup(setup=cleanup) -def test_strwidth(): - eq(vim.strwidth('abc'), 3) +def test_strwidth(vim: Nvim) -> None: + assert vim.strwidth('abc') == 3 # 6 + (neovim) # 19 * 2 (each japanese character occupies two cells) - eq(vim.strwidth('neovimのデザインかなりまともなのになってる。'), 44) + assert vim.strwidth('neovimのデザインかなりまともなのになってる。') == 44 -@with_setup(setup=cleanup) -def test_chdir(): + +def test_chdir(vim: Nvim) -> None: pwd = vim.eval('getcwd()') + root = os.path.abspath(os.sep) + # We can chdir to '/' on Windows, but then the pwd will be the root drive vim.chdir('/') - eq(vim.eval('getcwd()'), '/') + assert vim.eval('getcwd()') == root vim.chdir(pwd) - eq(vim.eval('getcwd()'), pwd) + assert vim.eval('getcwd()') == pwd -@with_setup(setup=cleanup) -def test_current_line(): - eq(vim.current.line, '') +def test_current_line(vim: Nvim) -> None: + assert vim.current.line == '' vim.current.line = 'abc' - eq(vim.current.line, 'abc') + assert vim.current.line == 'abc' + + +def test_current_line_delete(vim: Nvim) -> None: + vim.current.buffer[:] = ['one', 'two'] + assert len(vim.current.buffer[:]) == 2 + del vim.current.line + assert len(vim.current.buffer[:]) == 1 and vim.current.buffer[0] == 'two' + del vim.current.line + assert len(vim.current.buffer[:]) == 1 and not vim.current.buffer[0] -@with_setup(setup=cleanup) -def test_vars(): +def test_vars(vim: Nvim) -> None: vim.vars['python'] = [1, 2, {'3': 1}] - eq(vim.vars['python'], [1, 2, {'3': 1}]) - eq(vim.eval('g:python'), [1, 2, {'3': 1}]) + assert vim.vars['python'] == [1, 2, {'3': 1}] + assert vim.eval('g:python') == [1, 2, {'3': 1}] + assert vim.vars.get('python') == [1, 2, {'3': 1}] + + del vim.vars['python'] + with pytest.raises(KeyError): + vim.vars['python'] + assert vim.eval('exists("g:python")') == 0 + + with pytest.raises(KeyError): + del vim.vars['python'] + + assert vim.vars.get('python', 'default') == 'default' + + +def test_options(vim: Nvim) -> None: + assert vim.options['background'] == 'dark' + vim.options['background'] = 'light' + assert vim.options['background'] == 'light' + + +def test_local_options(vim: Nvim) -> None: + assert vim.windows[0].options['foldmethod'] == 'manual' + vim.windows[0].options['foldmethod'] = 'syntax' + assert vim.windows[0].options['foldmethod'] == 'syntax' + +def test_buffers(vim: Nvim) -> None: + buffers = [] -@with_setup(setup=cleanup) -def test_options(): - eq(vim.options['listchars'], 'tab:> ,trail:-,nbsp:+') - vim.options['listchars'] = 'tab:xy' - eq(vim.options['listchars'], 'tab:xy') + # Number of elements + assert len(vim.buffers) == 1 + # Indexing (by buffer number) + assert vim.buffers[vim.current.buffer.number] == vim.current.buffer -@with_setup(setup=cleanup) -def test_buffers(): - eq(len(vim.buffers), 1) - eq(vim.buffers[0], vim.current.buffer) + buffers.append(vim.current.buffer) vim.command('new') - eq(len(vim.buffers), 2) - eq(vim.buffers[1], vim.current.buffer) - vim.current.buffer = vim.buffers[0] - eq(vim.buffers[0], vim.current.buffer) + assert len(vim.buffers) == 2 + buffers.append(vim.current.buffer) + assert vim.buffers[vim.current.buffer.number] == vim.current.buffer + vim.current.buffer = buffers[0] + assert vim.buffers[vim.current.buffer.number] == buffers[0] + # Membership test + assert buffers[0] in vim.buffers + assert buffers[1] in vim.buffers + assert {} not in vim.buffers # type: ignore[operator] -@with_setup(setup=cleanup) -def test_windows(): - eq(len(vim.windows), 1) - eq(vim.windows[0], vim.current.window) + # Iteration + assert buffers == list(vim.buffers) + + +def test_windows(vim: Nvim) -> None: + assert len(vim.windows) == 1 + assert vim.windows[0] == vim.current.window vim.command('vsplit') vim.command('split') - eq(len(vim.windows), 3) - eq(vim.windows[0], vim.current.window) + assert len(vim.windows) == 3 + assert vim.windows[0] == vim.current.window vim.current.window = vim.windows[1] - eq(vim.windows[1], vim.current.window) + assert vim.windows[1] == vim.current.window -@with_setup(setup=cleanup) -def test_tabpages(): - eq(len(vim.tabpages), 1) - eq(vim.tabpages[0], vim.current.tabpage) +def test_tabpages(vim: Nvim) -> None: + assert len(vim.tabpages) == 1 + assert vim.tabpages[0] == vim.current.tabpage vim.command('tabnew') - eq(len(vim.tabpages), 2) - eq(len(vim.windows), 2) - eq(vim.windows[1], vim.current.window) - eq(vim.tabpages[1], vim.current.tabpage) + assert len(vim.tabpages) == 2 + assert len(vim.windows) == 2 + assert vim.windows[1] == vim.current.window + assert vim.tabpages[1] == vim.current.tabpage vim.current.window = vim.windows[0] # Switching window also switches tabpages if necessary(this probably # isn't the current behavior, but compatibility will be handled in the # python client with an optional parameter) - eq(vim.tabpages[0], vim.current.tabpage) - eq(vim.windows[0], vim.current.window) + assert vim.tabpages[0] == vim.current.tabpage + assert vim.windows[0] == vim.current.window vim.current.tabpage = vim.tabpages[1] - eq(vim.tabpages[1], vim.current.tabpage) - eq(vim.windows[1], vim.current.window) + assert vim.tabpages[1] == vim.current.tabpage + assert vim.windows[1] == vim.current.window -@with_setup(setup=cleanup) -def test_hash(): +def test_hash(vim: Nvim) -> None: d = {} d[vim.current.buffer] = "alpha" - eq(d[vim.current.buffer], "alpha") + assert d[vim.current.buffer] == 'alpha' vim.command('new') d[vim.current.buffer] = "beta" - eq(d[vim.current.buffer], "beta") + assert d[vim.current.buffer] == 'beta' vim.command('winc w') - eq(d[vim.current.buffer], "alpha") + assert d[vim.current.buffer] == 'alpha' vim.command('winc w') - eq(d[vim.current.buffer], "beta") + assert d[vim.current.buffer] == 'beta' + + +def test_python3(vim: Nvim) -> None: + """Tests whether python3 host can load.""" + rv = vim.exec_lua(''' + local prog, err = vim.provider.python.detect_by_module("neovim") + return { prog = prog, err = err }''') + assert rv['prog'] != "", rv['err'] + assert rv['prog'] == sys.executable + + assert sys.executable == vim.command_output( + 'python3 import sys; print(sys.executable)') + + assert 1 == vim.eval('has("python3")') + + +def test_python3_ex_eval(vim: Nvim) -> None: + assert '42' == vim.command_output('python3 =42') + assert '42' == vim.command_output('python3 = 42 ') + assert '42' == vim.command_output('py3= 42 ') + assert '42' == vim.command_output('py=42') + + # On syntax error or evaluation error, stacktrace information is printed + # Note: the pynvim API command_output() throws an exception on error + # because the Ex command :python will throw (wrapped with provider#python3#Call) + with pytest.raises(NvimError) as excinfo: + vim.command('py3= 1/0') + stacktrace = excinfo.value.args[0] + assert 'File "", line 1, in ' in stacktrace + assert 'ZeroDivisionError: division by zero' in stacktrace + + vim.command('python3 def raise_error(): raise RuntimeError("oops")') + with pytest.raises(NvimError) as excinfo: + vim.command_output('python3 =print("nooo", raise_error())') + stacktrace = excinfo.value.args[0] + assert 'File "", line 1, in raise_error' in stacktrace + assert 'RuntimeError: oops' in stacktrace + assert 'nooo' not in vim.command_output(':messages') + + +def test_python_cwd(vim: Nvim, tmp_path: Path) -> None: + vim.command('python3 import os') + cwd_before = vim.command_output('python3 print(os.getcwd())') + + # handle DirChanged #296 + vim.command('cd {}'.format(str(tmp_path))) + cwd_vim = vim.command_output('pwd') + cwd_python = vim.command_output('python3 print(os.getcwd())') + assert cwd_python == cwd_vim + assert cwd_python != cwd_before + + +lua_code = """ +local a = vim.api +local y = ... +function pynvimtest_func(x) + return x+y +end + +local function setbuf(buf,lines) + a.nvim_buf_set_lines(buf, 0, -1, true, lines) +end + + +local function getbuf(buf) + return a.nvim_buf_line_count(buf) +end + +pynvimtest = {setbuf=setbuf, getbuf=getbuf} + +return "eggspam" +""" + + +def test_lua(vim: Nvim) -> None: + assert vim.exec_lua(lua_code, 7) == "eggspam" + assert vim.lua.pynvimtest_func(3) == 10 + lua_module = vim.lua.pynvimtest + buf = vim.current.buffer + lua_module.setbuf(buf, ["a", "b", "c", "d"], async_=True) + assert lua_module.getbuf(buf) == 4 diff --git a/test/test_window.py b/test/test_window.py index 605a96e6..9267a14e 100644 --- a/test/test_window.py +++ b/test/test_window.py @@ -1,96 +1,127 @@ -import os -from nose.tools import with_setup, eq_ as eq, ok_ as ok -from test_common import vim, cleanup +import pytest +from pynvim.api import Nvim -@with_setup(setup=cleanup) -def test_buffer(): - eq(vim.current.buffer, vim.windows[0].buffer) + +def test_buffer(vim: Nvim) -> None: + assert vim.current.buffer == vim.windows[0].buffer vim.command('new') vim.current.window = vim.windows[1] - eq(vim.current.buffer, vim.windows[1].buffer) - ok(vim.windows[0].buffer != vim.windows[1].buffer) + assert vim.current.buffer == vim.windows[1].buffer + assert vim.windows[0].buffer != vim.windows[1].buffer -@with_setup(setup=cleanup) -def test_cursor(): - eq(vim.current.window.cursor, [1, 0]) +def test_cursor(vim: Nvim) -> None: + assert vim.current.window.cursor == (1, 0) vim.command('normal ityping\033o some text') - eq(vim.current.buffer[:], ['typing', ' some text']) - eq(vim.current.window.cursor, [2, 10]) - vim.current.window.cursor = [2, 6] + assert vim.current.buffer[:] == ['typing', ' some text'] + assert vim.current.window.cursor == (2, 10) + vim.current.window.cursor = (2, 6) vim.command('normal i dumb') - eq(vim.current.buffer[:], ['typing', ' some dumb text']) + assert vim.current.buffer[:] == ['typing', ' some dumb text'] -@with_setup(setup=cleanup) -def test_height(): +def test_height(vim: Nvim) -> None: vim.command('vsplit') - eq(vim.windows[1].height, vim.windows[0].height) + assert vim.windows[1].height == vim.windows[0].height vim.current.window = vim.windows[1] vim.command('split') - eq(vim.windows[1].height, vim.windows[0].height // 2) + assert vim.windows[1].height == vim.windows[0].height // 2 vim.windows[1].height = 2 - eq(vim.windows[1].height, 2) + assert vim.windows[1].height == 2 -@with_setup(setup=cleanup) -def test_width(): +def test_width(vim: Nvim) -> None: vim.command('split') - eq(vim.windows[1].width, vim.windows[0].width) + assert vim.windows[1].width == vim.windows[0].width vim.current.window = vim.windows[1] vim.command('vsplit') - eq(vim.windows[1].width, vim.windows[0].width // 2) + assert vim.windows[1].width == vim.windows[0].width // 2 vim.windows[1].width = 2 - eq(vim.windows[1].width, 2) + assert vim.windows[1].width == 2 -@with_setup(setup=cleanup) -def test_vars(): +def test_vars(vim: Nvim) -> None: vim.current.window.vars['python'] = [1, 2, {'3': 1}] - eq(vim.current.window.vars['python'], [1, 2, {'3': 1}]) - eq(vim.eval('w:python'), [1, 2, {'3': 1}]) + assert vim.current.window.vars['python'] == [1, 2, {'3': 1}] + assert vim.eval('w:python') == [1, 2, {'3': 1}] + assert vim.current.window.vars.get('python') == [1, 2, {'3': 1}] + + del vim.current.window.vars['python'] + with pytest.raises(KeyError): + vim.current.window.vars['python'] + assert vim.eval('exists("w:python")') == 0 + + with pytest.raises(KeyError): + del vim.current.window.vars['python'] + assert vim.current.window.vars.get('python', 'default') == 'default' -@with_setup(setup=cleanup) -def test_options(): + +def test_options(vim: Nvim) -> None: vim.current.window.options['colorcolumn'] = '4,3' - eq(vim.current.window.options['colorcolumn'], '4,3') + assert vim.current.window.options['colorcolumn'] == '4,3' + old_global_statusline = vim.options['statusline'] # global-local option vim.current.window.options['statusline'] = 'window-status' - eq(vim.current.window.options['statusline'], 'window-status') - eq(vim.options['statusline'], '') + assert vim.current.window.options['statusline'] == 'window-status' + assert vim.options['statusline'] == old_global_statusline + + with pytest.raises(KeyError) as excinfo: + vim.current.window.options['doesnotexist'] + assert excinfo.value.args == ("Invalid option name: 'doesnotexist'",) -@with_setup(setup=cleanup) -def test_position(): +def test_position(vim: Nvim) -> None: height = vim.windows[0].height width = vim.windows[0].width vim.command('split') vim.command('vsplit') - eq((vim.windows[0].row, vim.windows[0].col), (0, 0)) + assert (vim.windows[0].row, vim.windows[0].col) == (0, 0) vsplit_pos = width / 2 split_pos = height / 2 - eq(vim.windows[1].row, 0) - ok(vsplit_pos - 1 <= vim.windows[1].col <= vsplit_pos + 1) - ok(split_pos - 1 <= vim.windows[2].row <= split_pos + 1) - eq(vim.windows[2].col, 0) + assert vim.windows[1].row == 0 + assert vsplit_pos - 1 <= vim.windows[1].col <= vsplit_pos + 1 + assert split_pos - 1 <= vim.windows[2].row <= split_pos + 1 + assert vim.windows[2].col == 0 -@with_setup(setup=cleanup) -def test_tabpage(): +def test_tabpage(vim: Nvim) -> None: vim.command('tabnew') vim.command('vsplit') - eq(vim.windows[0].tabpage, vim.tabpages[0]) - eq(vim.windows[1].tabpage, vim.tabpages[1]) - eq(vim.windows[2].tabpage, vim.tabpages[1]) + assert vim.windows[0].tabpage == vim.tabpages[0] + assert vim.windows[1].tabpage == vim.tabpages[1] + assert vim.windows[2].tabpage == vim.tabpages[1] -@with_setup(setup=cleanup) -def test_valid(): +def test_valid(vim: Nvim) -> None: vim.command('split') window = vim.windows[1] vim.current.window = window - ok(window.valid) + assert window.valid vim.command('q') - ok(not window.valid) + assert not window.valid + + +def test_number(vim: Nvim) -> None: + curnum = vim.current.window.number + vim.command('bot split') + assert vim.current.window.number == curnum + 1 + vim.command('bot split') + assert vim.current.window.number == curnum + 2 + + +def test_handle(vim: Nvim) -> None: + hnd1 = vim.current.window.handle + vim.command('bot split') + hnd2 = vim.current.window.handle + assert hnd2 != hnd1 + vim.command('bot split') + hnd3 = vim.current.window.handle + assert hnd1 != hnd2 != hnd3 + vim.command('wincmd w') + assert vim.current.window.handle == hnd1 + + +def test_repr(vim: Nvim) -> None: + assert repr(vim.current.window) == "" diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..8010a9eb --- /dev/null +++ b/tox.ini @@ -0,0 +1,57 @@ +# https://tox.wiki/en/stable/config.html +# Note: to run individual jobs locally, do "tox run -e py310,311" + +[tox] +min_version = 4.0 +envlist = + py{37,38,39,310,311,312,313}-asyncio + checkqa +skip_missing_interpreters = + true + +[gh-actions] +# https://github.com/ymyzk/tox-gh-actions +python = + 3.7: py37 + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311 + 3.12: py312 + 3.13: py313 + pypy3: pypy3 + +[testenv] +extras = test +deps = + pytest-timeout +# cov: pytest-cov +# setenv = +# cov: PYTEST_ADDOPTS=--cov=. {env:PYTEST_ADDOPTS:} +# passenv = PYTEST_ADDOPTS + +# Note: Use python instead of python3 due to tox-dev/tox#2801 +commands = + python -m pytest --color yes -s --timeout 5 -vv {posargs} + +[testenv:checkqa] +deps = + mypy + flake8 + flake8-import-order + flake8-docstrings + pep8-naming + msgpack-types +ignore_errors = true +# see also: docs/development.rst +commands = + flake8 {posargs:pynvim test} + mypy --show-error-codes {posargs:pynvim test} + +[testenv:docs] +deps = + sphinx + sphinx-rtd-theme +changedir = {toxinidir}/docs +commands = + sphinx-build -b html -d {envtmpdir}/doctrees . {envtmpdir}/html