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 <jfcherng@gmail.com>
  • Loading branch information
jfcherng committed Aug 25, 2024
1 parent 8789f85 commit bd49983
Show file tree
Hide file tree
Showing 11 changed files with 300 additions and 115 deletions.
126 changes: 11 additions & 115 deletions plugin/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from sublime_lib import ResourcePath

from .constants import PACKAGE_NAME
from .dev_environment.helpers import find_dev_environment_handler
from .log import log_error, log_info, log_warning
from .template import load_string_template
from .utils import run_shell_command
Expand Down Expand Up @@ -90,19 +91,18 @@ 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("pyright.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 := find_dev_environment_handler(
dev_environment,
server_dir=Path(server_dir),
workspace_folders=tuple(folder.path for folder in 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:
Expand Down Expand Up @@ -218,110 +218,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.
37 changes: 37 additions & 0 deletions plugin/dev_environment/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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 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(
dev_environment: str,
*,
server_dir: Path,
workspace_folders: Sequence[str],
) -> BaseDevEnvironmentHandler | None:
if handler_cls := first_true(
list_dev_environment_handler_classes(),
pred=lambda cls_: cls_.can_support(dev_environment),
):
return handler_cls(server_dir=server_dir, workspace_folders=workspace_folders)
return None
15 changes: 15 additions & 0 deletions plugin/dev_environment/impl/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from __future__ import annotations

from .blender import BlenderDevEnvironmentHandler
from .gdb import GdbDevEnvironmentHandler
from .sublime_text import SublimeTextDevEnvironmentHandler
from .sublime_text_33 import SublimeText33DevEnvironmentHandler
from .sublime_text_38 import SublimeText38DevEnvironmentHandler

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

import json
import tempfile

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:
# add package dependencies into "python.analysis.extraPaths"
extra_paths: list[str] = settings.get("python.analysis.extraPaths") or []
extra_paths.extend(self.find_paths(settings))
settings.set("python.analysis.extraPaths", extra_paths)

@classmethod
def find_paths(cls, settings: DottedDict) -> list[str]:
with tempfile.NamedTemporaryFile("w") as tmp_file:
print(
R"""
import sys
import json
json.dump({"executable": sys.executable, "paths": sys.path}, sys.stdout)
exit(0)
""".strip(),
file=tmp_file,
)
args = (cls._get_dev_environment_binary(settings), "--background", "--python", tmp_file.name)
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}")
42 changes: 42 additions & 0 deletions plugin/dev_environment/impl/gdb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import annotations

import json
import tempfile

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:
# add package dependencies into "python.analysis.extraPaths"
extra_paths: list[str] = settings.get("python.analysis.extraPaths") or []
extra_paths.extend(self.find_paths(settings))
settings.set("python.analysis.extraPaths", extra_paths)

@classmethod
def find_paths(cls, settings: DottedDict) -> list[str]:
with tempfile.NamedTemporaryFile("w") as tmp_file:
print(
R"""
python
import sys
import json
json.dump({"executable": sys.executable, "paths": sys.path}, sys.stdout)
end
exit
""".strip(),
file=tmp_file,
)
args = (cls._get_dev_environment_binary(settings), "--batch", "--command", tmp_file.name)
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}")
40 changes: 40 additions & 0 deletions plugin/dev_environment/impl/sublime_text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

from pathlib import Path

import sublime
from LSP.plugin.core.collections import DottedDict

from ..interfaces import BaseSublimeTextDevEnvironmentHandler
from .sublime_text_33 import SublimeText33DevEnvironmentHandler
from .sublime_text_38 import SublimeText38DevEnvironmentHandler


class SublimeTextDevEnvironmentHandler(BaseSublimeTextDevEnvironmentHandler):
def handle(self, *, settings: DottedDict) -> None:
py_ver = self.detect_st_py_ver()
handler_cls: type[BaseSublimeTextDevEnvironmentHandler]

if py_ver == (3, 3):
handler_cls = SublimeText33DevEnvironmentHandler
elif py_ver == (3, 8):
handler_cls = SublimeText38DevEnvironmentHandler
else:
return

handler_cls(server_dir=self.server_dir, workspace_folders=self.workspace_folders).handle(settings=settings)

def detect_st_py_ver(self) -> tuple[int, int]:
if not self.workspace_folders:
return self.python_version

try:
# ST auto uses py38 for files in "Packages/User/"
if (first_folder := Path(self.workspace_folders[0]).resolve()) == Path(sublime.packages_path()) / "User":
return (3, 8)
# the project wants to use py38
if (first_folder / ".python-version").read_bytes().strip() == b"3.8":
return (3, 8)
except Exception:
pass
return self.python_version
13 changes: 13 additions & 0 deletions plugin/dev_environment/impl/sublime_text_33.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from __future__ import annotations

from ..interfaces import BaseSublimeTextDevEnvironmentHandler


class SublimeText33DevEnvironmentHandler(BaseSublimeTextDevEnvironmentHandler):
@classmethod
def name(cls) -> str:
return "sublime_text_33"

@property
def python_version(self) -> tuple[int, int]:
return (3, 3)
13 changes: 13 additions & 0 deletions plugin/dev_environment/impl/sublime_text_38.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from __future__ import annotations

from ..interfaces import BaseSublimeTextDevEnvironmentHandler


class SublimeText38DevEnvironmentHandler(BaseSublimeTextDevEnvironmentHandler):
@classmethod
def name(cls) -> str:
return "sublime_text_38"

@property
def python_version(self) -> tuple[int, int]:
return (3, 8)
Loading

0 comments on commit bd49983

Please sign in to comment.