diff --git a/LSP-pyright.sublime-settings b/LSP-pyright.sublime-settings index 3bc4862..a3e5e03 100644 --- a/LSP-pyright.sublime-settings +++ b/LSP-pyright.sublime-settings @@ -32,7 +32,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 + // "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": "", + // When the predefined setup is "blender", invoke this binary to query the additional search paths. + "pyright.dev_environment.blender.binary": "blender", + // When the predefined setup is "gdb", invoke this binary to query the additional search paths. + "pyright.dev_environment.gdb.binary": "gdb", // Offer auto-import completions. "python.analysis.autoImportCompletions": true, // Automatically add common search paths like 'src'? diff --git a/plugin/client.py b/plugin/client.py index 6b8e0dd..c7b4282 100644 --- a/plugin/client.py +++ b/plugin/client.py @@ -5,10 +5,11 @@ import re import shutil import sys +import tempfile import weakref from dataclasses import dataclass from pathlib import Path -from typing import Any, cast +from typing import Any, Callable, cast import jmespath import sublime @@ -19,8 +20,9 @@ from sublime_lib import ResourcePath from .constants import PACKAGE_NAME -from .log import log_info, log_warning +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 @@ -89,16 +91,22 @@ def on_settings_changed(self, settings: DottedDict) -> None: super().on_settings_changed(settings) dev_environment = settings.get("pyright.dev_environment") - extraPaths: list[str] = settings.get("python.analysis.extraPaths") or [] + extra_paths: list[str] = settings.get("python.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 "python.analysis.extraPaths" - extraPaths.extend(self.find_package_dependency_dirs(py_ver)) - - settings.set("python.analysis.extraPaths", extraPaths) - - self.update_status_bar_text() + 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) + except Exception as ex: + log_error(f"failed to update extra paths for dev environment {dev_environment}: {ex}") + finally: + self.update_status_bar_text() @classmethod def on_pre_start( @@ -254,6 +262,66 @@ def find_package_dependency_dirs(self, py_ver: tuple[int, int] = (3, 3)) -> list 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") diff --git a/plugin/utils.py b/plugin/utils.py index b1b776d..2b2e056 100644 --- a/plugin/utils.py +++ b/plugin/utils.py @@ -6,7 +6,7 @@ import sys from collections.abc import Generator, Iterable from pathlib import Path -from typing import Any, TypeVar +from typing import Any, Sequence, TypeVar from .log import log_error @@ -60,12 +60,14 @@ def get_default_startupinfo() -> Any: return None -def run_shell_command(command: str, *, cwd: str | Path | None = None) -> tuple[str, str, int] | None: +def run_shell_command( + command: str | Sequence[str], *, cwd: str | Path | None = None, shell: bool = True +) -> tuple[str, str, int] | None: try: proc = subprocess.Popen( command, cwd=cwd, - shell=True, + shell=shell, startupinfo=get_default_startupinfo(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, diff --git a/sublime-package.json b/sublime-package.json index 8d9f862..a6bd314 100644 --- a/sublime-package.json +++ b/sublime-package.json @@ -28,15 +28,29 @@ "", "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 \"python.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." ] }, + "pyright.dev_environment.blender.binary": { + "default": "blender", + "description": "When the predefined setup is \"blender\", invoke this binary to query the additional search paths.", + "type": "string" + }, + "pyright.dev_environment.gdb.binary": { + "default": "gdb", + "description": "When the predefined setup is \"gdb\", invoke this binary to query the additional search paths.", + "type": "string" + }, "pyright.disableLanguageServices": { "default": false, "description": "Disables type completion, definitions, and references.",