diff --git a/LSP-basedpyright.sublime-settings b/LSP-basedpyright.sublime-settings index 361fb4e..224d431 100644 --- a/LSP-basedpyright.sublime-settings +++ b/LSP-basedpyright.sublime-settings @@ -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'? diff --git a/plugin/client.py b/plugin/client.py index e02f6f6..6c037da 100644 --- a/plugin/client.py +++ b/plugin/client.py @@ -4,7 +4,6 @@ import os import re import shutil -import sys import weakref from dataclasses import dataclass from pathlib import Path @@ -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 @@ -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() @@ -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") diff --git a/plugin/dev_environment/__init__.py b/plugin/dev_environment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugin/dev_environment/helpers.py b/plugin/dev_environment/helpers.py new file mode 100644 index 0000000..c245409 --- /dev/null +++ b/plugin/dev_environment/helpers.py @@ -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 diff --git a/plugin/dev_environment/impl/__init__.py b/plugin/dev_environment/impl/__init__.py new file mode 100644 index 0000000..fec81c9 --- /dev/null +++ b/plugin/dev_environment/impl/__init__.py @@ -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", +) diff --git a/plugin/dev_environment/impl/blender.py b/plugin/dev_environment/impl/blender.py new file mode 100644 index 0000000..c33b0b0 --- /dev/null +++ b/plugin/dev_environment/impl/blender.py @@ -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}") diff --git a/plugin/dev_environment/impl/gdb.py b/plugin/dev_environment/impl/gdb.py new file mode 100644 index 0000000..ea3faae --- /dev/null +++ b/plugin/dev_environment/impl/gdb.py @@ -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}") diff --git a/plugin/dev_environment/impl/sublime_text.py b/plugin/dev_environment/impl/sublime_text.py new file mode 100644 index 0000000..0357059 --- /dev/null +++ b/plugin/dev_environment/impl/sublime_text.py @@ -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 diff --git a/plugin/dev_environment/interfaces.py b/plugin/dev_environment/interfaces.py new file mode 100644 index 0000000..fbc88cf --- /dev/null +++ b/plugin/dev_environment/interfaces.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Iterable, Sequence, final + +from LSP.plugin.core.collections import DottedDict + +from ..constants import SERVER_SETTING_ANALYSIS_EXTRAPATHS, SERVER_SETTING_DEV_ENVIRONMENT +from ..log import log_info +from ..utils import camel_to_snake, remove_suffix + + +class BaseDevEnvironmentHandler(ABC): + def __init__( + self, + *, + server_dir: str | Path, + workspace_folders: Sequence[str], + ) -> None: + self.server_dir = Path(server_dir) + """The language server directory.""" + self.workspace_folders = workspace_folders + """The workspace folders.""" + + @classmethod + def name(cls) -> str: + """The name of this environment.""" + return camel_to_snake(remove_suffix(cls.__name__, "DevEnvironmentHandler")) + + @final + @classmethod + def get_dev_environment_subsetting(cls, settings: DottedDict, subkey: str) -> Any: + """Gets the sub-setting of `XXX.dev_environment_NAME.SUBKEY`.""" + return settings.get(f"{SERVER_SETTING_DEV_ENVIRONMENT}_{cls.name()}.{subkey}") + + @classmethod + def can_support(cls, dev_environment: str) -> bool: + """Check if this class support the given `dev_environment`.""" + return cls.name() == dev_environment + + @abstractmethod + def handle(self, *, settings: DottedDict) -> None: + """Handle this environment.""" + + def _inject_extra_paths(self, *, settings: DottedDict, paths: Iterable[str | Path]) -> None: + """Appends the given `paths` to `XXX.analysis.extraPaths` setting.""" + extra_paths: list[str] = settings.get(SERVER_SETTING_ANALYSIS_EXTRAPATHS) or [] + extra_paths.extend(map(str, paths)) + log_info(f"Adding extra analysis paths: {paths}") + settings.set(SERVER_SETTING_ANALYSIS_EXTRAPATHS, extra_paths) diff --git a/sublime-package.json b/sublime-package.json index 0dd07ab..ebd62f9 100644 --- a/sublime-package.json +++ b/sublime-package.json @@ -384,13 +384,17 @@ "", "sublime_text", "sublime_text_33", - "sublime_text_38" + "sublime_text_38", + "blender", + "gdb" ], "markdownEnumDescriptions": [ "No modifications applied.", "Suitable for people who are developing ST python plugins. The Python version which the developed plugin runs on will be used. - `sys.path` from the plugin_host will be added into \"basedpyright.analysis.extraPaths\" so that ST package dependencies can be resolved by the LSP server.", "Similar to \"sublime_text\" but Python 3.3 forced.", - "Similar to \"sublime_text\" but Python 3.8 forced." + "Similar to \"sublime_text\" but Python 3.8 forced.", + "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.", + "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." ] }, "basedpyright.disableLanguageServices": {