Skip to content

Commit

Permalink
feat: add "blender" and "gdb" dev_environments
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 26, 2024
1 parent 2ad883a commit 27f843e
Show file tree
Hide file tree
Showing 10 changed files with 339 additions and 57 deletions.
12 changes: 12 additions & 0 deletions LSP-basedpyright.sublime-settings
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,19 @@
// so ST package dependecies can be resolved by the LSP server.
// - "sublime_text_33": Similar to "sublime_text" but Python 3.3 forced.
// - "sublime_text_38": Similar to "sublime_text" but Python 3.8 forced.
// - "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
// "basedpyright.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
// "basedpyright.dev_environment_gdb.binary" controls which exectuable to call to invoke GDB.
"basedpyright.dev_environment": "",
// When the predefined setup is "blender", invoke this binary to query the additional search paths.
"basedpyright.dev_environment_blender.binary": "blender",
// When the predefined setup is "gdb", invoke this binary to query the additional search paths.
"basedpyright.dev_environment_gdb.binary": "gdb",
// Offer auto-import completions.
"basedpyright.analysis.autoImportCompletions": true,
// Automatically add common search paths like 'src'?
Expand Down
70 changes: 15 additions & 55 deletions plugin/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import os
import re
import shutil
import sys
import weakref
from dataclasses import dataclass
from pathlib import Path
Expand All @@ -18,8 +17,9 @@
from more_itertools import first_true
from sublime_lib import ResourcePath

from .constants import PACKAGE_NAME
from .log import log_info, log_warning
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 .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,15 +90,19 @@ def can_start(
def on_settings_changed(self, settings: DottedDict) -> None:
super().on_settings_changed(settings)

dev_environment = settings.get("basedpyright.dev_environment")
extraPaths: list[str] = settings.get("basedpyright.analysis.extraPaths") or []

if dev_environment in {"sublime_text", "sublime_text_33", "sublime_text_38"}:
py_ver = self.detect_st_py_ver(dev_environment)
# add package dependencies into "basedpyright.analysis.extraPaths"
extraPaths.extend(self.find_package_dependency_dirs(py_ver))
if not ((session := self.weaksession()) and (server_dir := self._server_directory_path())):
return

settings.set("basedpyright.analysis.extraPaths", extraPaths)
dev_environment = settings.get(SERVER_SETTING_DEV_ENVIRONMENT) or ""
try:
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}')

self.update_status_bar_text()

Expand Down Expand Up @@ -212,50 +216,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 parse_server_version(cls) -> str:
lock_file_content = sublime.load_resource(f"Packages/{PACKAGE_NAME}/language-server/package-lock.json")
Expand Down
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}")
98 changes: 98 additions & 0 deletions plugin/dev_environment/impl/sublime_text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from __future__ import annotations

import contextlib
import os
import re
import sys
from abc import ABC
from pathlib import Path

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

from ..interfaces import BaseDevEnvironmentHandler


class BaseSublimeTextDevEnvironmentHandler(BaseDevEnvironmentHandler, ABC):
@property
def python_version(self) -> tuple[int, int]:
return (3, 3)

def handle(self, *, settings: DottedDict) -> None:
self._inject_extra_paths(settings=settings, paths=self.find_package_dependency_dirs())

def find_package_dependency_dirs(self) -> 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 self.python_version == (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 self.python_version == (3, 3):
dep_dirs.insert(0, str(self.server_dir / "resources/typings/sublime_text_py33"))

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


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

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


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

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


class SublimeTextDevEnvironmentHandler(BaseSublimeTextDevEnvironmentHandler):
def handle(self, *, settings: DottedDict) -> None:
handler_cls = self.resolve_handler_cls()
handler = handler_cls(server_dir=self.server_dir, workspace_folders=self.workspace_folders)
handler.handle(settings=settings)

def resolve_handler_cls(self) -> type[BaseSublimeTextDevEnvironmentHandler]:
py_ver = self.detect_st_py_ver()
if py_ver == (3, 3):
return SublimeText33DevEnvironmentHandler
if py_ver == (3, 8):
return SublimeText38DevEnvironmentHandler
raise ValueError(f"Unsupported Python version: {py_ver}")

def detect_st_py_ver(self) -> tuple[int, int]:
def _is_py38() -> bool:
try:
first_folder = Path(self.workspace_folders[0]).resolve()
except Exception:
return False

# ST auto uses py38 for files in "Packages/User/"
if (Path(sublime.packages_path()) / "User") in (first_folder, *first_folder.parents):
return True

with contextlib.suppress(Exception):
# the project wants to use py38
if (first_folder / ".python-version").read_bytes().strip() == b"3.8":
return True

return False

return (3, 8) if _is_py38() else self.python_version
Loading

0 comments on commit 27f843e

Please sign in to comment.