Skip to content

Commit

Permalink
feat: allow users to decide the order of strategies finding venv
Browse files Browse the repository at this point in the history
See plugin settings: pyright.venv.finding_order

Signed-off-by: Jack Cherng <[email protected]>
  • Loading branch information
jfcherng committed Jun 5, 2024
1 parent a7df5d8 commit 4937130
Show file tree
Hide file tree
Showing 6 changed files with 453 additions and 72 deletions.
11 changes: 11 additions & 0 deletions LSP-pyright.sublime-settings
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@
// - "sublime_text_33": Similar to "sublime_text" but Python 3.3 forced.
// - "sublime_text_38": Similar to "sublime_text" but Python 3.8 forced.
"pyright.dev_environment": "",
// The order of strategies for finding virtual environments.
"pyright.venv.finding_order": [
"local_dot_venv",
"env_var_conda_prefix",
"env_var_virtual_env",
"pdm",
"pipenv",
"poetry",
"pyenv",
"any_subdirectory",
],
// Offer auto-import completions.
"python.analysis.autoImportCompletions": true,
// Automatically add common search paths like 'src'?
Expand Down
5 changes: 5 additions & 0 deletions constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from __future__ import annotations

assert __package__

PACKAGE_NAME = __package__.partition(".")[0]
19 changes: 19 additions & 0 deletions log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from __future__ import annotations

from .constants import PACKAGE_NAME


def log_debug(message: str) -> None:
print(f"[{PACKAGE_NAME}][DEBUG] {message}")


def log_info(message: str) -> None:
print(f"[{PACKAGE_NAME}][INFO] {message}")


def log_warning(message: str) -> None:
print(f"[{PACKAGE_NAME}][WARNING] {message}")


def log_error(message: str) -> None:
print(f"[{PACKAGE_NAME}][ERROR] {message}")
133 changes: 61 additions & 72 deletions plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@
import shutil
import subprocess
import sys
from collections.abc import Callable
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast

import sublime
import sublime_plugin
from LSP.plugin import ClientConfig, DottedDict, MarkdownLangMap, Response, WorkspaceFolder
from LSP.plugin.core.protocol import CompletionItem, Hover, SignatureHelp
from lsp_utils import NpmClientHandler
from sublime_lib import ResourcePath

assert __package__
from .constants import PACKAGE_NAME
from .log import log_info
from .venv_finder import VenvInfo, find_venv


def plugin_loaded() -> None:
Expand All @@ -36,11 +40,19 @@ def get_default_startupinfo() -> Any:
return None


@dataclass
class WindowAttr:
venv_info: VenvInfo | None = None


class LspPyrightPlugin(NpmClientHandler):
package_name = __package__.partition(".")[0]
package_name = PACKAGE_NAME
server_directory = "language-server"
server_binary_path = os.path.join(server_directory, "node_modules", "pyright", "langserver.index.js")

window_attrs: dict[int, WindowAttr] = defaultdict(WindowAttr)
"""Per-window attributes. I.e., per-session attributes. The key is the window ID."""

@classmethod
def required_node_version(cls) -> str:
"""
Expand All @@ -62,6 +74,8 @@ def on_settings_changed(self, settings: DottedDict) -> None:

settings.set("python.analysis.extraPaths", extraPaths)

self.update_status_bar_text()

@classmethod
def on_pre_start(
cls,
Expand All @@ -72,9 +86,10 @@ def on_pre_start(
) -> str | None:
super().on_pre_start(window, initiating_view, workspace_folders, configuration)

python_path = cls.python_path(configuration.settings, workspace_folders)
print(f'{cls.name()}: INFO: Using python path "{python_path}"')
configuration.settings.set("python.pythonPath", python_path)
cls.update_venv_info(configuration.settings, workspace_folders, window=window)
if venv_info := cls.window_attrs[window.id()].venv_info:
log_info(f"Using python executable: {venv_info.python_executable}")
configuration.settings.set("python.pythonPath", str(venv_info.python_executable))
return None

@classmethod
Expand Down Expand Up @@ -118,6 +133,24 @@ def on_server_response_async(self, method: str, response: Response) -> None:
# custom methods #
# -------------- #

def update_status_bar_text(self) -> None:
status_parts: list[str] = []

if not (session := self.weaksession()):
return
window_id = session.window.id()

if venv_info := self.window_attrs[window_id].venv_info:
if venv_info.prompt:
status_parts.append(f"venv: {venv_info.prompt}")
if venv_info.python_version:
status_parts.append(f"py: {venv_info.python_version}")
if venv_info.venv_founder:
status_parts.append(f"by: {venv_info.venv_founder}")

if session := self.weaksession():
session.set_config_status_async("; ".join(status_parts))

def patch_markdown_content(self, content: str) -> str:
# add another linebreak before horizontal rule following fenced code block
content = re.sub("```\n---", "```\n\n---", content)
Expand Down Expand Up @@ -180,77 +213,33 @@ def find_package_dependency_dirs(self, py_ver: tuple[int, int] = (3, 3)) -> list
return list(filter(os.path.isdir, dep_dirs))

@classmethod
def python_path(cls, settings: DottedDict, workspace_folders: list[WorkspaceFolder]) -> str:
def update_venv_info(
cls,
settings: DottedDict,
workspace_folders: list[WorkspaceFolder],
*,
window: sublime.Window,
) -> None:
window_id = window.id()

if python_path := settings.get("python.pythonPath"):
return python_path
cls.window_attrs[window_id].venv_info = VenvInfo.from_python_executable(python_path)
return

venv_finding_order: list[str] | None = settings.get("pyright.venv.finding_order")
if workspace_folders and (first_folder := Path(workspace_folders[0].path).resolve()):
for folder in (first_folder, *first_folder.parents):
if python_path := cls.python_path_from_venv(folder):
return str(python_path)
if venv_info := find_venv(venv_finding_order, project_dir=folder):
cls.window_attrs[window_id].venv_info = venv_info
return

return shutil.which("python") or shutil.which("python3") or ""
if python_path := shutil.which("python") or shutil.which("python3") or "":
cls.window_attrs[window_id].venv_info = VenvInfo.from_python_executable(python_path)
return

@classmethod
def python_path_from_venv(cls, workspace_folder: Path) -> Path | None:
"""
Resolves the python binary path depending on environment variables and files in the workspace.
cls.window_attrs[window_id].venv_info = None

