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 25, 2024
1 parent d1a51a3 commit a177a42
Show file tree
Hide file tree
Showing 11 changed files with 301 additions and 119 deletions.
131 changes: 12 additions & 119 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 @@ -20,9 +18,9 @@
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
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,19 +88,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 +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.
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 ...constants import SERVER_SETTING_ANALYSIS_EXTRAPATHS
from ...utils import run_shell_command
from ..interfaces import BaseDevEnvironmentHandler


class BlenderDevEnvironmentHandler(BaseDevEnvironmentHandler):
def handle(self, *, settings: DottedDict) -> None:
extra_paths: list[str] = settings.get(SERVER_SETTING_ANALYSIS_EXTRAPATHS) or []
extra_paths.extend(self.find_paths(settings))
settings.set(SERVER_SETTING_ANALYSIS_EXTRAPATHS, extra_paths)

@classmethod
def find_paths(cls, settings: DottedDict) -> list[str]:
with tempfile.NamedTemporaryFile("w", encoding="utf-8") 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 ...constants import SERVER_SETTING_ANALYSIS_EXTRAPATHS
from ...utils import run_shell_command
from ..interfaces import BaseDevEnvironmentHandler


class GdbDevEnvironmentHandler(BaseDevEnvironmentHandler):
def handle(self, *, settings: DottedDict) -> None:
extra_paths: list[str] = settings.get(SERVER_SETTING_ANALYSIS_EXTRAPATHS) or []
extra_paths.extend(self.find_paths(settings))
settings.set(SERVER_SETTING_ANALYSIS_EXTRAPATHS, extra_paths)

@classmethod
def find_paths(cls, settings: DottedDict) -> list[str]:
with tempfile.NamedTemporaryFile("w", encoding="utf-8") 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 a177a42

Please sign in to comment.