diff --git a/docs/source/client_handlers.rst b/docs/source/client_handlers.rst index 416b8f1..88b4f6d 100644 --- a/docs/source/client_handlers.rst +++ b/docs/source/client_handlers.rst @@ -10,3 +10,8 @@ NpmClientHandler ---------------- .. autoclass:: lsp_utils.NpmClientHandler + +PipClientHandler +---------------- + +.. autoclass:: lsp_utils.PipClientHandler diff --git a/docs/source/server_resource_handlers.rst b/docs/source/server_resource_handlers.rst index 43ebb00..4c21a08 100644 --- a/docs/source/server_resource_handlers.rst +++ b/docs/source/server_resource_handlers.rst @@ -10,3 +10,8 @@ ServerResourceInterface ----------------------- .. autoclass:: lsp_utils.ServerResourceInterface + +ServerPipResource +----------------- + +.. autoclass:: lsp_utils.ServerPipResource diff --git a/st3/lsp_utils/__init__.py b/st3/lsp_utils/__init__.py index 5ded3f8..2476ecf 100644 --- a/st3/lsp_utils/__init__.py +++ b/st3/lsp_utils/__init__.py @@ -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 @@ -13,9 +15,11 @@ 'ClientHandler', 'GenericClientHandler', 'NpmClientHandler', + 'PipClientHandler', 'ServerResourceInterface', 'ServerStatus' 'ServerNpmResource', + 'ServerPipResource', 'notification_handler', 'request_handler', ] diff --git a/st3/lsp_utils/_client_handler/abstract_plugin.py b/st3/lsp_utils/_client_handler/abstract_plugin.py index 63d5609..28d9419 100644 --- a/st3/lsp_utils/_client_handler/abstract_plugin.py +++ b/st3/lsp_utils/_client_handler/abstract_plugin.py @@ -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) diff --git a/st3/lsp_utils/_client_handler/interface.py b/st3/lsp_utils/_client_handler/interface.py index fa92cb0..0ca10e2 100644 --- a/st3/lsp_utils/_client_handler/interface.py +++ b/st3/lsp_utils/_client_handler/interface.py @@ -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]: diff --git a/st3/lsp_utils/_client_handler/language_handler.py b/st3/lsp_utils/_client_handler/language_handler.py index ecba691..17c7e9b 100644 --- a/st3/lsp_utils/_client_handler/language_handler.py +++ b/st3/lsp_utils/_client_handler/language_handler.py @@ -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() @@ -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): diff --git a/st3/lsp_utils/generic_client_handler.py b/st3/lsp_utils/generic_client_handler.py index f12a9ae..266c07c 100644 --- a/st3/lsp_utils/generic_client_handler.py +++ b/st3/lsp_utils/generic_client_handler.py @@ -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]: diff --git a/st3/lsp_utils/helpers.py b/st3/lsp_utils/helpers.py index 0cd7a29..5557ed0 100644 --- a/st3/lsp_utils/helpers.py +++ b/st3/lsp_utils/helpers.py @@ -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 @@ -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. @@ -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. @@ -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)) diff --git a/st3/lsp_utils/npm_client_handler.py b/st3/lsp_utils/npm_client_handler.py index 43054d4..38d34c6 100644 --- a/st3/lsp_utils/npm_client_handler.py +++ b/st3/lsp_utils/npm_client_handler.py @@ -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'] @@ -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) diff --git a/st3/lsp_utils/pip_client_handler.py b/st3/lsp_utils/pip_client_handler.py new file mode 100644 index 0000000..6d959e6 --- /dev/null +++ b/st3/lsp_utils/pip_client_handler.py @@ -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 diff --git a/st3/lsp_utils/server_pip_resource.py b/st3/lsp_utils/server_pip_resource.py new file mode 100644 index 0000000..1414552 --- /dev/null +++ b/st3/lsp_utils/server_pip_resource.py @@ -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