From 9a2017a44e85b6ece7dd4568d2b32fed966c1c39 Mon Sep 17 00:00:00 2001 From: Jack Cherng Date: Mon, 26 Aug 2024 02:09:56 +0800 Subject: [PATCH] refactor: dev_environment handlers abstraction Signed-off-by: Jack Cherng --- plugin/client.py | 131 ++---------------- plugin/constants.py | 3 + plugin/dev_environment/__init__.py | 0 plugin/dev_environment/helpers.py | 37 +++++ plugin/dev_environment/impl/__init__.py | 15 ++ plugin/dev_environment/impl/blender.py | 48 +++++++ plugin/dev_environment/impl/gdb.py | 44 ++++++ plugin/dev_environment/impl/sublime_text.py | 40 ++++++ .../dev_environment/impl/sublime_text_33.py | 13 ++ .../dev_environment/impl/sublime_text_38.py | 13 ++ plugin/dev_environment/interfaces.py | 80 +++++++++++ 11 files changed, 305 insertions(+), 119 deletions(-) create mode 100644 plugin/dev_environment/__init__.py create mode 100644 plugin/dev_environment/helpers.py create mode 100644 plugin/dev_environment/impl/__init__.py create mode 100644 plugin/dev_environment/impl/blender.py create mode 100644 plugin/dev_environment/impl/gdb.py create mode 100644 plugin/dev_environment/impl/sublime_text.py create mode 100644 plugin/dev_environment/impl/sublime_text_33.py create mode 100644 plugin/dev_environment/impl/sublime_text_38.py create mode 100644 plugin/dev_environment/interfaces.py diff --git a/plugin/client.py b/plugin/client.py index c7b4282..1f19a42 100644 --- a/plugin/client.py +++ b/plugin/client.py @@ -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 @@ -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 @@ -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: @@ -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") diff --git a/plugin/constants.py b/plugin/constants.py index ce36a82..72c4a97 100644 --- a/plugin/constants.py +++ b/plugin/constants.py @@ -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" 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..fe5340e --- /dev/null +++ b/plugin/dev_environment/helpers.py @@ -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 diff --git a/plugin/dev_environment/impl/__init__.py b/plugin/dev_environment/impl/__init__.py new file mode 100644 index 0000000..fa07a2b --- /dev/null +++ b/plugin/dev_environment/impl/__init__.py @@ -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", +) diff --git a/plugin/dev_environment/impl/blender.py b/plugin/dev_environment/impl/blender.py new file mode 100644 index 0000000..202e08c --- /dev/null +++ b/plugin/dev_environment/impl/blender.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +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.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_binary(settings), "--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..be17681 --- /dev/null +++ b/plugin/dev_environment/impl/gdb.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +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.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_binary(settings), "--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..5d71b46 --- /dev/null +++ b/plugin/dev_environment/impl/sublime_text.py @@ -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 diff --git a/plugin/dev_environment/impl/sublime_text_33.py b/plugin/dev_environment/impl/sublime_text_33.py new file mode 100644 index 0000000..51913ae --- /dev/null +++ b/plugin/dev_environment/impl/sublime_text_33.py @@ -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) diff --git a/plugin/dev_environment/impl/sublime_text_38.py b/plugin/dev_environment/impl/sublime_text_38.py new file mode 100644 index 0000000..32b79a0 --- /dev/null +++ b/plugin/dev_environment/impl/sublime_text_38.py @@ -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) diff --git a/plugin/dev_environment/interfaces.py b/plugin/dev_environment/interfaces.py new file mode 100644 index 0000000..ee4ca45 --- /dev/null +++ b/plugin/dev_environment/interfaces.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import os +import re +import sys +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Sequence, final + +import sublime +from LSP.plugin.core.collections import DottedDict + +from ..constants import SERVER_SETTING_ANALYSIS_EXTRAPATHS, SERVER_SETTING_DEV_ENVIRONMENT +from ..utils import camel_to_snake, remove_suffix + + +class BaseDevEnvironmentHandler(ABC): + def __init__( + self, + *, + server_dir: Path, + workspace_folders: Sequence[str], + ) -> None: + self.server_dir = 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")) + + @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.""" + + @final + @classmethod + def _get_dev_environment_binary(cls, settings: DottedDict) -> str: + name = cls.name() + return settings.get(f"{SERVER_SETTING_DEV_ENVIRONMENT}.{name}.binary") or name + + +class BaseSublimeTextDevEnvironmentHandler(BaseDevEnvironmentHandler, ABC): + @property + def python_version(self) -> tuple[int, int]: + return (3, 3) + + def find_package_dependency_dirs(self, python_version: 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 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 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)) + + def handle(self, *, settings: DottedDict) -> None: + # add package dependencies into "python.analysis.extraPaths" + extra_paths: list[str] = settings.get(SERVER_SETTING_ANALYSIS_EXTRAPATHS) or [] + extra_paths.extend(self.find_package_dependency_dirs(self.python_version)) + settings.set(SERVER_SETTING_ANALYSIS_EXTRAPATHS, extra_paths)