Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: dev_environment handlers abstraction #356

Merged
merged 3 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions LSP-pyright.sublime-settings
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,16 @@
// - "blender": Suitable for people who are developing Blender add-ons. `sys.path` from Blender's embedded
// Python interpreter will be added into "python.analysis.extraPaths". Note that this requires
// invoking Blender, headless, to query the additional Python paths. The setting
// "pyright.dev_environment_blender_binary" controls which executable to call to invoke Blender.
// "pyright.dev_environment_blender.binary" controls which executable to call to invoke Blender.
// - "gdb": Suitable for people who are developing GDB automation scripts. `sys.path` from GDB's embedded
// Python interpreter will be added into "python.analysis.extraPaths". Note that this requires invoking
// GDB, in batch mode, to query the additional Python paths. The setting
// "pyright.dev_environment_gdb_binary" controls which exectuable to call to invoke GDB.
// "pyright.dev_environment_gdb.binary" controls which exectuable to call to invoke GDB.
"pyright.dev_environment": "",
// When the predefined setup is "blender", invoke this binary to query the additional search paths.
"pyright.dev_environment.blender.binary": "blender",
jfcherng marked this conversation as resolved.
Show resolved Hide resolved
"pyright.dev_environment_blender.binary": "blender",
// When the predefined setup is "gdb", invoke this binary to query the additional search paths.
"pyright.dev_environment.gdb.binary": "gdb",
"pyright.dev_environment_gdb.binary": "gdb",
// Offer auto-import completions.
"python.analysis.autoImportCompletions": true,
// Automatically add common search paths like 'src'?
Expand Down
138 changes: 15 additions & 123 deletions plugin/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@
import os
import re
import shutil
import sys
import tempfile
import weakref
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, cast
from typing import Any, cast

import jmespath
import sublime
Expand All @@ -19,10 +17,10 @@
from more_itertools import first_true
from sublime_lib import ResourcePath

from .constants import PACKAGE_NAME
from .constants import PACKAGE_NAME, SERVER_SETTING_DEV_ENVIRONMENT
from .dev_environment.helpers import get_dev_environment_handler
from .log import log_error, log_info, log_warning
from .template import load_string_template
from .utils import run_shell_command
from .virtual_env.helpers import find_venv_by_finder_names, find_venv_by_python_executable
from .virtual_env.venv_finder import BaseVenvInfo, get_finder_name_mapping

