Skip to content

Commit

Permalink
Add PipClientHandler implementation (#41)
Browse files Browse the repository at this point in the history
* Add ServerPipResource server interface implementation

This is a server resource implementation that manages a pip-based server
from dependencies provided through the "requirements.txt" file.

Also:
 - remote "GenericClientHandler.get_storage_path()" API and just use
   "GenericClientHanlder.storage_path()" instead which is provided by
   both AbstractPlugin and LanguageHandler
 - Document storage_path() and package_storage() of GenericClientHandler
  • Loading branch information
rchl authored Nov 30, 2020
1 parent 67adebc commit 448501a
Show file tree
Hide file tree
Showing 11 changed files with 204 additions and 27 deletions.
5 changes: 5 additions & 0 deletions docs/source/client_handlers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ NpmClientHandler
----------------

.. autoclass:: lsp_utils.NpmClientHandler

PipClientHandler
----------------

.. autoclass:: lsp_utils.PipClientHandler
5 changes: 5 additions & 0 deletions docs/source/server_resource_handlers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ ServerResourceInterface
-----------------------

.. autoclass:: lsp_utils.ServerResourceInterface

ServerPipResource
-----------------

.. autoclass:: lsp_utils.ServerPipResource
4 changes: 4 additions & 0 deletions st3/lsp_utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
from .api_wrapper_interface import ApiWrapperInterface
from .generic_client_handler import GenericClientHandler
from .npm_client_handler import NpmClientHandler
from .pip_client_handler import PipClientHandler
from .server_npm_resource import ServerNpmResource
from .server_pip_resource import ServerPipResource
from .server_resource_interface import ServerResourceInterface
from .server_resource_interface import ServerStatus

Expand All @@ -13,9 +15,11 @@
'ClientHandler',
'GenericClientHandler',
'NpmClientHandler',
'PipClientHandler',
'ServerResourceInterface',
'ServerStatus'
'ServerNpmResource',
'ServerPipResource',
'notification_handler',
'request_handler',
]
4 changes: 0 additions & 4 deletions st3/lsp_utils/_client_handler/abstract_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,6 @@ def get_default_settings_schema(cls) -> Dict[str, Any]:
'settings': {},
}

@classmethod
def get_storage_path(cls) -> str:
return cls.storage_path()

@classmethod
def on_settings_read_internal(cls, settings: sublime.Settings) -> None:
settings.set('enabled', True)
Expand Down
5 changes: 0 additions & 5 deletions st3/lsp_utils/_client_handler/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,6 @@ def get_displayed_name(cls) -> str:
def package_storage(cls) -> str:
...

@classmethod
@abstractmethod
def get_storage_path(cls) -> str:
...

@classmethod
@abstractmethod
def get_additional_variables(cls) -> Dict[str, str]:
Expand Down
6 changes: 1 addition & 5 deletions st3/lsp_utils/_client_handler/language_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def setup(cls) -> None:

def perform_install() -> None:
try:
message = '{}: Installing server in path: {}'.format(name, cls.get_storage_path())
message = '{}: Installing server in path: {}'.format(name, cls.storage_path())
log_and_show_message(message, show_in_status=False)
with ActivityIndicator(sublime.active_window(), message):
server.install_or_update()
Expand All @@ -143,10 +143,6 @@ def get_default_settings_schema(cls) -> Dict[str, Any]:
'settings': {},
}

@classmethod
def get_storage_path(cls) -> str:
return cls.storage_path()

# --- Internals ---------------------------------------------------------------------------------------------------

