Skip to content

Commit 6da59cf

Browse files
authored
fix: 插件依赖自动安装逻辑与 Dashboard 安装体验优化 (AstrBotDevs#5954)
* fix: install plugin requirements before first load * fix: handle pip option arguments correctly * fix: harden pip install input parsing * refactor: simplify pip install input parsing * fix: align plugin dependency install handling * fix: respect configured pip index overrides * test: parameterize plugin dependency install flows * refactor: simplify multiline pip input parsing * fix: install plugin dependencies before loading * fix: protect core dependencies from downgrades and simplify package input splitting * fix: enhance dependency conflict reporting and improve user-facing warnings * refactor: preserve pip log indentation and fix CodeQL URL sanitization alert * fix: explicit re-export for DependencyConflictError to satisfy ruff F401 * test: enhance index override verification in pip installer tests * fix: correctly map pip ERROR and WARNING outputs to proper log levels * refactor: show specific version conflicts in DependencyConflictError and revert log level mapping * refactor: simplify install() by decoupling pip logging, failure classification and constraint file management * refactor: further simplify pip installer and requirement parsing logic * refactor: simplify dependency installation logic and improve circular requirement reporting * style: organize imports in astrbot/core/__init__.py * refactor: optimize requirement parsing efficiency and flatten pip installer API * style: fix import sorting in astrbot/core/__init__.py * refactor: consolidate requirement parsing, optimize core protection, and improve exception propagation * fix: preserve valid pip requirement parsing * fix: skip empty pip installs and preserve blank output * chore: normalize gitignore entry style * fix: tighten pip trust and requirement parsing * refactor: centralize pip install parsing and failure handling * fix: redact pip argv credentials in logs * fix: surface plugin dependency install errors * fix: cache core constraints and clarify requirement installs * fix: harden pip requirement parsing for plugin installs * fix: simplify pip installer parsing internals * fix: tighten pip installer parsing and redaction * refactor: simplify plugin dependency install flow * fix: preserve core constraint conflict errors * fix: harden pip installer fallback resolution * refactor: split pip requirement and constraint helpers * refactor: simplify pip installer helper flow * refactor: streamline requirement precheck helpers * refactor: clarify core constraint resolution * fix: surface pip install failures explicitly * refactor: separate pip conflict context parsing * fix: harden core constraint resolution * test: cover pip installer failure call sites * refactor: remove dead requirements fallback helper * refactor: narrow core constraint error handling * refactor: unify requirement iteration * refactor: share requirement name parsing * test: align pip helper coverage * fix: bind pip output limit at runtime * refactor: reuse core requirement parser for tokens
1 parent 10ceacf commit 6da59cf

10 files changed

Lines changed: 3089 additions & 288 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,5 @@ GenieData/
6161
.codex/
6262
.opencode/
6363
.kilocode/
64+
.worktrees/
65+
docs/plans/

astrbot/core/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,21 @@
44
from astrbot.core.config.default import DB_PATH
55
from astrbot.core.db.sqlite import SQLiteDatabase
66
from astrbot.core.file_token_service import FileTokenService
7-
from astrbot.core.utils.pip_installer import PipInstaller
7+
from astrbot.core.utils.pip_installer import (
8+
DependencyConflictError as DependencyConflictError,
9+
)
10+
from astrbot.core.utils.pip_installer import (
11+
PipInstaller,
12+
)
13+
from astrbot.core.utils.requirements_utils import (
14+
RequirementsPrecheckFailed as RequirementsPrecheckFailed,
15+
)
16+
from astrbot.core.utils.requirements_utils import (
17+
find_missing_requirements as find_missing_requirements,
18+
)
19+
from astrbot.core.utils.requirements_utils import (
20+
find_missing_requirements_or_raise as find_missing_requirements_or_raise,
21+
)
822
from astrbot.core.utils.shared_preferences import SharedPreferences
923
from astrbot.core.utils.t2i.renderer import HtmlRenderer
1024

astrbot/core/star/star_manager.py

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
from packaging.specifiers import InvalidSpecifier, SpecifierSet
1515
from packaging.version import InvalidVersion, Version
1616

17-
from astrbot.core import logger, pip_installer, sp
17+
from astrbot.core import (
18+
DependencyConflictError,
19+
logger,
20+
pip_installer,
21+
sp,
22+
)
1823
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
1924
from astrbot.core.config.astrbot_config import AstrBotConfig
2025
from astrbot.core.config.default import VERSION
@@ -27,6 +32,10 @@
2732
)
2833
from astrbot.core.utils.io import remove_dir
2934
from astrbot.core.utils.metrics import Metric
35+
from astrbot.core.utils.requirements_utils import (
36+
RequirementsPrecheckFailed,
37+
find_missing_requirements_or_raise,
38+
)
3039