Expand Down Expand Up @@ -90,23 +88,21 @@ def can_start(
def on_settings_changed(self, settings: DottedDict) -> None:
super().on_settings_changed(settings)

dev_environment = settings.get("pyright.dev_environment")
extra_paths: list[str] = settings.get("python.analysis.extraPaths") or []
if not ((session := self.weaksession()) and (server_dir := self._server_directory_path())):
return

dev_environment = settings.get(SERVER_SETTING_DEV_ENVIRONMENT) or ""
try:
if dev_environment.startswith("sublime_text"):
py_ver = self.detect_st_py_ver(dev_environment)
# add package dependencies into "python.analysis.extraPaths"
extra_paths.extend(self.find_package_dependency_dirs(py_ver))
elif dev_environment == "blender":
extra_paths.extend(self.find_blender_paths(settings))
elif dev_environment == "gdb":
extra_paths.extend(self.find_gdb_paths(settings))
settings.set("python.analysis.extraPaths", extra_paths)
if handler := get_dev_environment_handler(
dev_environment,
server_dir=server_dir,
workspace_folders=tuple(map(str, session.get_workspace_folders())),
):
handler.handle(settings=settings)
except Exception as ex:
log_error(f"failed to update extra paths for dev environment {dev_environment}: {ex}")
finally:
self.update_status_bar_text()
log_error(f'Failed to update extra paths for dev environment "{dev_environment}": {ex}')

self.update_status_bar_text()

@classmethod
def on_pre_start(
Expand Down Expand Up @@ -218,110 +214,6 @@ def patch_markdown_content(self, content: str) -> str:
content = re.sub(r"\n:deprecated:", r"\n⚠️ __Deprecated:__", content)
return content

def detect_st_py_ver(self, dev_environment: str) -> tuple[int, int]:
default = (3, 3)

if dev_environment == "sublime_text_33":
return (3, 3)
if dev_environment == "sublime_text_38":
return (3, 8)
if dev_environment == "sublime_text":
if not ((session := self.weaksession()) and (workspace_folders := session.get_workspace_folders())):
return default
# ST auto uses py38 for files in "Packages/User/"
if (first_folder := Path(workspace_folders[0].path).resolve()) == Path(sublime.packages_path()) / "User":
return (3, 8)
# the project wants to use py38
try:
if (first_folder / ".python-version").read_bytes().strip() == b"3.8":
return (3, 8)
except Exception:
pass
return default

raise ValueError(f'Invalid "dev_environment" setting: {dev_environment}')

def find_package_dependency_dirs(self, py_ver: tuple[int, int] = (3, 3)) -> list[str]:
dep_dirs = sys.path.copy()

# replace paths for target Python version
# @see https://github.com/sublimelsp/LSP-pyright/issues/28
re_pattern = re.compile(r"(python3\.?)[38]", flags=re.IGNORECASE)
re_replacement = r"\g<1>8" if py_ver == (3, 8) else r"\g<1>3"
dep_dirs = [re_pattern.sub(re_replacement, dep_dir) for dep_dir in dep_dirs]

# move the "Packages/" to the last
# @see https://github.com/sublimelsp/LSP-pyright/pull/26#discussion_r520747708
packages_path = sublime.packages_path()
dep_dirs.remove(packages_path)
dep_dirs.append(packages_path)

# sublime stubs - add as first
if py_ver == (3, 3) and (server_dir := self._server_directory_path()):
dep_dirs.insert(0, os.path.join(server_dir, "resources", "typings", "sublime_text_py33"))

return list(filter(os.path.isdir, dep_dirs))

@classmethod
def _print_print_sys_paths(cls, sink: Callable[[str], None]) -> None:
sink("import sys")
sink("import json")
sink('json.dump({"executable": sys.executable, "paths": sys.path}, sys.stdout)')

@classmethod
def _get_dev_environment_binary(cls, settings: DottedDict, name: str) -> str:
return settings.get(f"settings.dev_environment.{name}.binary") or name
jfcherng marked this conversation as resolved.
Show resolved Hide resolved

@classmethod
def _check_json_is_dict(cls, name: str, output_dict: Any) -> dict[str, Any]:
if not isinstance(output_dict, dict):
raise RuntimeError(f"unexpected output when calling {name}; expected JSON dict")
return output_dict

@classmethod
def find_blender_paths(cls, settings: DottedDict) -> list[str]:
filename = "print_sys_path.py"
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, filename)
with open(filepath, "w") as fp:

def out(line: str) -> None:
print(line, file=fp)

cls._print_print_sys_paths(out)
out("exit(0)")
args = (cls._get_dev_environment_binary(settings, "blender"), "--background", "--python", filepath)
result = run_shell_command(args, shell=False)
if result is None or result[2] != 0:
raise RuntimeError("failed to run command")
# Blender prints a bunch of general information to stdout before printing the output of the python
# script. We want to ignore that initial information. We do that by finding the start of the JSON
# dict. This is a bit hacky and there must be a better way.
index = result[0].find('\n{"')
if index == -1:
raise RuntimeError("unexpected output when calling blender")
return cls._check_json_is_dict("blender", json.loads(result[0][index:].strip()))["paths"]

@classmethod
def find_gdb_paths(cls, settings: DottedDict) -> list[str]:
filename = "print_sys_path.commands"
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, filename)
with open(filepath, "w") as fp:

def out(line: str) -> None:
print(line, file=fp)

out("python")
cls._print_print_sys_paths(out)
out("end")
out("exit")
args = (cls._get_dev_environment_binary(settings, "gdb"), "--batch", "--command", filepath)
result = run_shell_command(args, shell=False)
if result is None or result[2] != 0:
raise RuntimeError("failed to run command")
return cls._check_json_is_dict("gdb", json.loads(result[0].strip()))["paths"]

@classmethod
def parse_server_version(cls) -> str:
lock_file_content = sublime.load_resource(f"Packages/{PACKAGE_NAME}/language-server/package-lock.json")
Expand Down
3 changes: 3 additions & 0 deletions plugin/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@
assert __package__

PACKAGE_NAME = __package__.partition(".")[0]

SERVER_SETTING_ANALYSIS_EXTRAPATHS = "python.analysis.extraPaths"
SERVER_SETTING_DEV_ENVIRONMENT = "pyright.dev_environment"
Empty file.
44 changes: 44 additions & 0 deletions plugin/dev_environment/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

from pathlib import Path
from typing import Generator, Sequence

from more_itertools import first_true

from .impl import (
BlenderDevEnvironmentHandler,
GdbDevEnvironmentHandler,
SublimeText33DevEnvironmentHandler,
SublimeText38DevEnvironmentHandler,
SublimeTextDevEnvironmentHandler,
)
from .interfaces import BaseDevEnvironmentHandler


