From 0c8fb85ab68d7d5642b49917b699fbea52c60bac Mon Sep 17 00:00:00 2001 From: Jorgen Schaefer Date: Sat, 26 Jan 2013 13:32:27 +0100 Subject: [PATCH] elpy-rpc: New RPC backend, replacing ropemacs, ropemode and pymacs. The new backend is a simple JSON-RPC API between Python and Emacs using a new Python elpy package. This allows us to debug and improve the Python interaction more easily, as well as to support Jedi. Closes #1 as Pymacs is not needed anymore. Closes #19 as Jedi is now officially supported. Closes #20 as the JSON-RPC backend is a lot easier to use. --- README.md | 56 +-- elpy.el | 759 +++++++++++++++++-------------- elpy/__init__.py | 41 ++ elpy/__main__.py | 19 + elpy/backends/__init__.py | 7 + elpy/backends/jedibackend.py | 127 ++++++ elpy/backends/nativebackend.py | 142 ++++++ elpy/backends/ropebackend.py | 146 ++++++ elpy/rpc.py | 122 +++++ elpy/server.py | 143 ++++++ elpy/tests/__init__.py | 1 + elpy/tests/support.py | 49 ++ elpy/tests/test_jedibackend.py | 164 +++++++ elpy/tests/test_nativebackend.py | 126 +++++ elpy/tests/test_ropebackend.py | 160 +++++++ elpy/tests/test_rpc.py | 148 ++++++ elpy/tests/test_server.py | 97 ++++ elpy/tests/test_support.py | 19 + 18 files changed, 1925 insertions(+), 401 deletions(-) create mode 100644 elpy/__init__.py create mode 100644 elpy/__main__.py create mode 100644 elpy/backends/__init__.py create mode 100644 elpy/backends/jedibackend.py create mode 100644 elpy/backends/nativebackend.py create mode 100644 elpy/backends/ropebackend.py create mode 100644 elpy/rpc.py create mode 100644 elpy/server.py create mode 100644 elpy/tests/__init__.py create mode 100644 elpy/tests/support.py create mode 100644 elpy/tests/test_jedibackend.py create mode 100644 elpy/tests/test_nativebackend.py create mode 100644 elpy/tests/test_ropebackend.py create mode 100644 elpy/tests/test_rpc.py create mode 100644 elpy/tests/test_server.py create mode 100644 elpy/tests/test_support.py diff --git a/README.md b/README.md index 8ffe5f51a..80aa8dda6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Python. ## Features -- **Code completion (using auto-complete and rope):** +- **Code completion (using auto-complete and rope or jedi):** Emacs will suggest completions as you type and, after a short delay, pop up a select box with proposed completions, including docstrings for those completions when available. @@ -17,15 +17,15 @@ Python. code belongs where. - **Snippet Expansion (using yasnippet):** Use powerful templates for quick code generation. -- **Code hinting (using eldoc and rope):** +- **Code hinting (using eldoc and rope or jedi):** While you write, the minibuffer will show the call signature of the current function. -- **Code Navigation (using rope, python.el, find-file-in-project, and idomenu)** +- **Code Navigation (using rope, jedi, python.el, find-file-in-project, and idomenu)** Quickly jump to the definition of a function or class, find callers of the current function, or browse all definitions in the current file. `C-c C-f` will also allow you to quickly open any file in your current project. -- **Inline Documentation (using rope):** +- **Inline Documentation (using rope or jedi):** Read the help() output of the object at point with a quick key shortcut. - **On-the-fly checks (using flymake):** Highlight errors in your code while you edit it. @@ -38,11 +38,6 @@ Python. - **Test running (using nose)** Run all your tests, the tests for the current module or just the current unit with a simple keystroke. -- **Refactoring (using rope):** - Use any of multiple powerful refactoring tools, such extracting - the region to a variable or a separate function, renaming - identifiers, modules or packages, or just automatically clean up - your imports. - **Easy IPython support for those who use it:** Simply run (elpy-use-ipython). @@ -53,17 +48,13 @@ Elpy requires Emacs 24. First, you need to install the Python dependencies: ``` -easy_install --user rope ropemode ropemacs +easy_install --user elpy rope ``` -Sadly, Pymacs itself is not available via pypi, so you need to install -it by hand: +or: ``` -git clone https://github.com/pinard/Pymacs.git -cd Pymacs -make -python setup.py install --user +easy_install --user elpy jedi ``` Then, add the following to your .emacs: @@ -98,7 +89,7 @@ If you want to use IPython (make sure it's installed), add: (elpy-use-ipython) ``` -If you find the (Python Elpy yas AC Rope ElDoc Fill) mode line +If you find the (Python Elpy yas AC ElDoc Fill) mode line annoying, also add: ``` @@ -191,27 +182,6 @@ C-c C-t m Test the current module C-c C-t o Test the current unit ``` -### Refactoring - -While Rope provides auto-completion, it's actually a refactoring tool. -Elpy wraps that in a simple interactive refactoring session. - -``` -C-c C-r Start refactoring interaction -``` - -### Project support - -Rope uses projects. Usually, you only need to set it up once and say -where the project root is, but these allow you to set up and configure -projects on the fly. - -``` -C-c C-p C-o Open a new Rope project -C-c C-p C-c Close the current Rope project -C-c C-p C-p Configure the current Rope project -``` - ## Other Tweaks The following would overwrite keys that can get in the way when using @@ -224,13 +194,3 @@ a-c-mode around, but if it really annoys you, use these. (define-key ac-completing-map (kbd "RET") nil) (define-key ac-completing-map (kbd "") nil) ``` - -## Dependencies from Source - -If you like to live on the edge, get the dependencies as source: - -- Pymacs: `git clone git://github.com/pinard/Pymacs.git` -- Ropemode: `hg clone https://bitbucket.org/agr/ropemode` -- Ropemacs: `hg clone https://bitbucket.org/agr/ropemacs` -- auto-complete: `git clone git://github.com/auto-complete/auto-complete.git` -- yasnippet: `git clone git://github.com/capitaomorte/yasnippet.git` diff --git a/elpy.el b/elpy.el index 3f8b69184..ffe32ce90 100644 --- a/elpy.el +++ b/elpy.el @@ -5,7 +5,7 @@ ;; Author: Jorgen Schaefer ;; URL: https://github.com/jorgenschaefer/elpy ;; Version: 0.7 -;; Package-Requires: ((pymacs "0.25") (auto-complete "1.4") (yasnippet "0.8") (fuzzy "0.1") (virtualenv "1.2") (highlight-indentation "0.5.0") (find-file-in-project "3.2") (idomenu "0.1") (nose "0.1.1")) +;; Package-Requires: ((auto-complete "1.4") (yasnippet "0.8") (fuzzy "0.1") (virtualenv "1.2") (highlight-indentation "0.5.0") (find-file-in-project "3.2") (idomenu "0.1") (nose "0.1.1")) ;; This program is free software; you can redistribute it and/or ;; modify it under the terms of the GNU General Public License @@ -32,7 +32,7 @@ ;; Features include: -;; - Code completion (using auto-complete and rope) +;; - Code completion (using auto-complete and rope or jedi) ;; Emacs will suggest completions as you type and, after a short ;; delay, pop up a select box with proposed completions, including ;; docstrings for those completions when available. @@ -45,17 +45,17 @@ ;; Some completion options are highlighted and will expand into full ;; code snippets that you just need to fill out. -;; - Code hinting (using eldoc and rope) +;; - Code hinting (using eldoc and rope or jedi) ;; While you write, the minibuffer will show the call signature of ;; the current function. -;; - Code Navigation (using rope, python.el, find-file-in-project, and idomenu) +;; - Code Navigation (using rope, jedi, python.el, find-file-in-project, and idomenu) ;; Quickly jump to the definition of a function or class, find ;; callers of the current function, or browse all definitions in the ;; current file. C-c C-f will also allow you to quickly open any ;; file in your current project. -;; - Inline Documentation (using rope) +;; - Inline Documentation (using rope or jedi) ;; Read the help() output of the object at point with a quick key ;; shortcut. @@ -74,12 +74,6 @@ ;; Run all your tests, the tests for the current module or just the ;; current unit with a simple keystroke. -;; - Refactoring (using rope) -;; Use any of multiple powerful refactoring tools, such extracting -;; the region to a variable or a separate function, renaming -;; identifiers, modules or packages, or just automatically clean up -;; your imports. - ;; - Easy IPython support for those who use it ;; Simply run (elpy-use-ipython). @@ -103,7 +97,7 @@ ;; (elpy-use-ipython) -;; If you find the (Python Elpy yas AC Rope ElDoc Fill) mode line +;; If you find the (Python Elpy yas AC ElDoc Fill) mode line ;; annoying, also add: ;; (elpy-clean-modeline) @@ -135,48 +129,6 @@ is not helpful and mostly annoying. Value set by elpy.") -(defvar ropemacs-enable-autoimport t - "Specifies whether autoimport should be enabled. - -Value set by elpy.") - -(defvar ropemacs-guess-project t - "Try to guess the project when needed. - -If non-nil, ropemacs tries to guess and open the project that contains -a file on which the rope command is performed when no project is -already opened. - -Value set by elpy.") - -(defvar ropemacs-confirm-saving nil - "Shows whether to confirm saving modified buffers before refactorings. - -If non-nil, you have to confirm saving all modified -python files before refactorings; otherwise they are -saved automatically. - -Value set by elpy.") - -(defvar ropemacs-enable-shortcuts nil - "Shows whether to bind ropemacs shortcuts keys. - -Value set by elpy, as we set our own key bindings.") - -(defvar ropemacs-local-prefix nil - "The prefix for ropemacs refactorings. - -Use nil to prevent binding keys. - -Value set by elpy, as we set our own key bindings.") - -(defvar ropemacs-global-prefix nil - "The prefix for ropemacs project commands. - -Use nil to prevent binding keys. - -Value set by elpy, as we set our own key bindings.") - (defvar ac-trigger-key "TAB" "Non-nil means `auto-complete' will start by typing this key. If you specify this TAB, for example, `auto-complete' will start by typing TAB, @@ -207,125 +159,7 @@ Value set by elpy.") ;; Now, load the various modes we use. -(defun elpy-install-python-packages (&optional ignored) - "Install the required Python packages for the user." - (with-current-buffer (get-buffer-create "*Python Install*") - (fundamental-mode) - (erase-buffer) - (display-buffer (current-buffer)) - (with-selected-window (get-buffer-window (current-buffer)) - (insert "Installing Python packages.\n" - "Scroll down to see if there were any errors.\n\n") - (let ((commandlist nil) - (packages '("rope" "ropemode" "ropemacs"))) - (cond - ((executable-find "easy_install") - (dolist (package packages) - (add-to-list 'commandlist - (format "easy_install --user %s" package)))) - ((executable-find "pip") - (dolist (package packages) - (add-to-list 'commandlist - (format "pip install --user %s" package)))) - (t - (insert "$ ...\n") - (insert "ERROR: Can't find either easy_install or pip, can't " - "install packages.\n"))) - (setq commandlist - (append commandlist - '("mkdir ~/elpy-temp-install" - "cd ~/elpy-temp-install && git clone https://github.com/pinard/Pymacs.git" - "cd ~/elpy-temp-install/Pymacs && make" - "cd ~/elpy-temp-install/Pymacs && python setup.py install --user"))) - (dolist (cmd commandlist) - (insert "$ " cmd "\n") - (sit-for 0) - (call-process "sh" nil (current-buffer) t - "-c" cmd) - (insert "\n") - (goto-char (point-max))) - (insert "\n" - "All done. Check for errors above and try to load Elpy again.\n\n") - (insert-text-button "Reload Elpy" - 'action 'elpy-load-python-packages))))) - -(defun elpy-installation-instructions (&optional error) - "Show installation instructions." - (with-help-window "*Elpy Installation*" - (with-current-buffer "*Elpy Installation*" - (insert "Elpy could not be loaded successfully.\n" - "\n") - (cond - ((and (eq (car error) 'error) - (stringp (cadr error)) - (string-match "Python:" (cadr error))) - (insert -"The following Python error occurred: - -" (cadr error) " -")) - ((and (eq (car error) 'error) - (stringp (cadr error)) - (string-match "Pymacs helper did not start" (cadr error)) - (with-current-buffer (get-buffer-create "*Pymacs*") - (goto-char (point-min)) - (re-search-forward "No module named Pymacs" nil t))) - (insert -"Python can not find the Pymacs module, which means that the Python -side of Pymacs was not correctly installed. -")) - (t - (insert "The following Emacs Lisp error occurred: - -" (format "%s" error) " -"))) - (insert " -The Emacs Lisp Python Environment requires a few Python packages -to be installed before working properly. You can just use the -following button to install them automatically, or you can follow -the instructions below to do so by hand. - -") - (insert-text-button "Install Python packages" - 'action 'elpy-install-python-packages) - (insert " - -If you are still having trouble, visit #emacs on -irc.freenode.net. - - -Manual installation: - -First, the easy ones. Please run the following command in a -shell: - - easy_install --user rope ropemode ropemacs - -If you do not have easy_install, pip might be available: - - pip install --user rope ropemode ropemacs - -The last missing module is Pymacs, which is sadly not available -via easy_install. You will need to run the following: - - git clone https://github.com/pinard/Pymacs.git - cd Pymacs - make - python setup.py install --user - -Try loading elpy again once that is done. Everything should work -then.")))) - -(defun elpy-load-python-packages (&rest ignored) - (condition-case err - (progn - (require 'pymacs) - (pymacs-load "ropemacs" "rope-")) - (error - (elpy-installation-instructions err)))) - (require 'python) -(elpy-load-python-packages) (require 'virtualenv) (require 'highlight-indentation) (require 'yasnippet) @@ -334,6 +168,7 @@ then.")))) (require 'idomenu) (require 'nose) (require 'flymake) +(require 'json) ;;;;;;;;;;;;;;; ;;; Elpy itself @@ -343,6 +178,20 @@ then.")))) :prefix "elpy-" :group 'languages) +(defcustom elpy-rpc-backend nil + "Your preferred backend. + +nil - Select a backend automatically. +rope - Use the Rope refactoring library. This will create + .ropeproject directories in your project roots. +jedi - Use the Jedi completion library. +native - Do not use any backend, use native Python methods only." + :type '(choice (const "rope") + (const "jedi") + (const "native") + (const nil)) + :group 'elpy) + (defcustom elpy-project-markers '(".git" ".svn" ".hg" ".ropeproject" "setup.py") "List of files and directories that mark a project. @@ -365,6 +214,7 @@ project." (define-key map (kbd "M-e") 'elpy-nav-forward-statement) (define-key map (kbd "M-a") 'elpy-nav-backward-statement) (define-key map (kbd "C-c C-j") 'idomenu) + (define-key map (kbd "M-.") 'elpy-goto-definition) (define-key map (kbd "C-c C-o") 'elpy-occur-definitions) (define-key map (kbd "") 'elpy-forward-definition) (define-key map (kbd "M-n") 'elpy-forward-definition) @@ -379,15 +229,9 @@ project." ;; Virtual Env support (define-key map (kbd "C-c C-e") 'virtualenv-workon) - ;; Goto - (define-key map (kbd "C-c C-g C-d") 'rope-goto-definition) - (define-key map (kbd "C-c C-g C-c") 'rope-find-occurrences) - (define-key map (kbd "C-c C-g C-i") 'rope-find-implementations) - (define-key map (kbd "C-c C-g C-g") 'rope-jump-to-global) - ;; Documentation (define-key map (kbd "C-c C-v") 'elpy-check) - (define-key map (kbd "C-c C-d") 'elpy-doc-rope) + (define-key map (kbd "C-c C-d") 'elpy-doc) (define-key map (kbd "C-c C-w C-s") 'elpy-doc-search) (define-key map (kbd "C-c C-w C-w") 'elpy-doc-show) (define-key map (kbd "C-c C-q") 'elpy-show-defun) @@ -398,13 +242,6 @@ project." (define-key map (kbd "C-c C-t m") 'nosetests-module) (define-key map (kbd "C-c C-t o") 'nosetests-one) - ;; Rope Project - (define-key map (kbd "C-c C-p C-o") 'rope-open-project) - (define-key map (kbd "C-c C-p C-c") 'rope-close-project) - (define-key map (kbd "C-c C-p C-p") 'rope-project-config) - - ;; Rope Refactoring - (define-key map (kbd "C-c C-r") 'elpy-refactor) map) "Key map for the Emacs Lisp Python Environment.") @@ -424,72 +261,18 @@ project." (define-minor-mode elpy-mode "Minor mode in Python buffers for the Emacs Lisp Python Environment. -Key bindings - -Indentation and Filling: - -TAB indent line if at the beginning of it, else complete -C-j `newline-and-indent' -C-c < `python-indent-shift-left' -C-c > `python-indent-shift-right' -C-M-q `prog-indent-sexp' -M-q `python-fill-paragraph' - -Python Shell Interaction: - -C-c C-z `python-shell-switch-to-shell' - -C-M-x `python-shell-send-defun' -C-c C-c `elpy-shell-send-region-or-buffer' - -Virtual Environments: +This mode fully supports virtualenvs. Once you switch a +virtualenv using \\[virtualenv-workon], you can use +\\[elpy-rpc-restart] to make the elpy Python process use your +virtualenv. -C-c C-e `virtualenv-workon' - -Code Navigation - -C-c C-j `idomenu' -C-c C-o `elpy-occur-definitions' -C-c C-f `find-file-in-project' -C-c C-g C-d `rope-goto-definition' -C-c C-g C-c `rope-find-occurrences' -C-c C-g C-i `rope-find-implementations' -C-c C-g C-g `rope-jump-to-global' - -C-M-up `python-nav-backward-up-list' -M-a `elpy-nav-backward-statement' -M-e `elpy-nav-forward-statement' - -Documentation - -C-c C-v `elpy-check' - -C-c C-d `elpy-doc-rope' -C-c C-w C-s `elpy-doc-search' -C-c C-w C-w `elpy-doc-show' - -Test running - -C-c C-s `nosetests-all' -C-c C-t m `nosetests-module' -C-c C-t o `nosetests-one' - -Project support - -C-c C-p C-o `rope-open-project' -C-c C-p C-c `rope-close-project' -C-c C-p C-p `rope-project-config' - -Refactoring - -C-c C-r `elpy-refactor'" +\\{elpy-mode-map}" :lighter " Elpy" (when (not (eq major-mode 'python-mode)) (error "Elpy only works with `python-mode'")) (cond (elpy-mode (when buffer-file-name - (elpy-setup-project) (setq ffip-project-root (elpy-project-root))) (eldoc-mode 1) (set (make-local-variable 'eldoc-documentation-function) @@ -499,12 +282,14 @@ C-c C-r `elpy-refactor'" (yas-reload-all) (yas-minor-mode 1) (setq ac-sources - '(ac-source-nropemacs-dot - ac-source-nropemacs + '(ac-source-elpy + ac-source-elpy-dot ac-source-abbrev ac-source-dictionary ac-source-words-in-same-mode-buffers)) - (auto-complete-mode 1)) + (auto-complete-mode 1) + (add-hook 'before-save-hook 'elpy-rpc-before-save nil t) + (add-hook 'after-save-hook 'elpy-rpc-after-save nil t)) (t (eldoc-mode 0) (flymake-mode 0) @@ -513,7 +298,81 @@ C-c C-r `elpy-refactor'" (auto-complete-mode 0) (setq ac-sources '(ac-source-abbrev ac-source-dictionary - ac-source-words-in-same-mode-buffers))))) + ac-source-words-in-same-mode-buffers)) + (remove-hook 'before-save-hook 'elpy-rpc-before-save t) + (remove-hook 'after-save-hook 'elpy-rpc-after-save t)))) + +(defun elpy-installation-instructions (message &optional show-elpy-module) + "Display a window with installation instructions for the Python +side of elpy. + +MESSAGE is shown as the first paragraph. + +If SHOW-ELPY-MODULE is non-nil, the help buffer will first +explain how to install the elpy module." + (with-help-window "*Elpy Installation*" + (with-current-buffer "*Elpy Installation*" + (let ((inhibit-read-only t)) + (erase-buffer) + (insert "Elpy Installation Instructions\n") + (insert "\n") + (insert message) + (when (not (bolp)) + (insert "\n")) + (insert "\n") + (when elpy-rpc-buffer + (let ((elpy-rpc-output (with-current-buffer elpy-rpc-buffer + (buffer-string)))) + (when (not (equal elpy-rpc-output "")) + (insert (format "The contents of the %s buffer " + (buffer-name elpy-rpc-buffer)) + "might provide further information " + "on the problem.\n") + (insert "\n")))) + (when show-elpy-module + (insert "Elpy requires the Python module \"elpy\". The module " + "is available from pypi, so you can install it using " + "the following command:\n") + (insert "\n") + (elpy-installation-command "elpy") + (insert "\n")) + (insert "To find possible completions, Elpy uses one of two " + "Python modules. Either \"rope\" or \"jedi\". To use " + "Elpy to its fullest potential, you should install " + "either one of them. Which one is a matter of taste. " + "You can try both and even switch at runtime using " + "M-x elpy-set-backend.\n") + (insert "\n") + (elpy-installation-command "rope") + (insert "\n") + (elpy-installation-command "jedi") + (insert "\n") + (insert "If you are using virtualenvs, you can use Elpy's " + "C-c C-e command to switch to a virtualenv of your " + "choice. Afterwards, running the command M-x " + "elpy-rpc-restart will use the packages in " + "that virtualenv.") + (fill-region (point-min) (point-max)))))) + +(defun elpy-installation-command (python-module) + "Insert an installation command description for PYTHON-MODULE." + (let ((command (cond + ((executable-find "pip") + (format "pip install --user %s" python-module)) + ((executable-find "easy_install") + (format "easy_install --user %s" python-module)) + (t + nil)))) + (if (not command) + (insert "... hm. It appears you have neither pip nor easy_install " + "available. You might want to get the python-pip or " + "or python-setuptools package.\n") + (insert-text-button "[run]" + 'action (lambda (button) + (async-shell-command + (button-get button 'command))) + 'command command) + (insert " " command "\n")))) (defvar elpy-project-root 'not-initialized "The root of the project the current buffer is in.") @@ -563,21 +422,10 @@ If no root directory is found, nil is returned." (not (file-exists-p (format "%s/__init__.py" dir))))))) -(defun elpy-setup-project () - "Set up the Rope project for the current file." - (let ((old (rope-get-project-root)) - (new (elpy-project-root))) - (cond - ;; Everything is set up correctly - ((and old new (equal old new)) - t) - ;; A better project exists, open it - (new - (rope-open-project new)) - ;; Project doesn't exist, create a new one - ((not new) - (rope-open-project) - (setq elpy-project-root (rope-get-project-root)))))) +(defun elpy-set-project-root (new-root) + "Set the Elpy project root to NEW-ROOT." + (interactive "DNew project root: ") + (setq elpy-project-root new-root)) (defun elpy-use-ipython () "Set defaults to use IPython instead of the standard interpreter." @@ -603,12 +451,12 @@ If no root directory is found, nil is returned." (defun elpy-clean-modeline () "Clean up the mode line by removing some lighters. -It's not necessary to see (Python Elpy yas AC Rope ElDoc) all the +It's not necessary to see (Python Elpy yas AC ElDoc) all the time. Honestly." (interactive) (setq eldoc-minor-mode-string nil) (dolist (mode '(elpy-mode yas-minor-mode auto-complete-mode - flymake-mode ropemacs-mode)) + flymake-mode)) (setcdr (assq mode minor-mode-alist) (list "")))) @@ -645,6 +493,21 @@ screen." (message "%s()" function) (message "Not in a function")))) +(defun elpy-goto-definition () + "Go to the definition of the symbol at point, if found." + (interactive) + (let ((location (elpy-rpc-get-definition))) + (if location + (elpy-goto-location (car location) (cadr location)) + (error "No definition found")))) + +(defun elpy-goto-location (filename offset) + "Show FILENAME at OFFSET to the user." + (let ((buffer (find-file filename))) + (with-current-buffer buffer + (with-selected-window (get-buffer-window buffer) + (goto-char (1+ offset)))))) + (defun elpy-nav-forward-statement () "Move forward one statement. @@ -698,55 +561,13 @@ Also, switch to that buffer." (select-window window) (switch-to-buffer "*Occur*")))) -(defvar elpy-refactor-list - '(("Redo" . rope-redo) - ("Undo" . rope-undo) - ("New Module" . rope-create-module) - ("New Package" . rope-create-package) - ("New Factory for Class at Point" . rope-introduce-factory) - ("Inline Function at Point" . rope-inline) - ("Region to Variable" . rope-extract-variable) - ("Region to Method" . rope-extract-method) - ("Module to Package" . rope-module-to-package) - ("Organize Imports" . rope-organize-imports) - ("Rename Identifier at Point" . rope-rename) - ("Rename Current Module" . rope-rename-current-module) - ("Move Current Module" . rope-move-current-module) - ("Change Signature of Function at Point" . rope-change-signature) - ("Move to Module" . rope-move) - ;; Didn't get this one to work at all - ;; ("Use Function Wherever Possible" . rope-use-function) - ;; Templates would require more complex explanation - ;; ("Restructure Code According to Template" . rope-restructure) - ) - "Valid arguments and functions to call for `elpy-refactor'.") - -(defvar elpy-refactor-history nil - "The history used for `elpy-refactor'.") -(defun elpy-refactor () - "Call a Rope refactoring command. - -See `elpy-refactor-list' for a list of commands." - (interactive) - (let* ((prompt (if elpy-refactor-history - (format "Refactor [%s]: " - (car elpy-refactor-history)) - "Refactor: ")) - (action (completing-read prompt - elpy-refactor-list - nil t nil - 'elpy-refactor-history - (car elpy-refactor-history))) - (command (cdr (assoc action elpy-refactor-list)))) - (when (functionp command) - (call-interactively command)))) ;;;;;;;;; ;;; Eldoc (defun elpy-eldoc-documentation () "Return a call tip for the python call at point." - (let ((calltip (rope-get-calltip))) + (let ((calltip (elpy-rpc-get-calltip))) (when calltip (with-temp-buffer ;; multiprocessing.queues.Queue.cancel_join_thread(self) @@ -814,20 +635,13 @@ description." ", "))) (message "%s" text))) -;;;;;;;;;;;;;;;;;;;;;; -;;; Rope documentation - -(defun elpy-rope-get-doc () - "Return a docstring for the symbol at point, or nil." - (let ((doc (rope-get-doc))) - (when (and doc - (not (equal doc ""))) - doc))) +;;;;;;;;;;;;;;;;; +;;; Documentation -(defun elpy-doc-rope () - "Show Rope documentation on the thing at point." +(defun elpy-doc () + "Show documentation on the thing at point." (interactive) - (let ((doc (or (elpy-rope-get-doc) + (let ((doc (or (elpy-rpc-get-docstring) ;; This will get the right position for ;; multiprocessing.Queue(quxqux_|_) (ignore-errors @@ -836,13 +650,19 @@ description." (with-syntax-table python-dotty-syntax-table (forward-symbol 1) (backward-char 1)) - (elpy-rope-get-doc)))))) + (elpy-rpc-get-docstring)))))) (if doc (with-help-window "*Python Doc*" - (princ doc)) + (with-current-buffer "*Python Doc*" + (erase-buffer) + (insert doc) + (goto-char (point-min)) + (while (re-search-forward "\\(.\\)\\1" nil t) + (replace-match (propertize (match-string 1) + 'face 'bold) + t t)))) (message "No documentation available.")))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;; Python web documentation @@ -951,6 +771,250 @@ This is an alist mapping titles to URLs." (kill-buffer buf))))) +;;;;;;;;;;;;;;;;;;;;; +;;; elpy-rpc backends + +;; elpy-rpc is a simple JSON-based RPC protocol. It's mostly JSON-RPC +;; 1.0, except we do not implement the full protocol as we do not need +;; all the features. Emacs starts a Python subprocess which runs a +;; special module. The module reads JSON-RPC requests and responds +;; with JSON-RPC responses. + +(defvar elpy-rpc-call-id 0 + "Call id for the current elpy-rpc call. + +See `elpy-rpc-call'.") +(make-variable-buffer-local 'elpy-rpc-call-id) + +(defvar elpy-rpc-buffer-p nil + "True iff the current buffer is an elpy-rpc buffer.") +(make-variable-buffer-local 'elpy-rpc-buffer-p) + +(defvar elpy-rpc-buffer nil + "The global elpy-rpc buffer.") + +(defun elpy-rpc (method-name &rest params) + "Run an elpy-rpc method on the elpy-rpc process." + (elpy-rpc-ensure-open) + (with-current-buffer elpy-rpc-buffer + (apply 'elpy-rpc-call method-name params))) + +(defun elpy-rpc-ensure-open () + "Ensure that the global elpy-rpc subprocess is active." + (when (not (and elpy-rpc-buffer + (get-buffer-process elpy-rpc-buffer) + (process-live-p (get-buffer-process elpy-rpc-buffer)))) + (when elpy-rpc-buffer + (kill-buffer elpy-rpc-buffer)) + (condition-case err + (setq elpy-rpc-buffer + (elpy-rpc-open "*elpy-rpc*" "python" "-m" "elpy")) + (error + (elpy-installation-instructions + (format "Could not start the Python subprocess: %s" + (cadr err)) + t) + (error (cadr err)))) + (cond + ;; User requested a backend that's not installed + (elpy-rpc-backend + (when (not (member elpy-rpc-backend (elpy-rpc-get-available-backends))) + (elpy-installation-instructions + (format (concat "The %s backend is unavailable. " + "Please install the appropriate Python library.") + elpy-rpc-backend)) + (error (format "Backend %s not found" elpy-rpc-backend))) + (elpy-rpc-set-backend elpy-rpc-backend)) + ;; User did not specifically request the native backend, but it's + ;; chosen by default. + ((and (not elpy-rpc-backend) + (equal "native" (elpy-rpc-get-backend))) + (elpy-installation-instructions + (concat "Only the basic native backend is available. " + "You might want to install an appropriate " + "Python library. If you are happy with the native " + "backend, please add the following to your .emacs:" + "\n\n(setq elpy-rpc-backend \"native\")")))))) + +(defun elpy-rpc-restart () + "Restart the elpy-rpc subprocess if it is running. + +Actually, just closes the elpy-rpc buffer" + (interactive) + (when elpy-rpc-buffer + (kill-buffer elpy-rpc-buffer) + (setq elpy-rpc-buffer nil))) + +(defun elpy-rpc-open (name program &rest program-args) + "Start a new elpy-rpc subprocess. + +NAME is a suggested name for the buffer and the name for the +process. The process will be PROGRAM called with PROGRAM-ARGS as +arguments. + +This function returns the buffer created to communicate with +elpy-rpc. This buffer needs to be the current buffer for +subsequent calls to `elpy-rpc-call'." + (let* ((buffer (generate-new-buffer name)) + ;; Leaving process-connection-type non-nil can truncate + ;; communication + (proc (let ((process-connection-type nil)) + (apply #'start-process name buffer program program-args)))) + (set-process-query-on-exit-flag proc nil) + (with-current-buffer buffer + (setq elpy-rpc-buffer-p t) + (let ((line (elpy-rpc--receive-line))) + (cond + ((equal line "elpy-rpc ready") + buffer) + ((string-match "No module named \\(.*\\)" line) + (goto-char (point-min)) + (insert line "\n") + (set-marker (process-mark proc) (point)) + (error (format "The Python module %s is not installed" + (match-string 1 line)))) + (t + (goto-char (point-min)) + (insert line "\n") + (set-marker (process-mark proc) (point)) + (error "Unknown output from Python elpy-rpc"))))))) + +(defun elpy-rpc-call (method &rest params) + "Call the METHOD with PARAMS on the current RPC server. + +Ths current buffer needs to be an elpy-rpc buffer." + (when (not elpy-rpc-buffer-p) + (error "`elpy-rpc-call' called outside of an RPC buffer")) + (erase-buffer) + (setq elpy-rpc-call-id (1+ elpy-rpc-call-id)) + (elpy-rpc--send-json `((id . ,elpy-rpc-call-id) + (method . ,method) + (params . ,params))) + (let ((response (elpy-rpc--receive-json))) + (cond + ((not (= elpy-rpc-call-id (cdr (assq 'id response)))) + (error "Protocol desynchronization, restart subprocess")) + ((cdr (assq 'error response)) + (error (cdr (assq 'error response)))) + (t + (cdr (assq 'result response)))))) + +(defun elpy-rpc--send-json (obj) + "Send an object encoded as JSON to the current process." + (process-send-string (get-buffer-process (current-buffer)) + (format "%s\n" (json-encode obj)))) + +(defun elpy-rpc--receive-line () + "Read a single line from the current process." + (let ((inhibit-quit nil)) + (while (not (progn + (goto-char (point-min)) + (re-search-forward "^\\(.*\\)\n" nil t))) + (accept-process-output))) + (let ((line (match-string 1))) + (replace-match "") + line)) + +(defun elpy-rpc--receive-json () + "Read a single JSON object from the current process." + (let ((json-array-type 'list)) + (json-read-from-string (elpy-rpc--receive-line)))) + +(defun elpy-rpc-get-completions () + "Call the find_completions API function. + +Returns a list of possible completions for the Python symbol at +point." + (elpy-rpc "get_completions" + (elpy-project-root) + buffer-file-name + (buffer-string) + (- (point) + (point-min)))) + +(defun elpy-rpc-get-calltip () + "Call the get_calltip API function. + +Returns a calltip string for the function call at point." + (elpy-rpc "get_calltip" + (elpy-project-root) + buffer-file-name + (buffer-string) + (- (point) + (point-min)))) + +(defun elpy-rpc-get-docstring () + "Call the get_docstring API function. + +Returns a possible multi-line docstring for the symbol at point." + (elpy-rpc "get_docstring" + (elpy-project-root) + buffer-file-name + (buffer-string) + (- (point) + (point-min)))) + +(defun elpy-rpc-get-definition () + "Call the find_definition API function. + +Returns nil or a list of (filename, point)." + (elpy-rpc "get_definition" + (elpy-project-root) + buffer-file-name + (buffer-string) + (- (point) + (point-min)))) + +(defun elpy-rpc-before-save () + "Call the before_save API function. + +Used for state keeping in the backend." + ;; If there is no backend, we do not need to keep state. + (when elpy-rpc-buffer + (elpy-rpc "before_save" + (elpy-project-root) + buffer-file-name))) + +(defun elpy-rpc-after-save () + "Call the after_save API function. + +Used for state keeping in the backend." + ;; If there is no backend, we do not need to keep state. + (when elpy-rpc-buffer + (elpy-rpc "before_save" + (elpy-project-root) + buffer-file-name))) + +(defun elpy-rpc-get-backend () + "Call the get_backend API function. + +Returns the name of the backend currently in use." + (elpy-rpc "get_backend")) + +(defun elpy-rpc-get-available-backends () + "Call the get_available_backends API function. + +Returns a list of names of available backends, depending on which +Python libraries are installed." + (elpy-rpc "get_available_backends")) + +(defun elpy-rpc-set-backend (backend) + "Call the set_backend API function. + +This changes the current backend to the named backend. Raises an +error if the backend is not supported." + (elpy-rpc "set_backend" backend)) + +(defun elpy-set-backend (backend) + "Set the backend used by elpy." + (interactive + (list (completing-read + (format "Switch elpy backend (currently %s): " + (elpy-rpc-get-backend)) + (elpy-rpc-get-available-backends) + nil t))) + (elpy-rpc-set-backend backend)) + ;;;;;;;; ;;; nose @@ -965,52 +1029,41 @@ This is an alist mapping titles to URLs." ;;;;;;;;;;;;;;;;; ;;; Auto-Complete -;; The default ropemacs interaction is distinctly broken and even -;; marked as unsupported. Make our own. - -;; Adapted from Michael Markert: -;; https://github.com/cofi/dotfiles/blob/master/emacs.d/config/cofi-python.el - -(defvar elpy--ropemacs-docs nil +(defvar elpy--ac-cache nil "List of current expansions and docstrings.") -(defun elpy--ropemacs-candidates () +(defun elpy--ac-candidates () "Return a list of possible expansions at points. -This also initializes `elpy--ropemacs-docs'." - (setq elpy--ropemacs-docs nil) - (dolist (completion (rope-extended-completions)) +This also initializes `elpy--ac-cache'." + (setq elpy--ac-cache nil) + (dolist (completion (elpy-rpc-get-completions)) (let ((name (car completion)) (doc (cadr completion))) (when (not (string-prefix-p "_" name)) (push (cons (concat ac-prefix name) doc) - elpy--ropemacs-docs)))) - (mapcar 'car elpy--ropemacs-docs)) + elpy--ac-cache)))) + (mapcar #'car elpy--ac-cache)) -(defun elpy--ropemacs-document (name) +(defun elpy--ac-document (name) "Return the documentation for the symbol NAME." - (assoc-default name elpy--ropemacs-docs)) - -(defun elpy--ropemacs-available () - "Return non-nil if rope is available for this file." - (locate-dominating-file buffer-file-name ".ropeproject")) + (assoc-default name elpy--ac-cache)) -(ac-define-source nropemacs - '((candidates . elpy--ropemacs-candidates) +(ac-define-source elpy + '((candidates . elpy--ac-candidates) (symbol . "p") - (document . elpy--ropemacs-document) - (cache . t) - (available . elpy--ropemacs-available))) + (document . elpy--ac-document) + (cache . t))) -(ac-define-source nropemacs-dot - '((candidates . elpy--ropemacs-candidates) +(ac-define-source elpy-dot + '((candidates . elpy--ac-candidates) (symbol . "p") - (document . elpy--ropemacs-document) + (document . elpy--ac-document) (cache . t) (prefix . c-dot) - (requires . 0) - (available . elpy--ropemacs-available))) + (requires . 0))) + ;; Functions for Emacs 24 before 24.3 (when (not (fboundp 'python-shell-send-region)) diff --git a/elpy/__init__.py b/elpy/__init__.py new file mode 100644 index 000000000..5db69d46f --- /dev/null +++ b/elpy/__init__.py @@ -0,0 +1,41 @@ +# Elpy, the Emacs Lisp Python Environment + +# Copyright (C) 2013 Jorgen Schaefer + +# Author: Jorgen Schaefer +# URL: http://github.com/jorgenschaefer/elpy + +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""The Emacs Lisp Python Environment. + +Elpy is a mode for Emacs to support writing Python code. This package +provides the backend within Python to support auto-completion, +documentation extraction, and navigation. + +Emacs will start the protocol by running the module itself, like so: + + python -m elpy + +This will emit a greeting string on a single line, and then wait for +the protocol to start. Details of the protocol can be found in +elpy.rpc.. + +This package is unlikely to be useful on its own. + +""" + +__author__ = "Jorgen Schaefer" +__version__ = "0.5" +__license__ = "GPL" diff --git a/elpy/__main__.py b/elpy/__main__.py new file mode 100644 index 000000000..bd4e1cefb --- /dev/null +++ b/elpy/__main__.py @@ -0,0 +1,19 @@ +"""Main interface to the RPC server. + +You should be able to just run the following to use this module: + +python -m elpy + +The first line should be "elpy-rpc ready". If it isn't, something +broke. + +""" + +import sys + +from elpy.server import ElpyRPCServer + +if __name__ == '__main__': + sys.stdout.write('elpy-rpc ready\n') + sys.stdout.flush() + ElpyRPCServer().serve_forever() diff --git a/elpy/backends/__init__.py b/elpy/backends/__init__.py new file mode 100644 index 000000000..22fd7bdd7 --- /dev/null +++ b/elpy/backends/__init__.py @@ -0,0 +1,7 @@ +"""Various backends supported by elpy. + +Each backend is provided in a single file. When adding a new backend, +it needs to be manually added to elpy.server, though. Backends are not +automatically detected. + +""" diff --git a/elpy/backends/jedibackend.py b/elpy/backends/jedibackend.py new file mode 100644 index 000000000..2f1e41333 --- /dev/null +++ b/elpy/backends/jedibackend.py @@ -0,0 +1,127 @@ +"""Elpy backend using the Jedi library. + +This backend uses the Jedi library: + +https://github.com/davidhalter/jedi + +""" + +from elpy.backends.nativebackend import NativeBackend + + +class JediBackend(NativeBackend): + """The Jedi backend class. + + Implements the RPC calls we can pass on to Jedi. Also subclasses + the native backend to provide methods Jedi does not provide, if + any. + + """ + def __new__(cls): + try: + import jedi + except: + return None + obj = super(JediBackend, cls).__new__(cls) + obj.jedi = jedi + return obj + + def __init__(self): + super(JediBackend, self).__init__() + self.name = "jedi" + + def rpc_get_completions(self, project_root, filename, source, offset): + line, column = pos_to_linecol(source, offset) + script = self.jedi.Script(source, line, column, filename, + source_encoding='utf-8') + proposals = script.complete() + return [[proposal.complete, proposal.doc] + for proposal in proposals] + + def rpc_get_definition(self, project_root, filename, source, offset): + line, column = pos_to_linecol(source, offset) + script = self.jedi.Script(source, line, column, filename, + source_encoding='utf-8') + locations = script.get_definition() + if not locations: + return None + else: + loc = locations[0] + try: + offset = linecol_to_pos(open(loc.module_path).read(), + *loc.start_pos) + except IOError: + return None + return (loc.module_path, offset) + + def rpc_get_calltip(self, project_root, filename, source, offset): + line, column = pos_to_linecol(source, offset) + script = self.jedi.Script(source, line, column, filename, + source_encoding='utf-8') + call = script.get_in_function_call() + if call is None: + return None + return "{}({})".format(call.call_name, + ", ".join(param.code + for param in call.params)) + + def rpc_get_docstring(self, project_root, filename, source, offset): + """Return a docstring for the symbol at offset. + + This uses the nativebackend, as apparently, Jedi does not know + how to do this. It can do a completion and find docstrings for + that, but not for the symbol at a location. Huh. + + """ + return super(JediBackend, self).rpc_get_docstring(project_root, + filename, + source, + offset) + + +# From the Jedi documentation: +# +# line is the current line you want to perform actions on (starting +# with line #1 as the first line). column represents the current +# column/indent of the cursor (starting with zero). source_path +# should be the path of your file in the file system. +# +# Now, why you'd offset a program to a piece of code in a string using +# line/column indeces is a bit beyond me. And even moreso, why you'd +# make lines one-based and columns zero-based is a complete mystery. +# But well, that's what it says. + +def pos_to_linecol(text, pos): + """Return a tuple of line and column for offset pos in text. + + Lines are one-based, columns zero-based. + + This is how Jedi wants it. Don't ask me why. + + """ + line_start = text.rfind("\n", 0, pos) + 1 + line = text.count("\n", 0, line_start) + 1 + col = pos - line_start + return line, col + + +def linecol_to_pos(text, line, col): + """Return the offset of this line and column in text. + + Lines are one-based, columns zero-based. + + This is how Jedi wants it. Don't ask me why. + + """ + nth_newline_offset = 0 + for i in xrange(line - 1): + new_offset = text.find("\n", nth_newline_offset) + if new_offset < 0: + raise ValueError("Text does not have {} lines." + .format(line)) + nth_newline_offset = new_offset + 1 + offset = nth_newline_offset + col + if offset > len(text): + raise ValueError("Line {} column {} is not within the text" + .format(line, col)) + return offset diff --git a/elpy/backends/nativebackend.py b/elpy/backends/nativebackend.py new file mode 100644 index 000000000..551beb6c7 --- /dev/null +++ b/elpy/backends/nativebackend.py @@ -0,0 +1,142 @@ +"""Elpy backend using native Python methods. + +This backend does not use any external packages, so should work even +if only the core Python library is available. This does make it +somewhat limited compared to the other backends. + +On the other hand, this backend also serves as a root class for the +other backends, so they can fall back to the native method if the +specific solutions do not work. + +""" + + +import pydoc +import re +import rlcompleter + + +class NativeBackend(object): + """Elpy backend that uses native Python implementations. + + Works as a stand-alone backend or as a fallback for other + backends. + + """ + + def __init__(self): + self.name = "native" + + def rpc_before_save(self, project_root, filename): + """Method called from before-save-hook""" + pass + + def rpc_after_save(self, project_root, filename): + """Method called from after-save-hook""" + pass + + def rpc_get_completions(self, project_root, filename, source, offset): + """Get completions for symbol at the offset. + + Wrapper around rlcompleter. + + """ + completer = rlcompleter.Completer() + symbol, start, end = find_dotted_symbol_backward(source, offset) + completions = [] + i = 0 + while True: + res = completer.complete(symbol, i) + if res is None: + break + completion = res[len(symbol):].rstrip("(") + completions.append((completion, None)) + i += 1 + return completions + + def rpc_get_definition(self, project_root, filename, source, offset): + """Get the location of the definition for the symbol at the offset. + + Not implemented in the native backend. + + """ + return None + + def rpc_get_calltip(self, project_root, filename, source, offset): + """Get the calltip for the function at the offset. + + Not implemented in the native backend. + + """ + return None + + def rpc_get_docstring(self, project_root, filename, source, offset): + """Get the docstring for the symbol at the offset. + + Uses pydoc and can return a string with backspace characters + for bold highlighting. + + """ + symbol, start, end = find_dotted_symbol(source, offset) + try: + doc = pydoc.render_doc(str(symbol), + "Elpy Pydoc Documentation for %s", + False) + except (ImportError, pydoc.ErrorDuringImport): + return None + return doc + + +# Helper functions + +_SYMBOL_RX = re.compile("[A-Za-z0-9_]") +_DOTTED_SYMBOL_RX = re.compile("[A-Za-z0-9_.]") + + +def find_symbol_backward(source, offset, regexp=_SYMBOL_RX): + """Find the Python symbol at offset in source. + + This will move backwards from offset until a non-symbol + constituing character is found. It will NOT move forwards. + + """ + end = offset + start = offset + while (start > 0 and + regexp.match(source[start - 1])): + start -= 1 + return (source[start:end], start, end) + + +def find_dotted_symbol_backward(source, offset): + """Find the Python symbol with dots at offset in source. + + This will move backwards from offset until a non-symbol + constituing character is found. It will NOT move forwards. + + """ + return find_symbol_backward(source, offset, + _DOTTED_SYMBOL_RX) + + +def find_symbol(source, offset, regexp=_SYMBOL_RX): + """Find the Python symbol at offset. + + This will move forward and backward from offset. + + """ + symbol, start, end = find_symbol_backward(source, offset, + regexp) + while (end < len(source) and + regexp.match(source[end])): + end += 1 + return (source[start:end], start, end) + + +def find_dotted_symbol(source, offset): + """Find the dotted Python symbol at offset. + + This will move forward and backward from offset. + + """ + return find_symbol(source, offset, _DOTTED_SYMBOL_RX) diff --git a/elpy/backends/ropebackend.py b/elpy/backends/ropebackend.py new file mode 100644 index 000000000..564ecd875 --- /dev/null +++ b/elpy/backends/ropebackend.py @@ -0,0 +1,146 @@ +"""Elpy backend using the Rope library. + +This backend uses the Rope library: + +http://rope.sourceforge.net/ + +""" + +import time + +from elpy.backends.nativebackend import NativeBackend + +VALIDATE_EVERY_SECONDS = 5 +MAXFIXES = 5 + + +class RopeBackend(NativeBackend): + """The Rope backend class. + + Implements the RPC calls we can pass on to Rope. Also subclasses + the native backend to provide methods Rope does not provide, if + any. + + """ + + def __init__(self): + super(RopeBackend, self).__init__() + self.name = "rope" + self.before_save_data = {} + self.projects = {} + self.last_validation = {} + + def __new__(cls): + values = cls.initialize() + if values is None: + return None + obj = super(RopeBackend, cls).__new__(cls) + obj.__dict__.update(values) + return obj + + @classmethod + def initialize(cls): + try: + from rope.contrib import codeassist + from rope.base import project + from rope.base import libutils + from rope.base.exceptions import BadIdentifierError + from rope.contrib import findit + return {'codeassist': codeassist, + 'projectlib': project, + 'libutils': libutils, + 'BadIdentifierError': BadIdentifierError, + 'findit': findit + } + except: + return None + + def get_project(self, project_root): + """Return a project object for the given path. + + This caches previously used project objects so they do not + have to be re-created. + + """ + if project_root is None: + raise ValueError("No project root is specified, " + "but required for Rope") + project = self.projects.get(project_root) + if project is None: + project = self.projectlib.Project(project_root) + self.projects[project_root] = project + last_validation = self.last_validation.get(project_root, 0.0) + now = time.time() + if (now - last_validation) > VALIDATE_EVERY_SECONDS: + project.validate() + self.last_validation[project_root] = now + return project + + def rpc_before_save(self, project_root, filename): + try: + self.before_save_data[project_root] = ( + filename, open(filename).read()) + except IOError: + pass + + def rpc_after_save(self, project_root, filename): + project = self.get_project(project_root) + old_filename, old_contents = self.before_save_data.get( + project_root, (None, None)) + if old_filename is not None: + if old_filename == filename: + self.libutils.report_change(project, + filename, + old_contents) + del self.before_save_data[project_root] + + def rpc_get_completions(self, project_root, filename, source, offset): + project = self.get_project(project_root) + resource = self.libutils.path_to_resource(project, filename, 'file') + proposals = self.codeassist.code_assist(project, source, offset, + resource, + maxfixes=MAXFIXES) + starting_offset = self.codeassist.starting_offset(source, offset) + prefixlen = offset - starting_offset + return [[proposal.name[prefixlen:], proposal.get_doc()] + for proposal in proposals] + + def rpc_get_definition(self, project_root, filename, source, offset): + project = self.get_project(project_root) + resource = self.libutils.path_to_resource(project, filename, 'file') + location = self.findit.find_definition(project, source, offset, + resource, MAXFIXES) + if location is None: + return None + else: + return (location.resource.real_path, location.offset) + + def rpc_get_calltip(self, project_root, filename, source, offset): + # Rewind offset to the last ( before offset + open_paren = source.rfind("(", 0, offset) + if open_paren > -1: + offset = open_paren + project = self.get_project(project_root) + resource = self.libutils.path_to_resource(project, filename, 'file') + try: + return self.codeassist.get_calltip(project, source, offset, + resource, MAXFIXES, + remove_self=True) + except (self.BadIdentifierError, IndexError): + # IndexError seems to be a bug in Rope. I don't know what + # it causing it, exactly. + return None + + def rpc_get_docstring(self, project_root, filename, source, offset): + project = self.get_project(project_root) + resource = self.libutils.path_to_resource(project, filename, 'file') + try: + docstring = self.codeassist.get_doc(project, source, offset, + resource, MAXFIXES) + except (self.BadIdentifierError, IndexError): + docstring = None + if docstring is None: + super(RopeBackend, self).rpc_get_docstring(project_root, filename, + source, offset) + else: + return docstring diff --git a/elpy/rpc.py b/elpy/rpc.py new file mode 100644 index 000000000..9443e71d1 --- /dev/null +++ b/elpy/rpc.py @@ -0,0 +1,122 @@ +"""A simple JSON-RPC-like server. + +The server will read and write lines of JSON-encoded method calls and +responses. + +See the documentation of the JSONRPCServer class for further details. + +""" + +import json +import sys + + +class JSONRPCServer(object): + """Simple JSON-RPC-like server. + + This class will read single-line JSON expressions from stdin, + decode them, and pass them to a handler. Return values from the + handler will be JSON-encoded and written to stdout. + + To implement a handler, you need to subclass this class and add + methods starting with "rpc_". Methods then will be found. + + Method calls should be encoded like this: + + {"id": 23, "method": "method_name", "params": ["foo", "bar"]} + + This will call self.rpc_method("foo", "bar"). + + Responses will be encoded like this: + + {"id": 23, "result": "foo"} + + Errors will be encoded like this: + + {"id": 23, "error": "Simple error message"} + + See http://www.jsonrpc.org/ for the inspiration of the protocol. + + """ + + def __init__(self, stdin=None, stdout=None): + """Return a new JSON-RPC server object. + + It will read lines of JSON data from stdin, and write the + responses to stdout. + + """ + if stdin is None: + self.stdin = sys.stdin + else: + self.stdin = stdin + if stdout is None: + self.stdout = sys.stdout + else: + self.stdout = stdout + + def read_json(self): + """Read a single line and decode it as JSON. + + Can raise an EOFError() when the input source was closed. + + """ + line = self.stdin.readline() + if line == '': + raise EOFError() + return json.loads(line) + + def write_json(self, **kwargs): + """Write an JSON object on a single line. + + The keyword arguments are interpreted as a single JSON object. + It's not possible with this method to write non-objects. + + """ + self.stdout.write(json.dumps(kwargs) + "\n") + self.stdout.flush() + + def handle_request(self): + """Handle a single JSON-RPC request. + + Read a request, call the appropriate handler method, and + return the encoded result. Errors in the handler method are + caught and encoded as error objects. Errors in the decoding + phase are not caught, as we can not respond with an error + response to them. + + """ + request = self.read_json() + if 'method' not in request: + raise ValueError("Received a bad request: {}" + .format(request)) + method_name = request['method'] + request_id = request.get('id', None) + params = request.get('params') or [] + try: + method = getattr(self, "rpc_" + method_name, None) + if method is None: + self.write_json(error=("Unknown method {}" + .format(method_name)), + id=request_id) + return + result = method(*params) + if request_id is not None: + self.write_json(result=result, + id=request_id) + except Exception as e: + self.write_json(error=str(e), + id=request_id) + + def serve_forever(self): + """Serve requests forever. + + Errors are not caught, so this is a slight misnomer. + + """ + + while True: + try: + self.handle_request() + except (KeyboardInterrupt, EOFError, SystemExit): + break diff --git a/elpy/server.py b/elpy/server.py new file mode 100644 index 000000000..7176fce7c --- /dev/null +++ b/elpy/server.py @@ -0,0 +1,143 @@ +"""Method implementations for the Elpy JSON-RPC server. + +This file implements the methods exported by the JSON-RPC server. It +handles backend selection and passes methods on to the selected +backend. + +""" + +from elpy.rpc import JSONRPCServer + +from elpy.backends.nativebackend import NativeBackend +from elpy.backends.ropebackend import RopeBackend +from elpy.backends.jedibackend import JediBackend + +BACKEND_MAP = { + 'native': NativeBackend, + 'rope': RopeBackend, + 'jedi': JediBackend, +} + + +class ElpyRPCServer(JSONRPCServer): + """The RPC server for elpy. + + See the rpc_* methods for exported method documentation. + + """ + + def __init__(self): + """Return a new RPC server object. + + As the default backend, we choose the first available from + rope, jedi, or native. + + """ + super(ElpyRPCServer, self).__init__() + for cls in [RopeBackend, JediBackend, NativeBackend]: + backend = cls() + if backend is not None: + self.backend = backend + break + + def rpc_echo(self, *args): + """Return the arguments. + + This is a simple test method to see if the protocol is + working. + + """ + return args + + def rpc_set_backend(self, backend_name): + """Set the current backend to backend_name. + + This will change the current backend. If the backend is not + found or can not find its library, it will raise a ValueError. + + """ + + backend_cls = BACKEND_MAP.get(backend_name) + if backend_cls is None: + raise ValueError("Unknown backend {}" + .format(backend_name)) + backend = backend_cls() + if backend is None: + raise ValueError("Backend {} could not find the " + "required Python library" + .format(backend_name)) + self.backend = backend + + def rpc_get_backend(self): + """Return the name of the current backend.""" + return self.backend.name + + def rpc_get_available_backends(self): + """Return a list of names of the available backends. + + A backend is "available" if the libraries it uses can be + loaded. + + """ + result = [] + for cls in BACKEND_MAP.values(): + backend = cls() + if backend is not None: + result.append(backend.name) + return result + + def rpc_before_save(self, project_root, filename): + """Bookkeeping method. + + Needs to be called before a file is saved. + + """ + return self.backend.rpc_before_save( + project_root, filename) + + def rpc_after_save(self, project_root, filename): + """Bookkeeping method. + + Needs to be called after a file is saved. + + """ + return self.backend.rpc_after_save( + project_root, filename) + + def rpc_get_completions(self, project_root, filename, source, offset): + """Complete symbol at offset in source. + + Returns a list of tuples of the full symbol including + completion and a possible docstring, or None. + + """ + return self.backend.rpc_get_completions( + project_root, filename, source, offset) + + def rpc_get_definition(self, project_root, filename, source, offset): + """Return the location where the symbol at offset is defined. + + The location is either a tuple of (filename, offset), or None + if no location could be found. + + """ + return self.backend.rpc_get_definition( + project_root, filename, source, offset) + + def rpc_get_calltip(self, project_root, filename, source, offset): + """Return the calltip for the symbol at offset. + + This is a string. If no calltip is found, return None. + + """ + return self.backend.rpc_get_calltip( + project_root, filename, source, offset) + + def rpc_get_docstring(self, project_root, filename, source, offset): + """Return the calltip for the symbol at offset. + + This is a string. If no calltip is found, return None. + + """ + return self.backend.rpc_get_docstring( + project_root, filename, source, offset) diff --git a/elpy/tests/__init__.py b/elpy/tests/__init__.py new file mode 100644 index 000000000..c3995440f --- /dev/null +++ b/elpy/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for elpy.""" diff --git a/elpy/tests/support.py b/elpy/tests/support.py new file mode 100644 index 000000000..ff682ec57 --- /dev/null +++ b/elpy/tests/support.py @@ -0,0 +1,49 @@ +"""Support classes and functions for the elpy test code.""" + +import os +import shutil +import tempfile +import unittest + + +class BackendTestCase(unittest.TestCase): + """Base class for backend tests. + + This class sets up a project root directory and provides an easy + way to create files within the project root. + + """ + + def setUp(self): + """Create the project root and make sure it gets cleaned up.""" + self.project_root = tempfile.mkdtemp(prefix="elpy-test") + self.addCleanup(shutil.rmtree, self.project_root, True) + + def project_file(self, relname, contents): + """Create a file named relname within the project root. + + Write contents into that file. + + """ + full_name = os.path.join(self.project_root, relname) + try: + os.makedirs(os.path.dirname(full_name)) + except OSError: + pass + with open(full_name, "w") as f: + f.write(contents) + return full_name + + +def source_and_offset(source): + """Return a source and offset from a source description. + + >>> source_and_offset("hello, _|_world") + ("hello, world", 7) + >>> source_and_offset("_|_hello, world") + ("hello, world", 0) + >>> source_and_offset("hello, world_|_") + ("hello, world", 12) + """ + offset = source.index("_|_") + return source[:offset] + source[offset + 3:], offset diff --git a/elpy/tests/test_jedibackend.py b/elpy/tests/test_jedibackend.py new file mode 100644 index 000000000..313c94ccc --- /dev/null +++ b/elpy/tests/test_jedibackend.py @@ -0,0 +1,164 @@ +"""Tests for the elpy.backends.jedibackend module.""" + +import unittest + +import mock +import __builtin__ + +from elpy.backends import jedibackend +from elpy.tests.support import BackendTestCase, source_and_offset + + +class JediBackendTestCase(BackendTestCase): + def setUp(self): + super(JediBackendTestCase, self).setUp() + self.backend = jedibackend.JediBackend() + + +class TestInit(JediBackendTestCase): + def test_should_have_jedi_as_name(self): + self.assertEqual(self.backend.name, "jedi") + + def test_should_return_object_if_jedi_available(self): + self.assertIsNotNone(jedibackend.JediBackend()) + + @mock.patch.object(__builtin__, '__import__') + def test_should_return_none_if_no_rope(self, import_): + import_.side_effect = ImportError + self.assertIsNone(jedibackend.JediBackend()) + + +class TestGetCompletions(JediBackendTestCase): + def test_should_complete_builtin(self): + source, offset = source_and_offset("o_|_") + self.assertEqual(sorted([name for (name, doc) in + self.backend.rpc_get_completions( + None, "test.py", source, offset)]), + sorted(['SError', 'bject', 'ct', 'pen', 'r', + 'rd', 'verflowError'])) + + def test_should_autoimport(self): + source, offset = source_and_offset( + "import threading\nthreading.T_|_mumble mumble") + self.assertEqual(sorted([name for (name, doc) in + self.backend.rpc_get_completions( + None, "test.py", source, offset)]), + sorted(['hread', 'hread', + 'hreadError', 'imer'])) + + +class TestGetDefinition(JediBackendTestCase): + def test_should_return_definition_location_same_file(self): + source, offset = source_and_offset("import threading\n" + "def test_function(a, b):\n" + " return a + b\n" + "\n" + "test_func_|_tion(\n") + filename = self.project_file("test.py", source) + self.assertEqual(self.backend.rpc_get_definition(self.project_root, + filename, + source, + offset), + (filename, 17)) + + def test_should_return_none_if_file_does_not_exist(self): + source, offset = source_and_offset( + "def foo():\n" + " pass\n" + "\n" + "fo_|_o()\n") + self.assertIsNone( + self.backend.rpc_get_definition(self.project_root, + self.project_root + + "/doesnotexist.py", + source, + offset)) + + def test_should_return_definition_location_different_file(self): + source1 = ("def test_function(a, b):\n" + " return a + b\n") + self.project_file("test1.py", source1) + source2, offset = source_and_offset("from test2 import test_function\n" + "test_function_|_(1, 2)\n") + file2 = self.project_file("test2.py", source2) + locations = self.backend.rpc_get_definition(self.project_root, + file2, + source2, + offset) + self.assertIsNone(locations) + + +class TestGetCalltip(JediBackendTestCase): + def test_should_return_calltip(self): + filename = self.project_file("test.py", "") + source, offset = source_and_offset("import threading\n" + "threading.Thread(_|_") + calltip = self.backend.rpc_get_calltip(self.project_root, + filename, + source, offset) + self.assertEqual(calltip, + "Thread(group=None, target=None, name=None, " + "args=(), kwargs=None, verbose=None)") + + def test_should_return_none_outside_of_all(self): + filename = self.project_file("test.py", "") + source, offset = source_and_offset("import thr_|_eading\n") + calltip = self.backend.rpc_get_calltip(self.project_root, + filename, + source, offset) + self.assertIsNone(calltip) + + +class TestGetDocstring(JediBackendTestCase): + def test_should_get_docstring(self): + filename = self.project_file("test.py", "") + source, offset = source_and_offset( + "import threading\nthreading.Thread.join_|_(") + docstring = self.backend.rpc_get_docstring(self.project_root, + filename, + source, + offset) + + import pydoc + wanted = pydoc.render_doc("threading.Thread.join", + "Elpy Pydoc Documentation for %s", + False) + self.assertEqual(docstring, wanted) + + +class TestPosToLinecol(unittest.TestCase): + def test_should_handle_beginning_of_string(self): + self.assertEqual(jedibackend.pos_to_linecol("foo", 0), + (1, 0)) + + def test_should_handle_end_of_line(self): + self.assertEqual(jedibackend.pos_to_linecol("foo\nbar\nbaz\nqux", 9), + (3, 1)) + + def test_should_handle_end_of_string(self): + self.assertEqual(jedibackend.pos_to_linecol("foo\nbar\nbaz\nqux", 14), + (4, 2)) + + +class TestLinecolToPos(unittest.TestCase): + def test_should_handle_beginning_of_string(self): + self.assertEqual(jedibackend.linecol_to_pos("foo", 1, 0), + 0) + + def test_should_handle_end_of_string(self): + self.assertEqual(jedibackend.linecol_to_pos("foo\nbar\nbaz\nqux", + 3, 1), + 9) + + def test_should_return_offset(self): + self.assertEqual(jedibackend.linecol_to_pos("foo\nbar\nbaz\nqux", + 4, 2), + 14) + + def test_should_fail_for_line_past_text(self): + self.assertRaises(ValueError, + jedibackend.linecol_to_pos, "foo\n", 3, 1) + + def test_should_fail_for_column_past_text(self): + self.assertRaises(ValueError, + jedibackend.linecol_to_pos, "foo\n", 1, 10) diff --git a/elpy/tests/test_nativebackend.py b/elpy/tests/test_nativebackend.py new file mode 100644 index 000000000..f81fd9451 --- /dev/null +++ b/elpy/tests/test_nativebackend.py @@ -0,0 +1,126 @@ +"""Tests for the elpy.backends.nativebackend backend.""" + +import pydoc + +from elpy.backends import nativebackend +from elpy.tests.support import BackendTestCase, source_and_offset + + +class NativeBackendTestCase(BackendTestCase): + def setUp(self): + super(NativeBackendTestCase, self).setUp() + self.backend = nativebackend.NativeBackend() + + +class TestInit(NativeBackendTestCase): + def test_should_have_native_as_name(self): + self.assertEqual(self.backend.name, "native") + + +class TestNoOpMethods(NativeBackendTestCase): + def test_should_have_rpc_before_save(self): + self.backend.rpc_before_save(None, None) + + def test_should_have_rpc_after_save(self): + self.backend.rpc_after_save(None, None) + + def test_should_have_rpc_get_definition(self): + self.assertIsNone(self.backend.rpc_get_definition(None, None, + None, None)) + + def test_should_have_rpc_get_calltip(self): + self.assertIsNone(self.backend.rpc_get_calltip(None, None, + None, None)) + + +class TestGetCompletions(NativeBackendTestCase): + def test_should_complete_simple_calls(self): + source, offset = source_and_offset("o_|_") + self.assertEqual(sorted([name for (name, doc) in + self.backend.rpc_get_completions( + None, None, source, offset)]), + sorted(["bject", "ct", "pen", "r", "rd"])) + + +class TestGetDocstring(NativeBackendTestCase): + def test_should_find_documentation(self): + source = "foo(open(" + offset = 6 # foo(op_|_en( + docstring = pydoc.render_doc("open", + "Elpy Pydoc Documentation for %s", + False) + self.assertEqual(self.backend.rpc_get_docstring(None, None, + source, offset), + docstring) + + +class TestFindSymbol(NativeBackendTestCase): + def test_should_find_symbol(self): + source, offset = source_and_offset("threading.current_th_|_read") + result = nativebackend.find_symbol_backward(source, offset) + self.assertEqual(result[0], "current_th") + self.assertEqual(source[result[1]:result[2]], + "current_th") + result = nativebackend.find_symbol(source, offset) + self.assertEqual(result[0], "current_thread") + self.assertEqual(source[result[1]:result[2]], + "current_thread") + + def test_should_find_empty_string_at_start_of_source(self): + source, offset = source_and_offset("_|_threading") + result = nativebackend.find_symbol_backward(source, offset) + self.assertEqual(result[0], "") + self.assertEqual(source[result[1]:result[2]], + "") + + def test_should_find_empty_string_at_start_of_symbol(self): + source, offset = source_and_offset("threading._|_current_thread()") + result = nativebackend.find_symbol_backward(source, offset) + self.assertEqual(result[0], "") + self.assertEqual(source[result[1]:result[2]], + "") + + def test_should_find_symbol_at_start_of_source(self): + source, offset = source_and_offset("thr_|_eading") + result = nativebackend.find_symbol_backward(source, offset) + self.assertEqual(result[0], "thr") + self.assertEqual(source[result[1]:result[2]], + "thr") + + +class TestFindDottedSymbol(NativeBackendTestCase): + def test_should_find_symbol(self): + source, offset = source_and_offset( + "foo(threading.current_th_|_read())") + result = nativebackend.find_dotted_symbol_backward(source, offset) + self.assertEqual(result[0], "threading.current_th") + self.assertEqual(source[result[1]:result[2]], + "threading.current_th") + result = nativebackend.find_dotted_symbol(source, offset) + self.assertEqual(result[0], "threading.current_thread") + self.assertEqual(source[result[1]:result[2]], + "threading.current_thread") + + def test_should_find_empty_string_at_start_of_source(self): + source = "threading.current_thread" + offset = 0 + result = nativebackend.find_dotted_symbol_backward(source, offset) + self.assertEqual(result[0], "") + self.assertEqual(source[result[1]:result[2]], + "") + + def test_should_find_empty_string_at_start_of_symbol(self): + source = "foo(threading.current_thread)" + offset = 4 # foo(_|_thr + result = nativebackend.find_dotted_symbol_backward(source, offset) + self.assertEqual(result[0], "") + self.assertEqual(source[result[1]:result[2]], + "") + + def test_should_find_symbol_at_start_of_source(self): + source = "threading.current_thread" + offset = 13 # threading.cur_|_rent + result = nativebackend.find_dotted_symbol_backward(source, offset) + self.assertEqual(result[0], "threading.cur") + self.assertEqual(source[result[1]:result[2]], + "threading.cur") diff --git a/elpy/tests/test_ropebackend.py b/elpy/tests/test_ropebackend.py new file mode 100644 index 000000000..8f5cf854e --- /dev/null +++ b/elpy/tests/test_ropebackend.py @@ -0,0 +1,160 @@ +"""Tests for elpy.backends.ropebackend.""" + +import __builtin__ +import mock + +from elpy.tests.support import BackendTestCase, source_and_offset +from elpy.backends import ropebackend + + +class RopeBackendTestCase(BackendTestCase): + def setUp(self): + super(RopeBackendTestCase, self).setUp() + self.backend = ropebackend.RopeBackend() + + def project_file(self, relname, contents): + filename = super(RopeBackendTestCase, self).project_file(relname, + "") + self.backend.rpc_before_save(self.project_root, filename) + filename = super(RopeBackendTestCase, self).project_file(relname, + contents) + self.backend.rpc_after_save(self.project_root, filename) + return filename + + +class TestInit(RopeBackendTestCase): + def test_should_have_rope_as_name(self): + self.assertEqual(self.backend.name, "rope") + + def test_should_return_object_if_rope_available(self): + self.assertIsNotNone(ropebackend.RopeBackend()) + + @mock.patch.object(__builtin__, '__import__') + def test_should_return_none_if_no_rope(self, import_): + import_.side_effect = ImportError + self.assertIsNone(ropebackend.RopeBackend()) + + +class TestGetProject(RopeBackendTestCase): + def test_should_raise_error_for_none_as_project_root(self): + self.assertRaises(ValueError, + self.backend.get_project, None) + + +class TestBeforeAfterSave(RopeBackendTestCase): + def test_should_not_fail_on_inexisting_file(self): + filename = self.project_root + "/doesnotexist" + self.backend.rpc_before_save(self.project_file, filename) + + +class TestGetCompletions(RopeBackendTestCase): + def test_should_return_completions(self): + source, offset = source_and_offset("import multiprocessing\n" + "multiprocessing.P_|_") + filename = self.project_file("test.py", source) + completions = self.backend.rpc_get_completions(self.project_root, + filename, + source, + offset) + self.assertEqual(sorted(name for (name, doc) in completions), + sorted(["ool", "rocess", "ipe", "rocessError"])) + self.assertIsInstance(dict(completions)['ool'], + unicode) + + +class TestGetDefinition(RopeBackendTestCase): + def test_should_return_location_in_same_file(self): + source, offset = source_and_offset( + "import threading\n" + "\n" + "\n" + "def other_function():\n" + " test_f_|_unction(1, 2)\n" + "\n" + "\n" + "def test_function(a, b):\n" + " return a + b\n") + filename = self.project_file("test.py", "") # Unsaved + definition = self.backend.rpc_get_definition(self.project_root, + filename, + source, + offset) + self.assertEqual(definition, (filename, 71)) + + def test_should_return_location_in_different_file(self): + source1 = ("def test_function(a, b):\n" + " return a + b\n") + file1 = self.project_file("test1.py", source1) + source2, offset = source_and_offset("from test1 import test_function\n" + "test_funct_|_ion(1, 2)\n") + file2 = self.project_file("test2.py", source2) + definition = self.backend.rpc_get_definition(self.project_root, + file2, + source2, + offset) + self.assertEqual(definition, (file1, 4)) + + def test_should_return_none_if_location_not_found(self): + source, offset = source_and_offset("test_f_|_unction()\n") + filename = self.project_file("test.py", source) + definition = self.backend.rpc_get_definition(self.project_root, + filename, + source, + offset) + self.assertIsNone(definition) + + def test_should_return_none_if_outside_of_symbol(self): + source, offset = source_and_offset("test_function(_|_)\n") + filename = self.project_file("test.py", source) + definition = self.backend.rpc_get_definition(self.project_root, + filename, + source, + offset) + self.assertIsNone(definition) + + +class TestGetCalltip(RopeBackendTestCase): + def test_should_get_calltip(self): + source, offset = source_and_offset( + "import threading\nthreading.Thread(_|_") + filename = self.project_file("test.py", source) + calltip = self.backend.rpc_get_calltip(self.project_root, + filename, + source, + offset) + self.assertEqual(calltip, + "threading.Thread.__init__(group=None, target=None, " + "name=None, args=(), kwargs=None, verbose=None)") + + def test_should_return_none_for_bad_identifier(self): + source, offset = source_and_offset( + "froblgoo(_|_") + filename = self.project_file("test.py", source) + calltip = self.backend.rpc_get_calltip(self.project_root, + filename, + source, + offset) + self.assertIsNone(calltip) + + +class TestGetDocstring(RopeBackendTestCase): + def test_should_get_docstring(self): + source, offset = source_and_offset( + "import threading\nthreading.Thread.join_|_(") + filename = self.project_file("test.py", source) + docstring = self.backend.rpc_get_docstring(self.project_root, + filename, + source, + offset) + self.assertEqual(docstring, + 'Thread.join(self, timeout=None):\n\n') + + def test_should_return_none_for_bad_identifier(self): + source, offset = source_and_offset( + "froblgoo_|_(\n") + filename = self.project_file("test.py", source) + docstring = self.backend.rpc_get_docstring(self.project_root, + filename, + source, + offset) + self.assertIsNone(docstring) diff --git a/elpy/tests/test_rpc.py b/elpy/tests/test_rpc.py new file mode 100644 index 000000000..e98869838 --- /dev/null +++ b/elpy/tests/test_rpc.py @@ -0,0 +1,148 @@ +"""Tests for elpy.rpc.""" + +import json +import unittest +import StringIO + +import sys + +from elpy import rpc + + +class TestJSONRPCServer(unittest.TestCase): + def setUp(self): + self.stdin = StringIO.StringIO() + self.stdout = StringIO.StringIO() + self.rpc = rpc.JSONRPCServer(self.stdin, self.stdout) + + def write(self, s): + self.stdin.truncate(0) + self.stdout.truncate(0) + self.stdin.write(s) + self.stdin.seek(0) + + def read(self): + value = self.stdout.getvalue() + self.stdin.truncate(0) + self.stdout.truncate(0) + return value + + +class TestInit(TestJSONRPCServer): + def test_should_use_arguments(self): + self.assertEqual(self.rpc.stdin, self.stdin) + self.assertEqual(self.rpc.stdout, self.stdout) + + def test_should_default_to_sys(self): + testrpc = rpc.JSONRPCServer() + self.assertEqual(sys.stdin, testrpc.stdin) + self.assertEqual(sys.stdout, testrpc.stdout) + + +class TestReadJson(TestJSONRPCServer): + def test_should_read_json(self): + objlist = [{'foo': 'bar'}, + {'baz': 'qux', 'fnord': 'argl\nbargl'}, + "beep\r\nbeep\r\nbeep"] + self.write("".join([(json.dumps(obj) + "\n") + for obj in objlist])) + for obj in objlist: + self.assertEqual(self.rpc.read_json(), + obj) + + def test_should_raise_eof_on_eof(self): + self.assertRaises(EOFError, self.rpc.read_json) + + def test_should_fail_on_malformed_json(self): + self.write("malformed json\n") + self.assertRaises(ValueError, + self.rpc.read_json) + + +class TestWriteJson(TestJSONRPCServer): + def test_should_write_json_line(self): + objlist = [{'foo': 'bar'}, + {'baz': 'qux', 'fnord': 'argl\nbargl'}, + ] + for obj in objlist: + self.rpc.write_json(**obj) + self.assertEqual(self.read(), + json.dumps(obj) + "\n") + + +class TestHandleRequest(TestJSONRPCServer): + def test_should_fail_if_json_does_not_contain_a_method(self): + self.write(json.dumps(dict(params=[], + id=23))) + self.assertRaises(ValueError, + self.rpc.handle_request) + + def test_should_call_right_method(self): + self.write(json.dumps(dict(method='foo', + params=[1, 2, 3], + id=23))) + self.rpc.rpc_foo = lambda *params: params + self.rpc.handle_request() + self.assertEqual(json.loads(self.read()), + dict(id=23, + result=[1, 2, 3])) + + def test_should_pass_defaults_for_missing_parameters(self): + def test_method(*params): + self.args = params + + self.write(json.dumps(dict(method='foo'))) + self.rpc.rpc_foo = test_method + self.rpc.handle_request() + self.assertEqual(self.args, ()) + self.assertEqual(self.read(), "") + + def test_should_return_error_for_missing_method(self): + self.write(json.dumps(dict(method='foo', + id=23))) + self.rpc.handle_request() + self.assertEqual(json.loads(self.read()), + dict(id=23, + error="Unknown method foo")) + + def test_should_return_error_for_exception_in_method(self): + def test_method(): + raise ValueError("An error was raised") + + self.write(json.dumps(dict(method='foo', + id=23))) + self.rpc.rpc_foo = test_method + self.rpc.handle_request() + self.assertEqual(json.loads(self.read()), + dict(id=23, + error="An error was raised")) + + +class TestServeForever(TestJSONRPCServer): + def handle_request(self): + self.hr_called += 1 + if self.hr_called > 10: + raise self.error() + + def setUp(self): + super(TestServeForever, self).setUp() + self.hr_called = 0 + self.error = KeyboardInterrupt + self.rpc.handle_request = self.handle_request + + def test_should_call_handle_request_repeatedly(self): + self.rpc.serve_forever() + self.assertEqual(self.hr_called, 11) + + def test_should_return_on_some_errors(self): + self.error = KeyboardInterrupt + self.rpc.serve_forever() + self.error = EOFError + self.rpc.serve_forever() + self.error = SystemExit + self.rpc.serve_forever() + + def test_should_fail_on_most_errors(self): + self.error = RuntimeError + self.assertRaises(RuntimeError, + self.rpc.serve_forever) diff --git a/elpy/tests/test_server.py b/elpy/tests/test_server.py new file mode 100644 index 000000000..79c325fb9 --- /dev/null +++ b/elpy/tests/test_server.py @@ -0,0 +1,97 @@ +"""Tests for the elpy.server module""" + +import unittest +import mock + +from elpy import server + + +class TestServer(unittest.TestCase): + def setUp(self): + self.patches = [mock.patch.object(server, 'NativeBackend'), + mock.patch.object(server, 'RopeBackend'), + mock.patch.object(server, 'JediBackend')] + (self.NativeBackend, + self.RopeBackend, + self.JediBackend) = [patch.__enter__() for patch in self.patches] + (server.BACKEND_MAP["native"], + server.BACKEND_MAP["rope"], + server.BACKEND_MAP["jedi"]) = (self.NativeBackend, + self.RopeBackend, + self.JediBackend) + self.NativeBackend.return_value.name = "native" + self.RopeBackend.return_value.name = "rope" + self.JediBackend.return_value.name = "jedi" + + def tearDown(self): + for patch in self.patches: + patch.__exit__(None, None) + + +class TestInit(TestServer): + def test_should_select_rope_if_available(self): + srv = server.ElpyRPCServer() + self.assertEqual(srv.rpc_get_backend(), "rope") + + def test_should_select_jedi_if_rope_is_not_available(self): + self.RopeBackend.return_value = None + srv = server.ElpyRPCServer() + self.assertEqual(srv.rpc_get_backend(), "jedi") + + def test_should_select_native_if_nothing_else_is_available(self): + self.RopeBackend.return_value = None + self.JediBackend.return_value = None + srv = server.ElpyRPCServer() + self.assertEqual(srv.rpc_get_backend(), "native") + + +class TestRPCEcho(TestServer): + def test_should_return_arguments(self): + srv = server.ElpyRPCServer() + self.assertEqual(srv.rpc_echo("hello", "world"), + ("hello", "world")) + + +class TestRPCGetSetBackend(TestServer): + def test_should_fail_on_inexisting_backend(self): + srv = server.ElpyRPCServer() + self.assertRaises(ValueError, + srv.rpc_set_backend, "doesnotexist") + + def test_should_fail_if_backend_is_inactive(self): + self.JediBackend.return_value = None + srv = server.ElpyRPCServer() + self.assertRaises(ValueError, + srv.rpc_set_backend, "jedi") + + def test_should_get_new_backend(self): + srv = server.ElpyRPCServer() + srv.rpc_set_backend("jedi") + self.assertEqual(srv.rpc_get_backend(), + "jedi") + + +class TestGetAvailableBackends(TestServer): + def test_should_return_available_backends(self): + srv = server.ElpyRPCServer() + self.JediBackend.return_value = None + self.assertEqual(sorted(srv.rpc_get_available_backends()), + sorted(["native", "rope"])) + + +class TestPassthroughRPCCalls(TestServer): + rpc_calls2 = ["rpc_before_save", "rpc_after_save"] + rpc_calls4 = ["rpc_get_completions", "rpc_get_definition", + "rpc_get_calltip", "rpc_get_docstring"] + + def test_should_pass_methods_to_backend(self): + backend = mock.MagicMock() + self.RopeBackend.return_value = backend + srv = server.ElpyRPCServer() + for method in self.rpc_calls2: + getattr(srv, method)("foo", "bar") + getattr(backend, method).assert_called_with("foo", "bar") + for method in self.rpc_calls4: + getattr(srv, method)("foo", "bar", 2, 3) + getattr(backend, method).assert_called_with("foo", "bar", + 2, 3) diff --git a/elpy/tests/test_support.py b/elpy/tests/test_support.py new file mode 100644 index 000000000..0ed1d6d62 --- /dev/null +++ b/elpy/tests/test_support.py @@ -0,0 +1,19 @@ +"""Tests for elpy.tests.support. Yep, we test test code.""" + +import unittest + +from elpy.tests.support import source_and_offset + + +class TestSourceAndOffset(unittest.TestCase): + def test_should_return_source_and_offset(self): + self.assertEqual(source_and_offset("hello, _|_world"), + ("hello, world", 7)) + + def test_should_handle_beginning_of_string(self): + self.assertEqual(source_and_offset("_|_hello, world"), + ("hello, world", 0)) + + def test_should_handle_end_of_string(self): + self.assertEqual(source_and_offset("hello, world_|_"), + ("hello, world", 12))