Skip to content

Commit 3914d76

Browse files
authored
fix: install only missing plugin dependencies (AstrBotDevs#6088)
* chore: ignore local worktrees * fix: install only missing plugin dependencies * fix: harden missing dependency install fallback * fix: clarify dependency install fallback logging * refactor: simplify dependency install test helpers * refactor: reuse requirements precheck planning
1 parent 3e2cb6a commit 3914d76

4 files changed

Lines changed: 542 additions & 43 deletions

File tree

astrbot/core/star/star_manager.py

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""插件的重载、启停、安装、卸载等操作。"""
22

33
import asyncio
4+
import contextlib
45
import functools
56
import inspect
67
import json
78
import logging
89
import os
910
import sys
11+
import tempfile
1012
import traceback
1113
from types import ModuleType
1214

@@ -29,12 +31,12 @@
2931
get_astrbot_config_path,
3032
get_astrbot_path,
3133
get_astrbot_plugin_path,
34+
get_astrbot_temp_path,
3235
)
3336
from astrbot.core.utils.io import remove_dir
3437
from astrbot.core.utils.metrics import Metric
3538
from astrbot.core.utils.requirements_utils import (
36-
RequirementsPrecheckFailed,
37-
find_missing_requirements_or_raise,
39+
plan_missing_requirements_install,
3840
)
3941

4042
from . import StarMetadata
@@ -74,30 +76,78 @@ def __init__(
7476
self.error = error
7577

7678

79+
@contextlib.contextmanager
80+
def _temporary_filtered_requirements_file(
81+
*,
82+
install_lines: tuple[str, ...],
83+
):
84+
filtered_requirements_path: str | None = None
85+
temp_dir = get_astrbot_temp_path()
86+
87+
try:
88+
os.makedirs(temp_dir, exist_ok=True)
89+
with tempfile.NamedTemporaryFile(
90+
mode="w",
91+
suffix="_plugin_requirements.txt",
92+
delete=False,
93+
dir=temp_dir,
94+
encoding="utf-8",
95+
) as filtered_requirements_file:
96+
filtered_requirements_file.write("\n".join(install_lines) + "\n")
97+
filtered_requirements_path = filtered_requirements_file.name
98+
99+
yield filtered_requirements_path
100+
finally:
101+
if filtered_requirements_path and os.path.exists(filtered_requirements_path):
102+
try:
103+
os.remove(filtered_requirements_path)
104+
except OSError as exc:
105+
logger.warning(
106+
"删除临时插件依赖文件失败:%s(路径:%s)",
107+
exc,
108+
filtered_requirements_path,
109+
)
110+
111+
77112
async def _install_requirements_with_precheck(
78113
*,
79114
plugin_label: str,
80115
requirements_path: str,
81116
) -> None:
82-
try:
83-
missing = find_missing_requirements_or_raise(requirements_path)
84-
except RequirementsPrecheckFailed:
117+
install_plan = plan_missing_requirements_install(requirements_path)
118+
119+
if install_plan is None:
85120
logger.info(
86-
f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): "
121+
f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): "
87122
f"{requirements_path}"
88123
)
89124
await pip_installer.install(requirements_path=requirements_path)
90125
return
91126

92-
if not missing:
127+
if not install_plan.missing_names:
93128
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
94129
return
95130

131+
if not install_plan.install_lines:
132+
fallback_reason = install_plan.fallback_reason or "unknown reason"
133+
logger.info(
134+
"检测到插件 %s 缺失依赖,但无法安全裁剪 requirements,回退到完整安装: %s (%s)",
135+
plugin_label,
136+
requirements_path,
137+
fallback_reason,
138+
)
139+
await pip_installer.install(requirements_path=requirements_path)
140+
return
141+
96142
logger.info(
97143
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
98-
f"{requirements_path} -> {sorted(missing)}"
144+
f"{requirements_path} -> {sorted(install_plan.missing_names)}"
99145
)
100-
await pip_installer.install(requirements_path=requirements_path)
146+
147+
with _temporary_filtered_requirements_file(
148+
install_lines=install_plan.install_lines,
149+
) as filtered_requirements_path:
150+
await pip_installer.install(requirements_path=filtered_requirements_path)
101151

102152

103153
class PluginManager:

astrbot/core/utils/requirements_utils.py

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import re
55
import shlex
66
import sys
7-
from collections.abc import Iterable, Iterator
7+
from collections.abc import Iterable, Iterator, Sequence
88
from dataclasses import dataclass
99

1010
from packaging.requirements import InvalidRequirement, Requirement
@@ -29,6 +29,13 @@ class ParsedPackageInput:
2929
requirement_names: frozenset[str]
3030

3131

32+
@dataclass(frozen=True)
33+
class MissingRequirementsPlan:
34+
missing_names: frozenset[str]
35+
install_lines: tuple[str, ...]
36+
fallback_reason: str | None = None
37+
38+
3239
def canonicalize_distribution_name(name: str) -> str:
3340
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
3441

@@ -364,8 +371,8 @@ def _load_requirement_lines_for_precheck(
364371
None,
365372
)
366373
if fallback_line is not None:
367-
logger.warning(
368-
"预检查缺失依赖失败,将回退到完整安装: unresolved direct reference in %s: %s",
374+
logger.info(
375+
"缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)",
369376
requirements_path,
370377
fallback_line,
371378
)
@@ -381,6 +388,13 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
381388
if not can_precheck or requirement_lines is None:
382389
return None
383390

391+
return find_missing_requirements_from_lines(requirement_lines)
392+
393+
394+
def find_missing_requirements_from_lines(
395+
requirement_lines: Sequence[str],
396+
) -> set[str] | None:
397+
384398
required = list(iter_requirements(lines=requirement_lines))
385399
if not required:
386400
return set()
@@ -401,6 +415,70 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
401415
return missing
402416

403417

418+
def build_missing_requirements_install_lines(
419+
requirements_path: str,
420+
requirement_lines: Sequence[str],
421+
missing_names: set[str] | frozenset[str],
422+
) -> tuple[str, ...] | None:
423+
wanted_names = set(missing_names)
424+
install_lines: list[str] = []
425+
for line in requirement_lines:
426+
parsed = _parse_requirement_line(line)
427+
if parsed is None:
428+
if looks_like_direct_reference(line) or line.startswith(("-", "--")):
429+
logger.debug(
430+
"缺失依赖行筛选回退到完整安装:requirements 中包含无法安全裁剪的 option/direct-reference 行: %s (%s)",
431+
requirements_path,
432+
line,
433+
)
434+
return None
435+
continue
436+
437+
name, _specifier = parsed
438+
if name in wanted_names:
439+
install_lines.append(line)
440+
441+
return tuple(install_lines)
442+
443+
444+
def plan_missing_requirements_install(
445+
requirements_path: str,
446+
) -> MissingRequirementsPlan | None:
447+
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
448+
requirements_path
449+
)
450+
if not can_precheck or requirement_lines is None:
451+
return None
452+
453+
missing = find_missing_requirements_from_lines(requirement_lines)
454+
if missing is None:
455+
return None
456+
457+
install_lines = build_missing_requirements_install_lines(
458+
requirements_path,
459+
requirement_lines,
460+
missing,
461+
)
462+
if install_lines is None:
463+
return None
464+
if missing and not install_lines:
465+
logger.warning(
466+
"预检查缺失依赖成功,但无法映射到可安装 requirement 行,将回退到完整安装: %s -> %s",
467+
requirements_path,
468+
sorted(missing),
469+
)
470+
return MissingRequirementsPlan(
471+
missing_names=frozenset(missing),
472+
install_lines=(),
473+
fallback_reason="unmapped missing requirement names",
474+
)
475+
476+
return MissingRequirementsPlan(
477+
missing_names=frozenset(missing),
478+
install_lines=install_lines,
479+
)
480+
481+
404482
def find_missing_requirements_or_raise(requirements_path: str) -> set[str]:
405483
missing = find_missing_requirements(requirements_path)
406484
if missing is None:

0 commit comments

Comments
 (0)