Add BasedPyright as alternative LSP for Python (#1705)

This commit is contained in:
Tyce Herrman authored and GitHub committed 2026-07-21 20:11:32 +02:00
1 parent 2c10e25c97
commit 96ee9b65e2
10 files changed
+499 -12

No files matched your search

+3
View File
@@ -2,6 +2,9 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
* Language Servers:
- Add `python_basedpyright` as an alternative Python language server
# v1.6.1 (2026-07-21)
* General:
+3 -2
View File
@@ -126,10 +126,11 @@ Some languages require additional installations or setup steps, as noted.
and the experimental [PHPantom](https://github.com/PHPantom-dev/phpantom_lsp) backend (language `php_phpantom`)
* **Python**
(by default, uses [Pyright](https://github.com/microsoft/pyright) (language `python`);
alternatives: [ty](https://github.com/astral-sh/ty) (language `python_ty`),
alternatives: [BasedPyright](https://github.com/DetachHead/basedpyright) (language `python_basedpyright`),
[ty](https://github.com/astral-sh/ty) (language `python_ty`),
[pyrefly](https://github.com/facebook/pyrefly) (language `python_pyrefly`),
[Jedi](https://github.com/palotas/jedi-language-server) (language `python_jedi`);
ty and pyrefly require `uv`/`uvx` in PATH)
Pyright, BasedPyright, ty, and pyrefly require `uv`/`uvx` in PATH)
* **QML**
(requires Qt 6, provides `qmlls` or `qmlls6` on PATH; see the [Qt qmlls documentation](https://doc.qt.io/qt-6/qtqml-tool-qmlls.html))
* **R**
+31 -4
View File
@@ -969,16 +969,43 @@ Supported settings:
#### Python
Serena uses Pyright for the `python` language key.
Serena supports several Python language servers through separate language keys.
##### Pyright (`python`)
Pyright is the default Python language server.
Supported settings:
| Setting | Default | Description |
|---|---|---|
| `ls_path` | current Python executable | Override the Python interpreter Serena uses to run `-m pyright.langserver`. |
| `pyright_version` | `1.1.403` | Override the exact Pyright package version Serena launches through `uvx` / `uv tool run`. |
| `ls_path` | managed executable | Override `pyright-langserver` and bypass the managed `uvx` / `uv tool run` invocation. The default `--stdio` argument is still applied. |
Note:
- There is currently no separate `python_ty` language key in Serena's current SolidLSP implementation.
##### BasedPyright (`python_basedpyright`)
To use [BasedPyright](https://github.com/DetachHead/basedpyright), select its separate experimental
language key:
```yaml
languages: [python_basedpyright]
ls_specific_settings:
python_basedpyright:
basedpyright_version: "1.39.9"
```
Supported settings:
| Setting | Default | Description |
|---|---|---|
| `basedpyright_version` | `1.39.9` | Override the exact BasedPyright package version Serena launches through `uvx` / `uv tool run`. |
| `ls_path` | managed executable | Override `basedpyright-langserver` and bypass the managed `uvx` / `uv tool run` invocation. The default `--stdio` argument is still applied. |
The generic [language-server launch settings](override-ls-path), including `ls_base_cmd`, `ls_args`,
and `ls_extra_args`, apply to both servers. `ls_args` replaces the default arguments, while
`ls_extra_args` appends to them.
Other alternative Python language keys are `python_ty`, `python_pyrefly`, and `python_jedi`.
#### Ruby
+9 -3
View File
@@ -109,7 +109,10 @@ Some parts of Serena rely on `uv` / `uvx`.
One important detail is that `uvx` ignores the lockfile when installing directly from a Git repository. Because of that, we pin Serena's Python dependencies exactly in `pyproject.toml` so that installations from Git still resolve to exact dependency versions rather than floating ranges.
For the `ty` Python language server, Serena also uses an exact pinned version when invoking it through `uvx`.
For the Pyright, BasedPyright, and `ty` Python language servers, Serena uses exact pinned versions when
invoking them through `uvx` / `uv tool run`. Pyright and BasedPyright default to `1.1.403` and `1.39.9`,
respectively; `ls_specific_settings.python.pyright_version` and
`ls_specific_settings.python_basedpyright.basedpyright_version` can override those pins.
```{dropdown} What Serena Downloads by Default for Language Servers
:open:
@@ -162,11 +165,14 @@ All of the above are installed with exact pinned package versions by default, in
- **F#**: installs pinned `fsautocomplete` via `dotnet tool install`.
- **Ruby (`ruby-lsp`)**: if not already available through Bundler or as a global executable, Serena installs a pinned `ruby-lsp` gem.
- **Python (`ty`)**: launched through `uvx` / `uv x` using an exact pinned `ty` version.
- **Python (`python`)**: Pyright is launched through `uvx` / `uv tool run` using an exact pinned version.
Supplying `ls_path` bypasses this managed invocation.
- **Python (`python_basedpyright`)**: BasedPyright is launched through `uvx` / `uv tool run` using an exact
pinned version. Supplying `ls_path` bypasses this managed invocation.
- **Python (`python_ty`)**: launched through `uvx` / `uv tool run` using an exact pinned `ty` version.
- **HLSL on macOS**: if no prebuilt binary is used, Serena builds `shader_language_server` from a pinned version using Cargo.
### No Automatic Download by Serena
- **Python (`pyright`)**: Serena uses the locally available Python environment and starts `pyright.langserver` from there.
- **Go (`gopls`)**, **Rust (`rust-analyzer`)**, and several other system-tool based integrations expect the language server to be available locally and do not download it automatically.
```
@@ -0,0 +1,197 @@
"""
Provides a standalone BasedPyright language server integration for Python.
"""
import logging
import re
import threading
from typing import cast
from overrides import override
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
BASEDPYRIGHT_VERSION = "1.39.9"
class BasedPyrightLanguageServer(SolidLanguageServer):
"""Provides Python language support using BasedPyright."""
_TIMEOUT_FOR_INITIAL_ANALYSIS = 60.0
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates a BasedPyrightLanguageServer instance. This class is not meant to be instantiated directly.
Use LanguageServer.create() instead.
"""
super().__init__(
config,
repository_root_path,
None,
str(config.code_language),
solidlsp_settings,
)
self.analysis_complete = threading.Event()
self.found_source_files = False
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return LanguageServerDependencyProviderUvx(
self._custom_settings,
self._ls_resources_dir,
package="basedpyright",
entrypoint="basedpyright-langserver",
default_version=BASEDPYRIGHT_VERSION,
version_setting_key="basedpyright_version",
extra_args=("--stdio",),
)
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"]
def _create_base_initialize_params(self) -> dict:
initialize_params = {
"initializationOptions": {
"exclude": [
"**/__pycache__",
"**/.venv",
"**/.env",
"**/build",
"**/dist",
"**/.pixi",
],
"reportMissingImports": "error",
},
"capabilities": {
"workspace": {
"workspaceEdit": {"documentChanges": True},
"didChangeConfiguration": {"dynamicRegistration": True},
"didChangeWatchedFiles": {"dynamicRegistration": True},
"symbol": {
"dynamicRegistration": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"executeCommand": {"dynamicRegistration": True},
},
"textDocument": {
"synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"signatureHelp": {
"dynamicRegistration": True,
"signatureInformation": {
"documentationFormat": ["markdown", "plaintext"],
"parameterInformation": {"labelOffsetSupport": True},
},
},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"symbolKind": {"valueSet": list(range(1, 27))},
"hierarchicalDocumentSymbolSupport": True,
},
"publishDiagnostics": {"relatedInformation": True},
},
},
}
return initialize_params
def _start_server(self) -> None:
"""Starts BasedPyright and waits for initial workspace analysis to complete."""
def execute_client_command_handler(params: dict) -> list:
return []
def do_nothing(params: dict) -> None:
return
def window_log_message(msg: dict) -> None:
message_text = msg.get("message", "")
log.info("LSP: window/logMessage: %s", message_text)
if re.search(r"Found \d+ source files?", message_text):
log.info("BasedPyright workspace scanning complete")
self.found_source_files = True
self.analysis_complete.set()
def handle_basedpyright_progress_notification(progress_kind: str, params: object | None) -> None:
message_text = ""
percentage: object | None = None
if isinstance(params, dict):
params_dict = cast("dict[str, object]", params)
raw_message = params_dict.get("message")
message_text = "" if raw_message is None else str(raw_message)
percentage = params_dict.get("percentage")
elif params is not None:
message_text = str(params)
progress_label = f"{message_text} ({percentage}%)" if percentage is not None else message_text
if progress_kind == "begin":
log.info("BasedPyright progress started: %s", progress_label)
return
if progress_kind == "report":
log.debug("BasedPyright progress update: %s", progress_label)
return
log.info("BasedPyright progress finished: %s", progress_label)
self.analysis_complete.set()
def basedpyright_begin_progress(params: object | None) -> None:
handle_basedpyright_progress_notification("begin", params)
def basedpyright_report_progress(params: object | None) -> None:
handle_basedpyright_progress_notification("report", params)
def basedpyright_end_progress(params: object | None) -> None:
handle_basedpyright_progress_notification("end", params)
def check_experimental_status(params: dict) -> None:
if params.get("quiescent") == True:
log.info("Received experimental/serverStatus with quiescent=true")
if not self.found_source_files:
self.analysis_complete.set()
self.server.on_request("client/registerCapability", do_nothing)
self.server.on_notification("language/status", do_nothing)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
self.server.on_notification("$/progress", do_nothing)
# BasedPyright retains the Pyright progress-notification method names.
self.server.on_notification("pyright/beginProgress", basedpyright_begin_progress)
self.server.on_notification("pyright/reportProgress", basedpyright_report_progress)
self.server.on_notification("pyright/endProgress", basedpyright_end_progress)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_notification("language/actionableNotification", do_nothing)
self.server.on_notification("experimental/serverStatus", check_experimental_status)
log.info("Starting basedpyright-langserver server process")
self.server.start()
initialize_params = self._create_initialize_params()
log.info("Sending initialize request from LSP client to BasedPyright server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
log.info("Received initialize response from BasedPyright server: %s", init_response)
assert "textDocumentSync" in init_response["capabilities"]
assert "completionProvider" in init_response["capabilities"]
assert "definitionProvider" in init_response["capabilities"]
self.server.notify.initialized({})
log.info(
"Waiting up to %ss for BasedPyright to complete initial workspace analysis...",
self._TIMEOUT_FOR_INITIAL_ANALYSIS,
)
if self.analysis_complete.wait(timeout=self._TIMEOUT_FOR_INITIAL_ANALYSIS):
log.info("BasedPyright initial analysis complete, server ready")
else:
log.warning("Timeout waiting for BasedPyright analysis completion, proceeding anyway")
self.analysis_complete.set()
+8 -1
View File
@@ -194,6 +194,8 @@ class Language(str, Enum):
"""Ty language server for Python (instead of pyright, which is the default)."""
PYTHON_PYREFLY = "python_pyrefly"
"""Pyrefly language server for Python (instead of pyright, which is the default)."""
PYTHON_BASEDPYRIGHT = "python_basedpyright"
"""BasedPyright language server for Python (instead of pyright, which is the default)."""
CSHARP_OMNISHARP = "csharp_omnisharp"
"""OmniSharp language server for C# (instead of the default csharp-ls by microsoft).
Currently has problems with finding references, and generally seems less stable and performant.
@@ -306,6 +308,7 @@ class Language(str, Enum):
self.PYTHON_JEDI,
self.PYTHON_TY,
self.PYTHON_PYREFLY,
self.PYTHON_BASEDPYRIGHT,
self.CSHARP_OMNISHARP,
self.RUBY_SOLARGRAPH,
self.PHP_PHPACTOR,
@@ -359,7 +362,7 @@ class Language(str, Enum):
@cache
def get_source_fn_matcher(self) -> FilenameMatcher:
match self:
case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY | self.PYTHON_PYREFLY:
case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY | self.PYTHON_PYREFLY | self.PYTHON_BASEDPYRIGHT:
return FilenameMatcher(".py", ".pyi")
case self.JAVA:
return FilenameMatcher(".java")
@@ -609,6 +612,10 @@ class Language(str, Enum):
from solidlsp.language_servers.pyrefly_server import PyreflyLanguageServer
return PyreflyLanguageServer
case self.PYTHON_BASEDPYRIGHT:
from solidlsp.language_servers.basedpyright_server import BasedPyrightLanguageServer
return BasedPyrightLanguageServer
case self.JAVA:
from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS
+3 -1
View File
@@ -47,13 +47,14 @@ _LANGUAGE_REPO_ALIASES: dict[Language, Language] = {
Language.PHP_PHPACTOR: Language.PHP,
Language.PHP_PHPANTOM: Language.PHP,
Language.PYTHON_JEDI: Language.PYTHON,
Language.PYTHON_BASEDPYRIGHT: Language.PYTHON,
Language.PYTHON_TY: Language.PYTHON,
Language.RUBY_SOLARGRAPH: Language.RUBY,
Language.PYTHON_TY: Language.PYTHON,
Language.PYTHON_PYREFLY: Language.PYTHON,
}
PYTHON_LANGUAGE_BACKENDS = [Language.PYTHON, Language.PYTHON_TY]
PYTHON_LANGUAGE_BACKENDS = [Language.PYTHON, Language.PYTHON_TY, Language.PYTHON_BASEDPYRIGHT]
def get_repo_path(language: Language) -> Path:
@@ -295,6 +296,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = {
Language.PYTHON_JEDI: [pytest.mark.python],
Language.PYTHON_TY: [pytest.mark.python],
Language.PYTHON_PYREFLY: [pytest.mark.python],
Language.PYTHON_BASEDPYRIGHT: [pytest.mark.python],
Language.RUST: [pytest.mark.rust],
Language.TYPESCRIPT: [pytest.mark.typescript],
Language.BSL: [pytest.mark.bsl],
+6 -1
View File
@@ -4,7 +4,12 @@ from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY, Language.PYTHON_PYREFLY]
PYTHON_BACKEND_LANGUAGES = [
Language.PYTHON,
Language.PYTHON_TY,
Language.PYTHON_PYREFLY,
Language.PYTHON_BASEDPYRIGHT,
]
def read_repo_file(language_server: SolidLanguageServer, relative_path: str) -> str:
+98
View File
@@ -0,0 +1,98 @@
from collections.abc import Iterator
from pathlib import Path
from textwrap import dedent
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.conftest import start_ls_context
from test.solidlsp.conftest import document_symbol_names
pytestmark = pytest.mark.python
@pytest.fixture(scope="module")
def basedpyright_project(tmp_path_factory: pytest.TempPathFactory) -> Path:
project_root = tmp_path_factory.mktemp("basedpyright-project")
files = {
"pyproject.toml": """
[tool.basedpyright]
typeCheckingMode = "basic"
reportExplicitAny = "error"
""",
"models.py": """
class Greeter:
def greet(self, name: str) -> str:
return f"Hello, {name}"
""",
"service.py": """
from models import Greeter
def build_message(name: str) -> str:
greeter = Greeter()
return greeter.greet(name)
""",
"consumer.py": """
from service import build_message
MESSAGE = build_message("Ada")
""",
"diagnostics.py": """
from typing import Any
def echo(value: Any) -> Any:
return value
""",
}
for relative_path, content in files.items():
(project_root / relative_path).write_text(dedent(content).lstrip(), encoding="utf-8")
return project_root
@pytest.fixture(scope="module")
def basedpyright_language_server(basedpyright_project: Path) -> Iterator[SolidLanguageServer]:
with start_ls_context(
language=Language.PYTHON_BASEDPYRIGHT,
repo_path=str(basedpyright_project),
) as language_server:
yield language_server
def test_basedpyright_starts(
basedpyright_language_server: SolidLanguageServer,
basedpyright_project: Path,
) -> None:
assert basedpyright_language_server.language == Language.PYTHON_BASEDPYRIGHT
assert basedpyright_language_server.is_running()
assert Path(basedpyright_language_server.language_server.repository_root_path).resolve() == basedpyright_project.resolve()
def test_basedpyright_document_symbols(basedpyright_language_server: SolidLanguageServer) -> None:
assert "build_message" in document_symbol_names(basedpyright_language_server, "service.py")
def test_basedpyright_definitions_and_references(basedpyright_language_server: SolidLanguageServer) -> None:
defining_symbol = basedpyright_language_server.request_defining_symbol("service.py", 4, 15)
assert defining_symbol is not None
assert defining_symbol["name"] == "Greeter"
assert defining_symbol["location"]["relativePath"] == "models.py"
symbols = basedpyright_language_server.request_document_symbols("models.py").get_all_symbols_and_roots()
greeter_symbol = next(symbol for symbol in symbols[0] if symbol.get("name") == "Greeter")
selection_start = greeter_symbol["selectionRange"]["start"]
references = basedpyright_language_server.request_references(
"models.py",
selection_start["line"],
selection_start["character"],
)
assert any(reference["uri"].endswith("/service.py") for reference in references), references
def test_basedpyright_specific_diagnostics(basedpyright_language_server: SolidLanguageServer) -> None:
diagnostics = basedpyright_language_server.request_text_document_diagnostics("diagnostics.py", min_severity=1)
messages = [diagnostic["message"] for diagnostic in diagnostics]
assert any("Any" in message for message in messages), messages
@@ -0,0 +1,141 @@
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from solidlsp.dependency_provider import LanguageServerDependencyProviderUvx
from solidlsp.language_servers.basedpyright_server import BASEDPYRIGHT_VERSION, BasedPyrightLanguageServer
from solidlsp.language_servers.pyright_server import PyrightServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.settings import SolidLSPSettings
def _make_basedpyright_server(
tmp_path: Path,
custom_settings: dict[str, object] | None = None,
) -> BasedPyrightLanguageServer:
settings = SolidLSPSettings(
solidlsp_dir=str(tmp_path / "global"),
project_data_path=str(tmp_path / "project"),
ls_specific_settings={Language.PYTHON_BASEDPYRIGHT: custom_settings or {}},
)
server_interface = Mock()
with patch.object(BasedPyrightLanguageServer, "_create_language_server_interface", return_value=server_interface):
return BasedPyrightLanguageServer(
LanguageServerConfig(code_language=Language.PYTHON_BASEDPYRIGHT),
str(tmp_path),
settings,
)
def _make_pyright_server(tmp_path: Path) -> PyrightServer:
settings = SolidLSPSettings(
solidlsp_dir=str(tmp_path / "global"),
project_data_path=str(tmp_path / "project"),
)
server_interface = Mock()
with patch.object(PyrightServer, "_create_language_server_interface", return_value=server_interface):
return PyrightServer(LanguageServerConfig(code_language=Language.PYTHON), str(tmp_path), settings)
def test_dependency_provider_uses_basedpyright_profile(tmp_path: Path) -> None:
server = _make_basedpyright_server(tmp_path)
provider = server._create_dependency_provider()
assert isinstance(provider, LanguageServerDependencyProviderUvx)
assert provider._package == "basedpyright"
assert provider._entrypoint == "basedpyright-langserver"
assert provider._default_version == BASEDPYRIGHT_VERSION
assert provider._version_setting_key == "basedpyright_version"
def test_version_override_builds_uvx_command(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
custom_version = "1.99.0"
server = _make_basedpyright_server(tmp_path, {"basedpyright_version": custom_version})
provider = server._create_dependency_provider()
monkeypatch.delenv("UVX", raising=False)
with patch("solidlsp.dependency_provider.shutil.which", return_value="/opt/bin/uvx"):
command = provider.create_launch_command()
assert command == [
"/opt/bin/uvx",
"-p",
"3.13",
"--from",
f"basedpyright=={custom_version}",
"basedpyright-langserver",
"--stdio",
]
def test_builds_uv_tool_run_fallback_command(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
server = _make_basedpyright_server(tmp_path)
provider = server._create_dependency_provider()
monkeypatch.delenv("UVX", raising=False)
def find_uv(executable: str) -> str | None:
return None if executable == "uvx" else "/opt/bin/uv"
with patch("solidlsp.dependency_provider.shutil.which", side_effect=find_uv):
command = provider.create_launch_command()
assert command == [
"/opt/bin/uv",
"tool",
"run",
"-p",
"3.13",
"--from",
f"basedpyright=={BASEDPYRIGHT_VERSION}",
"basedpyright-langserver",
"--stdio",
]
def test_ls_path_preserves_default_and_extra_arguments(tmp_path: Path) -> None:
server = _make_basedpyright_server(
tmp_path,
{
"ls_path": "/custom/basedpyright-langserver",
"ls_extra_args": ["--verbose"],
},
)
assert server._create_dependency_provider().create_launch_command() == [
"/custom/basedpyright-langserver",
"--stdio",
"--verbose",
]
def test_base_command_and_args_overrides_are_preserved(tmp_path: Path) -> None:
server = _make_basedpyright_server(
tmp_path,
{
"ls_base_cmd": ["custom-launcher", "basedpyright-langserver"],
"ls_args": ["--custom-stdio"],
"ls_extra_args": ["--verbose"],
},
)
assert server._create_dependency_provider().create_launch_command() == [
"custom-launcher",
"basedpyright-langserver",
"--custom-stdio",
"--verbose",
]
def test_language_registry_uses_separate_server_classes() -> None:
assert Language.PYTHON.get_ls_class() is PyrightServer
assert Language.PYTHON_BASEDPYRIGHT.get_ls_class() is BasedPyrightLanguageServer
def test_language_identity_separates_cache_directories(tmp_path: Path) -> None:
pyright_server = _make_pyright_server(tmp_path)
basedpyright_server = _make_basedpyright_server(tmp_path)
assert pyright_server.language == Language.PYTHON
assert basedpyright_server.language == Language.PYTHON_BASEDPYRIGHT
assert pyright_server.cache_dir != basedpyright_server.cache_dir