@see https://github.com/fannheyward/coc-pyright/blob/d58a468b1d7479a1b56906e386f44b997181e307/src/configSettings.ts#L47
"""

def binary_from_python_path(path: str | Path) -> Path | None:
path = Path(path)
if sublime.platform() == "windows":
binary_path = path / "Scripts/python.exe"
else:
binary_path = path / "bin/python"
return binary_path if binary_path.is_file() else None

# Config file, venv resolution command, post-processing
venv_config_files: list[tuple[str, str, Callable[[str | Path], Path | None] | None]] = [
(".pdm-python", "pdm info --python", None),
(".python-version", "pyenv which python", None),
("Pipfile", "pipenv --py", None),
("poetry.lock", "poetry env info -p", binary_from_python_path),
]

for config_file, command, post_processing in venv_config_files:
if not (workspace_folder / config_file).is_file():
continue
print(f"{cls.name()}: INFO: {config_file} detected. Run subprocess command: {command}")
try:
stdout, stderr = map(
str.rstrip,
subprocess.Popen(
command,
cwd=workspace_folder,
shell=True,
startupinfo=get_default_startupinfo(),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
).communicate(),
)
if stderr:
print(f"{cls.name()}: INFO: subprocess stderr: {stderr}")
python_path = stdout
if post_processing:
python_path = post_processing(python_path)
if python_path:
return Path(python_path)
except FileNotFoundError:
print(f"{cls.name()}: WARN: subprocess failed with file not found: {command[0]}")
except PermissionError as e:
print(f"{cls.name()}: WARN: subprocess failed with permission error: {e}")
except subprocess.CalledProcessError as e:
print(f"{cls.name()}: WARN: subprocess failed: {str(e.output).strip()}")

# virtual environment as subfolder in project
for maybe_venv_path in workspace_folder.iterdir():
try:
if (maybe_venv_path / "pyvenv.cfg").is_file() and (binary := binary_from_python_path(maybe_venv_path)):
return binary # found a venv
except PermissionError:
pass
return None
class PylanceEventListener(sublime_plugin.EventListener):
def on_pre_close_window(self, window: sublime.Window) -> None:
LspPyrightPlugin.window_attrs.pop(window.id(), None)
27 changes: 27 additions & 0 deletions sublime-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,33 @@
"Similar to \"sublime_text\" but Python 3.8 forced."
]
},
"pyright.venv.finding_order": {
"default": [],
"description": "The order of strategies for finding virtual environments.",
"items": {
"enum": [
"any_subdirectory",
"env_var_conda_prefix",
"env_var_virtual_env",
"local_dot_venv",
"pdm",
"pipenv",
"poetry",
"pyenv"
],
"markdownEnumDescriptions": [
"Finds the virtual environment with any subdirectory.",
"Finds the virtual environment using the `CONDA_PREFIX` environment variable.",
"Finds the virtual environment using the `VIRTUAL_ENV` environment variable.",
"Finds the virtual environment `.venv` or `venv` directory.",
"Finds the virtual environment using `pdm`.",
"Finds the virtual environment using `pipenv`.",
"Finds the virtual environment using `poetry`.",
"Finds the virtual environment using `pyenv`.",
]
},
"type": "array",
},
"pyright.disableLanguageServices": {
"default": false,
"description": "Disables type completion, definitions, and references.",
Expand Down
Loading

0 comments on commit 4937130

Please sign in to comment.