From af0344b00740f3906865fd18bd6d06a245127bfb Mon Sep 17 00:00:00 2001 From: Vincent Date: Tue, 7 Apr 2026 20:00:51 +0100 Subject: [PATCH 1/2] refactor: use pip uninstall instead of folder deletion on plugin reload When the plugin loader is destroyed (on server reload) or created (on startup after a crash), pip uninstall all endstone_* distributions instead of deleting the prefix folder. This avoids file-lock errors on Windows, preserves shared dependencies across reloads for faster loading, and handles crash recovery since _uninstall_plugins discovers distributions dynamically rather than relying on tracked state. --- endstone/plugin/plugin_loader.py | 125 ++++++++++++++++++++++++++----- 1 file changed, 107 insertions(+), 18 deletions(-) diff --git a/endstone/plugin/plugin_loader.py b/endstone/plugin/plugin_loader.py index 5e3671d97d..ba700e241e 100644 --- a/endstone/plugin/plugin_loader.py +++ b/endstone/plugin/plugin_loader.py @@ -4,7 +4,6 @@ import importlib import os import os.path -import shutil import site import subprocess import sys @@ -27,6 +26,17 @@ def find_python() -> Path: + """Finds the Python executable path. + + Checks ``ENDSTONE_PYTHON_EXECUTABLE`` environment variable first, then + falls back to platform-specific default locations under ``sys.base_prefix``. + + Returns: + The resolved path to the Python executable. + + Raises: + RuntimeError: If no valid Python executable is found. + """ paths = [] if os.environ.get("ENDSTONE_PYTHON_EXECUTABLE", None) is not None: paths.append(os.environ["ENDSTONE_PYTHON_EXECUTABLE"]) @@ -57,6 +67,14 @@ def find_python() -> Path: def _build_commands(commands: dict[str, Any]) -> list[Command]: + """Converts a plugin's command metadata dict into a list of Command objects. + + Args: + commands: Mapping of command name to keyword arguments for ``Command``. + + Returns: + A list of constructed Command instances. + """ results = [] for name, command in commands.items(): command = Command(name, **command) @@ -65,6 +83,20 @@ def _build_commands(commands: dict[str, Any]) -> list[Command]: def _build_permissions(permissions: dict[str, Any]) -> list[Permission]: + """Converts a plugin's permission metadata dict into a list of Permission objects. + + The ``"default"`` key in each permission entry is coerced from ``bool`` or + ``str`` to a ``PermissionDefault`` enum value. + + Args: + permissions: Mapping of permission name to keyword arguments for ``Permission``. + + Returns: + A list of constructed Permission instances. + + Raises: + TypeError: If a ``"default"`` value is not a bool, str, or PermissionDefault. + """ results = [] for name, permission in permissions.items(): if "default" in permission: @@ -86,14 +118,55 @@ def _build_permissions(permissions: dict[str, Any]) -> list[Permission]: class PythonPluginLoader(PluginLoader): + """Plugin loader for Python plugins distributed as wheel packages. + + Discovers plugins via ``endstone`` entry points and installs wheel files + using pip into a local prefix (``plugins/.local``). On destruction (e.g. + server reload), previously installed plugin packages are uninstalled via + pip to ensure a clean state. + """ + SUPPORTED_API = ["0.5", "0.6", "0.7", "0.8", "0.9", "0.10", "0.11", "0.12"] def __init__(self, server: Server): PluginLoader.__init__(self, server) - self._invalidate_caches() self._plugins: list[Plugin] = [] + self._uninstall_plugins() + self._invalidate_caches() + + def __del__(self) -> None: + self._uninstall_plugins() + + @staticmethod + def _find_plugin_eps() -> list[EntryPoint]: + """Returns all ``endstone`` entry points whose distribution name starts with ``endstone-``.""" + return [ + ep + for ep in entry_points(group="endstone") + if ep.dist is not None and ep.dist.name.replace("_", "-").startswith("endstone-") + ] + + @staticmethod + def _uninstall_plugins() -> None: + """Uninstalls all currently installed endstone plugin distributions via pip.""" + dists = [ep.dist.name for ep in PythonPluginLoader._find_plugin_eps()] # type: ignore[union-attr] + if not dists: + return + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "uninstall", + *dists, + "-y", + "--quiet", + "--disable-pip-version-check", + ], + ) def _invalidate_caches(self) -> None: + """Clears Python import caches and registers the local prefix as a site directory.""" importlib.invalidate_caches() for module in list(sys.modules.keys()): if module.startswith("endstone_"): @@ -102,21 +175,19 @@ def _invalidate_caches(self) -> None: self._prefix = os.path.join("plugins", ".local") for site_dir in site.getsitepackages(prefixes=[self._prefix]): site.addsitedir(site_dir) - if ( - os.path.exists(site_dir) - and os.path.commonpath([site_dir, self._prefix]) == self._prefix - and site_dir != self._prefix - ): - for directory in os.listdir(site_dir): - if not os.path.isdir(os.path.join(site_dir, directory)): - continue - if directory.startswith("endstone_") or directory.startswith("~"): - shutil.rmtree(os.path.join(site_dir, directory)) def load_plugin(self, file: str) -> Plugin | None: # type: ignore[override] - env = os.environ.copy() - env.pop("LD_PRELOAD", "") + """Installs a wheel file via pip and loads the plugin from it. + + Args: + file: Path to a ``.whl`` file. + Returns: + The loaded Plugin instance, or None if no valid entry point was found. + + Raises: + ValueError: If the package name cannot be determined from the wheel. + """ dist_name: str | None = pkginfo.Wheel(file).name if dist_name is None: raise ValueError(f"Could not determine package name from {file}") @@ -133,7 +204,7 @@ def load_plugin(self, file: str) -> Plugin | None: # type: ignore[override] "--no-warn-script-location", "--disable-pip-version-check", ], - env=env, + ) eps = distribution(dist_name).entry_points.select(group="endstone") @@ -145,11 +216,18 @@ def load_plugin(self, file: str) -> Plugin | None: # type: ignore[override] return None def load_plugins(self, directory: str) -> list[Plugin]: + """Loads all plugins from registered entry points and wheel files in the given directory. + + Args: + directory: Path to the directory containing ``.whl`` files. + + Returns: + A list of successfully loaded Plugin instances. + """ loaded_plugins = [] if not self._plugins: - eps = entry_points(group="endstone") - for ep in eps: + for ep in self._find_plugin_eps(): plugin = self._load_plugin_from_ep(ep) if plugin: loaded_plugins.append(plugin) @@ -162,7 +240,18 @@ def load_plugins(self, directory: str) -> list[Plugin]: return loaded_plugins def _load_plugin_from_ep(self, ep: EntryPoint) -> Plugin | None: - # enforce naming convention + """Loads a single plugin from an entry point. + + Validates the distribution naming convention, checks API version + compatibility, reads distribution metadata, and instantiates the + plugin class. + + Args: + ep: An ``endstone`` group entry point. + + Returns: + The loaded Plugin instance, or None if loading fails. + """ if ep.dist is None: return None if not ep.dist.name.replace("_", "-").startswith("endstone-"): From e5cb33ce478028021ca96e9c449bb94724a22390 Mon Sep 17 00:00:00 2001 From: Vincent Date: Tue, 7 Apr 2026 20:09:55 +0100 Subject: [PATCH 2/2] refactor: simplify plugin entry point discovery logic in plugin_loader --- endstone/plugin/plugin_loader.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/endstone/plugin/plugin_loader.py b/endstone/plugin/plugin_loader.py index ba700e241e..ec3ccf12d2 100644 --- a/endstone/plugin/plugin_loader.py +++ b/endstone/plugin/plugin_loader.py @@ -137,19 +137,10 @@ def __init__(self, server: Server): def __del__(self) -> None: self._uninstall_plugins() - @staticmethod - def _find_plugin_eps() -> list[EntryPoint]: - """Returns all ``endstone`` entry points whose distribution name starts with ``endstone-``.""" - return [ - ep - for ep in entry_points(group="endstone") - if ep.dist is not None and ep.dist.name.replace("_", "-").startswith("endstone-") - ] - @staticmethod def _uninstall_plugins() -> None: """Uninstalls all currently installed endstone plugin distributions via pip.""" - dists = [ep.dist.name for ep in PythonPluginLoader._find_plugin_eps()] # type: ignore[union-attr] + dists = [ep.dist.name for ep in entry_points(group="endstone")] # type: ignore[union-attr] if not dists: return subprocess.run( @@ -204,7 +195,6 @@ def load_plugin(self, file: str) -> Plugin | None: # type: ignore[override] "--no-warn-script-location", "--disable-pip-version-check", ], - ) eps = distribution(dist_name).entry_points.select(group="endstone") @@ -227,7 +217,7 @@ def load_plugins(self, directory: str) -> list[Plugin]: loaded_plugins = [] if not self._plugins: - for ep in self._find_plugin_eps(): + for ep in entry_points(group="endstone"): plugin = self._load_plugin_from_ep(ep) if plugin: loaded_plugins.append(plugin)