3140
from . import StarMetadata
3241
from .command_management import sync_command_configs
@@ -48,6 +57,49 @@ class PluginVersionIncompatibleError(Exception):
4857
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
4958

5059

60+
class PluginDependencyInstallError(Exception):
61+
"""Raised when plugin dependency installation fails."""
62+
63+
def __init__(
64+
self,
65+
*,
66+
plugin_label: str,
67+
requirements_path: str,
68+
error: Exception,
69+
) -> None:
70+
message = f"插件 {plugin_label} 依赖安装失败: {error!s}"
71+
super().__init__(message)
72+
self.plugin_label = plugin_label
73+
self.requirements_path = requirements_path
74+
self.error = error
75+
76+
77+
async def _install_requirements_with_precheck(
78+
*,
79+
plugin_label: str,
80+
requirements_path: str,
81+
) -> None:
82+
try:
83+
missing = find_missing_requirements_or_raise(requirements_path)
84+
except RequirementsPrecheckFailed:
85+
logger.info(
86+
f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): "
87+
f"{requirements_path}"
88+
)
89+
await pip_installer.install(requirements_path=requirements_path)
90+
return
91+
92+
if not missing:
93+
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
94+
return
95+
96+
logger.info(
97+
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
98+
f"{requirements_path} -> {sorted(missing)}"
99+
)
100+
await pip_installer.install(requirements_path=requirements_path)
101+
102+
51103
class PluginManager:
52104
def __init__(self, context: Context, config: AstrBotConfig) -> None:
53105
from .star_tools import StarTools
@@ -198,15 +250,37 @@ async def _check_plugin_dept_update(
198250
to_update.append(p.root_dir_name)
199251
for p in to_update:
200252
plugin_path = os.path.join(plugin_dir, p)
201-
if os.path.exists(os.path.join(plugin_path, "requirements.txt")):
202-
pth = os.path.join(plugin_path, "requirements.txt")
203-
logger.info(f"正在安装插件 {p} 所需的依赖库: {pth}")
204-
try:
205-
await pip_installer.install(requirements_path=pth)
206-
except Exception as e:
207-
logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}")
253+
await self._ensure_plugin_requirements(plugin_path, p)
208254
return True
209255

