From b0a15cc5864e19752ab59f6a2781346cdebf3de5 Mon Sep 17 00:00:00 2001 From: Rafal Chlodnicki Date: Thu, 4 Jan 2024 00:46:01 +0100 Subject: [PATCH] feat: integrate lsp_utils --- LSP.sublime-settings | 17 + Makefile | 21 + api/__init__.py | 25 + api/_client_handler/__init__.py | 9 + api/_client_handler/abstract_plugin.py | 155 ++ api/_client_handler/api_decorator.py | 86 ++ api/_client_handler/interface.py | 99 ++ api/_util/__init__.py | 5 + api/_util/weak_method.py | 34 + api/api_wrapper_interface.py | 46 + api/generic_client_handler.py | 204 +++ api/helpers.py | 95 ++ api/node_runtime.py | 511 ++++++ api/npm_client_handler.py | 168 ++ api/pip_client_handler.py | 77 + api/server_npm_resource.py | 140 ++ api/server_pip_resource.py | 109 ++ api/server_resource_interface.py | 66 + dependencies.json | 1 + docs/api/api_handler.rst | 30 + docs/api/client_handlers.rst | 17 + docs/api/conf.py | 135 ++ docs/api/index.rst | 35 + docs/api/server_resource_handlers.rst | 17 + docs/api/utilities.rst | 4 + plugin/core/constants.py | 2 + plugin/core/settings.py | 7 +- plugin/core/types.py | 3 +- plugin/tooling.py | 3 +- sublime-package.json | 25 + third_party/semantic_version/LICENSE | 22 + third_party/semantic_version/README.rst | 279 ++++ third_party/semantic_version/__init__.py | 9 + third_party/semantic_version/base.py | 1449 ++++++++++++++++++ third_party/semantic_version/update-info.log | 2 + 35 files changed, 3902 insertions(+), 5 deletions(-) create mode 100644 Makefile create mode 100644 api/__init__.py create mode 100644 api/_client_handler/__init__.py create mode 100644 api/_client_handler/abstract_plugin.py create mode 100644 api/_client_handler/api_decorator.py create mode 100644 api/_client_handler/interface.py create mode 100644 api/_util/__init__.py create mode 100644 api/_util/weak_method.py create mode 100644 api/api_wrapper_interface.py create mode 100644 api/generic_client_handler.py create mode 100644 api/helpers.py create mode 100755 api/node_runtime.py create mode 100644 api/npm_client_handler.py create mode 100644 api/pip_client_handler.py create mode 100644 api/server_npm_resource.py create mode 100644 api/server_pip_resource.py create mode 100644 api/server_resource_interface.py create mode 100644 docs/api/api_handler.rst create mode 100644 docs/api/client_handlers.rst create mode 100644 docs/api/conf.py create mode 100644 docs/api/index.rst create mode 100644 docs/api/server_resource_handlers.rst create mode 100644 docs/api/utilities.rst create mode 100644 third_party/semantic_version/LICENSE create mode 100644 third_party/semantic_version/README.rst create mode 100644 third_party/semantic_version/__init__.py create mode 100644 third_party/semantic_version/base.py create mode 100644 third_party/semantic_version/update-info.log diff --git a/LSP.sublime-settings b/LSP.sublime-settings index baf277228..f86a52acf 100644 --- a/LSP.sublime-settings +++ b/LSP.sublime-settings @@ -220,6 +220,23 @@ // "region", ], + // --- LSP API------------------------------------------------------------------------- + + // Specifies the type and priority of the Node.js installation that should be used for Node.js-based servers + // using the NpmClientHandler. + // The allowed values are: + // - 'system' - a Node.js runtime found on the PATH + // - 'local' - a Node.js runtime managed by LSP that doesn't affect the system + // The order in which the values are specified determines which one is tried first, with the later one being + // used as a fallback. + // You can also specify just a single value to disable the fallback. + "nodejs_runtime": ["system", "local"], + + // Uses Node.js runtime from the Electron package rather than the official distribution. This has the benefit of + // lower memory usage due to it having the pointer compression (https://v8.dev/blog/pointer-compression) enabled. + // Only relevant when using `local` variant of `nodejs_runtime`. + "local_use_electron": false, + // --- Debugging ---------------------------------------------------------------------- // Show verbose debug messages in the sublime console. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..5e8b0baa0 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +# Minimal makefile for Sphinx documentation + +# You can set these variables from the command line. +SOURCEDIR = api +BUILDDIR = dist + +.PHONY: clean + +install: + pip3 install sphinx sphinx-rtd-theme sphinx-autodoc-typehints ghp-import mkdocs mkdocs-material mkdocs-redirects +# sed -i -E 's/sublime\.DRAW_[A-Z_]*/0/g' source/modules/LSP/plugin/core/views.py +# sed -i -E 's/sublime\.HIDE_ON_MINIMAP/0/g' source/modules/LSP/plugin/core/views.py + +build: + sphinx-build -M html "$(SOURCEDIR)" "$(BUILDDIR)" + +deploy: + ghp-import --no-jekyll --push --force html + +clean: + rm -rf doctrees html diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 000000000..6bf5a6d72 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,25 @@ +from ._client_handler import ClientHandler +from ._client_handler import notification_handler +from ._client_handler import request_handler +from .api_wrapper_interface import ApiWrapperInterface +from .generic_client_handler import GenericClientHandler +from .node_runtime import NodeRuntime +from .npm_client_handler import NpmClientHandler +from .server_npm_resource import ServerNpmResource +from .server_pip_resource import ServerPipResource +from .server_resource_interface import ServerResourceInterface +from .server_resource_interface import ServerStatus + +__all__ = [ + 'ApiWrapperInterface', + 'ClientHandler', + 'GenericClientHandler', + 'NodeRuntime', + 'NpmClientHandler', + 'ServerResourceInterface', + 'ServerStatus', + 'ServerNpmResource', + 'ServerPipResource', + 'notification_handler', + 'request_handler', +] diff --git a/api/_client_handler/__init__.py b/api/_client_handler/__init__.py new file mode 100644 index 000000000..6c03556e2 --- /dev/null +++ b/api/_client_handler/__init__.py @@ -0,0 +1,9 @@ +from .abstract_plugin import ClientHandler +from .api_decorator import notification_handler +from .api_decorator import request_handler + +__all__ = [ + 'ClientHandler', + 'notification_handler', + 'request_handler', +] diff --git a/api/_client_handler/abstract_plugin.py b/api/_client_handler/abstract_plugin.py new file mode 100644 index 000000000..0016c9cc4 --- /dev/null +++ b/api/_client_handler/abstract_plugin.py @@ -0,0 +1,155 @@ +from .._util import weak_method +from ..api_wrapper_interface import ApiWrapperInterface +from ..server_resource_interface import ServerStatus +from .api_decorator import register_decorated_handlers +from .interface import ClientHandlerInterface +from functools import partial +from LSP.plugin import AbstractPlugin +from LSP.plugin import ClientConfig +from LSP.plugin import Notification +from LSP.plugin import register_plugin +from LSP.plugin import Request +from LSP.plugin import Response +from LSP.plugin import Session +from LSP.plugin import unregister_plugin +from LSP.plugin import WorkspaceFolder +from LSP.plugin.core.rpc import method2attr +from LSP.plugin.core.typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict +from os import path +from weakref import ref +import sublime + +__all__ = ['ClientHandler'] + +LanguagesDict = TypedDict('LanguagesDict', { + 'document_selector': Optional[str], + 'languageId': Optional[str], + 'scopes': Optional[List[str]], + 'syntaxes': Optional[List[str]], +}, total=False) +ApiNotificationHandler = Callable[[Any], None] +ApiRequestHandler = Callable[[Any, Callable[[Any], None]], None] + + +class ApiWrapper(ApiWrapperInterface): + def __init__(self, plugin: 'ref[AbstractPlugin]'): + self.__plugin = plugin + + def __session(self) -> Optional[Session]: + plugin = self.__plugin() + return plugin.weaksession() if plugin else None + + # --- ApiWrapperInterface ----------------------------------------------------------------------------------------- + + def on_notification(self, method: str, handler: ApiNotificationHandler) -> None: + def handle_notification(weak_handler: ApiNotificationHandler, params: Any) -> None: + weak_handler(params) + + plugin = self.__plugin() + if plugin: + setattr(plugin, method2attr(method), partial(handle_notification, weak_method(handler))) + + def on_request(self, method: str, handler: ApiRequestHandler) -> None: + def send_response(request_id: Any, result: Any) -> None: + session = self.__session() + if session: + session.send_response(Response(request_id, result)) + + def on_response(weak_handler: ApiRequestHandler, params: Any, request_id: Any) -> None: + weak_handler(params, lambda result: send_response(request_id, result)) + + plugin = self.__plugin() + if plugin: + setattr(plugin, method2attr(method), partial(on_response, weak_method(handler))) + + def send_notification(self, method: str, params: Any) -> None: + session = self.__session() + if session: + session.send_notification(Notification(method, params)) + + def send_request(self, method: str, params: Any, handler: Callable[[Any, bool], None]) -> None: + session = self.__session() + if session: + session.send_request( + Request(method, params), lambda result: handler(result, False), lambda result: handler(result, True)) + else: + handler(None, True) + + +class ClientHandler(AbstractPlugin, ClientHandlerInterface): + """ + The base class for creating an LSP plugin. + """ + + # --- AbstractPlugin handlers ------------------------------------------------------------------------------------- + + @classmethod + def name(cls) -> str: + return cls.get_displayed_name() + + @classmethod + def configuration(cls) -> Tuple[sublime.Settings, str]: + return cls.read_settings() + + @classmethod + def additional_variables(cls) -> Dict[str, str]: + return cls.get_additional_variables() + + @classmethod + def needs_update_or_installation(cls) -> bool: + if cls.manages_server(): + server = cls.get_server() + return bool(server and server.needs_installation()) + return False + + @classmethod + def install_or_update(cls) -> None: + server = cls.get_server() + if server: + server.install_or_update() + + @classmethod + def can_start(cls, window: sublime.Window, initiating_view: sublime.View, + workspace_folders: List[WorkspaceFolder], configuration: ClientConfig) -> Optional[str]: + if cls.manages_server(): + server = cls.get_server() + if not server or server.get_status() == ServerStatus.ERROR: + return "{}: Error installing server dependencies.".format(cls.package_name) + if server.get_status() != ServerStatus.READY: + return "{}: Server installation in progress...".format(cls.package_name) + message = cls.is_allowed_to_start(window, initiating_view, workspace_folders, configuration) + if message: + return message + # Lazily update command after server has initialized if not set manually by the user. + if not configuration.command: + configuration.command = cls.get_command() + return None + + @classmethod + def on_pre_start(cls, window: sublime.Window, initiating_view: sublime.View, + workspace_folders: List[WorkspaceFolder], configuration: ClientConfig) -> Optional[str]: + extra_paths = path.pathsep.join(cls.get_additional_paths()) + if extra_paths: + original_path = configuration.env.get('PATH') or '' + if isinstance(original_path, list): + original_path = path.pathsep.join(original_path) + configuration.env['PATH'] = path.pathsep.join([extra_paths, original_path]) + return None + + # --- ClientHandlerInterface -------------------------------------------------------------------------------------- + + @classmethod + def setup(cls) -> None: + register_plugin(cls) + + @classmethod + def cleanup(cls) -> None: + unregister_plugin(cls) + + # --- Internals --------------------------------------------------------------------------------------------------- + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + api = ApiWrapper(ref(self)) # type: ignore + register_decorated_handlers(self, api) + self.on_ready(api) diff --git a/api/_client_handler/api_decorator.py b/api/_client_handler/api_decorator.py new file mode 100644 index 000000000..7af3b31e1 --- /dev/null +++ b/api/_client_handler/api_decorator.py @@ -0,0 +1,86 @@ +from ..api_wrapper_interface import ApiWrapperInterface +from .interface import ClientHandlerInterface +from LSP.plugin.core.typing import Any, Callable, List, Optional, TypeVar, Union +import inspect + +__all__ = [ + "notification_handler", + "request_handler", + "register_decorated_handlers", +] + +T = TypeVar('T') +# the first argument is always "self" +NotificationHandler = Callable[[Any, Any], None] +RequestHandler = Callable[[Any, Any, Callable[[Any], None]], None] +MessageMethods = Union[str, List[str]] + +_HANDLER_MARKS = { + "notification": "__handle_notification_message_methods", + "request": "__handle_request_message_methods", +} + + +def notification_handler(notification_methods: MessageMethods) -> Callable[[NotificationHandler], NotificationHandler]: + """ + Marks the decorated function as a "notification" message handler. + + On server sending the notification, the decorated function will be called with the `params` argument which contains + the payload. + """ + + return _create_handler("notification", notification_methods) + + +def request_handler(request_methods: MessageMethods) -> Callable[[RequestHandler], RequestHandler]: + """ + Marks the decorated function as a "request" message handler. + + On server sending the request, the decorated function will be called with two arguments (`params` and `respond`). + The first argument (`params`) is the payload of the request and the second argument (`respond`) is the function that + must be used to respond to the request. The `respond` function takes any data that should be sent back to the + server. + """ + + return _create_handler("request", request_methods) + + +def _create_handler(client_event: str, message_methods: MessageMethods) -> Callable[[T], T]: + """ Marks the decorated function as a message handler. """ + + message_methods = [message_methods] if isinstance(message_methods, str) else message_methods + + def decorator(func: T) -> T: + setattr(func, _HANDLER_MARKS[client_event], message_methods) + return func + + return decorator + + +def register_decorated_handlers(client_handler: ClientHandlerInterface, api: ApiWrapperInterface) -> None: + """ + Register decorator-style custom message handlers. + + This method works as following: + + 1. Scan through all methods of `client_handler`. + 2. If a method is decorated, it will have a "handler mark" attribute which is set by the decorator. + 3. Register the method with wanted message methods, which are stored in the "handler mark" attribute. + + :param client_handler: The instance of the client handler. + :param api: The API instance for interacting with the server. + """ + for _, func in inspect.getmembers(client_handler, predicate=inspect.isroutine): + for client_event, handler_mark in _HANDLER_MARKS.items(): + message_methods = getattr(func, handler_mark, None) # type: Optional[List[str]] + if message_methods is None: + continue + + event_registrator = getattr(api, "on_" + client_event, None) + if callable(event_registrator): + for message_method in message_methods: + event_registrator(message_method, func) + + # it makes no sense that a handler handles both "notification" and "request" + # so we do early break once we've registered a handler + break diff --git a/api/_client_handler/interface.py b/api/_client_handler/interface.py new file mode 100644 index 000000000..151fda870 --- /dev/null +++ b/api/_client_handler/interface.py @@ -0,0 +1,99 @@ +from ..api_wrapper_interface import ApiWrapperInterface +from ..server_resource_interface import ServerResourceInterface +from abc import ABCMeta +from abc import abstractmethod +from LSP.plugin import ClientConfig +from LSP.plugin import DottedDict +from LSP.plugin import WorkspaceFolder +from LSP.plugin.core.typing import Dict, List, Optional, Tuple +import sublime + +__all__ = ['ClientHandlerInterface'] + + +class ClientHandlerInterface(metaclass=ABCMeta): + package_name = '' + + @classmethod + @abstractmethod + def setup(cls) -> None: + ... + + @classmethod + @abstractmethod + def cleanup(cls) -> None: + ... + + @classmethod + @abstractmethod + def get_displayed_name(cls) -> str: + ... + + @classmethod + @abstractmethod + def package_storage(cls) -> str: + ... + + @classmethod + @abstractmethod + def get_additional_variables(cls) -> Dict[str, str]: + ... + + @classmethod + @abstractmethod + def get_additional_paths(cls) -> List[str]: + ... + + @classmethod + @abstractmethod + def manages_server(cls) -> bool: + ... + + @classmethod + @abstractmethod + def get_command(cls) -> List[str]: + ... + + @classmethod + @abstractmethod + def binary_path(cls) -> str: + ... + + @classmethod + @abstractmethod + def get_server(cls) -> Optional[ServerResourceInterface]: + ... + + @classmethod + @abstractmethod + def get_binary_arguments(cls) -> List[str]: + ... + + @classmethod + @abstractmethod + def read_settings(cls) -> Tuple[sublime.Settings, str]: + ... + + @classmethod + @abstractmethod + def on_settings_read(cls, settings: sublime.Settings) -> bool: + ... + + @classmethod + @abstractmethod + def is_allowed_to_start( + cls, + window: sublime.Window, + initiating_view: Optional[sublime.View] = None, + workspace_folders: Optional[List[WorkspaceFolder]] = None, + configuration: Optional[ClientConfig] = None + ) -> Optional[str]: + ... + + @abstractmethod + def on_ready(self, api: ApiWrapperInterface) -> None: + ... + + @abstractmethod + def on_settings_changed(self, settings: DottedDict) -> None: + ... diff --git a/api/_util/__init__.py b/api/_util/__init__.py new file mode 100644 index 000000000..7eda31c72 --- /dev/null +++ b/api/_util/__init__.py @@ -0,0 +1,5 @@ +from .weak_method import weak_method + +__all__ = [ + 'weak_method', +] diff --git a/api/_util/weak_method.py b/api/_util/weak_method.py new file mode 100644 index 000000000..ff3bbdb2d --- /dev/null +++ b/api/_util/weak_method.py @@ -0,0 +1,34 @@ +from LSP.plugin.core.typing import Any, Callable +from types import MethodType +import weakref + + +__all__ = ['weak_method'] + + +# An implementation of weak method borrowed from sublime_lib [1] +# +# We need it to be able to weak reference bound methods as `weakref.WeakMethod` is not available in +# 3.3 runtime. +# +# The reason this is necessary is explained in the documentation of `weakref.WeakMethod`: +# > A custom ref subclass which simulates a weak reference to a bound method (i.e., a method defined +# > on a class and looked up on an instance). Since a bound method is ephemeral, a standard weak +# > reference cannot keep hold of it. +# +# [1] https://github.com/SublimeText/sublime_lib/blob/master/st3/sublime_lib/_util/weak_method.py + +def weak_method(method: Callable[..., Any]) -> Callable[..., Any]: + assert isinstance(method, MethodType) + self_ref = weakref.ref(method.__self__) + function_ref = weakref.ref(method.__func__) + + def wrapped(*args: Any, **kwargs: Any) -> Any: + self = self_ref() + fn = function_ref() + if self is None or fn is None: + print('[LSP.api] Error: weak_method not called due to a deleted reference', [self, fn]) + return + return fn(self, *args, **kwargs) # type: ignore + + return wrapped diff --git a/api/api_wrapper_interface.py b/api/api_wrapper_interface.py new file mode 100644 index 000000000..e13a2687b --- /dev/null +++ b/api/api_wrapper_interface.py @@ -0,0 +1,46 @@ +from abc import ABCMeta, abstractmethod +from LSP.plugin.core.typing import Any, Callable + +__all__ = ['ApiWrapperInterface'] + + +NotificationHandler = Callable[[Any], None] +RequestHandler = Callable[[Any, Callable[[Any], None]], None] + + +class ApiWrapperInterface(metaclass=ABCMeta): + """ + An interface for sending and receiving requests and notifications from and to the server. An implementation of it + is available through the :func:`GenericClientHandler.on_ready()` override. + """ + + @abstractmethod + def on_notification(self, method: str, handler: NotificationHandler) -> None: + """ + Registers a handler for given notification name. The handler will be called with optional params. + """ + ... + + @abstractmethod + def on_request(self, method: str, handler: RequestHandler) -> None: + """ + Registers a handler for given request name. The handler will be called with two arguments - first the params + sent with the request and second the function that must be used to respond to the request. The response + function takes params to respond with. + """ + ... + + @abstractmethod + def send_notification(self, method: str, params: Any) -> None: + """ + Sends a notification to the server. + """ + ... + + @abstractmethod + def send_request(self, method: str, params: Any, handler: Callable[[Any, bool], None]) -> None: + """ + Sends a request to the server. The handler will be called with the result received from the server and + a boolean value `False` if request has succeeded and `True` if it returned an error. + """ + ... diff --git a/api/generic_client_handler.py b/api/generic_client_handler.py new file mode 100644 index 000000000..8e86c43b4 --- /dev/null +++ b/api/generic_client_handler.py @@ -0,0 +1,204 @@ +from ._client_handler import ClientHandler +from .api_wrapper_interface import ApiWrapperInterface +from .helpers import rmtree_ex +from .server_resource_interface import ServerResourceInterface +from abc import ABCMeta +from LSP.plugin import ClientConfig +from LSP.plugin import DottedDict +from LSP.plugin import WorkspaceFolder +from LSP.plugin.core.typing import Any, Dict, List, Optional, Tuple +from package_control import events # type: ignore +import os +import sublime + +__all__ = ['GenericClientHandler'] + + +class GenericClientHandler(ClientHandler, metaclass=ABCMeta): + """ + An generic implementation of an LSP plugin handler. + """ + + package_name = '' + """ + The name of the released package. Also used for the name of the LSP client and for reading package settings. + + This name must be set and must match the basename of the corresponding `*.sublime-settings` file. + It's also used as a directory name for package storage when implementing a server resource interface. + Recommended to use `__package__` value fo this one. If you need to override handler name in the UI, + override :meth:`get_displayed_name()` also. + + :required: Yes + """ + + # --- ClientHandler handlers -------------------------------------------------------------------------------------- + + @classmethod + def setup(cls) -> None: + if not cls.package_name: + raise Exception('ERROR: [LSP.api] package_name is required to instantiate an instance of {}'.format(cls)) + super().setup() + + @classmethod + def cleanup(cls) -> None: + + def run_async() -> None: + if os.path.isdir(cls.package_storage()): + rmtree_ex(cls.package_storage()) + + if events.remove(cls.package_name): + sublime.set_timeout_async(run_async, 1000) + super().cleanup() + + @classmethod + def get_displayed_name(cls) -> str: + """ + Returns the name that will be shown in the ST UI (for example in the status field). + + Defaults to the value of :attr:`package_name`. + """ + 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'. + """ + return super().storage_path() + + @classmethod + def package_storage(cls) -> str: + """ + 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]: + """ + Returns a list of arguments to use to start the server. The default implementation returns combined result of + :meth:`binary_path()` and :meth:`get_binary_arguments()`. + """ + return [cls.binary_path()] + cls.get_binary_arguments() + + @classmethod + def binary_path(cls) -> str: + """ + The filesystem path to the server executable. + + The default implementation returns `binary_path` property of the server instance (returned from + :meth:`get_server()`), if available. + """ + if cls.manages_server(): + server = cls.get_server() + if server: + return server.binary_path + return '' + + @classmethod + def get_binary_arguments(cls) -> List[str]: + """ + Returns a list of extra arguments to append to the `command` when starting the server. + + See :meth:`get_command()`. + """ + return [] + + @classmethod + def read_settings(cls) -> Tuple[sublime.Settings, str]: + filename = "{}.sublime-settings".format(cls.package_name) + loaded_settings = sublime.load_settings(filename) + changed = cls.on_settings_read(loaded_settings) + if changed: + sublime.save_settings(filename) + filepath = "Packages/{}/{}".format(cls.package_name, filename) + return (loaded_settings, filepath) + + @classmethod + def get_additional_variables(cls) -> Dict[str, str]: + """ + Override to add more variables here to be expanded when reading settings. + + Default implementation adds a `${server_path}` variable that holds filesystem path to the server + binary (only when :meth:`manages_server` is `True`). + + Remember to call the super class and merge the results if overriding. + """ + return { + 'pathsep': os.pathsep, + 'server_path': cls.binary_path(), + } + + @classmethod + def get_additional_paths(cls) -> List[str]: + """ + Override to prepend additional paths to the default PATH environment variable. + + Remember to call the super class and merge the results if overriding. + """ + return [] + + @classmethod + def manages_server(cls) -> bool: + """ + Whether this handler manages a server. If the response is `True` then the :meth:`get_server()` should also be + implemented. + """ + return False + + @classmethod + def get_server(cls) -> Optional[ServerResourceInterface]: + """ + :returns: The instance of the server managed by this plugin. Only used when :meth:`manages_server()` + returns `True`. + """ + return None + + @classmethod + def on_settings_read(cls, settings: sublime.Settings) -> bool: + """ + Called when package settings were read. Receives a `sublime.Settings` object. + + It's recommended to use :meth:`on_settings_changed()` instead if you don't need to persistent your changes to + the disk. + + :returns: `True` to save modifications back into the settings file. + """ + return False + + @classmethod + def is_allowed_to_start( + cls, + window: sublime.Window, + initiating_view: Optional[sublime.View] = None, + workspace_folders: Optional[List[WorkspaceFolder]] = None, + configuration: Optional[ClientConfig] = None + ) -> Optional[str]: + """ + Determines if the session is allowed to start. + + :returns: A string describing the reason why we should not start a language server session, or `None` if we + should go ahead and start a session. + """ + return None + + 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) + + def on_ready(self, api: ApiWrapperInterface) -> None: + """ + Called when the instance is ready. + + :param api: The API instance for interacting with the server. + """ + pass + + def on_settings_changed(self, settings: DottedDict) -> None: + """ + Override this method to alter the settings that are returned to the server for the + workspace/didChangeConfiguration notification and the workspace/configuration requests. + + :param settings: The settings that the server should receive. + """ + pass diff --git a/api/helpers.py b/api/helpers.py new file mode 100644 index 000000000..c87f1df39 --- /dev/null +++ b/api/helpers.py @@ -0,0 +1,95 @@ +from LSP.plugin.core.typing import Any, Callable, Dict, List, Optional, Tuple +import os +import shutil +import sublime +import subprocess +import threading + +StringCallback = Callable[[str], None] +SemanticVersion = Tuple[int, int, int] + +is_windows = sublime.platform() == 'windows' + + +def run_command_sync( + args: List[str], + cwd: Optional[str] = None, + extra_env: Optional[Dict[str, str]] = None, + extra_paths: List[str] = [], + shell: bool = is_windows, +) -> Tuple[str, Optional[str]]: + """ + Runs the given command synchronously. + + :returns: A two-element tuple with the returned value and an optional error. If running the command has failed, the + first tuple element will be empty string and the second will contain the potential `stderr` output. If the + command has succeeded then the second tuple element will be `None`. + """ + try: + env = None + if extra_env or extra_paths: + env = os.environ.copy() + if extra_env: + env.update(extra_env) + if extra_paths: + env['PATH'] = os.path.pathsep.join(extra_paths) + os.path.pathsep + env['PATH'] + startupinfo = None + if is_windows: + startupinfo = subprocess.STARTUPINFO() # type: ignore + startupinfo.dwFlags |= subprocess.SW_HIDE | subprocess.STARTF_USESHOWWINDOW # type: ignore + output = subprocess.check_output( + args, cwd=cwd, shell=shell, stderr=subprocess.STDOUT, env=env, startupinfo=startupinfo) + 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, **kwargs: Any) -> None: + """ + Runs the given command asynchronously. + + On success calls the provided `on_success` callback with the value the the command has returned. + On error calls the provided `on_error` callback with the potential `stderr` output. + """ + + def execute(on_success: StringCallback, on_error: StringCallback, args: List[str]) -> None: + 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)) + thread.start() + + +def decode_bytes(data: bytes) -> str: + """ + Decodes provided bytes using `utf-8` decoding, ignoring potential decoding errors. + """ + return data.decode('utf-8', 'ignore') + + +def rmtree_ex(path: str, ignore_errors: bool = False) -> None: + # On Windows, "shutil.rmtree" will raise file not found errors when deleting a long path (>255 chars). + # See https://stackoverflow.com/a/14076169/4643765 + # See https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation + path = R'\\?\{}'.format(path) if sublime.platform() == 'windows' else path + shutil.rmtree(path, ignore_errors) + + +def version_to_string(version: SemanticVersion) -> str: + """ + Returns a string representation of a version tuple. + """ + return '.'.join([str(c) for c in version]) + + +def log_and_show_message(message: str, additional_logs: Optional[str] = None, show_in_status: bool = True) -> None: + """ + Logs the message in the console and optionally sets it as a status message on the window. + + :param message: The message to log or show in the status. + :param additional_logs: The extra value to log on a separate line. + :param show_in_status: Whether to briefly show the message in the status bar of the current window. + """ + print(message, '\n', additional_logs) if additional_logs else print(message) + if show_in_status: + sublime.active_window().status_message(message) diff --git a/api/node_runtime.py b/api/node_runtime.py new file mode 100755 index 000000000..cadd7b97c --- /dev/null +++ b/api/node_runtime.py @@ -0,0 +1,511 @@ +from .helpers import rmtree_ex +from .helpers import run_command_sync +from .helpers import SemanticVersion +from .helpers import version_to_string +from contextlib import contextmanager +from LSP.plugin.core.constants import SUBLIME_SETTINGS_FILENAME +from LSP.plugin.core.logging import debug +from LSP.plugin.core.typing import cast, Any, Dict, Generator, List, Optional, Tuple, Union +from LSP.third_party.semantic_version import NpmSpec, Version +from os import path +from os import remove +from sublime_lib import ActivityIndicator +import os +import shutil +import sublime +import subprocess +import sys +import tarfile +import urllib.request +import zipfile + +__all__ = ['NodeRuntime'] + +IS_WINDOWS_7_OR_LOWER = sys.platform == 'win32' and sys.getwindowsversion()[:2] <= (6, 1) # type: ignore + +NODE_RUNTIME_VERSION = '18.18.1' +NODE_DIST_URL = 'https://nodejs.org/dist/v{version}/{filename}' + +ELECTRON_RUNTIME_VERSION = '27.0.0' # includes Node.js v18.17.1 +ELECTRON_NODE_VERSION = '18.17.1' +ELECTRON_DIST_URL = 'https://github.com/electron/electron/releases/download/v{version}/{filename}' +YARN_URL = 'https://github.com/yarnpkg/yarn/releases/download/v1.22.21/yarn-1.22.21.js' + +NO_NODE_FOUND_MESSAGE = 'Could not start {package_name} due to not being able to resolve suitable Node.js \ +runtime on the PATH. Press the "Download Node.js" button to get required Node.js version \ +(note that it will be used only by LSP and will not affect your system otherwise).' + + +class NodeRuntime: + _node_runtime_resolved = False + _node_runtime = None # Optional[NodeRuntime] + """ + Cached instance of resolved Node.js runtime. This is only done once per-session to avoid unnecessary IO. + """ + + @classmethod + def get( + cls, package_name: str, storage_path: str, required_node_version: Union[str, SemanticVersion] + ) -> Optional['NodeRuntime']: + if isinstance(required_node_version, tuple): + required_semantic_version = NpmSpec('>={}'.format(version_to_string(required_node_version))) + else: + required_semantic_version = NpmSpec(required_node_version) + if cls._node_runtime_resolved: + if cls._node_runtime: + cls._node_runtime.check_satisfies_version(required_semantic_version) + return cls._node_runtime + cls._node_runtime_resolved = True + cls._node_runtime = cls._resolve_node_runtime(package_name, storage_path, required_semantic_version) + debug('Resolved Node.js Runtime for package {}: {}'.format(package_name, cls._node_runtime)) + return cls._node_runtime + + @classmethod + def _resolve_node_runtime( + cls, package_name: str, storage_path: str, required_node_version: NpmSpec + ) -> 'NodeRuntime': + resolved_runtime = None # type: Optional[NodeRuntime] + default_runtimes = ['system', 'local'] + settings = sublime.load_settings(SUBLIME_SETTINGS_FILENAME) + selected_runtimes = cast(List[str], settings.get('nodejs_runtime') or default_runtimes) + log_lines = ['--- LSP.api Node.js resolving start ---'] + for runtime_type in selected_runtimes: + if runtime_type == 'system': + log_lines.append('Resolving Node.js Runtime in env PATH for package {}...'.format(package_name)) + path_runtime = NodeRuntimePATH() + try: + path_runtime.check_binary_present() + except Exception as ex: + log_lines.append(' * Failed: {}'.format(ex)) + continue + try: + path_runtime.check_satisfies_version(required_node_version) + resolved_runtime = path_runtime + break + except Exception as ex: + log_lines.append(' * {}'.format(ex)) + elif runtime_type == 'local': + log_lines.append('Resolving Node.js Runtime from LSP.api for package {}...'.format(package_name)) + use_electron = cast(bool, settings.get('local_use_electron') or False) + runtime_dir = path.join(storage_path, 'LSP', 'node-runtime') + local_runtime = ElectronRuntimeLocal(runtime_dir) if use_electron else NodeRuntimeLocal(runtime_dir) + try: + local_runtime.check_binary_present() + except Exception as ex: + log_lines.append(' * Binaries check failed: {}'.format(ex)) + if selected_runtimes[0] != 'local': + if not sublime.ok_cancel_dialog( + NO_NODE_FOUND_MESSAGE.format(package_name=package_name), 'Download Node.js'): + log_lines.append(' * Download skipped') + continue + # Remove outdated runtimes. + if path.isdir(runtime_dir): + for directory in next(os.walk(runtime_dir))[1]: + old_dir = path.join(runtime_dir, directory) + print('[LSP.api] Deleting outdated Node.js runtime directory "{}"'.format(old_dir)) + try: + rmtree_ex(old_dir) + except Exception as ex: + log_lines.append(' * Failed deleting: {}'.format(ex)) + try: + local_runtime.install_node() + except Exception as ex: + log_lines.append(' * Failed downloading: {}'.format(ex)) + continue + try: + local_runtime.check_binary_present() + except Exception as ex: + log_lines.append(' * Failed: {}'.format(ex)) + continue + try: + local_runtime.check_satisfies_version(required_node_version) + resolved_runtime = local_runtime + break + except Exception as ex: + log_lines.append(' * {}'.format(ex)) + if not resolved_runtime: + log_lines.append('--- LSP.api Node.js resolving end ---') + print('\n'.join(log_lines)) + raise Exception('Failed resolving Node.js Runtime. Please check in the console for more details.') + return resolved_runtime + + def __init__(self) -> None: + self._node = None # type: Optional[str] + self._npm = None # type: Optional[str] + self._version = None # type: Optional[Version] + self._additional_paths = [] # type: List[str] + + def __repr__(self) -> str: + return '{}(node: {}, npm: {}, version: {})'.format( + self.__class__.__name__, self._node, self._npm, self._version if self._version else None) + + def install_node(self) -> None: + raise Exception('Not supported!') + + def node_bin(self) -> Optional[str]: + return self._node + + def npm_bin(self) -> Optional[str]: + return self._npm + + def node_env(self) -> Dict[str, str]: + if IS_WINDOWS_7_OR_LOWER: + return {'NODE_SKIP_PLATFORM_CHECK': '1'} + return {} + + def check_binary_present(self) -> None: + if self._node is None: + raise Exception('"node" binary not found') + if self._npm is None: + raise Exception('"npm" binary not found') + + def check_satisfies_version(self, required_node_version: NpmSpec) -> None: + node_version = self.resolve_version() + if node_version not in required_node_version: + raise Exception( + 'Node.js version requirement failed. Expected {}, got {}.'.format(required_node_version, node_version)) + + def resolve_version(self) -> Version: + if self._version: + return self._version + if not self._node: + raise Exception('Node.js not initialized') + # In this case we have fully resolved binary path already so shouldn't need `shell` on Windows. + version, error = run_command_sync([self._node, '--version'], extra_env=self.node_env(), shell=False) + if error is None: + self._version = Version(version.replace('v', '')) + else: + raise Exception('Failed resolving Node.js version. Error:\n{}'.format(error)) + return self._version + + def run_node( + self, + args: List[str], + stdin: int = subprocess.PIPE, + stdout: int = subprocess.PIPE, + stderr: int = subprocess.PIPE, + env: Dict[str, Any] = {} + ) -> Optional['subprocess.Popen[bytes]']: + node_bin = self.node_bin() + if node_bin is None: + return None + os_env = os.environ.copy() + os_env.update(self.node_env()) + os_env.update(env) + startupinfo = None + if sys.platform == 'win32': + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.SW_HIDE | subprocess.STARTF_USESHOWWINDOW + return subprocess.Popen( + [node_bin] + args, stdin=stdin, stdout=stdout, stderr=stderr, env=os_env, startupinfo=startupinfo) + + def run_install(self, cwd: str) -> None: + if not path.isdir(cwd): + raise Exception('Specified working directory "{}" does not exist'.format(cwd)) + if not self._node: + raise Exception('Node.js not installed. Use NodeInstaller to install it first.') + args = [ + 'ci', + '--omit=dev', + '--scripts-prepend-node-path=true', + '--verbose', + ] + stdout, error = run_command_sync( + self.npm_command() + args, cwd=cwd, extra_env=self.node_env(), extra_paths=self._additional_paths, + shell=False + ) + print('[LSP.api] START output of command: "{}"'.format(' '.join(args))) + print(stdout) + print('[LSP.api] Command output END') + if error is not None: + raise Exception('Failed to run npm command "{}":\n{}'.format(' '.join(args), error)) + + def npm_command(self) -> List[str]: + if self._npm is None: + raise Exception('Npm command not initialized') + return [self._npm] + + +class NodeRuntimePATH(NodeRuntime): + def __init__(self) -> None: + super().__init__() + self._node = shutil.which('node') + self._npm = shutil.which('npm') + + +class NodeRuntimeLocal(NodeRuntime): + def __init__(self, base_dir: str, node_version: str = NODE_RUNTIME_VERSION): + super().__init__() + self._base_dir = path.abspath(path.join(base_dir, node_version)) + self._node_version = node_version + self._node_dir = path.join(self._base_dir, 'node') + self._install_in_progress_marker_file = path.join(self._base_dir, '.installing') + self._resolve_paths() + + # --- NodeRuntime overrides ---------------------------------------------------------------------------------------- + + def npm_command(self) -> List[str]: + if not self._node or not self._npm: + raise Exception('Node.js or Npm command not initialized') + return [self._node, self._npm] + + def install_node(self) -> None: + os.makedirs(os.path.dirname(self._install_in_progress_marker_file), exist_ok=True) + open(self._install_in_progress_marker_file, 'a').close() + with ActivityIndicator(sublime.active_window(), 'Downloading Node.js'): + install_node = NodeInstaller(self._base_dir, self._node_version) + install_node.run() + self._resolve_paths() + remove(self._install_in_progress_marker_file) + self._resolve_paths() + + # --- private methods ---------------------------------------------------------------------------------------------- + + def _resolve_paths(self) -> None: + if path.isfile(self._install_in_progress_marker_file): + # Will trigger re-installation. + return + self._node = self._resolve_binary() + self._node_lib = self._resolve_lib() + self._npm = path.join(self._node_lib, 'npm', 'bin', 'npm-cli.js') + self._additional_paths = [path.dirname(self._node)] if self._node else [] + + def _resolve_binary(self) -> Optional[str]: + exe_path = path.join(self._node_dir, 'node.exe') + binary_path = path.join(self._node_dir, 'bin', 'node') + if path.isfile(exe_path): + return exe_path + if path.isfile(binary_path): + return binary_path + return None + + def _resolve_lib(self) -> str: + lib_path = path.join(self._node_dir, 'lib', 'node_modules') + if not path.isdir(lib_path): + lib_path = path.join(self._node_dir, 'node_modules') + return lib_path + + +class NodeInstaller: + '''Command to install a local copy of Node.js''' + + def __init__(self, base_dir: str, node_version: str = NODE_RUNTIME_VERSION) -> None: + """ + :param base_dir: The base directory for storing given Node.js runtime version + :param node_version: The Node.js version to install + """ + self._base_dir = base_dir + self._node_version = node_version + self._cache_dir = path.join(self._base_dir, 'cache') + + def run(self) -> None: + archive, url = self._node_archive() + print('[LSP.api] Downloading Node.js {} from {}'.format(self._node_version, url)) + if not self._archive_exists(archive): + self._download_node(url, archive) + self._install_node(archive) + + def _node_archive(self) -> Tuple[str, str]: + platform = sublime.platform() + arch = sublime.arch() + if platform == 'windows' and arch == 'x64': + node_os = 'win' + archive = 'zip' + elif platform == 'linux': + node_os = 'linux' + archive = 'tar.gz' + elif platform == 'osx': + node_os = 'darwin' + archive = 'tar.gz' + else: + raise Exception('{} {} is not supported'.format(arch, platform)) + filename = 'node-v{}-{}-{}.{}'.format(self._node_version, node_os, arch, archive) + dist_url = NODE_DIST_URL.format(version=self._node_version, filename=filename) + return filename, dist_url + + def _archive_exists(self, filename: str) -> bool: + archive = path.join(self._cache_dir, filename) + return path.isfile(archive) + + def _download_node(self, url: str, filename: str) -> None: + if not path.isdir(self._cache_dir): + os.makedirs(self._cache_dir) + archive = path.join(self._cache_dir, filename) + with urllib.request.urlopen(url) as response: + with open(archive, 'wb') as f: + shutil.copyfileobj(response, f) + + def _install_node(self, filename: str) -> None: + archive = path.join(self._cache_dir, filename) + opener = zipfile.ZipFile if filename.endswith('.zip') else tarfile.open # type: Any + try: + with opener(archive) as f: + names = f.namelist() if hasattr(f, 'namelist') else f.getnames() + install_dir, _ = next(x for x in names if '/' in x).split('/', 1) + bad_members = [x for x in names if x.startswith('/') or x.startswith('..')] + if bad_members: + raise Exception('{} appears to be malicious, bad filenames: {}'.format(filename, bad_members)) + f.extractall(self._base_dir) + with chdir(self._base_dir): + os.rename(install_dir, 'node') + except Exception as ex: + raise ex + finally: + remove(archive) + + +class ElectronRuntimeLocal(NodeRuntime): + def __init__(self, base_dir: str): + super().__init__() + self._base_dir = path.abspath(path.join(base_dir, ELECTRON_NODE_VERSION)) + self._yarn = path.join(self._base_dir, 'yarn.js') + self._install_in_progress_marker_file = path.join(self._base_dir, '.installing') + if not path.isfile(self._install_in_progress_marker_file): + self._resolve_paths() + + # --- NodeRuntime overrides ---------------------------------------------------------------------------------------- + + def node_env(self) -> Dict[str, str]: + extra_env = super().node_env() + extra_env.update({'ELECTRON_RUN_AS_NODE': 'true'}) + return extra_env + + def install_node(self) -> None: + os.makedirs(os.path.dirname(self._install_in_progress_marker_file), exist_ok=True) + open(self._install_in_progress_marker_file, 'a').close() + with ActivityIndicator(sublime.active_window(), 'Downloading Node.js'): + install_node = ElectronInstaller(self._base_dir) + install_node.run() + self._resolve_paths() + remove(self._install_in_progress_marker_file) + + def run_install(self, cwd: str) -> None: + self._run_yarn(['import'], cwd) + args = [ + 'install', + '--production', + '--frozen-lockfile', + '--scripts-prepend-node-path=true', + '--cache-folder={}'.format(path.join(self._base_dir, 'cache', 'yarn')), + # '--verbose', + ] + self._run_yarn(args, cwd) + + # --- private methods ---------------------------------------------------------------------------------------------- + + def _resolve_paths(self) -> None: + self._node = self._resolve_binary() + self._npm = path.join(self._base_dir, 'yarn.js') + + def _resolve_binary(self) -> Optional[str]: + binary_path = None + platform = sublime.platform() + if platform == 'osx': + binary_path = path.join(self._base_dir, 'Electron.app', 'Contents', 'MacOS', 'Electron') + elif platform == 'windows': + binary_path = path.join(self._base_dir, 'electron.exe') + else: + binary_path = path.join(self._base_dir, 'electron') + return binary_path if binary_path and path.isfile(binary_path) else None + + def _run_yarn(self, args: List[str], cwd: str) -> None: + if not path.isdir(cwd): + raise Exception('Specified working directory "{}" does not exist'.format(cwd)) + if not self._node: + raise Exception('Node.js not installed. Use NodeInstaller to install it first.') + stdout, error = run_command_sync( + [self._node, self._yarn] + args, cwd=cwd, extra_env=self.node_env(), shell=False + ) + print('[LSP.api] START output of command: "{}"'.format(' '.join(args))) + print(stdout) + print('[LSP.api] Command output END') + if error is not None: + raise Exception('Failed to run yarn command "{}":\n{}'.format(' '.join(args), error)) + + +class ElectronInstaller: + '''Command to install a local copy of Node.js''' + + def __init__(self, base_dir: str) -> None: + """ + :param base_dir: The base directory for storing given Node.js runtime version + """ + self._base_dir = base_dir + self._cache_dir = path.join(self._base_dir, 'cache') + + def run(self) -> None: + archive, url = self._node_archive() + print( + '[LSP.api] Downloading Electron {} (Node.js runtime {}) from {}'.format( + ELECTRON_RUNTIME_VERSION, ELECTRON_NODE_VERSION, url + ) + ) + if not self._archive_exists(archive): + self._download(url, archive) + self._install(archive) + self._download_yarn() + + def _node_archive(self) -> Tuple[str, str]: + platform = sublime.platform() + arch = sublime.arch() + if platform == 'windows': + platform_code = 'win32' + elif platform == 'linux': + platform_code = 'linux' + elif platform == 'osx': + platform_code = 'darwin' + else: + raise Exception('{} {} is not supported'.format(arch, platform)) + filename = 'electron-v{}-{}-{}.zip'.format(ELECTRON_RUNTIME_VERSION, platform_code, arch) + dist_url = ELECTRON_DIST_URL.format(version=ELECTRON_RUNTIME_VERSION, filename=filename) + return filename, dist_url + + def _archive_exists(self, filename: str) -> bool: + archive = path.join(self._cache_dir, filename) + return path.isfile(archive) + + def _download(self, url: str, filename: str) -> None: + if not path.isdir(self._cache_dir): + os.makedirs(self._cache_dir) + archive = path.join(self._cache_dir, filename) + with urllib.request.urlopen(url) as response: + with open(archive, 'wb') as f: + shutil.copyfileobj(response, f) + + def _install(self, filename: str) -> None: + archive = path.join(self._cache_dir, filename) + try: + if sublime.platform() == 'windows': + with zipfile.ZipFile(archive) as f: + names = f.namelist() + _, _ = next(x for x in names if '/' in x).split('/', 1) + bad_members = [x for x in names if x.startswith('/') or x.startswith('..')] + if bad_members: + raise Exception('{} appears to be malicious, bad filenames: {}'.format(filename, bad_members)) + f.extractall(self._base_dir) + else: + # ZipFile doesn't handle symlinks and permissions correctly on Linux and Mac. Use unzip instead. + _, error = run_command_sync(['unzip', archive, '-d', self._base_dir], cwd=self._cache_dir) + if error: + raise Exception('Error unzipping electron archive: {}'.format(error)) + except Exception as ex: + raise ex + finally: + remove(archive) + + def _download_yarn(self) -> None: + archive = path.join(self._base_dir, 'yarn.js') + with urllib.request.urlopen(YARN_URL) as response: + with open(archive, 'wb') as f: + shutil.copyfileobj(response, f) + + +@contextmanager +def chdir(new_dir: str) -> Generator[None, None, None]: + '''Context Manager for changing the working directory''' + cur_dir = os.getcwd() + os.chdir(new_dir) + try: + yield + finally: + os.chdir(cur_dir) diff --git a/api/npm_client_handler.py b/api/npm_client_handler.py new file mode 100644 index 000000000..b9334bffa --- /dev/null +++ b/api/npm_client_handler.py @@ -0,0 +1,168 @@ +from .generic_client_handler import GenericClientHandler +from .server_npm_resource import ServerNpmResource +from .server_resource_interface import ServerResourceInterface +from LSP.plugin import ClientConfig +from LSP.plugin import WorkspaceFolder +from LSP.plugin.core.typing import Dict, List, Optional, Tuple +from os import path +import sublime + +__all__ = ['NpmClientHandler'] + + +class NpmClientHandler(GenericClientHandler): + """ + An implementation of :class:`GenericClientHandler` for handling NPM-based LSP plugins. + + Automatically manages an NPM-based server by installing and updating it in the package storage directory. + """ + __server = None # type: Optional[ServerNpmResource] + + server_directory = '' + """ + The path to the server source directory, relative to the root directory of this package. + + :required: Yes + """ + + server_binary_path = '' + """ + The path to the server "binary", relative to plugin's storage directory. + + :required: Yes + """ + + skip_npm_install = False + """ + Whether to skip the step that runs "npm install" in case the server doesn't need any dependencies. + + :required: No + """ + + # --- NpmClientHandler handlers ----------------------------------------------------------------------------------- + + @classmethod + def minimum_node_version(cls) -> Tuple[int, int, int]: + """ + .. deprecated:: 2.1.0 + Use :meth:`required_node_version` instead. + + The minimum Node version required for this plugin. + + :returns: The semantic version tuple with the minimum required version. Defaults to :code:`(8, 0, 0)`. + """ + return (8, 0, 0) + + @classmethod + def required_node_version(cls) -> str: + """ + The NPM semantic version (typically a range) specifying which version of Node is required for this plugin. + + Examples: + - `16.1.1` - only allows a single version + - `16.x` - allows any build for major version 16 + - `>=16` - allows version 16 and above + - `16 - 18` allows any version between version 16 and 18 (inclusive). It's important to have spaces around + the dash in this case. + + Also see more examples and a testing playground at https://semver.npmjs.com/ . + + :returns: Required NPM semantic version. Defaults to :code:`0.0.0` which means "no restrictions". + """ + return '0.0.0' + + @classmethod + def get_additional_variables(cls) -> Dict[str, str]: + """ + Overrides :meth:`GenericClientHandler.get_additional_variables`, providing additional variable for use in the + settings. + + The additional variables are: + + - `${node_bin}`: - holds the binary path of currently used Node.js runtime. This can resolve to just `node` + when using Node.js runtime from the PATH or to a full filesystem path if using the local Node.js runtime. + - `${server_directory_path}` - holds filesystem path to the server directory (only + when :meth:`GenericClientHandler.manages_server()` is `True`). + + Remember to call the super class and merge the results if overriding. + """ + variables = super().get_additional_variables() + variables.update({ + 'node_bin': cls._node_bin(), + 'server_directory_path': cls._server_directory_path(), + }) + return variables + + @classmethod + def get_additional_paths(cls) -> List[str]: + node_bin = cls._node_bin() + if node_bin: + node_path = path.dirname(node_bin) + if node_path: + return [node_path] + return [] + + # --- GenericClientHandler handlers ------------------------------------------------------------------------------- + + @classmethod + def get_command(cls) -> List[str]: + return [cls._node_bin(), cls.binary_path()] + cls.get_binary_arguments() + + @classmethod + def get_binary_arguments(cls) -> List[str]: + return ['--stdio'] + + @classmethod + def manages_server(cls) -> bool: + return True + + @classmethod + def get_server(cls) -> Optional[ServerResourceInterface]: + if not cls.__server: + cls.__server = ServerNpmResource.create({ + 'package_name': cls.package_name, + 'server_directory': cls.server_directory, + 'server_binary_path': cls.server_binary_path, + 'package_storage': cls.package_storage(), + 'minimum_node_version': cls.minimum_node_version(), + 'required_node_version': cls.required_node_version(), + 'storage_path': cls.storage_path(), + 'skip_npm_install': cls.skip_npm_install, + }) + return cls.__server + + @classmethod + def cleanup(cls) -> None: + cls.__server = None + super().cleanup() + + @classmethod + def can_start(cls, window: sublime.Window, initiating_view: sublime.View, + workspace_folders: List[WorkspaceFolder], configuration: ClientConfig) -> Optional[str]: + reason = super().can_start(window, initiating_view, workspace_folders, configuration) + if reason: + return reason + node_env = cls._node_env() + if node_env: + configuration.env.update(node_env) + return None + + # --- Internal ---------------------------------------------------------------------------------------------------- + + @classmethod + def _server_directory_path(cls) -> str: + if cls.__server: + return cls.__server.server_directory_path + return '' + + @classmethod + def _node_bin(cls) -> str: + if cls.__server: + return cls.__server.node_bin + return '' + + @classmethod + def _node_env(cls) -> Optional[Dict[str, str]]: + if cls.__server: + return cls.__server.node_env + return None diff --git a/api/pip_client_handler.py b/api/pip_client_handler.py new file mode 100644 index 000000000..391037eb8 --- /dev/null +++ b/api/pip_client_handler.py @@ -0,0 +1,77 @@ +from .generic_client_handler import GenericClientHandler +from .server_pip_resource import ServerPipResource +from .server_resource_interface import ServerResourceInterface +from LSP.plugin.core.typing import List, Optional +from os import path +import shutil +import sublime + +__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 get_python_binary(cls) -> str: + """ + Returns a binary name or a full path to the Python interpreter used to create environment for the server. + + The default implementation returns `python` on Windows and `python3` on other platforms. When only the binary + name is specified then it will be expected that it can be found on the PATH. + """ + return 'python' if sublime.platform() == 'windows' else 'python3' + + # --- GenericClientHandler handlers ------------------------------------------------------------------------------- + + @classmethod + def manages_server(cls) -> bool: + return True + + @classmethod + def get_server(cls) -> Optional[ServerResourceInterface]: + if not cls.__server: + python_binary = cls.get_python_binary() + if not shutil.which(python_binary): + raise Exception('Python binary "{}" not found!'.format(python_binary)) + cls.__server = ServerPipResource( + cls.storage_path(), cls.package_name, cls.requirements_txt_path, cls.server_filename, python_binary) + return cls.__server + + @classmethod + def get_additional_paths(cls) -> List[str]: + server = cls.get_server() + if server: + return [path.dirname(server.binary_path)] + return [] diff --git a/api/server_npm_resource.py b/api/server_npm_resource.py new file mode 100644 index 000000000..ecacbde59 --- /dev/null +++ b/api/server_npm_resource.py @@ -0,0 +1,140 @@ +from .helpers import rmtree_ex +from .helpers import SemanticVersion +from .node_runtime import NodeRuntime +from .server_resource_interface import ServerResourceInterface +from .server_resource_interface import ServerStatus +from hashlib import md5 +from LSP.plugin.core.typing import Dict, Optional, TypedDict, Union +from os import makedirs +from os import path +from os import remove +from os import walk +from sublime_lib import ResourcePath + +__all__ = ['ServerNpmResource'] + +ServerNpmResourceCreateOptions = TypedDict('ServerNpmResourceCreateOptions', { + 'package_name': str, + 'server_directory': str, + 'server_binary_path': str, + 'package_storage': str, + 'storage_path': str, + 'minimum_node_version': SemanticVersion, + 'required_node_version': str, + 'skip_npm_install': bool, +}) + + +class ServerNpmResource(ServerResourceInterface): + """ + An implementation of :class:`LSP.api.ServerResourceInterface` implementing server management for + node-based severs. Handles installation and updates of the server in package storage. + """ + + @classmethod + def create(cls, options: ServerNpmResourceCreateOptions) -> 'ServerNpmResource': + package_name = options['package_name'] + server_directory = options['server_directory'] + server_binary_path = options['server_binary_path'] + package_storage = options['package_storage'] + storage_path = options['storage_path'] + minimum_node_version = options['minimum_node_version'] + required_node_version = options['required_node_version'] # type: Union[str, SemanticVersion] + skip_npm_install = options['skip_npm_install'] + # Fallback to "minimum_node_version" if "required_node_version" is 0.0.0 (not overridden). + if '0.0.0' == required_node_version: + required_node_version = minimum_node_version + node_runtime = NodeRuntime.get(package_name, storage_path, required_node_version) + if not node_runtime: + raise Exception('Failed resolving Node.js Runtime. Please see Sublime Text console for more information.') + return ServerNpmResource( + package_name, server_directory, server_binary_path, package_storage, node_runtime, skip_npm_install) + + def __init__(self, package_name: str, server_directory: str, server_binary_path: str, + package_storage: str, node_runtime: NodeRuntime, skip_npm_install: bool) -> None: + if not package_name or not server_directory or not server_binary_path or not node_runtime: + raise Exception('ServerNpmResource could not initialize due to wrong input') + self._status = ServerStatus.UNINITIALIZED + self._package_name = package_name + self._package_storage = package_storage + self._server_src = 'Packages/{}/{}/'.format(self._package_name, server_directory) + node_version = str(node_runtime.resolve_version()) + self._node_version = node_version + self._server_dest = path.join(package_storage, node_version, server_directory) + self._binary_path = path.join(package_storage, node_version, server_binary_path) + self._installation_marker_file = path.join(package_storage, node_version, '.installing') + self._node_runtime = node_runtime + self._skip_npm_install = skip_npm_install + + @property + def server_directory_path(self) -> str: + return self._server_dest + + @property + def node_bin(self) -> str: + node_bin = self._node_runtime.node_bin() + if node_bin is None: + raise Exception('Failed to resolve path to the Node.js runtime') + return node_bin + + @property + def node_env(self) -> Optional[Dict[str, str]]: + return self._node_runtime.node_env() + + # --- ServerResourceInterface ------------------------------------------------------------------------------------- + + @property + def binary_path(self) -> str: + return self._binary_path + + def get_status(self) -> int: + return self._status + + def needs_installation(self) -> bool: + installed = False + if self._skip_npm_install or path.isdir(path.join(self._server_dest, 'node_modules')): + # Server already installed. Check if version has changed or last installation did not complete. + src_package_json = ResourcePath(self._server_src, 'package.json') + if not src_package_json.exists(): + raise Exception('Missing required "package.json" in {}'.format(self._server_src)) + src_hash = md5(src_package_json.read_bytes()).hexdigest() + try: + with open(path.join(self._server_dest, 'package.json'), 'rb') as file: + dst_hash = md5(file.read()).hexdigest() + if src_hash == dst_hash and not path.isfile(self._installation_marker_file): + installed = True + except FileNotFoundError: + # Needs to be re-installed. + pass + if installed: + self._status = ServerStatus.READY + return False + return True + + def install_or_update(self) -> None: + try: + self._cleanup_package_storage() + makedirs(path.dirname(self._installation_marker_file), exist_ok=True) + open(self._installation_marker_file, 'a').close() + if path.isdir(self._server_dest): + rmtree_ex(self._server_dest) + ResourcePath(self._server_src).copytree(self._server_dest, exist_ok=True) + if not self._skip_npm_install: + self._node_runtime.run_install(cwd=self._server_dest) + remove(self._installation_marker_file) + except Exception as error: + self._status = ServerStatus.ERROR + raise Exception('Error installing the server:\n{}'.format(error)) + self._status = ServerStatus.READY + + def _cleanup_package_storage(self) -> None: + if not path.isdir(self._package_storage): + return + """Clean up subdirectories of package storage that belong to other node versions.""" + subdirectories = next(walk(self._package_storage))[1] + for directory in subdirectories: + if directory == self._node_version: + continue + node_storage_path = path.join(self._package_storage, directory) + print('[LSP.api] Deleting outdated storage directory "{}"'.format(node_storage_path)) + rmtree_ex(node_storage_path) diff --git a/api/server_pip_resource.py b/api/server_pip_resource.py new file mode 100644 index 000000000..4d941f7fe --- /dev/null +++ b/api/server_pip_resource.py @@ -0,0 +1,109 @@ +from .helpers import rmtree_ex +from .helpers import run_command_sync +from .server_resource_interface import ServerResourceInterface +from .server_resource_interface import ServerStatus +from hashlib import md5 +from LSP.plugin.core.typing import Any, Optional +from os import path +from sublime_lib import ResourcePath +import os +import sublime + +__all__ = ['ServerPipResource'] + + +class ServerPipResource(ServerResourceInterface): + """ + An implementation of :class:`LSP.api.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.api.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 run(cls, *args: Any, 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, python_binary: str) -> None: + self._storage_path = storage_path + self._package_name = package_name + self._requirements_path_relative = requirements_path + self._requirements_path = 'Packages/{}/{}'.format(self._package_name, requirements_path) + self._server_binary_filename = server_binary_filename + self._python_binary = python_binary + self._status = ServerStatus.UNINITIALIZED + + def basedir(self) -> str: + return path.join(self._storage_path, self._package_name) + + def bindir(self) -> str: + bin_dir = 'Scripts' if sublime.platform() == 'windows' else 'bin' + return path.join(self.basedir(), bin_dir) + + def server_binary(self) -> str: + return path.join(self.bindir(), self._server_binary_filename + self.file_extension()) + + def pip_binary(self) -> str: + return path.join(self.bindir(), 'pip' + self.file_extension()) + + def python_version(self) -> str: + return path.join(self.basedir(), 'python_version') + + # --- ServerResourceInterface handlers ---------------------------------------------------------------------------- + + @property + def binary_path(self) -> str: + return self.server_binary() + + def get_status(self) -> int: + return self._status + + def needs_installation(self) -> bool: + if not path.exists(self.server_binary()) or not path.exists(self.pip_binary()): + return True + if not path.exists(self.python_version()): + return True + with open(self.python_version(), 'r') as f: + if f.readline().strip() != self.run(self._python_binary, '--version').strip(): + return True + src_requirements_resource = ResourcePath(self._requirements_path) + if not src_requirements_resource.exists(): + raise Exception('Missing required "requirements.txt" in {}'.format(self._requirements_path)) + src_requirements_hash = md5(src_requirements_resource.read_bytes()).hexdigest() + try: + with open(path.join(self.basedir(), self._requirements_path_relative), 'rb') as file: + dst_requirements_hash = md5(file.read()).hexdigest() + if src_requirements_hash != dst_requirements_hash: + return True + except FileNotFoundError: + # Needs to be re-installed. + return True + self._status = ServerStatus.READY + return False + + def install_or_update(self) -> None: + rmtree_ex(self.basedir(), ignore_errors=True) + try: + os.makedirs(self.basedir(), exist_ok=True) + self.run(self._python_binary, '-m', 'venv', self._package_name, cwd=self._storage_path) + dest_requirements_txt_path = path.join(self._storage_path, self._package_name, 'requirements.txt') + ResourcePath(self._requirements_path).copy(dest_requirements_txt_path) + self.run(self.pip_binary(), 'install', '-r', dest_requirements_txt_path, '--disable-pip-version-check') + with open(self.python_version(), 'w') as f: + f.write(self.run(self._python_binary, '--version')) + except Exception as error: + self._status = ServerStatus.ERROR + raise Exception('Error installing the server:\n{}'.format(error)) + self._status = ServerStatus.READY diff --git a/api/server_resource_interface.py b/api/server_resource_interface.py new file mode 100644 index 000000000..489907135 --- /dev/null +++ b/api/server_resource_interface.py @@ -0,0 +1,66 @@ +from abc import ABCMeta +from abc import abstractmethod +from abc import abstractproperty + +__all__ = ['ServerStatus', 'ServerResourceInterface'] + + +class ServerStatus(): + """ + A :class:`ServerStatus` enum for use as a return value from :func:`ServerResourceInterface.get_status()`. + """ + + UNINITIALIZED = 1 + """Initial status of the server.""" + ERROR = 2 + """Initiallation or update has failed.""" + READY = 3 + """Server is ready to provide resources.""" + + +class ServerResourceInterface(metaclass=ABCMeta): + """ + An interface for implementating server resource handlers. Use this interface in plugins that manage their own + server binary (:func:`GenericClientHandler.manages_server` returns `True`). + + After implementing this interface, return an instance of implemented class from + :meth:`GenericClientHandler.get_server()`. + """ + + @abstractmethod + def needs_installation(self) -> bool: + """ + This is the place to check whether the binary needs an update, or whether it needs to be installed before + starting the language server. + + :returns: `True` if the server needs to be installed or updated. This will result in calling + :meth:`install_or_update()`. + """ + ... + + @abstractmethod + def install_or_update(self) -> None: + """ + Do the actual update/installation of the server binary. Don't start extra threads to do the work as everything + is handled automatically. + """ + ... + + @abstractmethod + def get_status(self) -> int: + """ + Determines the current status of the server. The state changes as the server is being installed, updated or + runs into an error doing those. Initialize with :attr:`ServerStatus.UNINITIALIZED` and change to either + Set to :attr:`ServerStatus.ERROR` or :attr:`ServerStatus.READY` depending on if the server was installed + correctly or is already installed. + + :returns: A number corresponding to the :class:`ServerStatus` class members. + """ + ... + + @abstractproperty + def binary_path(self) -> str: + """ + Returns a filesystem path to the server binary. + """ + ... diff --git a/dependencies.json b/dependencies.json index b7f778153..7f35e31f2 100644 --- a/dependencies.json +++ b/dependencies.json @@ -4,6 +4,7 @@ "bracex", "mdpopups", "pathlib", + "sublime_lib", "wcmatch" ] } diff --git a/docs/api/api_handler.rst b/docs/api/api_handler.rst new file mode 100644 index 000000000..a8e071104 --- /dev/null +++ b/docs/api/api_handler.rst @@ -0,0 +1,30 @@ +API Handler +=========== + +ApiWrapperInterface +------------------- + +.. autoclass:: LSP.api.ApiWrapperInterface + +API Decorators +-------------- + +Decorators can be used as an alternative to attaching listeners to the :class:`LSP.api.ApiWrapperInterface` instance obtained through :meth:`LSP.api.GenericClientHandler.on_ready()`. + +To use, attach the decorator to a function and call it with the name of the notification or the request that you want to handle. It can also be called with a list of names, if you want to use the same handler to handle multiple requests. + +Example usage: + +.. code:: py + + @notification_handler('eslint/status') + def handle_status(self, params: Any) -> None: + print(status) + + @request_handler('eslint/openDoc') + def handle_open_doc(self, params: Any, respond: Callable[[Any], None]) -> None: + webbrowser.open(params['url']) + respond({}) + +.. autoclass:: LSP.api.request_handler +.. autoclass:: LSP.api.notification_handler diff --git a/docs/api/client_handlers.rst b/docs/api/client_handlers.rst new file mode 100644 index 000000000..cf34b493f --- /dev/null +++ b/docs/api/client_handlers.rst @@ -0,0 +1,17 @@ +Client Handlers +=============== + +GenericClientHandler +-------------------- + +.. autoclass:: LSP.api.GenericClientHandler + +NpmClientHandler +---------------- + +.. autoclass:: LSP.api.NpmClientHandler + +PipClientHandler +---------------- + +.. autoclass:: LSP.api.pip_client_handler.PipClientHandler diff --git a/docs/api/conf.py b/docs/api/conf.py new file mode 100644 index 000000000..1b84a6ed6 --- /dev/null +++ b/docs/api/conf.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +sys.path.insert(0, os.path.abspath('../../..')) + +import sphinx_rtd_theme + +# -- Project information ----------------------------------------------------- + +project = 'LSP.api' +copyright = '2022, SublimeLSP' +author = 'Rafal Chlodnicki' + +# The short X.Y version +version = '' +# The full version, including alpha/beta/rc tags +release = '' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx_rtd_theme', + 'sphinx.ext.autodoc', + 'sphinx_autodoc_typehints', + 'sphinx.ext.intersphinx', +] + +always_document_param_types = True + +autodoc_inherit_docstrings = False +autodoc_member_order = 'bysource' +autodoc_mock_imports = [ + 'package_control', 'sublime', 'sublime_api', 'sublime_lib', 'sublime_plugin', 'markdown', 'mdpopups', 'wcmatch' +] +autodoc_default_options = { + 'members': True, + 'inherited-members': False, + # 'show-inheritance': True, + # 'mock_imports': ['sublime_plugin'] +} + +intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = ['.rst'] + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = 'en' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + + +# -- Options for HTML output ------------------------------------------------- + +html_context = { + 'display_github': True, + 'github_user': 'sublimelsp', + 'github_repo': 'lsp_utils', + 'github_version': 'master/docs/source/', +} + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +html_theme_options = { + # sphinx_rtd_theme + 'collapse_navigation': False, +} + +html_experimental_html5_writer = True + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + +html_use_index = False +html_use_smartypants = False +html_compact_lists = True diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 000000000..8dba2e2fa --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,35 @@ +lsp_utils +========= + +Module with LSP-related utilities for Sublime Text + +How to use +---------- + +1. Create a `dependencies.json` file in your package root with the following contents: + +.. code:: py + + { + "*": { + "*": [ + "lsp_utils", + "sublime_lib" + ] + } + } + +2. Run the **Package Control: Satisfy Dependencies** command via command palette + + +See also documentation on dependencies_. + +.. _dependencies: https://packagecontrol.io/docs/dependencies + +.. toctree:: + :caption: API Documentation + + client_handlers + server_resource_handlers + api_handler + utilities diff --git a/docs/api/server_resource_handlers.rst b/docs/api/server_resource_handlers.rst new file mode 100644 index 000000000..4c21a0846 --- /dev/null +++ b/docs/api/server_resource_handlers.rst @@ -0,0 +1,17 @@ +Server Resource Handlers +======================== + +ServerStatus +------------ + +.. autoclass:: lsp_utils.ServerStatus + +ServerResourceInterface +----------------------- + +.. autoclass:: lsp_utils.ServerResourceInterface + +ServerPipResource +----------------- + +.. autoclass:: lsp_utils.ServerPipResource diff --git a/docs/api/utilities.rst b/docs/api/utilities.rst new file mode 100644 index 000000000..6dfd642a2 --- /dev/null +++ b/docs/api/utilities.rst @@ -0,0 +1,4 @@ +Utilities +--------- + +.. automodule:: lsp_utils.helpers diff --git a/plugin/core/constants.py b/plugin/core/constants.py index 8d249e4aa..8c1b97ff2 100644 --- a/plugin/core/constants.py +++ b/plugin/core/constants.py @@ -15,6 +15,8 @@ # Keys for View.add_regions HOVER_HIGHLIGHT_KEY = 'lsp_hover_highlight' +# Settings +SUBLIME_SETTINGS_FILENAME = 'LSP.sublime-settings' # Setting keys CODE_LENS_ENABLED_KEY = 'lsp_show_code_lens' HOVER_ENABLED_KEY = 'lsp_show_hover_popups' diff --git a/plugin/core/settings.py b/plugin/core/settings.py index 319de6e55..c2a94afc7 100644 --- a/plugin/core/settings.py +++ b/plugin/core/settings.py @@ -1,4 +1,5 @@ from .collections import DottedDict +from .constants import SUBLIME_SETTINGS_FILENAME from .logging import debug from .types import ClientConfig, debounced from .types import read_dict_setting @@ -89,13 +90,13 @@ def _set_enabled(self, config_name: str, is_enabled: bool) -> None: plugin_settings.set("enabled", is_enabled) sublime.save_settings(settings_basename) return - settings = sublime.load_settings("LSP.sublime-settings") + settings = sublime.load_settings(SUBLIME_SETTINGS_FILENAME) clients = settings.get("clients") if isinstance(clients, dict): config = clients.setdefault(config_name, {}) config["enabled"] = is_enabled settings.set("clients", clients) - sublime.save_settings("LSP.sublime-settings") + sublime.save_settings(SUBLIME_SETTINGS_FILENAME) def enable(self, config_name: str) -> None: self._set_enabled(config_name, True) @@ -129,7 +130,7 @@ def load_settings() -> None: if _global_settings is None: _global_settings = sublime.load_settings("Preferences.sublime-settings") if _settings_obj is None: - _settings_obj = sublime.load_settings("LSP.sublime-settings") + _settings_obj = sublime.load_settings(SUBLIME_SETTINGS_FILENAME) _settings = Settings(_settings_obj) _settings_registration = SettingsRegistration(_settings_obj, _on_sublime_settings_changed) diff --git a/plugin/core/types.py b/plugin/core/types.py index 7fac717c2..f2b37cc92 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -1,4 +1,5 @@ from .collections import DottedDict +from .constants import SUBLIME_SETTINGS_FILENAME from .file_watcher import FileWatcherEventType from .logging import debug, set_debug_logging from .protocol import TextDocumentSyncKind @@ -827,7 +828,7 @@ def resolve_transport_config(self, variables: Dict[str, str]) -> TransportConfig return TransportConfig(self.name, command, tcp_port, env, listener_socket) def set_view_status(self, view: sublime.View, message: str) -> None: - if sublime.load_settings("LSP.sublime-settings").get("show_view_status"): + if sublime.load_settings(SUBLIME_SETTINGS_FILENAME).get("show_view_status"): status = "{} ({})".format(self.name, message) if message else self.name view.set_status(self.status_key, status) diff --git a/plugin/tooling.py b/plugin/tooling.py index 71592ad57..51ea315c1 100644 --- a/plugin/tooling.py +++ b/plugin/tooling.py @@ -1,3 +1,4 @@ +from .core.constants import SUBLIME_SETTINGS_FILENAME from .core.css import css from .core.logging import debug from .core.registry import windows @@ -394,7 +395,7 @@ def line(s: str) -> None: line(' - project data:\n{}'.format(self.json_dump(window.project_data()))) line('\n## LSP configuration\n') - lsp_settings_contents = self.read_resource('Packages/User/LSP.sublime-settings') + lsp_settings_contents = self.read_resource('Packages/User/{}'.format(SUBLIME_SETTINGS_FILENAME)) if lsp_settings_contents is not None: line(self.json_dump(sublime.decode_value(lsp_settings_contents))) else: diff --git a/sublime-package.json b/sublime-package.json index 17a0f17ee..eb9efd581 100644 --- a/sublime-package.json +++ b/sublime-package.json @@ -676,6 +676,31 @@ "minItems": 0, "deprecationMessage": "Instead of a global option, this option is now on a per-client basis. Moreover, this is now an object instead of an array." }, + "nodejs_runtime": { + "type": "array", + "markdownDescription": "Specifies the type and priority of the Node.js installation that should be used for Node.js-based servers.\n\nThe allowed values are:\n\n- `system` - a Node.js runtime found on the PATH\n- `local` - a Node.js runtime managed by LSP that doesn't affect the system\n\nThe order in which the values are specified determines which one is tried first,\nwith the later one being used as a fallback.\nYou can also specify just a single value to disable the fallback.", + "default": [ + "system", + "local", + ], + "items": { + "type": "string", + "enum": [ + "system", + "local" + ], + "markdownEnumDescriptions": [ + "Node.js runtime found on the PATH", + "Node.js runtime managed by LSP" + ] + }, + "uniqueItems": true, + }, + "local_use_electron": { + "type": "boolean", + "default": false, + "markdownDescription": "Uses Node.js runtime from the Electron package rather than the official distribution. This has the benefit of lower memory usage due to it having the [pointer compression](https://v8.dev/blog/pointer-compression) enabled.\n\nOnly relevant when using `local` variant of `nodejs_runtime`." + }, "log_debug": { "type": "boolean", "default": false, diff --git a/third_party/semantic_version/LICENSE b/third_party/semantic_version/LICENSE new file mode 100644 index 000000000..66aba18ca --- /dev/null +++ b/third_party/semantic_version/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) The python-semanticversion project +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/semantic_version/README.rst b/third_party/semantic_version/README.rst new file mode 100644 index 000000000..c50163a4c --- /dev/null +++ b/third_party/semantic_version/README.rst @@ -0,0 +1,279 @@ +Introduction +============ + +This small python library provides a few tools to handle `SemVer`_ in Python. +It follows strictly the 2.0.0 version of the SemVer scheme. + +.. image:: https://github.com/rbarrois/python-semanticversion/actions/workflows/test.yml/badge.svg + :target: https://github.com/rbarrois/python-semanticversion/actions/workflows/test.yml + +.. image:: https://img.shields.io/pypi/v/semantic_version.svg + :target: https://python-semanticversion.readthedocs.io/en/latest/changelog.html + :alt: Latest Version + +.. image:: https://img.shields.io/pypi/pyversions/semantic_version.svg + :target: https://pypi.python.org/pypi/semantic_version/ + :alt: Supported Python versions + +.. image:: https://img.shields.io/pypi/wheel/semantic_version.svg + :target: https://pypi.python.org/pypi/semantic_version/ + :alt: Wheel status + +.. image:: https://img.shields.io/pypi/l/semantic_version.svg + :target: https://pypi.python.org/pypi/semantic_version/ + :alt: License + +Links +----- + +- Package on `PyPI`_: https://pypi.org/project/semantic-version/ +- Doc on `ReadTheDocs `_: https://python-semanticversion.readthedocs.io/ +- Source on `GitHub `_: http://github.com/rbarrois/python-semanticversion/ +- Build on Github Actions: https://github.com/rbarrois/python-semanticversion/actions +- Semantic Version specification: `SemVer`_ + + +Getting started +=============== + +Install the package from `PyPI`_, using pip: + +.. code-block:: sh + + pip install semantic-version + +Or from GitHub: + +.. code-block:: sh + + $ git clone git://github.com/rbarrois/python-semanticversion.git + + +Import it in your code: + + +.. code-block:: python + + import semantic_version + + +This module provides classes to handle semantic versions: + +- ``Version`` represents a version number (``0.1.1-alpha+build.2012-05-15``) +- ``BaseSpec``-derived classes represent requirement specifications (``>=0.1.1,<0.3.0``): + + - ``SimpleSpec`` describes a natural description syntax + - ``NpmSpec`` is used for NPM-style range descriptions. + +Versions +-------- + +Defining a ``Version`` is quite simple: + + +.. code-block:: pycon + + >>> import semantic_version + >>> v = semantic_version.Version('0.1.1') + >>> v.major + 0 + >>> v.minor + 1 + >>> v.patch + 1 + >>> v.prerelease + [] + >>> v.build + [] + >>> list(v) + [0, 1, 1, [], []] + +If the provided version string is invalid, a ``ValueError`` will be raised: + +.. code-block:: pycon + + >>> semantic_version.Version('0.1') + Traceback (most recent call last): + File "", line 1, in + File "/Users/rbarrois/dev/semantic_version/src/semantic_version/base.py", line 64, in __init__ + major, minor, patch, prerelease, build = self.parse(version_string, partial) + File "/Users/rbarrois/dev/semantic_version/src/semantic_version/base.py", line 86, in parse + raise ValueError('Invalid version string: %r' % version_string) + ValueError: Invalid version string: '0.1' + + +One may also create a ``Version`` with named components: + +.. code-block:: pycon + + >>> semantic_version.Version(major=0, minor=1, patch=2) + Version('0.1.2') + +In that case, ``major``, ``minor`` and ``patch`` are mandatory, and must be integers. +``prerelease`` and ``build``, if provided, must be tuples of strings: + +.. code-block:: pycon + + >>> semantic_version.Version(major=0, minor=1, patch=2, prerelease=('alpha', '2')) + Version('0.1.2-alpha.2') + + +Some user-supplied input might not match the semantic version scheme. +For such cases, the ``Version.coerce`` method will try to convert any +version-like string into a valid semver version: + +.. code-block:: pycon + + >>> Version.coerce('0') + Version('0.0.0') + >>> Version.coerce('0.1.2.3.4') + Version('0.1.2+3.4') + >>> Version.coerce('0.1.2a3') + Version('0.1.2-a3') + +Working with versions +""""""""""""""""""""" + +Obviously, versions can be compared: + + +.. code-block:: pycon + + >>> semantic_version.Version('0.1.1') < semantic_version.Version('0.1.2') + True + >>> semantic_version.Version('0.1.1') > semantic_version.Version('0.1.1-alpha') + True + >>> semantic_version.Version('0.1.1') <= semantic_version.Version('0.1.1-alpha') + False + +You can also get a new version that represents a bump in one of the version levels: + +.. code-block:: pycon + + >>> v = semantic_version.Version('0.1.1+build') + >>> new_v = v.next_major() + >>> str(new_v) + '1.0.0' + >>> v = semantic_version.Version('1.1.1+build') + >>> new_v = v.next_minor() + >>> str(new_v) + '1.2.0' + >>> v = semantic_version.Version('1.1.1+build') + >>> new_v = v.next_patch() + >>> str(new_v) + '1.1.2' + + + +Requirement specification +------------------------- + +python-semanticversion provides a couple of ways to describe a range of accepted +versions: + +- The ``SimpleSpec`` class provides a simple, easily understood scheme -- + somewhat inspired from PyPI range notations; +- The ``NpmSpec`` class supports the whole NPM range specification scheme: + + .. code-block:: pycon + + >>> Version('0.1.2') in NpmSpec('0.1.0-alpha.2 .. 0.2.4') + True + >>> Version('0.1.2') in NpmSpec('>=0.1.1 <0.1.3 || 2.x') + True + >>> Version('2.3.4') in NpmSpec('>=0.1.1 <0.1.3 || 2.x') + True + +The ``SimpleSpec`` scheme +""""""""""""""""""""""""" + +Basic usage is simply a comparator and a base version: + +.. code-block:: pycon + + >>> s = SimpleSpec('>=0.1.1') # At least 0.1.1 + >>> s.match(Version('0.1.1')) + True + >>> s.match(Version('0.1.1-alpha1')) # pre-release doesn't satisfy version spec + False + >>> s.match(Version('0.1.0')) + False + +Combining specifications can be expressed as follows: + + .. code-block:: pycon + + >>> SimpleSpec('>=0.1.1,<0.3.0') + +Simpler test syntax is also available using the ``in`` keyword: + +.. code-block:: pycon + + >>> s = SimpleSpec('==0.1.1') + >>> Version('0.1.1+git7ccc72') in s # build variants are equivalent to full versions + True + >>> Version('0.1.1-alpha1') in s # pre-release variants don't match the full version. + False + >>> Version('0.1.2') in s + False + + +Refer to the full documentation at +https://python-semanticversion.readthedocs.io/en/latest/ for more details on the +``SimpleSpec`` scheme. + + + +Using a specification +""""""""""""""""""""" + +The ``SimpleSpec.filter`` method filters an iterable of ``Version``: + +.. code-block:: pycon + + >>> s = SimpleSpec('>=0.1.0,<0.4.0') + >>> versions = (Version('0.%d.0' % i) for i in range(6)) + >>> for v in s.filter(versions): + ... print v + 0.1.0 + 0.2.0 + 0.3.0 + +It is also possible to select the 'best' version from such iterables: + + +.. code-block:: pycon + + >>> s = SimpleSpec('>=0.1.0,<0.4.0') + >>> versions = (Version('0.%d.0' % i) for i in range(6)) + >>> s.select(versions) + Version('0.3.0') + + + +Contributing +============ + +In order to contribute to the source code: + +- Open an issue on `GitHub`_: https://github.com/rbarrois/python-semanticversion/issues +- Fork the `repository `_ + and submit a pull request on `GitHub`_ +- Or send me a patch (mailto:raphael.barrois+semver@polytechnique.org) + +When submitting patches or pull requests, you should respect the following rules: + +- Coding conventions are based on :pep:`8` +- The whole test suite must pass after adding the changes +- The test coverage for a new feature must be 100% +- New features and methods should be documented in the ``reference`` section + and included in the ``changelog`` +- Include your name in the ``contributors`` section + +.. note:: All files should contain the following header:: + + # -*- encoding: utf-8 -*- + # Copyright (c) The python-semanticversion project + +.. _SemVer: http://semver.org/ +.. _PyPI: http://pypi.python.org/ diff --git a/third_party/semantic_version/__init__.py b/third_party/semantic_version/__init__.py new file mode 100644 index 000000000..c50f30503 --- /dev/null +++ b/third_party/semantic_version/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Copyright (c) The python-semanticversion project +# This code is distributed under the two-clause BSD License. + + +from .base import compare, match, validate, SimpleSpec, NpmSpec, Spec, SpecItem, Version + + +__author__ = "Raphaƫl Barrois " diff --git a/third_party/semantic_version/base.py b/third_party/semantic_version/base.py new file mode 100644 index 000000000..777c27ac4 --- /dev/null +++ b/third_party/semantic_version/base.py @@ -0,0 +1,1449 @@ +# -*- coding: utf-8 -*- +# Copyright (c) The python-semanticversion project +# This code is distributed under the two-clause BSD License. + +import functools +import re +import warnings + + +def _has_leading_zero(value): + return (value + and value[0] == '0' + and value.isdigit() + and value != '0') + + +class MaxIdentifier(object): + __slots__ = [] + + def __repr__(self): + return 'MaxIdentifier()' + + def __eq__(self, other): + return isinstance(other, self.__class__) + + +@functools.total_ordering +class NumericIdentifier(object): + __slots__ = ['value'] + + def __init__(self, value): + self.value = int(value) + + def __repr__(self): + return 'NumericIdentifier(%r)' % self.value + + def __eq__(self, other): + if isinstance(other, NumericIdentifier): + return self.value == other.value + return NotImplemented + + def __lt__(self, other): + if isinstance(other, MaxIdentifier): + return True + elif isinstance(other, AlphaIdentifier): + return True + elif isinstance(other, NumericIdentifier): + return self.value < other.value + else: + return NotImplemented + + +@functools.total_ordering +class AlphaIdentifier(object): + __slots__ = ['value'] + + def __init__(self, value): + self.value = value.encode('ascii') + + def __repr__(self): + return 'AlphaIdentifier(%r)' % self.value + + def __eq__(self, other): + if isinstance(other, AlphaIdentifier): + return self.value == other.value + return NotImplemented + + def __lt__(self, other): + if isinstance(other, MaxIdentifier): + return True + elif isinstance(other, NumericIdentifier): + return False + elif isinstance(other, AlphaIdentifier): + return self.value < other.value + else: + return NotImplemented + + +class Version(object): + + version_re = re.compile(r'^(\d+)\.(\d+)\.(\d+)(?:-([0-9a-zA-Z.-]+))?(?:\+([0-9a-zA-Z.-]+))?$') + partial_version_re = re.compile(r'^(\d+)(?:\.(\d+)(?:\.(\d+))?)?(?:-([0-9a-zA-Z.-]*))?(?:\+([0-9a-zA-Z.-]*))?$') + + def __init__( + self, + version_string=None, + major=None, + minor=None, + patch=None, + prerelease=None, + build=None, + partial=False): + if partial: + warnings.warn( + "Partial versions will be removed in 3.0; use SimpleSpec('1.x.x') instead.", + DeprecationWarning, + stacklevel=2, + ) + has_text = version_string is not None + has_parts = not (major is minor is patch is prerelease is build is None) + if not has_text ^ has_parts: + raise ValueError("Call either Version('1.2.3') or Version(major=1, ...).") + + if has_text: + major, minor, patch, prerelease, build = self.parse(version_string, partial) + else: + # Convenience: allow to omit prerelease/build. + prerelease = tuple(prerelease or ()) + if not partial: + build = tuple(build or ()) + self._validate_kwargs(major, minor, patch, prerelease, build, partial) + + self.major = major + self.minor = minor + self.patch = patch + self.prerelease = prerelease + self.build = build + + self.partial = partial + + # Cached precedence keys + # _cmp_precedence_key is used for semver-precedence comparison + self._cmp_precedence_key = self._build_precedence_key(with_build=False) + # _sort_precedence_key is used for self.precedence_key, esp. for sorted(...) + self._sort_precedence_key = self._build_precedence_key(with_build=True) + + @classmethod + def _coerce(cls, value, allow_none=False): + if value is None and allow_none: + return value + return int(value) + + def next_major(self): + if self.prerelease and self.minor == self.patch == 0: + return Version( + major=self.major, + minor=0, + patch=0, + partial=self.partial, + ) + else: + return Version( + major=self.major + 1, + minor=0, + patch=0, + partial=self.partial, + ) + + def next_minor(self): + if self.prerelease and self.patch == 0: + return Version( + major=self.major, + minor=self.minor, + patch=0, + partial=self.partial, + ) + else: + return Version( + major=self.major, + minor=self.minor + 1, + patch=0, + partial=self.partial, + ) + + def next_patch(self): + if self.prerelease: + return Version( + major=self.major, + minor=self.minor, + patch=self.patch, + partial=self.partial, + ) + else: + return Version( + major=self.major, + minor=self.minor, + patch=self.patch + 1, + partial=self.partial, + ) + + def truncate(self, level='patch'): + """Return a new Version object, truncated up to the selected level.""" + if level == 'build': + return self + elif level == 'prerelease': + return Version( + major=self.major, + minor=self.minor, + patch=self.patch, + prerelease=self.prerelease, + partial=self.partial, + ) + elif level == 'patch': + return Version( + major=self.major, + minor=self.minor, + patch=self.patch, + partial=self.partial, + ) + elif level == 'minor': + return Version( + major=self.major, + minor=self.minor, + patch=None if self.partial else 0, + partial=self.partial, + ) + elif level == 'major': + return Version( + major=self.major, + minor=None if self.partial else 0, + patch=None if self.partial else 0, + partial=self.partial, + ) + else: + raise ValueError("Invalid truncation level `%s`." % level) + + @classmethod + def coerce(cls, version_string, partial=False): + """Coerce an arbitrary version string into a semver-compatible one. + + The rule is: + - If not enough components, fill minor/patch with zeroes; unless + partial=True + - If more than 3 dot-separated components, extra components are "build" + data. If some "build" data already appeared, append it to the + extra components + + Examples: + >>> Version.coerce('0.1') + Version(0, 1, 0) + >>> Version.coerce('0.1.2.3') + Version(0, 1, 2, (), ('3',)) + >>> Version.coerce('0.1.2.3+4') + Version(0, 1, 2, (), ('3', '4')) + >>> Version.coerce('0.1+2-3+4_5') + Version(0, 1, 0, (), ('2-3', '4-5')) + """ + base_re = re.compile(r'^\d+(?:\.\d+(?:\.\d+)?)?') + + match = base_re.match(version_string) + if not match: + raise ValueError( + "Version string lacks a numerical component: %r" + % version_string + ) + + version = version_string[:match.end()] + if not partial: + # We need a not-partial version. + while version.count('.') < 2: + version += '.0' + + # Strip leading zeros in components + # Version is of the form nn, nn.pp or nn.pp.qq + version = '.'.join( + # If the part was '0', we end up with an empty string. + part.lstrip('0') or '0' + for part in version.split('.') + ) + + if match.end() == len(version_string): + return Version(version, partial=partial) + + rest = version_string[match.end():] + + # Cleanup the 'rest' + rest = re.sub(r'[^a-zA-Z0-9+.-]', '-', rest) + + if rest[0] == '+': + # A 'build' component + prerelease = '' + build = rest[1:] + elif rest[0] == '.': + # An extra version component, probably 'build' + prerelease = '' + build = rest[1:] + elif rest[0] == '-': + rest = rest[1:] + if '+' in rest: + prerelease, build = rest.split('+', 1) + else: + prerelease, build = rest, '' + elif '+' in rest: + prerelease, build = rest.split('+', 1) + else: + prerelease, build = rest, '' + + build = build.replace('+', '.') + + if prerelease: + version = '%s-%s' % (version, prerelease) + if build: + version = '%s+%s' % (version, build) + + return cls(version, partial=partial) + + @classmethod + def parse(cls, version_string, partial=False, coerce=False): + """Parse a version string into a tuple of components: + (major, minor, patch, prerelease, build). + + Args: + version_string (str), the version string to parse + partial (bool), whether to accept incomplete input + coerce (bool), whether to try to map the passed in string into a + valid Version. + """ + if not version_string: + raise ValueError('Invalid empty version string: %r' % version_string) + + if partial: + version_re = cls.partial_version_re + else: + version_re = cls.version_re + + match = version_re.match(version_string) + if not match: + raise ValueError('Invalid version string: %r' % version_string) + + major, minor, patch, prerelease, build = match.groups() + + if _has_leading_zero(major): + raise ValueError("Invalid leading zero in major: %r" % version_string) + if _has_leading_zero(minor): + raise ValueError("Invalid leading zero in minor: %r" % version_string) + if _has_leading_zero(patch): + raise ValueError("Invalid leading zero in patch: %r" % version_string) + + major = int(major) + minor = cls._coerce(minor, partial) + patch = cls._coerce(patch, partial) + + if prerelease is None: + if partial and (build is None): + # No build info, strip here + return (major, minor, patch, None, None) + else: + prerelease = () + elif prerelease == '': + prerelease = () + else: + prerelease = tuple(prerelease.split('.')) + cls._validate_identifiers(prerelease, allow_leading_zeroes=False) + + if build is None: + if partial: + build = None + else: + build = () + elif build == '': + build = () + else: + build = tuple(build.split('.')) + cls._validate_identifiers(build, allow_leading_zeroes=True) + + return (major, minor, patch, prerelease, build) + + @classmethod + def _validate_identifiers(cls, identifiers, allow_leading_zeroes=False): + for item in identifiers: + if not item: + raise ValueError( + "Invalid empty identifier %r in %r" + % (item, '.'.join(identifiers)) + ) + + if item[0] == '0' and item.isdigit() and item != '0' and not allow_leading_zeroes: + raise ValueError("Invalid leading zero in identifier %r" % item) + + @classmethod + def _validate_kwargs(cls, major, minor, patch, prerelease, build, partial): + if ( + major != int(major) + or minor != cls._coerce(minor, partial) + or patch != cls._coerce(patch, partial) + or prerelease is None and not partial + or build is None and not partial + ): + raise ValueError( + "Invalid kwargs to Version(major=%r, minor=%r, patch=%r, " + "prerelease=%r, build=%r, partial=%r" % ( + major, minor, patch, prerelease, build, partial + )) + if prerelease is not None: + cls._validate_identifiers(prerelease, allow_leading_zeroes=False) + if build is not None: + cls._validate_identifiers(build, allow_leading_zeroes=True) + + def __iter__(self): + return iter((self.major, self.minor, self.patch, self.prerelease, self.build)) + + def __str__(self): + version = '%d' % self.major + if self.minor is not None: + version = '%s.%d' % (version, self.minor) + if self.patch is not None: + version = '%s.%d' % (version, self.patch) + + if self.prerelease or (self.partial and self.prerelease == () and self.build is None): + version = '%s-%s' % (version, '.'.join(self.prerelease)) + if self.build or (self.partial and self.build == ()): + version = '%s+%s' % (version, '.'.join(self.build)) + return version + + def __repr__(self): + return '%s(%r%s)' % ( + self.__class__.__name__, + str(self), + ', partial=True' if self.partial else '', + ) + + def __hash__(self): + # We don't include 'partial', since this is strictly equivalent to having + # at least a field being `None`. + return hash((self.major, self.minor, self.patch, self.prerelease, self.build)) + + def _build_precedence_key(self, with_build=False): + """Build a precedence key. + + The "build" component should only be used when sorting an iterable + of versions. + """ + if self.prerelease: + prerelease_key = tuple( + NumericIdentifier(part) if part.isdigit() else AlphaIdentifier(part) + for part in self.prerelease + ) + else: + prerelease_key = ( + MaxIdentifier(), + ) + + if not with_build: + return ( + self.major, + self.minor, + self.patch, + prerelease_key, + ) + + build_key = tuple( + NumericIdentifier(part) if part.isdigit() else AlphaIdentifier(part) + for part in self.build or () + ) + + return ( + self.major, + self.minor, + self.patch, + prerelease_key, + build_key, + ) + + @property + def precedence_key(self): + return self._sort_precedence_key + + def __cmp__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + if self < other: + return -1 + elif self > other: + return 1 + elif self == other: + return 0 + else: + return NotImplemented + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return ( + self.major == other.major + and self.minor == other.minor + and self.patch == other.patch + and (self.prerelease or ()) == (other.prerelease or ()) + and (self.build or ()) == (other.build or ()) + ) + + def __ne__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return tuple(self) != tuple(other) + + def __lt__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self._cmp_precedence_key < other._cmp_precedence_key + + def __le__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self._cmp_precedence_key <= other._cmp_precedence_key + + def __gt__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self._cmp_precedence_key > other._cmp_precedence_key + + def __ge__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self._cmp_precedence_key >= other._cmp_precedence_key + + +class SpecItem(object): + """A requirement specification.""" + + KIND_ANY = '*' + KIND_LT = '<' + KIND_LTE = '<=' + KIND_EQUAL = '==' + KIND_SHORTEQ = '=' + KIND_EMPTY = '' + KIND_GTE = '>=' + KIND_GT = '>' + KIND_NEQ = '!=' + KIND_CARET = '^' + KIND_TILDE = '~' + KIND_COMPATIBLE = '~=' + + # Map a kind alias to its full version + KIND_ALIASES = { + KIND_SHORTEQ: KIND_EQUAL, + KIND_EMPTY: KIND_EQUAL, + } + + re_spec = re.compile(r'^(<|<=||=|==|>=|>|!=|\^|~|~=)(\d.*)$') + + def __init__(self, requirement_string, _warn=True): + if _warn: + warnings.warn( + "The `SpecItem` class will be removed in 3.0.", + DeprecationWarning, + stacklevel=2, + ) + kind, spec = self.parse(requirement_string) + self.kind = kind + self.spec = spec + self._clause = Spec(requirement_string).clause + + @classmethod + def parse(cls, requirement_string): + if not requirement_string: + raise ValueError("Invalid empty requirement specification: %r" % requirement_string) + + # Special case: the 'any' version spec. + if requirement_string == '*': + return (cls.KIND_ANY, '') + + match = cls.re_spec.match(requirement_string) + if not match: + raise ValueError("Invalid requirement specification: %r" % requirement_string) + + kind, version = match.groups() + if kind in cls.KIND_ALIASES: + kind = cls.KIND_ALIASES[kind] + + spec = Version(version, partial=True) + if spec.build is not None and kind not in (cls.KIND_EQUAL, cls.KIND_NEQ): + raise ValueError( + "Invalid requirement specification %r: build numbers have no ordering." + % requirement_string + ) + return (kind, spec) + + @classmethod + def from_matcher(cls, matcher): + if matcher == Always(): + return cls('*', _warn=False) + elif matcher == Never(): + return cls('<0.0.0-', _warn=False) + elif isinstance(matcher, Range): + return cls('%s%s' % (matcher.operator, matcher.target), _warn=False) + + def match(self, version): + return self._clause.match(version) + + def __str__(self): + return '%s%s' % (self.kind, self.spec) + + def __repr__(self): + return '' % (self.kind, self.spec) + + def __eq__(self, other): + if not isinstance(other, SpecItem): + return NotImplemented + return self.kind == other.kind and self.spec == other.spec + + def __hash__(self): + return hash((self.kind, self.spec)) + + +def compare(v1, v2): + return Version(v1).__cmp__(Version(v2)) + + +def match(spec, version): + return Spec(spec).match(Version(version)) + + +def validate(version_string): + """Validates a version string againt the SemVer specification.""" + try: + Version.parse(version_string) + return True + except ValueError: + return False + + +DEFAULT_SYNTAX = 'simple' + + +class BaseSpec(object): + """A specification of compatible versions. + + Usage: + >>> Spec('>=1.0.0', syntax='npm') + + A version matches a specification if it matches any + of the clauses of that specification. + + Internally, a Spec is AnyOf( + AllOf(Matcher, Matcher, Matcher), + AllOf(...), + ) + """ + SYNTAXES = {} + + @classmethod + def register_syntax(cls, subclass): + syntax = subclass.SYNTAX + if syntax is None: + raise ValueError("A Spec needs its SYNTAX field to be set.") + elif syntax in cls.SYNTAXES: + raise ValueError( + "Duplicate syntax for %s: %r, %r" + % (syntax, cls.SYNTAXES[syntax], subclass) + ) + cls.SYNTAXES[syntax] = subclass + return subclass + + def __init__(self, expression): + super(BaseSpec, self).__init__() + self.expression = expression + self.clause = self._parse_to_clause(expression) + + @classmethod + def parse(cls, expression, syntax=DEFAULT_SYNTAX): + """Convert a syntax-specific expression into a BaseSpec instance.""" + return cls.SYNTAXES[syntax](expression) + + @classmethod + def _parse_to_clause(cls, expression): + """Converts an expression to a clause.""" + raise NotImplementedError() + + def filter(self, versions): + """Filter an iterable of versions satisfying the Spec.""" + for version in versions: + if self.match(version): + yield version + + def match(self, version): + """Check whether a Version satisfies the Spec.""" + return self.clause.match(version) + + def select(self, versions): + """Select the best compatible version among an iterable of options.""" + options = list(self.filter(versions)) + if options: + return max(options) + return None + + def __contains__(self, version): + """Whether `version in self`.""" + if isinstance(version, Version): + return self.match(version) + return False + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + + return self.clause == other.clause + + def __hash__(self): + return hash(self.clause) + + def __str__(self): + return self.expression + + def __repr__(self): + return '<%s: %r>' % (self.__class__.__name__, self.expression) + + +class Clause(object): + __slots__ = [] + + def match(self, version): + raise NotImplementedError() + + def __and__(self, other): + raise NotImplementedError() + + def __or__(self, other): + raise NotImplementedError() + + def __eq__(self, other): + raise NotImplementedError() + + def prettyprint(self, indent='\t'): + """Pretty-print the clause. + """ + return '\n'.join(self._pretty()).replace('\t', indent) + + def _pretty(self): + """Actual pretty-printing logic. + + Yields: + A list of string. Indentation is performed with \t. + """ + yield repr(self) + + def __ne__(self, other): + return not self == other + + def simplify(self): + return self + + +class AnyOf(Clause): + __slots__ = ['clauses'] + + def __init__(self, *clauses): + super(AnyOf, self).__init__() + self.clauses = frozenset(clauses) + + def match(self, version): + return any(c.match(version) for c in self.clauses) + + def simplify(self): + subclauses = set() + for clause in self.clauses: + simplified = clause.simplify() + if isinstance(simplified, AnyOf): + subclauses |= simplified.clauses + elif simplified == Never(): + continue + else: + subclauses.add(simplified) + if len(subclauses) == 1: + return subclauses.pop() + return AnyOf(*subclauses) + + def __hash__(self): + return hash((AnyOf, self.clauses)) + + def __iter__(self): + return iter(self.clauses) + + def __eq__(self, other): + return isinstance(other, self.__class__) and self.clauses == other.clauses + + def __and__(self, other): + if isinstance(other, AllOf): + return other & self + elif isinstance(other, Matcher) or isinstance(other, AnyOf): + return AllOf(self, other) + else: + return NotImplemented + + def __or__(self, other): + if isinstance(other, AnyOf): + clauses = list(self.clauses | other.clauses) + elif isinstance(other, Matcher) or isinstance(other, AllOf): + clauses = list(self.clauses | set([other])) + else: + return NotImplemented + return AnyOf(*clauses) + + def __repr__(self): + return 'AnyOf(%s)' % ', '.join(sorted(repr(c) for c in self.clauses)) + + def _pretty(self): + yield 'AnyOF(' + for clause in self.clauses: + lines = list(clause._pretty()) + for line in lines[:-1]: + yield '\t' + line + yield '\t' + lines[-1] + ',' + yield ')' + + +class AllOf(Clause): + __slots__ = ['clauses'] + + def __init__(self, *clauses): + super(AllOf, self).__init__() + self.clauses = frozenset(clauses) + + def match(self, version): + return all(clause.match(version) for clause in self.clauses) + + def simplify(self): + subclauses = set() + for clause in self.clauses: + simplified = clause.simplify() + if isinstance(simplified, AllOf): + subclauses |= simplified.clauses + elif simplified == Always(): + continue + else: + subclauses.add(simplified) + if len(subclauses) == 1: + return subclauses.pop() + return AllOf(*subclauses) + + def __hash__(self): + return hash((AllOf, self.clauses)) + + def __iter__(self): + return iter(self.clauses) + + def __eq__(self, other): + return isinstance(other, self.__class__) and self.clauses == other.clauses + + def __and__(self, other): + if isinstance(other, Matcher) or isinstance(other, AnyOf): + clauses = list(self.clauses | set([other])) + elif isinstance(other, AllOf): + clauses = list(self.clauses | other.clauses) + else: + return NotImplemented + return AllOf(*clauses) + + def __or__(self, other): + if isinstance(other, AnyOf): + return other | self + elif isinstance(other, Matcher): + return AnyOf(self, AllOf(other)) + elif isinstance(other, AllOf): + return AnyOf(self, other) + else: + return NotImplemented + + def __repr__(self): + return 'AllOf(%s)' % ', '.join(sorted(repr(c) for c in self.clauses)) + + def _pretty(self): + yield 'AllOF(' + for clause in self.clauses: + lines = list(clause._pretty()) + for line in lines[:-1]: + yield '\t' + line + yield '\t' + lines[-1] + ',' + yield ')' + + +class Matcher(Clause): + __slots__ = [] + + def __and__(self, other): + if isinstance(other, AllOf): + return other & self + elif isinstance(other, Matcher) or isinstance(other, AnyOf): + return AllOf(self, other) + else: + return NotImplemented + + def __or__(self, other): + if isinstance(other, AnyOf): + return other | self + elif isinstance(other, Matcher) or isinstance(other, AllOf): + return AnyOf(self, other) + else: + return NotImplemented + + +class Never(Matcher): + __slots__ = [] + + def match(self, version): + return False + + def __hash__(self): + return hash((Never,)) + + def __eq__(self, other): + return isinstance(other, self.__class__) + + def __and__(self, other): + return self + + def __or__(self, other): + return other + + def __repr__(self): + return 'Never()' + + +class Always(Matcher): + __slots__ = [] + + def match(self, version): + return True + + def __hash__(self): + return hash((Always,)) + + def __eq__(self, other): + return isinstance(other, self.__class__) + + def __and__(self, other): + return other + + def __or__(self, other): + return self + + def __repr__(self): + return 'Always()' + + +class Range(Matcher): + OP_EQ = '==' + OP_GT = '>' + OP_GTE = '>=' + OP_LT = '<' + OP_LTE = '<=' + OP_NEQ = '!=' + + # <1.2.3 matches 1.2.3-a1 + PRERELEASE_ALWAYS = 'always' + # <1.2.3 does not match 1.2.3-a1 + PRERELEASE_NATURAL = 'natural' + # 1.2.3-a1 is only considered if target == 1.2.3-xxx + PRERELEASE_SAMEPATCH = 'same-patch' + + # 1.2.3 matches 1.2.3+* + BUILD_IMPLICIT = 'implicit' + # 1.2.3 matches only 1.2.3, not 1.2.3+4 + BUILD_STRICT = 'strict' + + __slots__ = ['operator', 'target', 'prerelease_policy', 'build_policy'] + + def __init__(self, operator, target, prerelease_policy=PRERELEASE_NATURAL, build_policy=BUILD_IMPLICIT): + super(Range, self).__init__() + if target.build and operator not in (self.OP_EQ, self.OP_NEQ): + raise ValueError( + "Invalid range %s%s: build numbers have no ordering." + % (operator, target)) + self.operator = operator + self.target = target + self.prerelease_policy = prerelease_policy + self.build_policy = self.BUILD_STRICT if target.build else build_policy + + def match(self, version): + if self.build_policy != self.BUILD_STRICT: + version = version.truncate('prerelease') + + if version.prerelease: + same_patch = self.target.truncate() == version.truncate() + + if self.prerelease_policy == self.PRERELEASE_SAMEPATCH and not same_patch: + return False + + if self.operator == self.OP_EQ: + if self.build_policy == self.BUILD_STRICT: + return ( + self.target.truncate('prerelease') == version.truncate('prerelease') + and version.build == self.target.build + ) + return version == self.target + elif self.operator == self.OP_GT: + return version > self.target + elif self.operator == self.OP_GTE: + return version >= self.target + elif self.operator == self.OP_LT: + if ( + version.prerelease + and self.prerelease_policy == self.PRERELEASE_NATURAL + and version.truncate() == self.target.truncate() + and not self.target.prerelease + ): + return False + return version < self.target + elif self.operator == self.OP_LTE: + return version <= self.target + else: + assert self.operator == self.OP_NEQ + if self.build_policy == self.BUILD_STRICT: + return not ( + self.target.truncate('prerelease') == version.truncate('prerelease') + and version.build == self.target.build + ) + + if ( + version.prerelease + and self.prerelease_policy == self.PRERELEASE_NATURAL + and version.truncate() == self.target.truncate() + and not self.target.prerelease + ): + return False + return version != self.target + + def __hash__(self): + return hash((Range, self.operator, self.target, self.prerelease_policy)) + + def __eq__(self, other): + return ( + isinstance(other, self.__class__) + and self.operator == other.operator + and self.target == other.target + and self.prerelease_policy == other.prerelease_policy + ) + + def __str__(self): + return '%s%s' % (self.operator, self.target) + + def __repr__(self): + policy_part = ( + '' if self.prerelease_policy == self.PRERELEASE_NATURAL + else ', prerelease_policy=%r' % self.prerelease_policy + ) + ( + '' if self.build_policy == self.BUILD_IMPLICIT + else ', build_policy=%r' % self.build_policy + ) + return 'Range(%r, %r%s)' % ( + self.operator, + self.target, + policy_part, + ) + + +@BaseSpec.register_syntax +class SimpleSpec(BaseSpec): + + SYNTAX = 'simple' + + @classmethod + def _parse_to_clause(cls, expression): + return cls.Parser.parse(expression) + + class Parser: + NUMBER = r'\*|0|[1-9][0-9]*' + NAIVE_SPEC = re.compile(r"""^ + (?P<|<=||=|==|>=|>|!=|\^|~|~=) + (?P{nb})(?:\.(?P{nb})(?:\.(?P{nb}))?)? + (?:-(?P[a-z0-9A-Z.-]*))? + (?:\+(?P[a-z0-9A-Z.-]*))? + $ + """.format(nb=NUMBER), + re.VERBOSE, + ) + + @classmethod + def parse(cls, expression): + blocks = expression.split(',') + clause = Always() + for block in blocks: + if not cls.NAIVE_SPEC.match(block): + raise ValueError("Invalid simple block %r" % block) + clause &= cls.parse_block(block) + + return clause + + PREFIX_CARET = '^' + PREFIX_TILDE = '~' + PREFIX_COMPATIBLE = '~=' + PREFIX_EQ = '==' + PREFIX_NEQ = '!=' + PREFIX_GT = '>' + PREFIX_GTE = '>=' + PREFIX_LT = '<' + PREFIX_LTE = '<=' + + PREFIX_ALIASES = { + '=': PREFIX_EQ, + '': PREFIX_EQ, + } + + EMPTY_VALUES = ['*', 'x', 'X', None] + + @classmethod + def parse_block(cls, expr): + if not cls.NAIVE_SPEC.match(expr): + raise ValueError("Invalid simple spec component: %r" % expr) + prefix, major_t, minor_t, patch_t, prerel, build = cls.NAIVE_SPEC.match(expr).groups() + prefix = cls.PREFIX_ALIASES.get(prefix, prefix) + + major = None if major_t in cls.EMPTY_VALUES else int(major_t) + minor = None if minor_t in cls.EMPTY_VALUES else int(minor_t) + patch = None if patch_t in cls.EMPTY_VALUES else int(patch_t) + + if major is None: # '*' + target = Version(major=0, minor=0, patch=0) + if prefix not in (cls.PREFIX_EQ, cls.PREFIX_GTE): + raise ValueError("Invalid simple spec: %r" % expr) + elif minor is None: + target = Version(major=major, minor=0, patch=0) + elif patch is None: + target = Version(major=major, minor=minor, patch=0) + else: + target = Version( + major=major, + minor=minor, + patch=patch, + prerelease=prerel.split('.') if prerel else (), + build=build.split('.') if build else (), + ) + + if (major is None or minor is None or patch is None) and (prerel or build): + raise ValueError("Invalid simple spec: %r" % expr) + + if build is not None and prefix not in (cls.PREFIX_EQ, cls.PREFIX_NEQ): + raise ValueError("Invalid simple spec: %r" % expr) + + if prefix == cls.PREFIX_CARET: + # Accept anything with the same most-significant digit + if target.major: + high = target.next_major() + elif target.minor: + high = target.next_minor() + else: + high = target.next_patch() + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, high) + + elif prefix == cls.PREFIX_TILDE: + assert major is not None + # Accept any higher patch in the same minor + # Might go higher if the initial version was a partial + if minor is None: + high = target.next_major() + else: + high = target.next_minor() + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, high) + + elif prefix == cls.PREFIX_COMPATIBLE: + assert major is not None + # ~1 is 1.0.0..2.0.0; ~=2.2 is 2.2.0..3.0.0; ~=1.4.5 is 1.4.5..1.5.0 + if minor is None or patch is None: + # We got a partial version + high = target.next_major() + else: + high = target.next_minor() + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, high) + + elif prefix == cls.PREFIX_EQ: + if major is None: + return Range(Range.OP_GTE, target) + elif minor is None: + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, target.next_major()) + elif patch is None: + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, target.next_minor()) + elif build == '': + return Range(Range.OP_EQ, target, build_policy=Range.BUILD_STRICT) + else: + return Range(Range.OP_EQ, target) + + elif prefix == cls.PREFIX_NEQ: + assert major is not None + if minor is None: + # !=1.x => <1.0.0 || >=2.0.0 + return Range(Range.OP_LT, target) | Range(Range.OP_GTE, target.next_major()) + elif patch is None: + # !=1.2.x => <1.2.0 || >=1.3.0 + return Range(Range.OP_LT, target) | Range(Range.OP_GTE, target.next_minor()) + elif prerel == '': + # !=1.2.3- + return Range(Range.OP_NEQ, target, prerelease_policy=Range.PRERELEASE_ALWAYS) + elif build == '': + # !=1.2.3+ or !=1.2.3-a2+ + return Range(Range.OP_NEQ, target, build_policy=Range.BUILD_STRICT) + else: + return Range(Range.OP_NEQ, target) + + elif prefix == cls.PREFIX_GT: + assert major is not None + if minor is None: + # >1.x => >=2.0 + return Range(Range.OP_GTE, target.next_major()) + elif patch is None: + return Range(Range.OP_GTE, target.next_minor()) + else: + return Range(Range.OP_GT, target) + + elif prefix == cls.PREFIX_GTE: + return Range(Range.OP_GTE, target) + + elif prefix == cls.PREFIX_LT: + assert major is not None + if prerel == '': + # <1.2.3- + return Range(Range.OP_LT, target, prerelease_policy=Range.PRERELEASE_ALWAYS) + return Range(Range.OP_LT, target) + + else: + assert prefix == cls.PREFIX_LTE + assert major is not None + if minor is None: + # <=1.x => <2.0 + return Range(Range.OP_LT, target.next_major()) + elif patch is None: + return Range(Range.OP_LT, target.next_minor()) + else: + return Range(Range.OP_LTE, target) + + +class LegacySpec(SimpleSpec): + def __init__(self, *expressions): + warnings.warn( + "The Spec() class will be removed in 3.1; use SimpleSpec() instead.", + PendingDeprecationWarning, + stacklevel=2, + ) + + if len(expressions) > 1: + warnings.warn( + "Passing 2+ arguments to SimpleSpec will be removed in 3.0; concatenate them with ',' instead.", + DeprecationWarning, + stacklevel=2, + ) + expression = ','.join(expressions) + super(LegacySpec, self).__init__(expression) + + @property + def specs(self): + return list(self) + + def __iter__(self): + warnings.warn( + "Iterating over the components of a SimpleSpec object will be removed in 3.0.", + DeprecationWarning, + stacklevel=2, + ) + try: + clauses = list(self.clause) + except TypeError: # Not an iterable + clauses = [self.clause] + for clause in clauses: + yield SpecItem.from_matcher(clause) + + +Spec = LegacySpec + + +@BaseSpec.register_syntax +class NpmSpec(BaseSpec): + SYNTAX = 'npm' + + @classmethod + def _parse_to_clause(cls, expression): + return cls.Parser.parse(expression) + + class Parser: + JOINER = '||' + HYPHEN = ' - ' + + NUMBER = r'x|X|\*|0|[1-9][0-9]*' + PART = r'[a-zA-Z0-9.-]*' + NPM_SPEC_BLOCK = re.compile(r""" + ^(?:v)? # Strip optional initial v + (?P<|<=|>=|>|=|\^|~|) # Operator, can be empty + (?P{nb})(?:\.(?P{nb})(?:\.(?P{nb}))?)? + (?:-(?P{part}))? # Optional re-release + (?:\+(?P{part}))? # Optional build + $""".format(nb=NUMBER, part=PART), + re.VERBOSE, + ) + + @classmethod + def range(cls, operator, target): + return Range(operator, target, prerelease_policy=Range.PRERELEASE_SAMEPATCH) + + @classmethod + def parse(cls, expression): + result = Never() + groups = expression.split(cls.JOINER) + for group in groups: + group = group.strip() + if not group: + group = '>=0.0.0' + + subclauses = [] + if cls.HYPHEN in group: + low, high = group.split(cls.HYPHEN, 2) + subclauses = cls.parse_simple('>=' + low) + cls.parse_simple('<=' + high) + + else: + blocks = group.split(' ') + for block in blocks: + if not cls.NPM_SPEC_BLOCK.match(block): + raise ValueError("Invalid NPM block in %r: %r" % (expression, block)) + + subclauses.extend(cls.parse_simple(block)) + + prerelease_clauses = [] + non_prerel_clauses = [] + for clause in subclauses: + if clause.target.prerelease: + if clause.operator in (Range.OP_GT, Range.OP_GTE): + prerelease_clauses.append(Range( + operator=Range.OP_LT, + target=Version( + major=clause.target.major, + minor=clause.target.minor, + patch=clause.target.patch + 1, + ), + prerelease_policy=Range.PRERELEASE_ALWAYS, + )) + elif clause.operator in (Range.OP_LT, Range.OP_LTE): + prerelease_clauses.append(Range( + operator=Range.OP_GTE, + target=Version( + major=clause.target.major, + minor=clause.target.minor, + patch=0, + prerelease=(), + ), + prerelease_policy=Range.PRERELEASE_ALWAYS, + )) + prerelease_clauses.append(clause) + non_prerel_clauses.append(cls.range( + operator=clause.operator, + target=clause.target.truncate(), + )) + else: + non_prerel_clauses.append(clause) + if prerelease_clauses: + result |= AllOf(*prerelease_clauses) + result |= AllOf(*non_prerel_clauses) + + return result + + PREFIX_CARET = '^' + PREFIX_TILDE = '~' + PREFIX_EQ = '=' + PREFIX_GT = '>' + PREFIX_GTE = '>=' + PREFIX_LT = '<' + PREFIX_LTE = '<=' + + PREFIX_ALIASES = { + '': PREFIX_EQ, + } + + PREFIX_TO_OPERATOR = { + PREFIX_EQ: Range.OP_EQ, + PREFIX_LT: Range.OP_LT, + PREFIX_LTE: Range.OP_LTE, + PREFIX_GTE: Range.OP_GTE, + PREFIX_GT: Range.OP_GT, + } + + EMPTY_VALUES = ['*', 'x', 'X', None] + + @classmethod + def parse_simple(cls, simple): + match = cls.NPM_SPEC_BLOCK.match(simple) + + prefix, major_t, minor_t, patch_t, prerel, build = match.groups() + + prefix = cls.PREFIX_ALIASES.get(prefix, prefix) + major = None if major_t in cls.EMPTY_VALUES else int(major_t) + minor = None if minor_t in cls.EMPTY_VALUES else int(minor_t) + patch = None if patch_t in cls.EMPTY_VALUES else int(patch_t) + + if build is not None and prefix not in [cls.PREFIX_EQ]: + # Ignore the 'build' part when not comparing to a specific part. + build = None + + if major is None: # '*', 'x', 'X' + target = Version(major=0, minor=0, patch=0) + if prefix not in [cls.PREFIX_EQ, cls.PREFIX_GTE]: + raise ValueError("Invalid expression %r" % simple) + prefix = cls.PREFIX_GTE + elif minor is None: + target = Version(major=major, minor=0, patch=0) + elif patch is None: + target = Version(major=major, minor=minor, patch=0) + else: + target = Version( + major=major, + minor=minor, + patch=patch, + prerelease=prerel.split('.') if prerel else (), + build=build.split('.') if build else (), + ) + + if (major is None or minor is None or patch is None) and (prerel or build): + raise ValueError("Invalid NPM spec: %r" % simple) + + if prefix == cls.PREFIX_CARET: + if target.major: # ^1.2.4 => >=1.2.4 <2.0.0 ; ^1.x => >=1.0.0 <2.0.0 + high = target.truncate().next_major() + elif target.minor: # ^0.1.2 => >=0.1.2 <0.2.0 + high = target.truncate().next_minor() + elif minor is None: # ^0.x => >=0.0.0 <1.0.0 + high = target.truncate().next_major() + elif patch is None: # ^0.2.x => >=0.2.0 <0.3.0 + high = target.truncate().next_minor() + else: # ^0.0.1 => >=0.0.1 <0.0.2 + high = target.truncate().next_patch() + return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, high)] + + elif prefix == cls.PREFIX_TILDE: + assert major is not None + if minor is None: # ~1.x => >=1.0.0 <2.0.0 + high = target.next_major() + else: # ~1.2.x => >=1.2.0 <1.3.0; ~1.2.3 => >=1.2.3 <1.3.0 + high = target.next_minor() + return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, high)] + + elif prefix == cls.PREFIX_EQ: + if major is None: + return [cls.range(Range.OP_GTE, target)] + elif minor is None: + return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, target.next_major())] + elif patch is None: + return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, target.next_minor())] + else: + return [cls.range(Range.OP_EQ, target)] + + elif prefix == cls.PREFIX_GT: + assert major is not None + if minor is None: # >1.x + return [cls.range(Range.OP_GTE, target.next_major())] + elif patch is None: # >1.2.x => >=1.3.0 + return [cls.range(Range.OP_GTE, target.next_minor())] + else: + return [cls.range(Range.OP_GT, target)] + + elif prefix == cls.PREFIX_GTE: + return [cls.range(Range.OP_GTE, target)] + + elif prefix == cls.PREFIX_LT: + assert major is not None + return [cls.range(Range.OP_LT, target)] + + else: + assert prefix == cls.PREFIX_LTE + assert major is not None + if minor is None: # <=1.x => <2.0.0 + return [cls.range(Range.OP_LT, target.next_major())] + elif patch is None: # <=1.2.x => <1.3.0 + return [cls.range(Range.OP_LT, target.next_minor())] + else: + return [cls.range(Range.OP_LTE, target)] diff --git a/third_party/semantic_version/update-info.log b/third_party/semantic_version/update-info.log new file mode 100644 index 000000000..6d6b80668 --- /dev/null +++ b/third_party/semantic_version/update-info.log @@ -0,0 +1,2 @@ +ref: 2.10.0 +272a363824b1e09ae4e494ad00092f8782248821