def __init__(self):
Expand Down
13 changes: 12 additions & 1 deletion st3/lsp_utils/generic_client_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,20 @@ def get_displayed_name(cls) -> str:
"""
return cls.package_name

@classmethod
def storage_path(cls) -> str:
"""
The storage path. Use this as your base directory to install server files. Its path is '$DATA/Package Storage'.
You should have an additional subdirectory preferrably the same name as your plugin. For instance:
"""
return super().storage_path()

@classmethod
def package_storage(cls) -> str:
return os.path.join(cls.get_storage_path(), cls.package_name)
"""
The storage path for this package. Its path is '$DATA/Package Storage/[Package_Name]'.
"""
return os.path.join(cls.storage_path(), cls.package_name)

@classmethod
def get_command(cls) -> List[str]:
Expand Down
11 changes: 5 additions & 6 deletions st3/lsp_utils/helpers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from LSP.plugin.core.typing import Callable, List, Optional, Tuple
from LSP.plugin.core.typing import Any, Callable, List, Optional, Tuple, Union
import re
import sublime
import subprocess
Expand All @@ -8,7 +8,7 @@
SemanticVersion = Tuple[int, int, int]


def run_command_sync(args: List[str]) -> Tuple[str, Optional[str]]:
def run_command_sync(args: List[str], cwd: Optional[str] = None) -> Tuple[str, Optional[str]]:
"""
Runs the given command synchronously.
Expand All @@ -17,14 +17,13 @@ def run_command_sync(args: List[str]) -> Tuple[str, Optional[str]]:
command has succeeded then the second tuple element will be `None`.
"""
try:
output = subprocess.check_output(
args, shell=sublime.platform() == 'windows', stderr=subprocess.STDOUT)
output = subprocess.check_output(args, cwd=cwd, shell=sublime.platform() == 'windows', stderr=subprocess.STDOUT)
return (decode_bytes(output).strip(), None)
except subprocess.CalledProcessError as error:
return ('', decode_bytes(error.output).strip())


def run_command_async(args: List[str], on_success: StringCallback, on_error: StringCallback) -> None:
def run_command_async(args: List[str], on_success: StringCallback, on_error: StringCallback, **kwargs: Any) -> None:
"""
Runs the given command asynchronously.
Expand All @@ -33,7 +32,7 @@ def run_command_async(args: List[str], on_success: StringCallback, on_error: Str
"""

def execute(on_success, on_error, args):
result, error = run_command_sync(args)
result, error = run_command_sync(args, **kwargs)
on_error(error) if error is not None else on_success(result)

thread = threading.Thread(target=execute, args=(on_success, on_error, args))
Expand Down
7 changes: 1 addition & 6 deletions st3/lsp_utils/npm_client_handler.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from .generic_client_handler import GenericClientHandler
from .server_npm_resource import ServerNpmResource
from .server_resource_interface import ServerResourceInterface
from abc import abstractproperty
from LSP.plugin.core.typing import Any, Dict, List, Optional, Tuple
from LSP.plugin.core.typing import Dict, List, Optional, Tuple
import sublime

__all__ = ['NpmClientHandler']
Expand Down Expand Up @@ -91,7 +90,3 @@ def _server_directory_path(cls) -> str:
if cls.__server:
return cls.__server.server_directory_path
return ''

def __init__(self, *args: Any, **kwargs: Any) -> None:
# Seems unnecessary to override but it's to hide the original argument from the documentation.
super().__init__(*args, **kwargs)
52 changes: 52 additions & 0 deletions st3/lsp_utils/pip_client_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from .generic_client_handler import GenericClientHandler
from .server_pip_resource import ServerPipResource
from .server_resource_interface import ServerResourceInterface
from LSP.plugin.core.typing import Any, Optional

__all__ = ['PipClientHandler']


class PipClientHandler(GenericClientHandler):
"""
An implementation of :class:`GenericClientHandler` for handling pip-based LSP plugins.
Automatically manages a pip-based server by installing and updating dependencies based on provided
`requirements.txt` file.
"""
__server = None # type: Optional[ServerPipResource]

requirements_txt_path = ''
"""
The path to the `requirements.txt` file containing a list of dependencies required by the server.
If the package `LSP-foo` has a `requirements.txt` file at the root then the path will be just `requirements.txt`.
The file format is `dependency_name==dependency_version` or just a direct path to the dependency (for example to
a github repo). For example:
.. code::
pyls==0.1.2
colorama==1.2.2
git+https://github.com/tomv564/pyls-mypy.git
:required: Yes
"""

server_filename = ''
"""
The file name of the binary used to start the server.
:required: Yes
"""

@classmethod
def manages_server(cls) -> bool:
return True

@classmethod
def get_server(cls) -> Optional[ServerResourceInterface]:
if not cls.__server:
cls.__server = ServerPipResource(cls.storage_path(), cls.package_name, cls.requirements_txt_path,
cls.server_filename)
return cls.__server
119 changes: 119 additions & 0 deletions st3/lsp_utils/server_pip_resource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
from .helpers import run_command_sync
from .server_resource_interface import ServerResourceInterface
from .server_resource_interface import ServerStatus
from LSP.plugin.core.typing import Dict, List, Optional, Tuple
from sublime_lib import ResourcePath
import os
import shutil
import sublime

__all__ = ['ServerPipResource']


class ServerPipResource(ServerResourceInterface):
"""
An implementation of :class:`lsp_utils.ServerResourceInterface` implementing server management for
pip-based servers. Handles installation and updates of the server in the package storage.
:param storage_path: The path to the package storage (pass :meth:`lsp_utils.GenericClientHandler.storage_path()`)
:param package_name: The package name (used as a directory name for storage)
:param requirements_path: The path to the `requirements.txt` file, relative to the package directory.
If the package `LSP-foo` has a `requirements.txt` file at the root then the path will be `requirements.txt`.
:param server_binary_filename: The name of the file used to start the server.
"""

@classmethod
def file_extension(cls) -> str:
return '.exe' if sublime.platform() == 'windows' else ''

@classmethod
def python_exe(cls) -> str:
return 'python' if sublime.platform() == 'windows' else 'python3'

@classmethod
def run(cls, *args, cwd: Optional[str] = None) -> str:
output, error = run_command_sync(list(args), cwd=cwd)
if error:
raise Exception(error)
return output

def __init__(self, storage_path: str, package_name: str, requirements_path: str,
server_binary_filename: str) -> None:
self._storage_path = storage_path
self._package_name = package_name
self._requirements_path = 'Packages/{}/{}'.format(self._package_name, requirements_path)
self._server_binary_filename = server_binary_filename
self._status = ServerStatus.UNINITIALIZED

def parse_requirements(self, requirements_path: str) -> List[Tuple[str, Optional[str]]]:
requirements = [] # type: List[Tuple[str, Optional[str]]]
lines = [line.strip() for line in ResourcePath(self._requirements_path).read_text().splitlines()]
for line in lines:
if line:
parts = line.split('==')
if len(parts) == 2:
requirements.append(tuple(parts))
elif len(parts) == 1:
requirements.append((parts[0], None))
return requirements

def basedir(self) -> str:
return os.path.join(self._storage_path, self._package_name)

def bindir(self) -> str:
bin_dir = 'Scripts' if sublime.platform() == 'windows' else 'bin'
return os.path.join(self.basedir(), bin_dir)

def server_exe(self) -> str:
return os.path.join(self.bindir(), self._server_binary_filename + self.file_extension())

def pip_exe(self) -> str:
return os.path.join(self.bindir(), 'pip' + self.file_extension())

def python_version(self) -> str:
return os.path.join(self.basedir(), 'python_version')

# --- ServerResourceInterface handlers ----------------------------------------------------------------------------

@property
def binary_path(self) -> str:
return self.server_exe()

def needs_installation(self) -> bool:
if os.path.exists(self.server_exe()) and os.path.exists(self.pip_exe()):
if not os.path.exists(self.python_version()):
return True
with open(self.python_version(), 'r') as f:
if f.readline().strip() != self.run(self.python_exe(), '--version').strip():
return True
for line in self.run(self.pip_exe(), 'freeze').splitlines():
requirements = self.parse_requirements(self._requirements_path)
for requirement, version in requirements:
if not version:
continue
prefix = requirement + '=='
if line.startswith(prefix):
stored_version_str = line[len(prefix):].strip()
if stored_version_str != version:
return True
self._status = ServerStatus.READY
return False
return True

def install_or_update(self) -> None:
shutil.rmtree(self.basedir(), ignore_errors=True)
try:
os.makedirs(self.basedir(), exist_ok=True)
self.run(self.python_exe(), '-m', 'venv', self._package_name, cwd=self._storage_path)
dest_requirements_txt_path = os.path.join(self._storage_path, self._package_name, 'requirements.txt')
ResourcePath(self._requirements_path).copy(dest_requirements_txt_path)
self.run(self.pip_exe(), 'install', '-r', dest_requirements_txt_path, '--disable-pip-version-check')
with open(self.python_version(), 'w') as f:
f.write(self.run(self.python_exe(), '--version'))
except Exception:
shutil.rmtree(self.basedir(), ignore_errors=True)
raise
self._status = ServerStatus.READY

def get_status(self) -> int:
return self._status

0 comments on commit 448501a

Please sign in to comment.