256+
async def _ensure_plugin_requirements(
257+
self,
258+
plugin_dir_path: str,
259+
plugin_label: str,
260+
) -> None:
261+
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
262+
if not os.path.exists(requirements_path):
263+
return
264+
265+
try:
266+
await _install_requirements_with_precheck(
267+
plugin_label=plugin_label,
268+
requirements_path=requirements_path,
269+
)
270+
except asyncio.CancelledError:
271+
raise
272+
except DependencyConflictError as e:
273+
logger.error(f"插件 {plugin_label} 依赖冲突: {e!s}")
274+
raise
275+
except Exception as e:
276+
dependency_error = PluginDependencyInstallError(
277+
plugin_label=plugin_label,
278+
requirements_path=requirements_path,
279+
error=e,
280+
)
281+
logger.exception(str(dependency_error))
282+
raise dependency_error from e
283+
210284
async def _import_plugin_with_dependency_recovery(
211285
self,
212286
path: str,
@@ -422,7 +496,7 @@ def _build_failed_plugin_record(
422496
root_dir_name: str,
423497
plugin_dir_path: str,
424498
reserved: bool,
425-
error: Exception | str,
499+
error: BaseException | str,
426500
error_trace: str,
427501
) -> dict:
428502
record: dict = {
@@ -495,6 +569,9 @@ async def reload_failed_plugin(self, dir_name):
495569

496570
self._cleanup_plugin_state(dir_name)
497571

572+
plugin_path = os.path.join(self.plugin_store_path, dir_name)
573+
await self._ensure_plugin_requirements(plugin_path, dir_name)
574+
498575
success, error = await self.load(specified_dir_name=dir_name)
499576
if success:
500577
self.failed_plugin_dict.pop(dir_name, None)
@@ -1078,6 +1155,10 @@ async def install_plugin(
10781155

10791156
# reload the plugin
10801157
dir_name = os.path.basename(plugin_path)
1158+
await self._ensure_plugin_requirements(
1159+
plugin_path,
1160+
dir_name,
1161+
)
10811162
success, error_message = await self.load(
10821163
specified_dir_name=dir_name,
10831164
ignore_version_check=ignore_version_check,
@@ -1317,6 +1398,12 @@ async def update_plugin(self, plugin_name: str, proxy="") -> None:
13171398
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
13181399

13191400
await self.updator.update(plugin, proxy=proxy)
1401+
if plugin.root_dir_name:
1402+
plugin_dir_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
1403+
await self._ensure_plugin_requirements(
1404+
plugin_dir_path,
1405+
plugin_name,
1406+
)
13201407
await self.reload(plugin_name)
13211408

13221409
async def turn_off_plugin(self, plugin_name: str) -> None:
@@ -1488,6 +1575,7 @@ async def install_plugin_from_file(
14881575
os.remove(zip_file_path)
14891576
except BaseException as e:
14901577
logger.warning(f"删除插件压缩包失败: {e!s}")
1578+
await self._ensure_plugin_requirements(desti_dir, dir_name)
14911579
# await self.reload()
14921580
success, error_message = await self.load(
14931581
specified_dir_name=dir_name,
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import contextlib
2+
import functools
3+
import importlib.metadata as importlib_metadata
4+
import logging
5+
import os
6+
from collections.abc import Iterator
7+
8+
from packaging.requirements import Requirement
9+
10+
from astrbot.core.utils.requirements_utils import (
11+
canonicalize_distribution_name,
12+
collect_installed_distribution_versions,
13+
get_requirement_check_paths,
14+
)
15+
16+
logger = logging.getLogger("astrbot")
17+
18+
19+
def _resolve_core_dist_name(core_dist_name: str | None) -> str | None:
20+
if core_dist_name:
21+
try:
22+
importlib_metadata.distribution(core_dist_name)
23+
return core_dist_name
24+
except importlib_metadata.PackageNotFoundError:
25+
return None
26+
27+
try:
28+
importlib_metadata.distribution("AstrBot")
29+
return "AstrBot"
30+
except importlib_metadata.PackageNotFoundError:
31+
pass
32+
33+
if not __package__:
34+
return None
35+
36+
top_pkg = __package__.split(".")[0]
37+
for dist in importlib_metadata.distributions():
38+
try:
39+
top_level = dist.read_text("top_level.txt") or ""
40+
except Exception:
41+
continue
42+
if top_pkg in top_level.splitlines():
43+
if "Name" in dist.metadata:
44+
return dist.metadata["Name"]
45+
46+
return None
47+
48+
49+
@functools.cache
50+
def _get_core_constraints(core_dist_name: str | None) -> tuple[str, ...]:
51+
try:
52+
resolved_core_dist_name = _resolve_core_dist_name(core_dist_name)
53+
except Exception as exc:
54+
logger.warning("解析核心分发名称失败: %s", exc)
55+
return ()
56+
57+
if not resolved_core_dist_name:
58+
return ()
59+
60+
try:
61+
dist = importlib_metadata.distribution(resolved_core_dist_name)
62+
except importlib_metadata.PackageNotFoundError:
63+
return ()
64+
except Exception as exc:
65+
logger.warning("读取核心分发元数据失败 (%s): %s", resolved_core_dist_name, exc)
66+
return ()
67+
68+
if not dist or not dist.requires:
69+
return ()
70+
71+
installed = collect_installed_distribution_versions(get_requirement_check_paths())
72+
if not installed:
73+
return ()
74+
75+
constraints: list[str] = []
76+
for req_str in dist.requires:
77+
try:
78+
req = Requirement(req_str)
79+
if req.marker and not req.marker.evaluate():
80+
continue
81+
name = canonicalize_distribution_name(req.name)
82+
if name in installed:
83+
constraints.append(f"{name}=={installed[name]}")
84+
except Exception:
85+
continue
86+
87+
return tuple(constraints)
88+
89+
90+
class CoreConstraintsProvider:
91+
def __init__(self, core_dist_name: str | None) -> None:
92+
self._core_dist_name = core_dist_name
93+
94+
@contextlib.contextmanager
95+
def constraints_file(self) -> Iterator[str | None]:
96+
constraints = _get_core_constraints(self._core_dist_name)
97+
if not constraints:
98+
yield None
99+
return
100+
101+
path: str | None = None
102+
try:
103+
import tempfile
104+
105+
with tempfile.NamedTemporaryFile(
106+
mode="w", suffix="_constraints.txt", delete=False, encoding="utf-8"
107+
) as f:
108+
f.write("\n".join(constraints))
109+
path = f.name
110+
logger.info("已启用核心依赖版本保护 (%d 个约束)", len(constraints))
111+
except Exception as exc:
112+
logger.warning("创建临时约束文件失败: %s", exc)
113+
yield None
114+
return
115+
116+
try:
117+
yield path
118+
finally:
119+
if path and os.path.exists(path):
120+
with contextlib.suppress(Exception):
121+
os.remove(path)

0 commit comments

Comments
 (0)