def find_dev_environment_handler_class(dev_environment: str) -> type[BaseDevEnvironmentHandler] | None:
return first_true(
list_dev_environment_handler_classes(),
pred=lambda handler_cls: handler_cls.can_support(dev_environment),
)


def get_dev_environment_handler(
dev_environment: str,
*,
server_dir: str | Path,
workspace_folders: Sequence[str],
) -> BaseDevEnvironmentHandler | None:
if handler_cls := find_dev_environment_handler_class(dev_environment):
return handler_cls(
server_dir=server_dir,
workspace_folders=workspace_folders,
)
return None


def list_dev_environment_handler_classes() -> Generator[type[BaseDevEnvironmentHandler], None, None]:
yield BlenderDevEnvironmentHandler
yield GdbDevEnvironmentHandler
yield SublimeText33DevEnvironmentHandler
yield SublimeText38DevEnvironmentHandler
yield SublimeTextDevEnvironmentHandler
17 changes: 17 additions & 0 deletions plugin/dev_environment/impl/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from __future__ import annotations

from .blender import BlenderDevEnvironmentHandler
from .gdb import GdbDevEnvironmentHandler
from .sublime_text import (
SublimeText33DevEnvironmentHandler,
SublimeText38DevEnvironmentHandler,
SublimeTextDevEnvironmentHandler,
)

__all__ = (
"BlenderDevEnvironmentHandler",
"GdbDevEnvironmentHandler",
"SublimeText33DevEnvironmentHandler",
"SublimeText38DevEnvironmentHandler",
"SublimeTextDevEnvironmentHandler",
)
50 changes: 50 additions & 0 deletions plugin/dev_environment/impl/blender.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

import json
import tempfile
from pathlib import Path

from LSP.plugin.core.collections import DottedDict

from ...utils import run_shell_command
from ..interfaces import BaseDevEnvironmentHandler


class BlenderDevEnvironmentHandler(BaseDevEnvironmentHandler):
def handle(self, *, settings: DottedDict) -> None:
self._inject_extra_paths(settings=settings, paths=self.find_paths(settings))

@classmethod
def find_paths(cls, settings: DottedDict) -> list[str]:
with tempfile.TemporaryDirectory() as tmpdir:
filepath = Path(tmpdir) / "print_sys_path.py"
filepath.write_text(
R"""
import sys
import json
json.dump({"executable": sys.executable, "paths": sys.path}, sys.stdout)
exit(0)
""".strip(),
encoding="utf-8",
)
args = (
cls.get_dev_environment_subsetting(settings, "binary"),
"--background",
"--python",
str(filepath),
)
result = run_shell_command(args, shell=False)

if not result or result[2] != 0:
raise RuntimeError(f"Failed to run command: {args}")

# Blender prints a bunch of general information to stdout before printing the output of the python
# script. We want to ignore that initial information. We do that by finding the start of the JSON
# dict. This is a bit hacky and there must be a better way.
if (index := result[0].find('\n{"')) == -1:
raise RuntimeError("Unexpected output when calling blender")

try:
return json.loads(result[0][index:])["paths"]
except json.JSONDecodeError as e:
raise RuntimeError(f"Failed to parse JSON: {e}")
46 changes: 46 additions & 0 deletions plugin/dev_environment/impl/gdb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from __future__ import annotations

import json
import tempfile
from pathlib import Path

from LSP.plugin.core.collections import DottedDict

from ...utils import run_shell_command
from ..interfaces import BaseDevEnvironmentHandler


class GdbDevEnvironmentHandler(BaseDevEnvironmentHandler):
def handle(self, *, settings: DottedDict) -> None:
self._inject_extra_paths(settings=settings, paths=self.find_paths(settings))

@classmethod
def find_paths(cls, settings: DottedDict) -> list[str]:
with tempfile.TemporaryDirectory() as tmpdir:
filepath = Path(tmpdir) / "print_sys_path.commands"
filepath.write_text(
R"""
python
import sys
import json
json.dump({"executable": sys.executable, "paths": sys.path}, sys.stdout)
end
exit
""".strip(),
encoding="utf-8",
)
args = (
cls.get_dev_environment_subsetting(settings, "binary"),
"--batch",
"--command",
str(filepath),
)
result = run_shell_command(args, shell=False)

if not result or result[2] != 0:
raise RuntimeError(f"Failed to run command: {args}")

try:
return json.loads(result[0])["paths"]
except json.JSONDecodeError as e:
raise RuntimeError(f"Failed to parse JSON: {e}")
Loading