From 1122166b7cfa8458fafcad99651ba40997f98be3 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 26 May 2026 15:31:56 +0200 Subject: [PATCH] Remove pyright and fortls from serena-agent package, instead install with uvx on the fly Added a dedicated LSDependencyProvider for that --- CHANGELOG.md | 2 + pyproject.toml | 2 - src/solidlsp/language_servers/common.py | 37 ++++++++++++++ .../fortran_language_server.py | 38 +++++++-------- .../language_servers/pyright_server.py | 22 +++++---- src/solidlsp/language_servers/ty_server.py | 34 ++++--------- src/solidlsp/ls.py | 45 ++++++++++++++++- uv.lock | 48 ------------------- 8 files changed, 124 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4411a500..088d77d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Status of the `main` branch. Changes prior to the next official version change w * General: - Not existing paths return `False` on is ignored checks (instead of raising an error) + - Add `serena-agent` CLI command so that `uvx serena-agent` can be used as entrypoint. + - Fortls and pyright are now installed on the fly instead of being bundled in the serena-agent package. # v1.5.1 (2026-05-18) diff --git a/pyproject.toml b/pyproject.toml index a544f193..5d856691 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,8 +18,6 @@ classifiers = [ ] dependencies = [ "requests==2.33.0", - "pyright==1.1.403", - "fortls==3.2.2", "overrides==7.7.0", "python-dotenv==1.2.2", "mcp==1.27.0", diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py index 20017630..16a97411 100644 --- a/src/solidlsp/language_servers/common.py +++ b/src/solidlsp/language_servers/common.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import os import platform +import shutil import subprocess from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, replace @@ -154,6 +155,42 @@ class RuntimeDependencyCollection: ) +DEFAULT_UVX_PYTHON_VERSION = "3.13" + + +def build_uvx_launch_command( + package: str, + version: str, + entrypoint: str, + extra_args: Sequence[str] = (), + python_version: str = DEFAULT_UVX_PYTHON_VERSION, +) -> list[str]: + """Build a command that runs a pinned PyPI package's console script on demand via ``uvx`` / ``uv x``. + + Resolution order: + 1. Prefer ``uvx`` (env var ``UVX`` or PATH lookup). + 2. Fall back to ``uv x`` if only ``uv`` is on PATH. + 3. Raise ``RuntimeError`` if neither is available. + + :param package: PyPI package name (e.g. ``"pyright"``). + :param version: Pinned package version. + :param entrypoint: Console script provided by the package (e.g. ``"pyright-langserver"``). + :param extra_args: Arguments appended after the entrypoint (e.g. ``("--stdio",)``). + :param python_version: Python interpreter version passed via ``-p`` (uv will fetch it if missing). + """ + base_args = ["-p", python_version, "--from", f"{package}=={version}", entrypoint, *extra_args] + + uvx_path = os.environ.get("UVX") or shutil.which("uvx") + if uvx_path is not None: + return [uvx_path, *base_args] + + uv_path = shutil.which("uv") + if uv_path is not None: + return [uv_path, "x", *base_args] + + raise RuntimeError("Could not find 'uvx' or 'uv' in PATH. Install uv (https://docs.astral.sh/uv/).") + + def build_npm_install_command(package_name: str, version: str, registry: str | None = None) -> list[str]: """Build a pinned npm install command for a package in a Serena-managed install directory.""" command = ["npm", "install", "--prefix", "./"] diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py index a80efc68..45d7071b 100644 --- a/src/solidlsp/language_servers/fortran_language_server.py +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -6,19 +6,26 @@ import logging import os import pathlib import re -import shutil from overrides import override from solidlsp import ls_types -from solidlsp.ls import DocumentSymbols, LSPConstants, LSPFileBuffer, SolidLanguageServer +from solidlsp.ls import ( + DocumentSymbols, + LanguageServerDependencyProvider, + LanguageServerDependencyProviderUvx, + LSPConstants, + LSPFileBuffer, + SolidLanguageServer, +) from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams -from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +FORTLS_VERSION = "3.2.2" + class FortranLanguageServer(SolidLanguageServer): """Fortran Language Server implementation using fortls.""" @@ -170,24 +177,17 @@ class FortranLanguageServer(SolidLanguageServer): return DocumentSymbols(fixed_root_symbols) - @staticmethod - def _check_fortls_installation() -> str: - """Check if fortls is available.""" - fortls_path = shutil.which("fortls") - if fortls_path is None: - raise RuntimeError("fortls is not installed or not in PATH.\nInstall it with: pip install fortls") - return fortls_path - def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): - # Check fortls installation - fortls_path = self._check_fortls_installation() + super().__init__(config, repository_root_path, None, "fortran", solidlsp_settings) - # Command to start fortls language server - # fortls uses stdio for LSP communication by default - fortls_cmd = f"{fortls_path}" - - super().__init__( - config, repository_root_path, ProcessLaunchInfo(cmd=fortls_cmd, cwd=repository_root_path), "fortran", solidlsp_settings + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return LanguageServerDependencyProviderUvx( + self._custom_settings, + self._ls_resources_dir, + package="fortls", + entrypoint="fortls", + default_version=FORTLS_VERSION, + version_setting_key="fortls_version", ) @staticmethod diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py index 7f774836..d4aa1265 100644 --- a/src/solidlsp/language_servers/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -6,19 +6,20 @@ import logging import os import pathlib import re -import sys import threading from typing import cast from overrides import override -from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +PYRIGHT_VERSION = "1.1.403" + class PyrightServer(SolidLanguageServer): """ @@ -44,14 +45,15 @@ class PyrightServer(SolidLanguageServer): self.found_source_files = False def _create_dependency_provider(self) -> LanguageServerDependencyProvider: - return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - - class DependencyProvider(LanguageServerDependencyProviderSinglePath): - def _get_or_install_core_dependency(self) -> str: - return sys.executable - - def _create_launch_command(self, core_path: str) -> list[str]: - return [core_path, "-m", "pyright.langserver", "--stdio"] + return LanguageServerDependencyProviderUvx( + self._custom_settings, + self._ls_resources_dir, + package="pyright", + entrypoint="pyright-langserver", + default_version=PYRIGHT_VERSION, + version_setting_key="pyright_version", + extra_args=("--stdio",), + ) @override def is_ignored_dirname(self, dirname: str) -> bool: diff --git a/src/solidlsp/language_servers/ty_server.py b/src/solidlsp/language_servers/ty_server.py index 0adb8f64..1e0832f0 100644 --- a/src/solidlsp/language_servers/ty_server.py +++ b/src/solidlsp/language_servers/ty_server.py @@ -10,12 +10,11 @@ You can pass the following entries in ``ls_specific_settings["python_ty"]``: import logging import os import pathlib -import shutil from typing import cast from typing_extensions import override -from solidlsp.ls import LanguageServerDependencyProvider, SolidLanguageServer +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings @@ -44,28 +43,15 @@ class TyLanguageServer(SolidLanguageServer): ) def _create_dependency_provider(self) -> LanguageServerDependencyProvider: - return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - - class DependencyProvider(LanguageServerDependencyProvider): - def create_launch_command(self) -> list[str]: - # respecting an explicit override - ls_path = self._custom_settings.get("ls_path") - if ls_path is not None: - return [ls_path, "server"] - - ty_version = self._custom_settings.get("ty_version", TY_VERSION) - - # preferring uvx for on-demand execution - uvx_path = os.environ.get("UVX") or shutil.which("uvx") - if uvx_path is not None: - return [uvx_path, "--from", f"ty=={ty_version}", "ty", "server"] - - # falling back to uv's uvx-compatible subcommand when only `uv` is available - uv_path = shutil.which("uv") - if uv_path is not None: - return [uv_path, "x", "--from", f"ty=={ty_version}", "ty", "server"] - - raise RuntimeError("Could not find 'uvx' or 'uv' in PATH.\nInstall uv or provide ls_specific_settings.python_ty.ls_path.") + return LanguageServerDependencyProviderUvx( + self._custom_settings, + self._ls_resources_dir, + package="ty", + entrypoint="ty", + default_version=TY_VERSION, + version_setting_key="ty_version", + extra_args=("server",), + ) @override def is_ignored_dirname(self, dirname: str) -> bool: diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 64bf112c..ecf1dea7 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -8,7 +8,7 @@ import shutil import threading from abc import ABC, abstractmethod from collections import defaultdict -from collections.abc import Callable, Hashable, Iterator +from collections.abc import Callable, Hashable, Iterator, Sequence from contextlib import contextmanager from copy import copy from pathlib import Path, PurePath @@ -22,6 +22,7 @@ from sensai.util.string import ToStringMixin from serena.util.file_system import match_path from serena.util.text_utils import MatchedConsecutiveLines from solidlsp import ls_types +from solidlsp.language_servers.common import build_uvx_launch_command from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_process import LanguageServerInterface, StdioLanguageServer @@ -333,6 +334,48 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide """ +class LanguageServerDependencyProviderUvx(LanguageServerDependencyProvider): + """ + Dependency provider for language servers distributed as a PyPI package, run on demand via ``uvx`` / ``uv x``. + + The pinned package version can be overridden by the user via the LS-specific setting given by + ``version_setting_key``. Alternatively, the LS-specific setting "ls_path" can be set to the path of an + already-installed language server executable, in which case it is launched directly, bypassing uv entirely. + """ + + def __init__( + self, + custom_settings: "SolidLSPSettings.CustomLSSettings", + ls_resources_dir: str, + *, + package: str, + entrypoint: str, + default_version: str, + version_setting_key: str, + extra_args: Sequence[str] = (), + ): + """ + :param package: the PyPI package name (e.g. ``"pyright"``) + :param entrypoint: the console script provided by the package (e.g. ``"pyright-langserver"``) + :param default_version: the package version to pin unless overridden + :param version_setting_key: the LS-specific setting key through which the user can override the version + :param extra_args: arguments appended after the entrypoint (e.g. ``("--stdio",)``) + """ + super().__init__(custom_settings, ls_resources_dir) + self._package = package + self._entrypoint = entrypoint + self._default_version = default_version + self._version_setting_key = version_setting_key + self._extra_args = tuple(extra_args) + + def create_launch_command(self) -> list[str]: + ls_path = self._custom_settings.get("ls_path") + if ls_path is not None: + return [ls_path, *self._extra_args] + version = self._custom_settings.get(self._version_setting_key, self._default_version) + return build_uvx_launch_command(self._package, version, self._entrypoint, self._extra_args) + + class SolidLanguageServer(ABC): """ High-level abstraction for language server interaction, which wraps the underlying low-level LSP interface diff --git a/uv.lock b/uv.lock index 7c3141fb..953f8a9c 100644 --- a/uv.lock +++ b/uv.lock @@ -671,19 +671,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] -[[package]] -name = "fortls" -version = "3.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "json5" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c1/2b/db1e5cd07fc9e74a2e4fb8f65946f8fd79ef72211001af00982a04d977d5/fortls-3.2.2.tar.gz", hash = "sha256:b43b2b8cbd447ae848c63b8f008c2df96fd48c3a967b33f6ed64b3421496883b", size = 7085222, upload-time = "2025-03-16T18:03:11.377Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/7e/41a55a49e764243fd72b1ed202a95d9a9193586d8665bdc94e4df92050e4/fortls-3.2.2-py3-none-any.whl", hash = "sha256:b7ae8378ce9741064b8340d3f1fbbbf7ab697285e174a0208fed98485c4edfe3", size = 278359, upload-time = "2025-03-16T18:03:09.969Z" }, -] - [[package]] name = "gitdb" version = "4.0.12" @@ -1135,15 +1122,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/4f/1195bbac8e0c2acc5f740661631d8d750dc38d4a32b23ee5df3cde6f4e0d/joblib-1.5.1-py3-none-any.whl", hash = "sha256:4719a31f054c7d766948dcd83e9613686b27114f190f717cec7eaa2084f8a74a", size = 307746, upload-time = "2025-05-23T12:04:35.124Z" }, ] -[[package]] -name = "json5" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -1616,15 +1594,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] -[[package]] -name = "nodeenv" -version = "1.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, -] - [[package]] name = "overrides" version = "7.7.0" @@ -2235,19 +2204,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/86/637cda4983dc0936b73a385f3906256953ac434537b812814cb0b6d231a2/pyobjc_framework_webkit-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1aaa3bf12c7b68e1a36c0b294d2728e06f2cc220775e6dc4541d5046290e4dc8", size = 50680, upload-time = "2025-11-14T10:07:23.331Z" }, ] -[[package]] -name = "pyright" -version = "1.1.403" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526, upload-time = "2025-07-09T07:15:52.882Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504, upload-time = "2025-07-09T07:15:50.958Z" }, -] - [[package]] name = "pystray" version = "0.19.5" @@ -2867,7 +2823,6 @@ dependencies = [ { name = "dotenv" }, { name = "filelock" }, { name = "flask" }, - { name = "fortls" }, { name = "jinja2" }, { name = "joblib" }, { name = "lsprotocol" }, @@ -2877,7 +2832,6 @@ dependencies = [ { name = "psutil" }, { name = "pydantic" }, { name = "pygls" }, - { name = "pyright" }, { name = "pystray" }, { name = "python-dotenv" }, { name = "python-multipart" }, @@ -2938,7 +2892,6 @@ requires-dist = [ { name = "dotenv", specifier = "==0.9.9" }, { name = "filelock", specifier = "==3.25.2" }, { name = "flask", specifier = "==3.1.3" }, - { name = "fortls", specifier = "==3.2.2" }, { name = "google-genai", marker = "extra == 'google'", specifier = "==1.27.0" }, { name = "jinja2", specifier = "==3.1.6" }, { name = "jinja2", marker = "extra == 'dev'", specifier = "==3.1.6" }, @@ -2956,7 +2909,6 @@ requires-dist = [ { name = "pygls", specifier = "==2.1.1" }, { name = "pyinstrument", marker = "extra == 'dev'", specifier = "==5.1.1" }, { name = "pyjwt", marker = "extra == 'dev'", specifier = "==2.12.0" }, - { name = "pyright", specifier = "==1.1.403" }, { name = "pystray", specifier = "==0.19.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==8.4.1" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = "==2.4.0" },