From c42739a012c56657628ce92a117aac8b9823816b Mon Sep 17 00:00:00 2001 From: Andrea Cedraro Date: Fri, 25 Dec 2015 11:25:00 +0100 Subject: [PATCH] Make python completer use JediHTTP --- .gitmodules | 6 +- third_party/JediHTTP | 1 + third_party/jedi | 1 - ycmd/completers/python/jedi_completer.py | 300 +++++++++++++++++----- ycmd/tests/detailed_diagnostics_test.py | 51 ++++ ycmd/tests/get_completions_test.py | 21 +- ycmd/tests/handlers_test.py | 29 ++- ycmd/tests/misc_handlers_test.py | 8 +- ycmd/tests/python/diagnostics_test.py | 42 --- ycmd/tests/python/get_completions_test.py | 1 - ycmd/tests/python/python_handlers_test.py | 31 +++ ycmd/tests/python/subcommands_test.py | 107 +++++++- ycmd/tests/python/testdata/goto_file1.py | 2 + ycmd/tests/python/testdata/goto_file2.py | 1 + ycmd/tests/python/testdata/goto_file3.py | 2 + ycmd/tests/python/testdata/goto_file4.py | 2 + ycmd/tests/python/testdata/goto_file5.py | 4 + ycmd/tests/subcommands_test.py | 31 +-- ycmd/tests/test_utils.py | 23 ++ 19 files changed, 502 insertions(+), 161 deletions(-) create mode 160000 third_party/JediHTTP delete mode 160000 third_party/jedi create mode 100644 ycmd/tests/detailed_diagnostics_test.py delete mode 100644 ycmd/tests/python/diagnostics_test.py create mode 100644 ycmd/tests/python/testdata/goto_file1.py create mode 100644 ycmd/tests/python/testdata/goto_file2.py create mode 100644 ycmd/tests/python/testdata/goto_file3.py create mode 100644 ycmd/tests/python/testdata/goto_file4.py create mode 100644 ycmd/tests/python/testdata/goto_file5.py diff --git a/.gitmodules b/.gitmodules index 1b9d3f55f4..656ad47514 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,6 @@ [submodule "third_party/OmniSharpServer"] path = third_party/OmniSharpServer url = https://github.com/nosami/OmniSharpServer -[submodule "third_party/jedi"] - path = third_party/jedi - url = https://github.com/davidhalter/jedi [submodule "third_party/argparse"] path = third_party/argparse url = https://github.com/bewest/argparse @@ -25,3 +22,6 @@ [submodule "third_party/tern"] path = third_party/tern url = https://github.com/ternjs/tern.git +[submodule "third_party/JediHTTP"] + path = third_party/JediHTTP + url = https://github.com/vheon/JediHTTP diff --git a/third_party/JediHTTP b/third_party/JediHTTP new file mode 160000 index 0000000000..e19e32ca78 --- /dev/null +++ b/third_party/JediHTTP @@ -0,0 +1 @@ +Subproject commit e19e32ca78b2ff43287d77391b976e1e254a0012 diff --git a/third_party/jedi b/third_party/jedi deleted file mode 160000 index 66557903ae..0000000000 --- a/third_party/jedi +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 66557903ae4c1174eb437a8feeeb718e69d5fa3a diff --git a/ycmd/completers/python/jedi_completer.py b/ycmd/completers/python/jedi_completer.py index c64296e299..e7e3b7d7c5 100644 --- a/ycmd/completers/python/jedi_completer.py +++ b/ycmd/completers/python/jedi_completer.py @@ -21,24 +21,57 @@ from ycmd.utils import ToUtf8IfNeeded from ycmd.completers.completer import Completer -from ycmd import responses +from ycmd import responses, utils +from jedihttp import hmaclib -try: - import jedi -except ImportError: - raise ImportError( - 'Error importing jedi. Make sure the jedi submodule has been checked out. ' - 'In the YouCompleteMe folder, run "git submodule update --init --recursive"') +import logging +import urlparse +import requests +import threading +import sys +import os +import base64 + + +HMAC_SECRET_LENGTH = 16 +PYTHON_EXECUTABLE_PATH = sys.executable +LOG_FILENAME_FORMAT = os.path.join( utils.PathToTempDir(), + u'jedihttp_{port}_{std}.log' ) +PATH_TO_JEDIHTTP = os.path.join( os.path.abspath( os.path.dirname( __file__ ) ), + '..', '..', '..', + 'third_party', 'JediHTTP', 'jedihttp.py' ) + + +class HmacAuth( requests.auth.AuthBase ): + def __init__( self, secret ): + self._hmac_helper = hmaclib.JediHTTPHmacHelper( secret ) + + def __call__( self, req ): + self._hmac_helper.SignRequestHeaders( req.headers, + req.method, + req.path_url, + req.body ) + return req class JediCompleter( Completer ): """ - A Completer that uses the Jedi completion engine. + A Completer that uses the Jedi engine HTTP wrapper JediHTTP. https://jedi.readthedocs.org/en/latest/ + https://github.com/vheon/JediHTTP """ def __init__( self, user_options ): super( JediCompleter, self ).__init__( user_options ) + self._server_lock = threading.RLock() + self._jedihttp_port = None + self._jedihttp_phandle = None + self._logger = logging.getLogger( __name__ ) + self._logfile_stdout = None + self._logfile_stderr = None + self._keep_logfiles = user_options[ 'server_keep_logfiles' ] + self._hmac_secret = '' + self._StartServer() def SupportedFiletypes( self ): @@ -46,24 +79,126 @@ def SupportedFiletypes( self ): return [ 'python' ] - def _GetJediScript( self, request_data ): - filename = request_data[ 'filepath' ] - contents = request_data[ 'file_data' ][ filename ][ 'contents' ] - line = request_data[ 'line_num' ] - # Jedi expects columns to start at 0, not 1 - column = request_data[ 'column_num' ] - 1 + def Shutdown( self ): + if self.ServerIsRunning(): + self._StopServer() + + + def ServerIsReady( self ): + """ Check if JediHTTP server is ready. """ + try: + return bool( self._GetResponse( '/ready' ) ) + except Exception: + return False + + + def ServerIsRunning( self ): + """ Check if JediHTTP server is running (up and serving). """ + with self._server_lock: + if not self._jedihttp_phandle or not self._jedihttp_port: + return False + + try: + return bool( self._GetResponse( '/healthy' ) ) + except Exception: + return False + + + def RestartServer( self, request_data ): + """ Restart the JediHTTP Server. """ + with self._server_lock: + self._StopServer() + self._StartServer() + + + def _StopServer( self ): + with self._server_lock: + self._logger.info( 'Stopping JediHTTP' ) + if self._jedihttp_phandle: + self._jedihttp_phandle.terminate() + self._jedihttp_phandle = None + self._jedihttp_port = None + + if not self._keep_logfiles: + utils.RemoveIfExists( self._logfile_stdout ) + utils.RemoveIfExists( self._logfile_stderr ) + + + def _StartServer( self ): + with self._server_lock: + self._logger.info( 'Starting JediHTTP server' ) + self._ChoosePort() + self._GenerateHmacSecret() + + with hmaclib.TemporaryHmacSecretFile( self._hmac_secret ) as hmac_file: + command = [ PYTHON_EXECUTABLE_PATH, + PATH_TO_JEDIHTTP, + '--port', str( self._jedihttp_port ), + '--hmac-file-secret', hmac_file.name ] + + self._logfile_stdout = LOG_FILENAME_FORMAT.format( + port = self._jedihttp_port, std = 'stdout' ) + self._logfile_stderr = LOG_FILENAME_FORMAT.format( + port = self._jedihttp_port, std = 'stderr' ) + + with open( self._logfile_stderr, 'w' ) as logerr: + with open( self._logfile_stdout, 'w' ) as logout: + self._jedihttp_phandle = utils.SafePopen( command, + stdout = logout, + stderr = logerr ) + + + def _ChoosePort( self ): + if not self._jedihttp_port: + self._jedihttp_port = utils.GetUnusedLocalhostPort() + self._logger.info( u'using port {0}'.format( self._jedihttp_port ) ) + + + def _GenerateHmacSecret( self ): + self._hmac_secret = base64.b64encode( os.urandom( HMAC_SECRET_LENGTH ) ) + + + def _GetResponse( self, handler, request_data = {} ): + """ Handle communication with server """ + target = urlparse.urljoin( self._ServerLocation(), handler ) + parameters = self._TranslateRequestForJediHTTP( request_data ) + response = requests.post( target, + json = parameters, + auth = HmacAuth( self._hmac_secret ) ) + response.raise_for_status() + return response.json() - return jedi.Script( contents, line, column, filename ) + + def _TranslateRequestForJediHTTP( self, request_data ): + if not request_data: + return {} + + path = request_data[ 'filepath' ] + source = request_data[ 'file_data' ][ path ][ 'contents' ] + line = request_data[ 'line_num' ] + # JediHTTP as Jedi itself expects columns to start at 0, not 1 + col = request_data[ 'column_num' ] - 1 + + return { + 'source': source, + 'line': line, + 'col': col, + 'source_path': path + } + + + def _ServerLocation( self ): + return 'http://127.0.0.1:' + str( self._jedihttp_port ) def _GetExtraData( self, completion ): location = {} - if completion.module_path: - location[ 'filepath' ] = ToUtf8IfNeeded( completion.module_path ) - if completion.line: - location[ 'line_num' ] = completion.line - if completion.column: - location[ 'column_num' ] = completion.column + 1 + if completion[ 'module_path' ]: + location[ 'filepath' ] = ToUtf8IfNeeded( completion[ 'module_path' ] ) + if completion[ 'line' ]: + location[ 'line_num' ] = completion[ 'line' ] + if completion[ 'column' ]: + location[ 'column_num' ] = completion[ 'column' ] + 1 if location: extra_data = {} @@ -74,13 +209,24 @@ def _GetExtraData( self, completion ): def ComputeCandidatesInner( self, request_data ): - script = self._GetJediScript( request_data ) return [ responses.BuildCompletionData( - ToUtf8IfNeeded( completion.name ), - ToUtf8IfNeeded( completion.description ), - ToUtf8IfNeeded( completion.docstring() ), + ToUtf8IfNeeded( completion[ 'name' ] ), + ToUtf8IfNeeded( completion[ 'description' ] ), + ToUtf8IfNeeded( completion[ 'docstring' ] ), extra_data = self._GetExtraData( completion ) ) - for completion in script.completions() ] + for completion in self._JediCompletions( request_data ) ] + + + def _JediCompletions( self, request_data ): + return self._GetResponse( '/completions', request_data )[ 'completions' ] + + + def DefinedSubcommands( self ): + # We don't want expose this sub-command because is not really needed for + # the user but is useful in tests for tearing down the server + subcommands = super( JediCompleter, self ).DefinedSubcommands() + subcommands.remove( 'StopServer' ) + return subcommands def GetSubcommandsMap( self ): @@ -92,87 +238,103 @@ def GetSubcommandsMap( self ): 'GoTo' : ( lambda self, request_data, args: self._GoTo( request_data ) ), 'GetDoc' : ( lambda self, request_data, args: - self._GetDoc( request_data ) ) + self._GetDoc( request_data ) ), + 'StopServer' : ( lambda self, request_data, args: + self.Shutdown() ), + 'RestartServer' : ( lambda self, request_data, args: + self.RestartServer() ) } def _GoToDefinition( self, request_data ): - definitions = self._GetDefinitionsList( request_data ) - if definitions: - return self._BuildGoToResponse( definitions ) - else: + definitions = self._GetDefinitionsList( '/gotodefinition', request_data ) + if not definitions: raise RuntimeError( 'Can\'t jump to definition.' ) + return self._BuildGoToResponse( definitions ) def _GoToDeclaration( self, request_data ): - definitions = self._GetDefinitionsList( request_data, declaration = True ) - if definitions: - return self._BuildGoToResponse( definitions ) - else: + definitions = self._GetDefinitionsList( '/gotoassignment', request_data ) + if not definitions: raise RuntimeError( 'Can\'t jump to declaration.' ) + return self._BuildGoToResponse( definitions ) def _GoTo( self, request_data ): - definitions = ( self._GetDefinitionsList( request_data ) or - self._GetDefinitionsList( request_data, declaration = True ) ) - if definitions: - return self._BuildGoToResponse( definitions ) - else: + try: + return self._GoToDefinition( request_data ) + except Exception: + pass + + try: + return self._GoToDeclaration( request_data ) + except Exception: raise RuntimeError( 'Can\'t jump to definition or declaration.' ) def _GetDoc( self, request_data ): - definitions = self._GetDefinitionsList( request_data ) - if definitions: + try: + definitions = self._GetDefinitionsList( '/gotodefinition', request_data ) return self._BuildDetailedInfoResponse( definitions ) - else: + except Exception: raise RuntimeError( 'Can\'t find a definition.' ) - def _GetDefinitionsList( self, request_data, declaration = False ): - definitions = [] - script = self._GetJediScript( request_data ) + def _GetDefinitionsList( self, handler, request_data ): try: - if declaration: - definitions = script.goto_assignments() - else: - definitions = script.goto_definitions() - except jedi.NotFoundError: - raise RuntimeError( - 'Cannot follow nothing. Put your cursor on a valid name.' ) - - return definitions + response = self._GetResponse( handler, request_data ) + return response[ 'definitions' ] + except Exception: + raise RuntimeError( 'Cannot follow nothing. Put your cursor on a valid name.' ) def _BuildGoToResponse( self, definition_list ): if len( definition_list ) == 1: definition = definition_list[ 0 ] - if definition.in_builtin_module(): - if definition.is_keyword: - raise RuntimeError( - 'Cannot get the definition of Python keywords.' ) + if definition[ 'in_builtin_module' ]: + if definition[ 'is_keyword' ]: + raise RuntimeError( 'Cannot get the definition of Python keywords.' ) else: raise RuntimeError( 'Builtin modules cannot be displayed.' ) else: - return responses.BuildGoToResponse( definition.module_path, - definition.line, - definition.column + 1 ) + return responses.BuildGoToResponse( definition[ 'module_path' ], + definition[ 'line' ], + definition[ 'column' ] + 1 ) else: # multiple definitions defs = [] for definition in definition_list: - if definition.in_builtin_module(): + if definition[ 'in_builtin_module' ]: defs.append( responses.BuildDescriptionOnlyGoToResponse( - 'Builtin ' + definition.description ) ) + 'Builtin ' + definition[ 'description' ] ) ) else: defs.append( - responses.BuildGoToResponse( definition.module_path, - definition.line, - definition.column + 1, - definition.description ) ) + responses.BuildGoToResponse( definition[ 'module_path' ], + definition[ 'line' ], + definition[ 'column' ] + 1, + definition[ 'description' ] ) ) return defs + def _BuildDetailedInfoResponse( self, definition_list ): - docs = [ definition.docstring() for definition in definition_list ] + docs = [ definition[ 'docstring' ] for definition in definition_list ] return responses.BuildDetailedInfoResponse( '\n---\n'.join( docs ) ) + + def DebugInfo( self, request_data ): + with self._server_lock: + if self.ServerIsRunning(): + return ( 'JediHTTP running at 127.0.0.1:{0}\n' + ' stdout log: {1}\n' + ' stderr log: {2}' ).format( self._jedihttp_port, + self._logfile_stdout, + self._logfile_stderr ) + + if self._logfile_stdout and self._logfile_stderr: + return ( 'JediHTTP is no longer running\n' + ' stdout log: {1}\n' + ' stderr log: {2}' ).format( self._logfile_stdout, + self._logfile_stderr ) + + return 'JediHTTP is not running' + diff --git a/ycmd/tests/detailed_diagnostics_test.py b/ycmd/tests/detailed_diagnostics_test.py new file mode 100644 index 0000000000..5ee8194e14 --- /dev/null +++ b/ycmd/tests/detailed_diagnostics_test.py @@ -0,0 +1,51 @@ +# Copyright (C) 2016 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd 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. +# +# ycmd 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 ycmd. If not, see . + +from nose.tools import eq_ +from hamcrest import assert_that +from ..responses import NoDiagnosticSupport, BuildDisplayMessageResponse +from .handlers_test import Handlers_test +from .test_utils import DummyCompleter +from mock import patch +import httplib + + +class Diagnostics_test( Handlers_test ): + + def DoesntWork_test( self ): + with self.PatchCompleter( DummyCompleter, filetype = 'dummy_filetype' ): + diag_data = self._BuildRequest( contents = "foo = 5", + line_num = 2, + filetype = 'dummy_filetype' ) + + response = self._app.post_json( '/detailed_diagnostic', + diag_data, + expect_errors = True ) + + eq_( response.status_code, httplib.INTERNAL_SERVER_ERROR ) + assert_that( response.json, self._ErrorMatcher( NoDiagnosticSupport ) ) + + + @patch( 'ycmd.tests.test_utils.DummyCompleter.GetDetailedDiagnostic', + return_value = BuildDisplayMessageResponse( "detailed diagnostic" ) ) + def DoesWork_test( self, *args ): + with self.PatchCompleter( DummyCompleter, filetype = 'dummy_filetype' ): + diag_data = self._BuildRequest( contents = "foo = 5", + filetype = 'dummy_filetype' ) + + response = self._app.post_json( '/detailed_diagnostic', diag_data ) + assert_that( response.json, self._MessageMatcher( "detailed diagnostic" ) ) diff --git a/ycmd/tests/get_completions_test.py b/ycmd/tests/get_completions_test.py index 14ad3f532d..b879b02f96 100644 --- a/ycmd/tests/get_completions_test.py +++ b/ycmd/tests/get_completions_test.py @@ -23,6 +23,8 @@ from hamcrest import assert_that, has_items from .. import handlers from .handlers_test import Handlers_test +from ycmd.tests.test_utils import DummyCompleter +from mock import patch class GetCompletions_test( Handlers_test ): @@ -93,15 +95,18 @@ def IdentifierCompleter_WorksForSpecialIdentifierChars_test( self ): ) - def ForceSemantic_Works_test( self ): - completion_data = self._BuildRequest( filetype = 'python', - force_semantic = True ) + @patch( 'ycmd.tests.test_utils.DummyCompleter.CandidatesList', + return_value = [ 'foo', 'bar', 'qux' ] ) + def ForceSemantic_Works_test( self, *args ): + with self.PatchCompleter( DummyCompleter, 'dummy_filetype' ): + completion_data = self._BuildRequest( filetype = 'dummy_filetype', + force_semantic = True ) - results = self._app.post_json( '/completions', - completion_data ).json[ 'completions' ] - assert_that( results, has_items( self._CompletionEntryMatcher( 'abs' ), - self._CompletionEntryMatcher( 'open' ), - self._CompletionEntryMatcher( 'bool' ) ) ) + results = self._app.post_json( '/completions', + completion_data ).json[ 'completions' ] + assert_that( results, has_items( self._CompletionEntryMatcher( 'foo' ), + self._CompletionEntryMatcher( 'bar' ), + self._CompletionEntryMatcher( 'qux' ) ) ) def IdentifierCompleter_SyntaxKeywordsAdded_test( self ): diff --git a/ycmd/tests/handlers_test.py b/ycmd/tests/handlers_test.py index 95db86427d..c88dc90a92 100644 --- a/ycmd/tests/handlers_test.py +++ b/ycmd/tests/handlers_test.py @@ -22,8 +22,10 @@ from webtest import TestApp from .. import handlers from ycmd import user_options_store -from hamcrest import has_entries, has_entry +from hamcrest import has_entries, has_entry, contains_string from test_utils import BuildRequest +from mock import patch +import contextlib import bottle import os @@ -40,6 +42,14 @@ def setUp( self ): self._app = TestApp( handlers.app ) + @contextlib.contextmanager + def PatchCompleter( self, completer, filetype ): + user_options = handlers._server_state._user_options + with patch.dict( 'ycmd.handlers._server_state._filetype_completers', + { filetype: completer( user_options ) } ): + yield + + @staticmethod def _BuildRequest( **kwargs ): return BuildRequest( **kwargs ) @@ -75,12 +85,19 @@ def _ChangeSpecificOptions( options ): @staticmethod - def _ErrorMatcher( cls, msg ): + def _ErrorMatcher( cls, msg = None ): """ Returns a hamcrest matcher for a server exception response """ - return has_entries( { - 'exception': has_entry( 'TYPE', cls.__name__ ), - 'message': msg, - } ) + entry = { 'exception': has_entry( 'TYPE', cls.__name__ ) } + + if msg: + entry.update( { 'message': msg } ) + + return has_entries( entry ) + + + @staticmethod + def _MessageMatcher( msg ): + return has_entry( 'message', contains_string( msg ) ) def _PathToTestFile( self, *args ): diff --git a/ycmd/tests/misc_handlers_test.py b/ycmd/tests/misc_handlers_test.py index f88c0faaff..c16e57d55d 100644 --- a/ycmd/tests/misc_handlers_test.py +++ b/ycmd/tests/misc_handlers_test.py @@ -20,14 +20,16 @@ from nose.tools import ok_ from .handlers_test import Handlers_test +from ycmd.tests.test_utils import DummyCompleter class MiscHandlers_test( Handlers_test ): def SemanticCompletionAvailable_test( self ): - request_data = self._BuildRequest( filetype = 'python' ) - ok_( self._app.post_json( '/semantic_completion_available', - request_data ).json ) + with self.PatchCompleter( DummyCompleter, filetype = 'dummy_filetype' ): + request_data = self._BuildRequest( filetype = 'dummy_filetype' ) + ok_( self._app.post_json( '/semantic_completion_available', + request_data ).json ) def EventNotification_AlwaysJsonResponse_test( self ): diff --git a/ycmd/tests/python/diagnostics_test.py b/ycmd/tests/python/diagnostics_test.py deleted file mode 100644 index 897881eb2a..0000000000 --- a/ycmd/tests/python/diagnostics_test.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python -# -# Copyright (C) 2015 ycmd contributors. -# -# This file is part of ycmd. -# -# ycmd 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. -# -# ycmd 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 ycmd. If not, see . - -from nose.tools import eq_ -from hamcrest import assert_that, has_entry -from ...responses import NoDiagnosticSupport -from ..handlers_test import Handlers_test -import httplib - - -class Python_Diagnostics_test( Handlers_test ): - - def DoesntWork_test( self ): - diag_data = self._BuildRequest( contents = "foo = 5", - line_num = 2, - filetype = 'python' ) - - response = self._app.post_json( '/detailed_diagnostic', - diag_data, - expect_errors = True ) - - eq_( response.status_code, httplib.INTERNAL_SERVER_ERROR ) - assert_that( response.json, - has_entry( 'exception', - has_entry( 'TYPE', - NoDiagnosticSupport.__name__ ) ) ) diff --git a/ycmd/tests/python/get_completions_test.py b/ycmd/tests/python/get_completions_test.py index a5ca2d773e..76773c5ada 100644 --- a/ycmd/tests/python/get_completions_test.py +++ b/ycmd/tests/python/get_completions_test.py @@ -51,7 +51,6 @@ def CombineRequest( request, data ): 'contents': contents, } ) ) - # We ignore errors here and we check the response code ourself. # This is to allow testing of requests returning errors. response = self._app.post_json( '/completions', diff --git a/ycmd/tests/python/python_handlers_test.py b/ycmd/tests/python/python_handlers_test.py index 7296c93f0d..f47b1cb173 100644 --- a/ycmd/tests/python/python_handlers_test.py +++ b/ycmd/tests/python/python_handlers_test.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU General Public License # along with ycmd. If not, see . +import time from ..handlers_test import Handlers_test @@ -24,3 +25,33 @@ class Python_Handlers_test( Handlers_test ): def __init__( self ): self._file = __file__ + + + def setUp( self ): + super( Python_Handlers_test, self ).setUp() + self.WaitUntilJediHTTPServerReady() + + + def tearDown( self ): + self.StopJediHTTPServer() + + + def WaitUntilJediHTTPServerReady( self ): + retries = 10 + + while retries > 0: + result = self._app.get( '/ready', { 'subserver': 'python' } ).json + if result: + return + + time.sleep( 0.2 ) + retries = retries - 1 + + raise RuntimeError( "Timeout waiting for JediHTTP" ) + + + def StopJediHTTPServer( self ): + request = self._BuildRequest( completer_target = 'filetype_default', + command_arguments = [ 'StopServer' ], + filetype = 'python' ) + self._app.post_json( '/run_completer_command', request ) diff --git a/ycmd/tests/python/subcommands_test.py b/ycmd/tests/python/subcommands_test.py index 5c8a30b448..4df7baea82 100644 --- a/ycmd/tests/python/subcommands_test.py +++ b/ycmd/tests/python/subcommands_test.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU General Public License # along with ycmd. If not, see . +from hamcrest import assert_that from nose.tools import eq_ from python_handlers_test import Python_Handlers_test import os.path @@ -24,26 +25,106 @@ class Python_Subcommands_test( Python_Handlers_test ): - def GoTo_ZeroBasedLineAndColumn_test( self ): + def GoTo_Variation_ZeroBasedLineAndColumn_test( self ): + tests = [ + { + 'command_arguments': [ 'GoToDefinition' ], + 'response': { + 'filepath': os.path.abspath( '/foo.py' ), + 'line_num': 2, + 'column_num': 5 + } + }, + { + 'command_arguments': [ 'GoToDeclaration' ], + 'response': { + 'filepath': os.path.abspath( '/foo.py' ), + 'line_num': 7, + 'column_num': 1 + } + } + ] + for test in tests: + yield self._Run_GoTo_Variation_ZeroBasedLineAndColumn, test + + + def _Run_GoTo_Variation_ZeroBasedLineAndColumn( self, test ): + # Example taken directly from jedi docs + # http://jedi.jedidjah.ch/en/latest/docs/plugin-api.html#examples contents = """ -def foo(): - pass +def my_func(): + print 'called' -foo() +alias = my_func +my_list = [1, None, alias] +inception = my_list[2] + +inception() """ + goto_data = self._BuildRequest( + completer_target = 'filetype_default', + command_arguments = test[ 'command_arguments' ], + line_num = 9, + contents = contents, + filetype = 'python', + filepath = '/foo.py' + ) + + eq_( test[ 'response' ], + self._app.post_json( '/run_completer_command', goto_data ).json ) + + + def GoToDefinition_NotFound_test( self ): + filepath = self._PathToTestFile( 'goto_file5.py' ) + goto_data = self._BuildRequest( command_arguments = [ 'GoToDefinition' ], + line_num = 4, + contents = open( filepath ).read(), + filetype = 'python', + filepath = filepath ) + + response = self._app.post_json( '/run_completer_command', + goto_data, + expect_errors = True ).json + assert_that( response, + self._ErrorMatcher( RuntimeError, "Can\'t jump to definition." ) ) + + + def GoTo_test( self ): + # Tests taken from https://github.com/Valloric/YouCompleteMe/issues/1236 + tests = [ + { + 'request': { 'filename': 'goto_file1.py', 'line_num': 2 }, + 'response': { + 'filepath': self._PathToTestFile( 'goto_file3.py' ), + 'line_num': 1, + 'column_num': 5 + } + }, + { + 'request': { 'filename': 'goto_file4.py', 'line_num': 2 }, + 'response': { + 'filepath': self._PathToTestFile( 'goto_file4.py' ), + 'line_num': 1, + 'column_num': 18 + } + } + ] + for test in tests: + yield self._Run_GoTo, test + + + def _Run_GoTo( self, test ): + filepath = self._PathToTestFile( test[ 'request' ][ 'filename' ] ) goto_data = self._BuildRequest( completer_target = 'filetype_default', - command_arguments = ['GoToDefinition'], - line_num = 5, - contents = contents, + command_arguments = [ 'GoTo' ], + line_num = test[ 'request' ][ 'line_num' ], + contents = open( filepath ).read(), filetype = 'python', - filepath = '/foo.py' ) + filepath = filepath ) - eq_( { - 'filepath': os.path.abspath( '/foo.py' ), - 'line_num': 2, - 'column_num': 5 - }, self._app.post_json( '/run_completer_command', goto_data ).json ) + eq_( test[ 'response' ], + self._app.post_json( '/run_completer_command', goto_data ).json ) def GetDoc_Method_test( self ): diff --git a/ycmd/tests/python/testdata/goto_file1.py b/ycmd/tests/python/testdata/goto_file1.py new file mode 100644 index 0000000000..7573ea1e4e --- /dev/null +++ b/ycmd/tests/python/testdata/goto_file1.py @@ -0,0 +1,2 @@ +from goto_file2 import foo +foo diff --git a/ycmd/tests/python/testdata/goto_file2.py b/ycmd/tests/python/testdata/goto_file2.py new file mode 100644 index 0000000000..dd02fd0ced --- /dev/null +++ b/ycmd/tests/python/testdata/goto_file2.py @@ -0,0 +1 @@ +from goto_file3 import bar as foo diff --git a/ycmd/tests/python/testdata/goto_file3.py b/ycmd/tests/python/testdata/goto_file3.py new file mode 100644 index 0000000000..5be982ddee --- /dev/null +++ b/ycmd/tests/python/testdata/goto_file3.py @@ -0,0 +1,2 @@ +def bar(): + pass diff --git a/ycmd/tests/python/testdata/goto_file4.py b/ycmd/tests/python/testdata/goto_file4.py new file mode 100644 index 0000000000..eb235e09a4 --- /dev/null +++ b/ycmd/tests/python/testdata/goto_file4.py @@ -0,0 +1,2 @@ +from math import sin +sin diff --git a/ycmd/tests/python/testdata/goto_file5.py b/ycmd/tests/python/testdata/goto_file5.py new file mode 100644 index 0000000000..0cf868b903 --- /dev/null +++ b/ycmd/tests/python/testdata/goto_file5.py @@ -0,0 +1,4 @@ +class Foo(object): + pass + +Bar diff --git a/ycmd/tests/subcommands_test.py b/ycmd/tests/subcommands_test.py index 8ed3516169..584fcf7e9f 100644 --- a/ycmd/tests/subcommands_test.py +++ b/ycmd/tests/subcommands_test.py @@ -20,25 +20,26 @@ from nose.tools import eq_ from .handlers_test import Handlers_test - +from ycmd.tests.test_utils import DummyCompleter +from mock import patch class Subcommands_test( Handlers_test ): - def Basic_test( self ): - subcommands_data = self._BuildRequest( completer_target = 'python' ) + @patch( 'ycmd.tests.test_utils.DummyCompleter.GetSubcommandsMap', + return_value = { 'A': lambda x: x, 'B': lambda x: x, 'C': lambda x: x } ) + def Basic_test( self, *args ): + with self.PatchCompleter( DummyCompleter, 'dummy_filetype' ): + subcommands_data = self._BuildRequest( completer_target = 'dummy_filetype' ) - eq_( [ 'GetDoc', - 'GoTo', - 'GoToDeclaration', - 'GoToDefinition' ], - self._app.post_json( '/defined_subcommands', subcommands_data ).json ) + eq_( [ 'A', 'B', 'C' ], + self._app.post_json( '/defined_subcommands', subcommands_data ).json ) - def NoExplicitCompleterTargetSpecified_test( self ): - subcommands_data = self._BuildRequest( filetype = 'python' ) + @patch( 'ycmd.tests.test_utils.DummyCompleter.GetSubcommandsMap', + return_value = { 'A': lambda x: x, 'B': lambda x: x, 'C': lambda x: x } ) + def NoExplicitCompleterTargetSpecified_test( self, *args ): + with self.PatchCompleter( DummyCompleter, 'dummy_filetype' ): + subcommands_data = self._BuildRequest( filetype = 'dummy_filetype' ) - eq_( [ 'GetDoc', - 'GoTo', - 'GoToDeclaration', - 'GoToDefinition' ], - self._app.post_json( '/defined_subcommands', subcommands_data ).json ) + eq_( [ 'A', 'B', 'C' ], + self._app.post_json( '/defined_subcommands', subcommands_data ).json ) diff --git a/ycmd/tests/test_utils.py b/ycmd/tests/test_utils.py index 8e063e2551..2c19ad5080 100644 --- a/ycmd/tests/test_utils.py +++ b/ycmd/tests/test_utils.py @@ -19,6 +19,10 @@ # along with ycmd. If not, see . +from ycmd.completers.completer import Completer +from ycmd.responses import BuildCompletionData + + def BuildRequest( **kwargs ): filepath = kwargs[ 'filepath' ] if 'filepath' in kwargs else '/foo' contents = kwargs[ 'contents' ] if 'contents' in kwargs else '' @@ -47,3 +51,22 @@ def BuildRequest( **kwargs ): request[ key ] = value return request + + +class DummyCompleter( Completer ): + def __init__( self, user_options ): + super( DummyCompleter, self ).__init__( user_options ) + + + def SupportedFiletypes( self ): + return [] + + + def ComputeCandidatesInner( self, request_data ): + return [ BuildCompletionData( candidate ) + for candidate in self.CandidatesList() ] + + + # This method is here for testing purpose, so it can be mocked during tests + def CandidatesList( self ): + return []