Skip to content

Commit

Permalink
refactor: dev_environment handlers abstraction
Browse files Browse the repository at this point in the history
Signed-off-by: Jack Cherng <[email protected]>
  • Loading branch information
jfcherng committed Aug 26, 2024
1 parent d1a51a3 commit 786e49b
Show file tree
Hide file tree
Showing 10 changed files with 308 additions and 123 deletions.
4 changes: 2 additions & 2 deletions LSP-pyright.sublime-settings
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@
// - "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",
Expand Down
135 changes: 14 additions & 121 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 find_dev_environment_handler_class
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,21 +88,20 @@ 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_cls := find_dev_environment_handler_class(dev_environment):
handler = handler_cls(
server_dir=Path(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}")
log_error(f'Failed to update extra paths for dev environment "{dev_environment}": {ex}')
finally:
self.update_status_bar_text()

Expand Down Expand Up @@ -218,110 +215,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

@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.
29 changes: 29 additions & 0 deletions plugin/dev_environment/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from __future__ import annotations

from typing import Generator

from more_itertools import first_true

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


def list_dev_environment_handler_classes() -> Generator[type[BaseDevEnvironmentHandler], None, None]:
yield BlenderDevEnvironmentHandler
yield GdbDevEnvironmentHandler
yield SublimeText33DevEnvironmentHandler
yield SublimeText38DevEnvironmentHandler
yield SublimeTextDevEnvironmentHandler


def find_dev_environment_handler_class(dev_environment: str) -> type[BaseDevEnvironmentHandler] | None:
return first_true(
list_dev_environment_handler_classes(),
pred=lambda cls_: cls_.can_support(dev_environment),
)
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

0 comments on commit 786e49b

Please sign in to comment.