From 399f96c483a0b693d805dc65065b5cd9c93c8bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Wed, 11 Mar 2026 00:13:01 +0900 Subject: [PATCH 1/6] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 486b9628df..d04a707804 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ GenieData/ .codex/ .opencode/ .kilocode/ +.worktrees/ From dedfc2f4060bf45823163f199816faa2604e1a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 12 Mar 2026 11:08:09 +0900 Subject: [PATCH 2/6] fix: install only missing plugin dependencies --- astrbot/core/star/star_manager.py | 36 +++- astrbot/core/utils/requirements_utils.py | 49 ++++++ tests/test_pip_helper_modules.py | 62 +++++++ tests/test_plugin_manager.py | 209 ++++++++++++++++++++--- 4 files changed, 326 insertions(+), 30 deletions(-) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index cf000c5a48..97d7c909cd 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -7,6 +7,7 @@ import logging import os import sys +import tempfile import traceback from types import ModuleType @@ -29,12 +30,12 @@ get_astrbot_config_path, get_astrbot_path, get_astrbot_plugin_path, + get_astrbot_temp_path, ) from astrbot.core.utils.io import remove_dir from astrbot.core.utils.metrics import Metric from astrbot.core.utils.requirements_utils import ( - RequirementsPrecheckFailed, - find_missing_requirements_or_raise, + plan_missing_requirements_install, ) from . import StarMetadata @@ -79,9 +80,9 @@ async def _install_requirements_with_precheck( plugin_label: str, requirements_path: str, ) -> None: - try: - missing = find_missing_requirements_or_raise(requirements_path) - except RequirementsPrecheckFailed: + install_plan = plan_missing_requirements_install(requirements_path) + + if install_plan is None: logger.info( f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): " f"{requirements_path}" @@ -89,15 +90,34 @@ async def _install_requirements_with_precheck( await pip_installer.install(requirements_path=requirements_path) return - if not missing: + if not install_plan.missing_names: logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。") return logger.info( f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: " - f"{requirements_path} -> {sorted(missing)}" + f"{requirements_path} -> {sorted(install_plan.missing_names)}" ) - await pip_installer.install(requirements_path=requirements_path) + + filtered_requirements_path: str | None = None + try: + os.makedirs(get_astrbot_temp_path(), exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + suffix="_plugin_requirements.txt", + delete=False, + dir=get_astrbot_temp_path(), + encoding="utf-8", + ) as filtered_requirements_file: + filtered_requirements_file.write( + "\n".join(install_plan.install_lines) + "\n" + ) + filtered_requirements_path = filtered_requirements_file.name + + await pip_installer.install(requirements_path=filtered_requirements_path) + finally: + if filtered_requirements_path and os.path.exists(filtered_requirements_path): + os.remove(filtered_requirements_path) class PluginManager: diff --git a/astrbot/core/utils/requirements_utils.py b/astrbot/core/utils/requirements_utils.py index 7f38272568..a5cf14c33c 100644 --- a/astrbot/core/utils/requirements_utils.py +++ b/astrbot/core/utils/requirements_utils.py @@ -29,6 +29,12 @@ class ParsedPackageInput: requirement_names: frozenset[str] +@dataclass(frozen=True) +class MissingRequirementsPlan: + missing_names: frozenset[str] + install_lines: tuple[str, ...] + + def canonicalize_distribution_name(name: str) -> str: return re.sub(r"[-_.]+", "-", name).strip("-").lower() @@ -401,6 +407,49 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None: return missing +def build_missing_requirements_install_lines( + requirements_path: str, + missing_names: set[str] | frozenset[str], +) -> tuple[str, ...] | None: + can_precheck, requirement_lines = _load_requirement_lines_for_precheck( + requirements_path + ) + if not can_precheck or requirement_lines is None: + return None + + wanted_names = set(missing_names) + install_lines: list[str] = [] + for line in requirement_lines: + parsed = _parse_requirement_line(line) + if parsed is None: + if looks_like_direct_reference(line) or line.startswith(("-", "--")): + return None + continue + + name, _specifier = parsed + if name in wanted_names: + install_lines.append(line) + + return tuple(install_lines) + + +def plan_missing_requirements_install( + requirements_path: str, +) -> MissingRequirementsPlan | None: + missing = find_missing_requirements(requirements_path) + if missing is None: + return None + + install_lines = build_missing_requirements_install_lines(requirements_path, missing) + if install_lines is None: + return None + + return MissingRequirementsPlan( + missing_names=frozenset(missing), + install_lines=install_lines, + ) + + def find_missing_requirements_or_raise(requirements_path: str) -> set[str]: missing = find_missing_requirements(requirements_path) if missing is None: diff --git a/tests/test_pip_helper_modules.py b/tests/test_pip_helper_modules.py index dcb5cdb219..2dd322d4b5 100644 --- a/tests/test_pip_helper_modules.py +++ b/tests/test_pip_helper_modules.py @@ -145,6 +145,68 @@ def test_find_missing_requirements_or_raise_uses_requirements_exception(tmp_path requirements_utils.find_missing_requirements_or_raise(str(requirements_path)) +def test_build_missing_requirements_install_lines_keeps_only_missing_lines(tmp_path): + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text( + 'aiohttp>=3.0\nboto3==1.2; python_version >= "3.0"\nbotocore\n', + encoding="utf-8", + ) + + install_lines = requirements_utils.build_missing_requirements_install_lines( + str(requirements_path), {"boto3", "botocore"} + ) + + assert install_lines == ( + 'boto3==1.2; python_version >= "3.0"', + "botocore", + ) + + +def test_build_missing_requirements_install_lines_returns_empty_tuple_when_all_satisfied( + tmp_path, +): + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("aiohttp>=3.0\nboto3\n", encoding="utf-8") + + install_lines = requirements_utils.build_missing_requirements_install_lines( + str(requirements_path), set() + ) + + assert install_lines == () + + +def test_build_missing_requirements_install_lines_returns_none_for_option_lines( + tmp_path, +): + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text( + "--extra-index-url https://example.com/simple\nboto3\n", + encoding="utf-8", + ) + + install_lines = requirements_utils.build_missing_requirements_install_lines( + str(requirements_path), {"boto3"} + ) + + assert install_lines is None + + +def test_build_missing_requirements_install_lines_skips_inactive_marker_lines( + tmp_path, +): + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text( + 'boto3\nother-package; sys_platform == "win32"\n', + encoding="utf-8", + ) + + install_lines = requirements_utils.build_missing_requirements_install_lines( + str(requirements_path), {"boto3"} + ) + + assert install_lines == ("boto3",) + + def test_find_missing_requirements_logs_path_and_reason_on_precheck_fallback( monkeypatch, tmp_path, diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 1b52990a58..f70f552323 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -76,7 +76,10 @@ async def mock_reload(specified_dir_name=None): def _build_dependency_install_mock(events, fail: bool): async def mock_install_requirements( - *, requirements_path: str = None, package_name: str = None, **kwargs + *, + requirements_path: str | None = None, + package_name: str | None = None, + **kwargs, ): del kwargs if requirements_path: @@ -89,25 +92,95 @@ async def mock_install_requirements( return mock_install_requirements +def _build_dependency_install_content_mock(events, fail: bool): + async def mock_install_requirements( + *, + requirements_path: str | None = None, + package_name: str | None = None, + **kwargs, + ): + del kwargs + if requirements_path: + path = Path(requirements_path) + events.append(("deps", str(path), path.read_text(encoding="utf-8"))) + if package_name: + events.append(("deps_pkg", package_name)) + if fail: + raise Exception("pip failed") + + return mock_install_requirements + + +def _build_dependency_install_assert_tempdir_mock(events, fail: bool): + async def mock_install_requirements( + *, + requirements_path: str | None = None, + package_name: str | None = None, + **kwargs, + ): + del kwargs, package_name + assert requirements_path is not None + path = Path(requirements_path) + events.append(("deps", str(path), path.read_text(encoding="utf-8"))) + if fail: + raise Exception("pip failed") + + return mock_install_requirements + + def _mock_missing_requirements(monkeypatch, missing: set[str]): + from astrbot.core.utils.requirements_utils import MissingRequirementsPlan + monkeypatch.setattr( - "astrbot.core.star.star_manager.find_missing_requirements_or_raise", - lambda requirements_path: missing, + "astrbot.core.star.star_manager.plan_missing_requirements_install", + lambda requirements_path: MissingRequirementsPlan( + missing_names=frozenset(missing), + install_lines=tuple(sorted(missing)), + ), ) def _mock_precheck_fails(monkeypatch): - from astrbot.core import RequirementsPrecheckFailed + monkeypatch.setattr( + "astrbot.core.star.star_manager.plan_missing_requirements_install", + lambda requirements_path: None, + ) + + +def _mock_missing_requirements_plan(monkeypatch, missing_names, install_lines): + from astrbot.core.utils.requirements_utils import MissingRequirementsPlan + + monkeypatch.setattr( + "astrbot.core.star.star_manager.plan_missing_requirements_install", + lambda requirements_path: MissingRequirementsPlan( + missing_names=frozenset(missing_names), + install_lines=tuple(install_lines), + ), + ) - def mock_fail(requirements_path): - raise RequirementsPrecheckFailed("mock precheck failure") +def _mock_precheck_plan_failure(monkeypatch): monkeypatch.setattr( - "astrbot.core.star.star_manager.find_missing_requirements_or_raise", - mock_fail, + "astrbot.core.star.star_manager.plan_missing_requirements_install", + lambda requirements_path: None, ) +def _assert_dependency_install_event_matches( + event, + *, + expected_original_path: Path, + expected_content: str | None = None, +): + assert event[0] == "deps" + used_path = Path(event[1]) + if expected_content is None: + assert used_path == expected_original_path + else: + assert used_path != expected_original_path + assert used_path.name.endswith("_plugin_requirements.txt") + + # --- Fixtures --- @@ -188,13 +261,21 @@ def mock_load_and_register(*args, **kwargs): if dependency_install_fails: with pytest.raises(PluginDependencyInstallError, match="pip failed"): await plugin_manager_pm.install_plugin(TEST_PLUGIN_REPO) - assert events == [("deps", str(plugin_path / "requirements.txt"))] + assert len(events) == 1 + _assert_dependency_install_event_matches( + events[0], + expected_original_path=plugin_path / "requirements.txt", + expected_content="networkx\n", + ) else: await plugin_manager_pm.install_plugin(TEST_PLUGIN_REPO) - assert events == [ - ("deps", str(plugin_path / "requirements.txt")), - ("load", TEST_PLUGIN_DIR), - ] + assert len(events) == 2 + _assert_dependency_install_event_matches( + events[0], + expected_original_path=plugin_path / "requirements.txt", + expected_content="networkx\n", + ) + assert events[1] == ("load", TEST_PLUGIN_DIR) @pytest.mark.asyncio @@ -265,13 +346,21 @@ def mock_load_and_register(*args, **kwargs): if dependency_install_fails: with pytest.raises(PluginDependencyInstallError, match="pip failed"): await plugin_manager_pm.reload_failed_plugin(TEST_PLUGIN_DIR) - assert events == [("deps", str(local_updator / "requirements.txt"))] + assert len(events) == 1 + _assert_dependency_install_event_matches( + events[0], + expected_original_path=local_updator / "requirements.txt", + expected_content="networkx\n", + ) else: await plugin_manager_pm.reload_failed_plugin(TEST_PLUGIN_DIR) - assert events == [ - ("deps", str(local_updator / "requirements.txt")), - ("load", TEST_PLUGIN_DIR), - ] + assert len(events) == 2 + _assert_dependency_install_event_matches( + events[0], + expected_original_path=local_updator / "requirements.txt", + expected_content="networkx\n", + ) + assert events[1] == ("load", TEST_PLUGIN_DIR) @pytest.mark.asyncio @@ -337,7 +426,9 @@ async def mock_install_requirements(*args, **kwargs): mock_install_requirements, ) - with pytest.raises(PluginDependencyInstallError, match="install failed") as exc_info: + with pytest.raises( + PluginDependencyInstallError, match="install failed" + ) as exc_info: await plugin_manager_pm._ensure_plugin_requirements( str(local_updator), TEST_PLUGIN_DIR, @@ -403,10 +494,20 @@ async def mock_update(plugin, proxy=""): if dependency_install_fails: with pytest.raises(PluginDependencyInstallError, match="pip failed"): await plugin_manager_pm.update_plugin(TEST_PLUGIN_NAME) - assert ("deps", str(local_updator / "requirements.txt")) in events + dep_event = next(event for event in events if event[0] == "deps") + _assert_dependency_install_event_matches( + dep_event, + expected_original_path=local_updator / "requirements.txt", + expected_content="networkx\n", + ) else: await plugin_manager_pm.update_plugin(TEST_PLUGIN_NAME) - assert ("deps", str(local_updator / "requirements.txt")) in events + dep_event = next(event for event in events if event[0] == "deps") + _assert_dependency_install_event_matches( + dep_event, + expected_original_path=local_updator / "requirements.txt", + expected_content="networkx\n", + ) assert ("reload", TEST_PLUGIN_DIR) in events @@ -468,5 +569,69 @@ def mock_load_and_register(*args, **kwargs): await plugin_manager_pm.install_plugin(TEST_PLUGIN_REPO) - assert ("deps", str(plugin_path / "requirements.txt")) in events + dep_event = next(event for event in events if event[0] == "deps") + _assert_dependency_install_event_matches( + dep_event, + expected_original_path=plugin_path / "requirements.txt", + ) assert ("load", TEST_PLUGIN_DIR) in events + + +@pytest.mark.asyncio +async def test_ensure_plugin_requirements_installs_only_missing_requirement_lines( + plugin_manager_pm: PluginManager, local_updator: Path, monkeypatch +): + requirements_path = local_updator / "requirements.txt" + requirements_path.write_text( + "aiohttp>=3.0\nboto3==1.2\nbotocore\n", + encoding="utf-8", + ) + events = [] + _mock_missing_requirements_plan( + monkeypatch, {"boto3", "botocore"}, ["boto3==1.2", "botocore"] + ) + + monkeypatch.setattr( + "astrbot.core.star.star_manager.pip_installer.install", + _build_dependency_install_content_mock(events, False), + ) + + await plugin_manager_pm._ensure_plugin_requirements( + str(local_updator), + TEST_PLUGIN_DIR, + ) + + assert len(events) == 1 + kind, used_path, content = events[0] + assert kind == "deps" + assert used_path != str(requirements_path) + assert content == "boto3==1.2\nbotocore\n" + assert not Path(used_path).exists() + + +@pytest.mark.asyncio +async def test_ensure_plugin_requirements_creates_temp_dir_before_filtered_install( + plugin_manager_pm: PluginManager, local_updator: Path, monkeypatch, tmp_path +): + requirements_path = local_updator / "requirements.txt" + requirements_path.write_text("boto3\n", encoding="utf-8") + temp_dir = tmp_path / "missing-temp-dir" + events = [] + _mock_missing_requirements_plan(monkeypatch, {"boto3"}, ["boto3"]) + + monkeypatch.setattr( + "astrbot.core.star.star_manager.get_astrbot_temp_path", + lambda: str(temp_dir), + ) + monkeypatch.setattr( + "astrbot.core.star.star_manager.pip_installer.install", + _build_dependency_install_assert_tempdir_mock(events, False), + ) + + await plugin_manager_pm._ensure_plugin_requirements( + str(local_updator), + TEST_PLUGIN_DIR, + ) + + assert temp_dir.is_dir() + assert len(events) == 1 From 53a5bfaa28b7d28273622f1ec41ab8c812be5eea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 12 Mar 2026 11:21:57 +0900 Subject: [PATCH 3/6] fix: harden missing dependency install fallback --- astrbot/core/star/star_manager.py | 5 +++-- astrbot/core/utils/requirements_utils.py | 7 +++++++ tests/test_pip_helper_modules.py | 18 +++++++++++++++++ tests/test_plugin_manager.py | 25 ++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 97d7c909cd..accd3085ba 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -100,13 +100,14 @@ async def _install_requirements_with_precheck( ) filtered_requirements_path: str | None = None + temp_dir = get_astrbot_temp_path() try: - os.makedirs(get_astrbot_temp_path(), exist_ok=True) + os.makedirs(temp_dir, exist_ok=True) with tempfile.NamedTemporaryFile( mode="w", suffix="_plugin_requirements.txt", delete=False, - dir=get_astrbot_temp_path(), + dir=temp_dir, encoding="utf-8", ) as filtered_requirements_file: filtered_requirements_file.write( diff --git a/astrbot/core/utils/requirements_utils.py b/astrbot/core/utils/requirements_utils.py index a5cf14c33c..ba802a9264 100644 --- a/astrbot/core/utils/requirements_utils.py +++ b/astrbot/core/utils/requirements_utils.py @@ -443,6 +443,13 @@ def plan_missing_requirements_install( install_lines = build_missing_requirements_install_lines(requirements_path, missing) if install_lines is None: return None + if missing and not install_lines: + logger.warning( + "预检查缺失依赖成功,但无法映射到可安装 requirement 行,将回退到完整安装: %s -> %s", + requirements_path, + sorted(missing), + ) + return None return MissingRequirementsPlan( missing_names=frozenset(missing), diff --git a/tests/test_pip_helper_modules.py b/tests/test_pip_helper_modules.py index 2dd322d4b5..b86ccb77c1 100644 --- a/tests/test_pip_helper_modules.py +++ b/tests/test_pip_helper_modules.py @@ -207,6 +207,24 @@ def test_build_missing_requirements_install_lines_skips_inactive_marker_lines( assert install_lines == ("boto3",) +def test_plan_missing_requirements_install_returns_none_when_missing_names_cannot_map_to_lines( + monkeypatch, + tmp_path, +): + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("boto3\n", encoding="utf-8") + + monkeypatch.setattr( + requirements_utils, + "find_missing_requirements", + lambda path: {"botocore"}, + ) + + plan = requirements_utils.plan_missing_requirements_install(str(requirements_path)) + + assert plan is None + + def test_find_missing_requirements_logs_path_and_reason_on_precheck_fallback( monkeypatch, tmp_path, diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index f70f552323..e3132a4532 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -635,3 +635,28 @@ async def test_ensure_plugin_requirements_creates_temp_dir_before_filtered_insta assert temp_dir.is_dir() assert len(events) == 1 + + +@pytest.mark.asyncio +async def test_ensure_plugin_requirements_falls_back_when_missing_names_have_no_install_lines( + plugin_manager_pm: PluginManager, local_updator: Path, monkeypatch +): + requirements_path = local_updator / "requirements.txt" + requirements_path.write_text("boto3\n", encoding="utf-8") + events = [] + + monkeypatch.setattr( + "astrbot.core.star.star_manager.plan_missing_requirements_install", + lambda path: None, + ) + monkeypatch.setattr( + "astrbot.core.star.star_manager.pip_installer.install", + _build_dependency_install_mock(events, False), + ) + + await plugin_manager_pm._ensure_plugin_requirements( + str(local_updator), + TEST_PLUGIN_DIR, + ) + + assert events == [("deps", str(requirements_path))] From 1fc54bf0f31c84190220bf4a2f763f6e41e4c865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 12 Mar 2026 11:32:12 +0900 Subject: [PATCH 4/6] fix: clarify dependency install fallback logging --- astrbot/core/star/star_manager.py | 22 +++++++++- astrbot/core/utils/requirements_utils.py | 16 +++++-- tests/test_pip_helper_modules.py | 40 ++++++++++++++--- tests/test_plugin_manager.py | 56 +++++++++++++++++++++++- 4 files changed, 122 insertions(+), 12 deletions(-) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index accd3085ba..c83ead42b4 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -84,7 +84,7 @@ async def _install_requirements_with_precheck( if install_plan is None: logger.info( - f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): " + f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): " f"{requirements_path}" ) await pip_installer.install(requirements_path=requirements_path) @@ -94,6 +94,17 @@ async def _install_requirements_with_precheck( logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。") return + if not install_plan.install_lines: + fallback_reason = install_plan.fallback_reason or "unknown reason" + logger.info( + "检测到插件 %s 缺失依赖,但无法安全裁剪 requirements,回退到完整安装: %s (%s)", + plugin_label, + requirements_path, + fallback_reason, + ) + await pip_installer.install(requirements_path=requirements_path) + return + logger.info( f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: " f"{requirements_path} -> {sorted(install_plan.missing_names)}" @@ -118,7 +129,14 @@ async def _install_requirements_with_precheck( await pip_installer.install(requirements_path=filtered_requirements_path) finally: if filtered_requirements_path and os.path.exists(filtered_requirements_path): - os.remove(filtered_requirements_path) + try: + os.remove(filtered_requirements_path) + except OSError as exc: + logger.warning( + "删除临时插件依赖文件失败:%s(路径:%s)", + exc, + filtered_requirements_path, + ) class PluginManager: diff --git a/astrbot/core/utils/requirements_utils.py b/astrbot/core/utils/requirements_utils.py index ba802a9264..39665f05cb 100644 --- a/astrbot/core/utils/requirements_utils.py +++ b/astrbot/core/utils/requirements_utils.py @@ -33,6 +33,7 @@ class ParsedPackageInput: class MissingRequirementsPlan: missing_names: frozenset[str] install_lines: tuple[str, ...] + fallback_reason: str | None = None def canonicalize_distribution_name(name: str) -> str: @@ -370,8 +371,8 @@ def _load_requirement_lines_for_precheck( None, ) if fallback_line is not None: - logger.warning( - "预检查缺失依赖失败,将回退到完整安装: unresolved direct reference in %s: %s", + logger.info( + "缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)", requirements_path, fallback_line, ) @@ -423,6 +424,11 @@ def build_missing_requirements_install_lines( parsed = _parse_requirement_line(line) if parsed is None: if looks_like_direct_reference(line) or line.startswith(("-", "--")): + logger.debug( + "缺失依赖行筛选回退到完整安装:requirements 中包含无法安全裁剪的 option/direct-reference 行: %s (%s)", + requirements_path, + line, + ) return None continue @@ -449,7 +455,11 @@ def plan_missing_requirements_install( requirements_path, sorted(missing), ) - return None + return MissingRequirementsPlan( + missing_names=frozenset(missing), + install_lines=(), + fallback_reason="unmapped missing requirement names", + ) return MissingRequirementsPlan( missing_names=frozenset(missing), diff --git a/tests/test_pip_helper_modules.py b/tests/test_pip_helper_modules.py index b86ccb77c1..935860a482 100644 --- a/tests/test_pip_helper_modules.py +++ b/tests/test_pip_helper_modules.py @@ -222,7 +222,35 @@ def test_plan_missing_requirements_install_returns_none_when_missing_names_canno plan = requirements_utils.plan_missing_requirements_install(str(requirements_path)) - assert plan is None + assert plan is not None + assert plan.missing_names == frozenset({"botocore"}) + assert plan.install_lines == () + assert plan.fallback_reason == "unmapped missing requirement names" + + +def test_build_missing_requirements_install_lines_logs_why_option_lines_fall_back( + monkeypatch, + tmp_path, +): + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text( + "--extra-index-url https://example.com/simple\nboto3\n", + encoding="utf-8", + ) + info_logs = [] + + monkeypatch.setattr( + "astrbot.core.utils.requirements_utils.logger.info", + lambda line, *args: info_logs.append(line % args if args else line), + ) + + install_lines = requirements_utils.build_missing_requirements_install_lines( + str(requirements_path), {"boto3"} + ) + + assert install_lines is None + assert any(str(requirements_path) in log for log in info_logs) + assert any("option/direct-reference" in log for log in info_logs) def test_find_missing_requirements_logs_path_and_reason_on_precheck_fallback( @@ -231,18 +259,18 @@ def test_find_missing_requirements_logs_path_and_reason_on_precheck_fallback( ): requirements_path = tmp_path / "requirements.txt" requirements_path.write_text("git+https://example.com/demo.git\n", encoding="utf-8") - warning_logs = [] + info_logs = [] monkeypatch.setattr( - "astrbot.core.utils.requirements_utils.logger.warning", - lambda line, *args: warning_logs.append(line % args if args else line), + "astrbot.core.utils.requirements_utils.logger.info", + lambda line, *args: info_logs.append(line % args if args else line), ) missing = requirements_utils.find_missing_requirements(str(requirements_path)) assert missing is None - assert any(str(requirements_path) in log for log in warning_logs) - assert any("direct reference" in log for log in warning_logs) + assert any(str(requirements_path) in log for log in info_logs) + assert any("option/direct-reference" in log for log in info_logs) def test_load_requirement_lines_for_precheck_uses_parse_requirement_line_result( diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index e3132a4532..d79c357fda 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -1,4 +1,5 @@ import asyncio +import os from pathlib import Path import pytest @@ -647,7 +648,14 @@ async def test_ensure_plugin_requirements_falls_back_when_missing_names_have_no_ monkeypatch.setattr( "astrbot.core.star.star_manager.plan_missing_requirements_install", - lambda path: None, + lambda path: __import__( + "astrbot.core.utils.requirements_utils", + fromlist=["MissingRequirementsPlan"], + ).MissingRequirementsPlan( + missing_names=frozenset({"botocore"}), + install_lines=(), + fallback_reason="unmapped missing requirement names", + ), ) monkeypatch.setattr( "astrbot.core.star.star_manager.pip_installer.install", @@ -660,3 +668,49 @@ async def test_ensure_plugin_requirements_falls_back_when_missing_names_have_no_ ) assert events == [("deps", str(requirements_path))] + + +@pytest.mark.asyncio +async def test_ensure_plugin_requirements_does_not_mask_install_error_when_cleanup_fails( + plugin_manager_pm: PluginManager, local_updator: Path, monkeypatch, tmp_path +): + requirements_path = local_updator / "requirements.txt" + requirements_path.write_text("boto3\n", encoding="utf-8") + temp_dir = tmp_path / "cleanup-fails" + _mock_missing_requirements_plan(monkeypatch, {"boto3"}, ["boto3"]) + warning_logs = [] + + async def mock_install_requirements( + *, requirements_path: str | None = None, **kwargs + ): + del kwargs, requirements_path + raise RuntimeError("pip failed") + + original_remove = os.remove + + def flaky_remove(path): + if str(path).endswith("_plugin_requirements.txt"): + raise OSError("cleanup failed") + return original_remove(path) + + monkeypatch.setattr( + "astrbot.core.star.star_manager.get_astrbot_temp_path", + lambda: str(temp_dir), + ) + monkeypatch.setattr( + "astrbot.core.star.star_manager.pip_installer.install", + mock_install_requirements, + ) + monkeypatch.setattr("astrbot.core.star.star_manager.os.remove", flaky_remove) + monkeypatch.setattr( + "astrbot.core.star.star_manager.logger.warning", + lambda line, *args: warning_logs.append(line % args if args else line), + ) + + with pytest.raises(PluginDependencyInstallError, match="pip failed"): + await plugin_manager_pm._ensure_plugin_requirements( + str(local_updator), + TEST_PLUGIN_DIR, + ) + + assert any("删除临时插件依赖文件失败" in log for log in warning_logs) From aa7b51674766eebc2f5eb77e797529cd3945481f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 12 Mar 2026 11:41:25 +0900 Subject: [PATCH 5/6] refactor: simplify dependency install test helpers --- astrbot/core/star/star_manager.py | 63 +++++++++++--------- tests/test_pip_helper_modules.py | 2 + tests/test_plugin_manager.py | 97 +++++++++++-------------------- 3 files changed, 72 insertions(+), 90 deletions(-) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index c83ead42b4..57be1e9a99 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -1,6 +1,7 @@ """插件的重载、启停、安装、卸载等操作。""" import asyncio +import contextlib import functools import inspect import json @@ -75,6 +76,39 @@ def __init__( self.error = error +@contextlib.contextmanager +def _temporary_filtered_requirements_file( + *, + install_lines: tuple[str, ...], +): + filtered_requirements_path: str | None = None + temp_dir = get_astrbot_temp_path() + + try: + os.makedirs(temp_dir, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + suffix="_plugin_requirements.txt", + delete=False, + dir=temp_dir, + encoding="utf-8", + ) as filtered_requirements_file: + filtered_requirements_file.write("\n".join(install_lines) + "\n") + filtered_requirements_path = filtered_requirements_file.name + + yield filtered_requirements_path + finally: + if filtered_requirements_path and os.path.exists(filtered_requirements_path): + try: + os.remove(filtered_requirements_path) + except OSError as exc: + logger.warning( + "删除临时插件依赖文件失败:%s(路径:%s)", + exc, + filtered_requirements_path, + ) + + async def _install_requirements_with_precheck( *, plugin_label: str, @@ -110,33 +144,10 @@ async def _install_requirements_with_precheck( f"{requirements_path} -> {sorted(install_plan.missing_names)}" ) - filtered_requirements_path: str | None = None - temp_dir = get_astrbot_temp_path() - try: - os.makedirs(temp_dir, exist_ok=True) - with tempfile.NamedTemporaryFile( - mode="w", - suffix="_plugin_requirements.txt", - delete=False, - dir=temp_dir, - encoding="utf-8", - ) as filtered_requirements_file: - filtered_requirements_file.write( - "\n".join(install_plan.install_lines) + "\n" - ) - filtered_requirements_path = filtered_requirements_file.name - + with _temporary_filtered_requirements_file( + install_lines=install_plan.install_lines, + ) as filtered_requirements_path: await pip_installer.install(requirements_path=filtered_requirements_path) - finally: - if filtered_requirements_path and os.path.exists(filtered_requirements_path): - try: - os.remove(filtered_requirements_path) - except OSError as exc: - logger.warning( - "删除临时插件依赖文件失败:%s(路径:%s)", - exc, - filtered_requirements_path, - ) class PluginManager: diff --git a/tests/test_pip_helper_modules.py b/tests/test_pip_helper_modules.py index 935860a482..cb3bea25f0 100644 --- a/tests/test_pip_helper_modules.py +++ b/tests/test_pip_helper_modules.py @@ -237,6 +237,7 @@ def test_build_missing_requirements_install_lines_logs_why_option_lines_fall_bac "--extra-index-url https://example.com/simple\nboto3\n", encoding="utf-8", ) + info_logs = [] monkeypatch.setattr( @@ -259,6 +260,7 @@ def test_find_missing_requirements_logs_path_and_reason_on_precheck_fallback( ): requirements_path = tmp_path / "requirements.txt" requirements_path.write_text("git+https://example.com/demo.git\n", encoding="utf-8") + info_logs = [] monkeypatch.setattr( diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index d79c357fda..b1dafc87e1 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -7,6 +7,7 @@ from astrbot.core.star.star_manager import PluginDependencyInstallError, PluginManager from astrbot.core.utils.pip_installer import PipInstallError +from astrbot.core.utils.requirements_utils import MissingRequirementsPlan # --- Test Data & Helpers --- @@ -75,25 +76,12 @@ async def mock_reload(specified_dir_name=None): return mock_reload -def _build_dependency_install_mock(events, fail: bool): - async def mock_install_requirements( - *, - requirements_path: str | None = None, - package_name: str | None = None, - **kwargs, - ): - del kwargs - if requirements_path: - events.append(("deps", str(requirements_path))) - if package_name: - events.append(("deps_pkg", package_name)) - if fail: - raise Exception("pip failed") - - return mock_install_requirements - - -def _build_dependency_install_content_mock(events, fail: bool): +def _build_dependency_install_mock( + events, + fail: bool, + *, + capture_content: bool = False, +): async def mock_install_requirements( *, requirements_path: str | None = None, @@ -103,7 +91,10 @@ async def mock_install_requirements( del kwargs if requirements_path: path = Path(requirements_path) - events.append(("deps", str(path), path.read_text(encoding="utf-8"))) + event = ("deps", str(path)) + if capture_content: + event = (*event, path.read_text(encoding="utf-8")) + events.append(event) if package_name: events.append(("deps_pkg", package_name)) if fail: @@ -112,55 +103,28 @@ async def mock_install_requirements( return mock_install_requirements -def _build_dependency_install_assert_tempdir_mock(events, fail: bool): - async def mock_install_requirements( - *, - requirements_path: str | None = None, - package_name: str | None = None, - **kwargs, - ): - del kwargs, package_name - assert requirements_path is not None - path = Path(requirements_path) - events.append(("deps", str(path), path.read_text(encoding="utf-8"))) - if fail: - raise Exception("pip failed") - - return mock_install_requirements - - def _mock_missing_requirements(monkeypatch, missing: set[str]): - from astrbot.core.utils.requirements_utils import MissingRequirementsPlan - - monkeypatch.setattr( - "astrbot.core.star.star_manager.plan_missing_requirements_install", - lambda requirements_path: MissingRequirementsPlan( - missing_names=frozenset(missing), - install_lines=tuple(sorted(missing)), - ), - ) - - -def _mock_precheck_fails(monkeypatch): - monkeypatch.setattr( - "astrbot.core.star.star_manager.plan_missing_requirements_install", - lambda requirements_path: None, - ) + _mock_missing_requirements_plan(monkeypatch, missing, sorted(missing)) -def _mock_missing_requirements_plan(monkeypatch, missing_names, install_lines): - from astrbot.core.utils.requirements_utils import MissingRequirementsPlan - +def _mock_missing_requirements_plan( + monkeypatch, + missing_names, + install_lines, + *, + fallback_reason: str | None = None, +): monkeypatch.setattr( "astrbot.core.star.star_manager.plan_missing_requirements_install", lambda requirements_path: MissingRequirementsPlan( missing_names=frozenset(missing_names), install_lines=tuple(install_lines), + fallback_reason=fallback_reason, ), ) -def _mock_precheck_plan_failure(monkeypatch): +def _mock_precheck_fails(monkeypatch): monkeypatch.setattr( "astrbot.core.star.star_manager.plan_missing_requirements_install", lambda requirements_path: None, @@ -172,14 +136,22 @@ def _assert_dependency_install_event_matches( *, expected_original_path: Path, expected_content: str | None = None, + expect_filtered_tempfile: bool | None = None, ): assert event[0] == "deps" used_path = Path(event[1]) - if expected_content is None: + should_be_filtered = expected_content is not None + if expect_filtered_tempfile is not None: + should_be_filtered = expect_filtered_tempfile + + if not should_be_filtered: assert used_path == expected_original_path else: assert used_path != expected_original_path assert used_path.name.endswith("_plugin_requirements.txt") + if expected_content is not None: + if len(event) >= 3: + assert event[2] == expected_content # --- Fixtures --- @@ -594,7 +566,7 @@ async def test_ensure_plugin_requirements_installs_only_missing_requirement_line monkeypatch.setattr( "astrbot.core.star.star_manager.pip_installer.install", - _build_dependency_install_content_mock(events, False), + _build_dependency_install_mock(events, False, capture_content=True), ) await plugin_manager_pm._ensure_plugin_requirements( @@ -626,7 +598,7 @@ async def test_ensure_plugin_requirements_creates_temp_dir_before_filtered_insta ) monkeypatch.setattr( "astrbot.core.star.star_manager.pip_installer.install", - _build_dependency_install_assert_tempdir_mock(events, False), + _build_dependency_install_mock(events, False, capture_content=True), ) await plugin_manager_pm._ensure_plugin_requirements( @@ -648,10 +620,7 @@ async def test_ensure_plugin_requirements_falls_back_when_missing_names_have_no_ monkeypatch.setattr( "astrbot.core.star.star_manager.plan_missing_requirements_install", - lambda path: __import__( - "astrbot.core.utils.requirements_utils", - fromlist=["MissingRequirementsPlan"], - ).MissingRequirementsPlan( + lambda path: MissingRequirementsPlan( missing_names=frozenset({"botocore"}), install_lines=(), fallback_reason="unmapped missing requirement names", From d5b670685a7152b692084e25a18b99ef743db5c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 12 Mar 2026 11:47:44 +0900 Subject: [PATCH 6/6] refactor: reuse requirements precheck planning --- astrbot/core/utils/requirements_utils.py | 30 +++++++--- tests/test_pip_helper_modules.py | 72 ++++++++++++++++++++---- 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/astrbot/core/utils/requirements_utils.py b/astrbot/core/utils/requirements_utils.py index 39665f05cb..e031de8468 100644 --- a/astrbot/core/utils/requirements_utils.py +++ b/astrbot/core/utils/requirements_utils.py @@ -4,7 +4,7 @@ import re import shlex import sys -from collections.abc import Iterable, Iterator +from collections.abc import Iterable, Iterator, Sequence from dataclasses import dataclass from packaging.requirements import InvalidRequirement, Requirement @@ -388,6 +388,13 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None: if not can_precheck or requirement_lines is None: return None + return find_missing_requirements_from_lines(requirement_lines) + + +def find_missing_requirements_from_lines( + requirement_lines: Sequence[str], +) -> set[str] | None: + required = list(iter_requirements(lines=requirement_lines)) if not required: return set() @@ -410,14 +417,9 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None: def build_missing_requirements_install_lines( requirements_path: str, + requirement_lines: Sequence[str], missing_names: set[str] | frozenset[str], ) -> tuple[str, ...] | None: - can_precheck, requirement_lines = _load_requirement_lines_for_precheck( - requirements_path - ) - if not can_precheck or requirement_lines is None: - return None - wanted_names = set(missing_names) install_lines: list[str] = [] for line in requirement_lines: @@ -442,11 +444,21 @@ def build_missing_requirements_install_lines( def plan_missing_requirements_install( requirements_path: str, ) -> MissingRequirementsPlan | None: - missing = find_missing_requirements(requirements_path) + can_precheck, requirement_lines = _load_requirement_lines_for_precheck( + requirements_path + ) + if not can_precheck or requirement_lines is None: + return None + + missing = find_missing_requirements_from_lines(requirement_lines) if missing is None: return None - install_lines = build_missing_requirements_install_lines(requirements_path, missing) + install_lines = build_missing_requirements_install_lines( + requirements_path, + requirement_lines, + missing, + ) if install_lines is None: return None if missing and not install_lines: diff --git a/tests/test_pip_helper_modules.py b/tests/test_pip_helper_modules.py index cb3bea25f0..506dd09453 100644 --- a/tests/test_pip_helper_modules.py +++ b/tests/test_pip_helper_modules.py @@ -153,7 +153,13 @@ def test_build_missing_requirements_install_lines_keeps_only_missing_lines(tmp_p ) install_lines = requirements_utils.build_missing_requirements_install_lines( - str(requirements_path), {"boto3", "botocore"} + str(requirements_path), + [ + "aiohttp>=3.0", + 'boto3==1.2; python_version >= "3.0"', + "botocore", + ], + {"boto3", "botocore"}, ) assert install_lines == ( @@ -169,7 +175,7 @@ def test_build_missing_requirements_install_lines_returns_empty_tuple_when_all_s requirements_path.write_text("aiohttp>=3.0\nboto3\n", encoding="utf-8") install_lines = requirements_utils.build_missing_requirements_install_lines( - str(requirements_path), set() + str(requirements_path), ["aiohttp>=3.0", "boto3"], set() ) assert install_lines == () @@ -185,7 +191,9 @@ def test_build_missing_requirements_install_lines_returns_none_for_option_lines( ) install_lines = requirements_utils.build_missing_requirements_install_lines( - str(requirements_path), {"boto3"} + str(requirements_path), + ["--extra-index-url https://example.com/simple", "boto3"], + {"boto3"}, ) assert install_lines is None @@ -201,7 +209,9 @@ def test_build_missing_requirements_install_lines_skips_inactive_marker_lines( ) install_lines = requirements_utils.build_missing_requirements_install_lines( - str(requirements_path), {"boto3"} + str(requirements_path), + ["boto3", 'other-package; sys_platform == "win32"'], + {"boto3"}, ) assert install_lines == ("boto3",) @@ -216,8 +226,8 @@ def test_plan_missing_requirements_install_returns_none_when_missing_names_canno monkeypatch.setattr( requirements_utils, - "find_missing_requirements", - lambda path: {"botocore"}, + "find_missing_requirements_from_lines", + lambda lines: {"botocore"}, ) plan = requirements_utils.plan_missing_requirements_install(str(requirements_path)) @@ -228,6 +238,42 @@ def test_plan_missing_requirements_install_returns_none_when_missing_names_canno assert plan.fallback_reason == "unmapped missing requirement names" +def test_plan_missing_requirements_install_loads_requirement_lines_once( + monkeypatch, + tmp_path, +): + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("boto3\nbotocore\n", encoding="utf-8") + calls = [] + + def mock_load(path): + calls.append(path) + return True, ["boto3", "botocore"] + + monkeypatch.setattr( + requirements_utils, + "_load_requirement_lines_for_precheck", + mock_load, + ) + monkeypatch.setattr( + requirements_utils, + "collect_installed_distribution_versions", + lambda paths: {}, + ) + monkeypatch.setattr( + requirements_utils, + "get_requirement_check_paths", + lambda: ["/tmp/site-packages"], + ) + + plan = requirements_utils.plan_missing_requirements_install(str(requirements_path)) + + assert plan is not None + assert plan.missing_names == frozenset({"boto3", "botocore"}) + assert plan.install_lines == ("boto3", "botocore") + assert calls == [str(requirements_path)] + + def test_build_missing_requirements_install_lines_logs_why_option_lines_fall_back( monkeypatch, tmp_path, @@ -238,20 +284,22 @@ def test_build_missing_requirements_install_lines_logs_why_option_lines_fall_bac encoding="utf-8", ) - info_logs = [] + debug_logs = [] monkeypatch.setattr( - "astrbot.core.utils.requirements_utils.logger.info", - lambda line, *args: info_logs.append(line % args if args else line), + "astrbot.core.utils.requirements_utils.logger.debug", + lambda line, *args: debug_logs.append(line % args if args else line), ) install_lines = requirements_utils.build_missing_requirements_install_lines( - str(requirements_path), {"boto3"} + str(requirements_path), + ["--extra-index-url https://example.com/simple", "boto3"], + {"boto3"}, ) assert install_lines is None - assert any(str(requirements_path) in log for log in info_logs) - assert any("option/direct-reference" in log for log in info_logs) + assert any(str(requirements_path) in log for log in debug_logs) + assert any("option/direct-reference" in log for log in debug_logs) def test_find_missing_requirements_logs_path_and_reason_on_precheck_fallback(