fix: 插件依赖自动安装逻辑与 Dashboard 安装体验优化 (#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
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.utils import core_constraints as core_constraints_module
|
||||
from astrbot.core.utils import requirements_utils
|
||||
from astrbot.core.utils.core_constraints import CoreConstraintsProvider
|
||||
|
||||
|
||||
def test_requirements_utils_parse_package_install_input_collects_specs_and_names():
|
||||
parsed = requirements_utils.parse_package_install_input(
|
||||
"--index-url https://example.com/simple demo-package\nanother-package>=1.0\n"
|
||||
)
|
||||
|
||||
assert parsed.specs == (
|
||||
"--index-url",
|
||||
"https://example.com/simple",
|
||||
"demo-package",
|
||||
"another-package>=1.0",
|
||||
)
|
||||
assert parsed.requirement_names == {"demo-package", "another-package"}
|
||||
|
||||
|
||||
def test_core_constraints_provider_writes_constraints_file_from_fallback_distribution(
|
||||
monkeypatch,
|
||||
):
|
||||
class FakeFallbackDistribution:
|
||||
metadata = {"Name": "AstrBot-App"}
|
||||
requires = ["shared-lib>=1.0"]
|
||||
|
||||
def read_text(self, name):
|
||||
if name == "top_level.txt":
|
||||
return "astrbot\n"
|
||||
return ""
|
||||
|
||||
fake_distribution = FakeFallbackDistribution()
|
||||
|
||||
def mock_distribution(name):
|
||||
if name == "AstrBot":
|
||||
raise core_constraints_module.importlib_metadata.PackageNotFoundError
|
||||
if name == "AstrBot-App":
|
||||
return fake_distribution
|
||||
raise core_constraints_module.importlib_metadata.PackageNotFoundError
|
||||
|
||||
def mock_distributions(path=None):
|
||||
del path
|
||||
return [fake_distribution]
|
||||
|
||||
monkeypatch.setattr(
|
||||
core_constraints_module.importlib_metadata,
|
||||
"distribution",
|
||||
mock_distribution,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
core_constraints_module.importlib_metadata,
|
||||
"distributions",
|
||||
mock_distributions,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
core_constraints_module,
|
||||
"collect_installed_distribution_versions",
|
||||
lambda paths: {"shared-lib": "2.0"},
|
||||
)
|
||||
|
||||
core_constraints_module._get_core_constraints.cache_clear()
|
||||
try:
|
||||
provider = CoreConstraintsProvider(None)
|
||||
with provider.constraints_file() as constraints_path:
|
||||
assert constraints_path is not None
|
||||
assert (
|
||||
Path(constraints_path).read_text(encoding="utf-8") == "shared-lib==2.0"
|
||||
)
|
||||
finally:
|
||||
core_constraints_module._get_core_constraints.cache_clear()
|
||||
|
||||
|
||||
def test_resolve_core_dist_name_skips_distribution_without_name(monkeypatch):
|
||||
class NamelessDistribution:
|
||||
metadata = {}
|
||||
|
||||
def read_text(self, name):
|
||||
if name == "top_level.txt":
|
||||
return "astrbot\n"
|
||||
return ""
|
||||
|
||||
class NamedDistribution:
|
||||
metadata = {"Name": "AstrBot-App"}
|
||||
|
||||
def read_text(self, name):
|
||||
if name == "top_level.txt":
|
||||
return "astrbot\n"
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr(
|
||||
core_constraints_module.importlib_metadata,
|
||||
"distribution",
|
||||
lambda name: (_ for _ in ()).throw(
|
||||
core_constraints_module.importlib_metadata.PackageNotFoundError
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
core_constraints_module.importlib_metadata,
|
||||
"distributions",
|
||||
lambda: [NamelessDistribution(), NamedDistribution()],
|
||||
)
|
||||
|
||||
assert core_constraints_module._resolve_core_dist_name(None) == "AstrBot-App"
|
||||
|
||||
|
||||
def test_find_missing_requirements_returns_none_when_precheck_gate_fails(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
requirements_path = tmp_path / "requirements.txt"
|
||||
requirements_path.write_text("demo-package\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
requirements_utils,
|
||||
"_load_requirement_lines_for_precheck",
|
||||
lambda path: (False, None),
|
||||
)
|
||||
|
||||
missing = requirements_utils.find_missing_requirements(str(requirements_path))
|
||||
|
||||
assert missing is None
|
||||
|
||||
|
||||
def test_parse_package_install_input_tracks_only_named_direct_references():
|
||||
named = requirements_utils.parse_package_install_input(
|
||||
"git+https://example.com/demo.git#egg=demo-package"
|
||||
)
|
||||
unnamed = requirements_utils.parse_package_install_input(
|
||||
"git+https://example.com/demo.git"
|
||||
)
|
||||
|
||||
assert named.requirement_names == {"demo-package"}
|
||||
assert unnamed.requirement_names == set()
|
||||
|
||||
|
||||
def test_find_missing_requirements_or_raise_uses_requirements_exception(tmp_path):
|
||||
requirements_path = tmp_path / "requirements.txt"
|
||||
requirements_path.write_text("-e ../sharedlib\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(requirements_utils.RequirementsPrecheckFailed):
|
||||
requirements_utils.find_missing_requirements_or_raise(str(requirements_path))
|
||||
|
||||
|
||||
def test_find_missing_requirements_logs_path_and_reason_on_precheck_fallback(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
requirements_path = tmp_path / "requirements.txt"
|
||||
requirements_path.write_text("git+https://example.com/demo.git\n", encoding="utf-8")
|
||||
warning_logs = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.utils.requirements_utils.logger.warning",
|
||||
lambda line, *args: warning_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)
|
||||
|
||||
|
||||
def test_load_requirement_lines_for_precheck_uses_parse_requirement_line_result(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
requirements_path = tmp_path / "requirements.txt"
|
||||
requirements_path.write_text("git+https://example.com/demo.git\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
requirements_utils,
|
||||
"_parse_requirement_line",
|
||||
lambda line: ("demo-package", None) if line.startswith("git+") else None,
|
||||
)
|
||||
|
||||
can_precheck, requirement_lines = (
|
||||
requirements_utils._load_requirement_lines_for_precheck(str(requirements_path))
|
||||
)
|
||||
|
||||
assert can_precheck is True
|
||||
assert requirement_lines == ["git+https://example.com/demo.git"]
|
||||
|
||||
|
||||
def test_collect_installed_distribution_versions_skips_nameless_distribution(
|
||||
monkeypatch,
|
||||
):
|
||||
class NamelessDistribution:
|
||||
metadata = {}
|
||||
version = "1.0"
|
||||
|
||||
class NamedDistribution:
|
||||
metadata = {"Name": "demo-package"}
|
||||
version = "2.0"
|
||||
|
||||
monkeypatch.setattr(
|
||||
requirements_utils.importlib_metadata,
|
||||
"distributions",
|
||||
lambda path: [NamelessDistribution(), NamedDistribution()],
|
||||
)
|
||||
|
||||
installed = requirements_utils.collect_installed_distribution_versions(
|
||||
["/tmp/test"]
|
||||
)
|
||||
|
||||
assert installed == {"demo-package": "2.0"}
|
||||
|
||||
|
||||
def test_get_core_constraints_logs_resolution_step_context(monkeypatch):
|
||||
warning_logs = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
core_constraints_module,
|
||||
"_resolve_core_dist_name",
|
||||
lambda core_dist_name: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.utils.core_constraints.logger.warning",
|
||||
lambda line, *args: warning_logs.append(line % args if args else line),
|
||||
)
|
||||
|
||||
core_constraints_module._get_core_constraints.cache_clear()
|
||||
try:
|
||||
constraints = core_constraints_module._get_core_constraints(None)
|
||||
finally:
|
||||
core_constraints_module._get_core_constraints.cache_clear()
|
||||
|
||||
assert constraints == ()
|
||||
assert any("解析核心分发名称失败" in log for log in warning_logs)
|
||||
|
||||
|
||||
def test_iter_requirements_supports_direct_line_input():
|
||||
parsed = list(
|
||||
requirements_utils.iter_requirements(
|
||||
lines=["demo-package>=1.0", 'other-package; sys_platform == "win32"']
|
||||
)
|
||||
)
|
||||
|
||||
assert parsed == [
|
||||
("demo-package", requirements_utils.Requirement("demo-package>=1.0").specifier)
|
||||
]
|
||||
|
||||
|
||||
def test_parse_requirement_name_and_spec_preserves_direct_reference_rules():
|
||||
named = requirements_utils._parse_requirement_name_and_spec(
|
||||
"git+https://example.com/demo.git#egg=demo-package"
|
||||
)
|
||||
unnamed = requirements_utils._parse_requirement_name_and_spec(
|
||||
"git+https://example.com/demo.git"
|
||||
)
|
||||
|
||||
assert named == ("demo-package", None)
|
||||
assert unnamed == (None, None)
|
||||
|
||||
|
||||
def test_parse_requirement_name_and_spec_handles_plain_requirement_token():
|
||||
parsed = requirements_utils._parse_requirement_name_and_spec("demo-package>=1.0")
|
||||
|
||||
assert parsed == (
|
||||
"demo-package",
|
||||
requirements_utils.Requirement("demo-package>=1.0").specifier,
|
||||
)
|
||||
Reference in New Issue
Block a user