Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 115 additions & 19 deletions astrbot/core/star/star_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import sys
import tempfile
import traceback
from dataclasses import dataclass
from enum import Enum, auto
from types import ModuleType

import yaml
Expand All @@ -37,6 +39,7 @@
from astrbot.core.utils.io import remove_dir
from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.requirements_utils import (
MissingRequirementsPlan,
plan_missing_requirements_install,
)

Expand Down Expand Up @@ -77,6 +80,19 @@ def __init__(
self.error = error


class ImportDependencyRecoveryMode(Enum):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider simplifying the new import-dependency recovery logic by replacing the enum/dataclass and helper with straightforward local flags and a single, linear control flow inside _import_plugin_with_dependency_recovery.

The new ImportDependencyRecoveryMode / ImportDependencyRecoveryState plus _try_import_from_installed_dependencies do add a mini state machine and extra indirection without buying much reuse. You can keep all the new behaviors (preload, version-mismatch skip, reserved handling) while simplifying control flow by:

  • Dropping the enum/dataclass in favor of local flags.
  • Inlining _try_import_from_installed_dependencies into _import_plugin_with_dependency_recovery.
  • Keeping all decisions localized in one function so the happy path and failure paths are easy to read.

A possible refactor (abridged for focus) could look like:

async def _import_plugin_with_dependency_recovery(
    self,
    path: str,
    module_str: str,
    root_dir_name: str,
    requirements_path: str,
    *,
    reserved: bool = False,
) -> ModuleType:
    has_requirements = os.path.exists(requirements_path)
    install_plan = None
    should_preload = False
    should_attempt_recover = False
    should_skip_recover_due_to_version_mismatch = False

    if not reserved and has_requirements:
        install_plan = plan_missing_requirements_install(requirements_path)
        if install_plan is None:
            # No precomputed plan; we can still try recovery on failure
            should_attempt_recover = True
        elif install_plan.version_mismatch_names:
            # Known-bad versions → skip recovery, force reinstall
            should_skip_recover_due_to_version_mismatch = True
        else:
            # We have a clean plan, so we can safely preload and later recover
            should_preload = True
            should_attempt_recover = True

    if should_preload:
        try:
            pip_installer.prefer_installed_dependencies(
                requirements_path=requirements_path,
            )
        except Exception as preload_exc:
            logger.info(
                "插件 %s 预加载已安装依赖失败,将继续常规导入: %s",
                root_dir_name,
                preload_exc,
            )

    try:
        return __import__(path, fromlist=[module_str])
    except ModuleNotFoundError as import_exc:
        recovered_module: ModuleType | None = None

        if should_attempt_recover and not should_skip_recover_due_to_version_mismatch:
            try:
                logger.info(
                    "插件 %s 导入失败,尝试从已安装依赖恢复: %s",
                    root_dir_name,
                    import_exc,
                )
                pip_installer.prefer_installed_dependencies(
                    requirements_path=requirements_path,
                )
                recovered_module = __import__(path, fromlist=[module_str])
                logger.info(
                    "插件 %s 已从 site-packages 恢复依赖,跳过重新安装。",
                    root_dir_name,
                )
            except Exception as recover_exc:
                logger.info(
                    "插件 %s 已安装依赖恢复失败,将重新安装依赖: %s",
                    root_dir_name,
                    recover_exc,
                )

        if recovered_module is None:
            if should_skip_recover_due_to_version_mismatch and install_plan:
                logger.info(
                    "插件 %s 预检查检测到版本不匹配,"
                    "跳过已安装依赖恢复: %s",
                    root_dir_name,
                    sorted(install_plan.version_mismatch_names),
                )
            await self._check_plugin_dept_update(target_plugin=root_dir_name)
            return __import__(path, fromlist=[module_str])

        return recovered_module

This preserves:

  • Preload behavior when we have a safe install_plan.
  • Recovery attempt when possible.
  • Skipping recovery when version mismatches are pre-detected.
  • reserved/missing requirements disabling recovery entirely.

And it removes:

  • ImportDependencyRecoveryMode
  • ImportDependencyRecoveryState
  • _resolve_import_dependency_recovery_state
  • _try_import_from_installed_dependencies

making the import-recovery flow read as a single, straightforward sequence.

DISABLED = auto()
PRELOAD_AND_RECOVER = auto()
RECOVER_ON_FAILURE = auto()
REINSTALL_ON_FAILURE = auto()


@dataclass(frozen=True)
class ImportDependencyRecoveryState:
mode: ImportDependencyRecoveryMode
install_plan: MissingRequirementsPlan | None = None


@contextlib.contextmanager
def _temporary_filtered_requirements_file(
*,
Expand Down Expand Up @@ -137,7 +153,10 @@ async def _install_requirements_with_precheck(
requirements_path,
fallback_reason,
)
await pip_installer.install(requirements_path=requirements_path)
await pip_installer.install(
requirements_path=requirements_path,
allow_target_upgrade=bool(install_plan.version_mismatch_names),
)
return

logger.info(
Expand All @@ -148,7 +167,10 @@ async def _install_requirements_with_precheck(
with _temporary_filtered_requirements_file(
install_lines=install_plan.install_lines,
) as filtered_requirements_path:
await pip_installer.install(requirements_path=filtered_requirements_path)
await pip_installer.install(
requirements_path=filtered_requirements_path,
allow_target_upgrade=bool(install_plan.version_mismatch_names),
)


class PluginManager:
Expand Down Expand Up @@ -332,33 +354,106 @@ async def _ensure_plugin_requirements(
logger.exception(str(dependency_error))
raise dependency_error from e

@staticmethod
def _resolve_import_dependency_recovery_state(
requirements_path: str,
*,
reserved: bool,
) -> ImportDependencyRecoveryState:
if reserved or not os.path.exists(requirements_path):
return ImportDependencyRecoveryState(ImportDependencyRecoveryMode.DISABLED)

install_plan = plan_missing_requirements_install(requirements_path)
if install_plan is None:
return ImportDependencyRecoveryState(
ImportDependencyRecoveryMode.RECOVER_ON_FAILURE
)
if install_plan.version_mismatch_names:
return ImportDependencyRecoveryState(
ImportDependencyRecoveryMode.REINSTALL_ON_FAILURE,
install_plan=install_plan,
)

return ImportDependencyRecoveryState(
ImportDependencyRecoveryMode.PRELOAD_AND_RECOVER,
install_plan=install_plan,
)

@staticmethod
def _try_import_from_installed_dependencies(
path: str,
module_str: str,
root_dir_name: str,
requirements_path: str,
import_exc: Exception,
) -> ModuleType | None:
try:
logger.info(
f"插件 {root_dir_name} 导入失败,尝试从已安装依赖恢复: {import_exc!s}"
)
pip_installer.prefer_installed_dependencies(
requirements_path=requirements_path
)
module = __import__(path, fromlist=[module_str])
logger.info(
f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。"
)
return module
except Exception as recover_exc:
logger.info(
f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}"
)
return None

async def _import_plugin_with_dependency_recovery(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider simplifying _import_plugin_with_dependency_recovery by inlining the recovery decision logic and removing the enum/dataclass-based mini state machine and helpers.

You can get the same behavior with much less abstraction by inlining the decision logic into _import_plugin_with_dependency_recovery and dropping the enum/dataclass + static helpers.

1. Remove the mini state machine

ImportDependencyRecoveryMode, ImportDependencyRecoveryState, _resolve_import_dependency_recovery_state, and _try_import_from_installed_dependencies can be removed and replaced by simple flags derived from plan_missing_requirements_install.

You can rewrite _import_plugin_with_dependency_recovery like this:

from astrbot.core.utils.requirements_utils import plan_missing_requirements_install

async def _import_plugin_with_dependency_recovery(
    self,
    path: str,
    module_str: str,
    root_dir_name: str,
    requirements_path: str,
    *,
    reserved: bool = False,
) -> ModuleType:
    # Pre‑compute plan and flags
    has_requirements = (not reserved) and os.path.exists(requirements_path)
    install_plan = plan_missing_requirements_install(requirements_path) if has_requirements else None
    has_version_mismatch = bool(install_plan and install_plan.version_mismatch_names)
    can_use_installed_recovery = has_requirements and not has_version_mismatch

    # Optional preload before first import
    if can_use_installed_recovery and install_plan is not None:
        try:
            pip_installer.prefer_installed_dependencies(requirements_path=requirements_path)
        except Exception as preload_exc:
            logger.info(
                "插件 %s 预加载已安装依赖失败,将继续常规导入: %s",
                root_dir_name,
                preload_exc,
            )

    try:
        return __import__(path, fromlist=[module_str])
    except (ModuleNotFoundError, ImportError) as import_exc:
        # One recovery attempt using already-installed deps
        if can_use_installed_recovery:
            try:
                logger.info(
                    "插件 %s 导入失败,尝试从已安装依赖恢复: %s",
                    root_dir_name,
                    import_exc,
                )
                pip_installer.prefer_installed_dependencies(requirements_path=requirements_path)
                module = __import__(path, fromlist=[module_str])
                logger.info("插件 %s 已从 site-packages 恢复依赖,跳过重新安装。", root_dir_name)
                return module
            except Exception as recover_exc:
                logger.info(
                    "插件 %s 已安装依赖恢复失败,将重新安装依赖: %s",
                    root_dir_name,
                    recover_exc,
                )
        elif has_version_mismatch:
            logger.info(
                "插件 %s 预检查检测到版本不匹配,跳过已安装依赖恢复: %s",
                root_dir_name,
                sorted(install_plan.version_mismatch_names),  # type: ignore[arg-type]
            )

        await self._check_plugin_dept_update(target_plugin=root_dir_name)
        return __import__(path, fromlist=[module_str])

This preserves:

  • reserved short‑circuiting.
  • Preload before first import when there is a clean plan.
  • Recovery via prefer_installed_dependencies on failure when there is no version mismatch.
  • Skipping recovery and logging when version_mismatch_names is non‑empty, then forcing reinstall.

After this, you can safely delete:

class ImportDependencyRecoveryMode(Enum): ...
@dataclass(frozen=True)
class ImportDependencyRecoveryState: ...
@staticmethod
def _resolve_import_dependency_recovery_state(...): ...
@staticmethod
def _try_import_from_installed_dependencies(...): ...

The behavior remains identical, but the control flow is linear and localized in one method, reducing the cognitive load and the number of moving parts you need to update when policies change.

self,
path: str,
module_str: str,
root_dir_name: str,
requirements_path: str,
*,
reserved: bool = False,
) -> ModuleType:
recovery_state = self._resolve_import_dependency_recovery_state(
requirements_path,
reserved=reserved,
Comment on lines +417 to +419
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (bug_risk): Reserved plugins (or missing requirements files) still trigger dependency reinstall on import failure, which might not be desired.

_resolve_import_dependency_recovery_state returns DISABLED when reserved=True or the requirements file is missing, but the import-failure path still calls await self._check_plugin_dept_update(...) and re-imports. If reserved is intended to block dependency changes, consider short‑circuiting before _check_plugin_dept_update when recovery_state.mode is DISABLED.

)

if recovery_state.mode is ImportDependencyRecoveryMode.PRELOAD_AND_RECOVER:
try:
pip_installer.prefer_installed_dependencies(
requirements_path=requirements_path
)
Comment on lines +424 to +426
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling pip_installer.prefer_installed_dependencies immediately after plan_missing_requirements_install results in redundant work. prefer_installed_dependencies re-parses the requirements.txt file and re-scans the site-packages to identify candidate modules, both of which were just completed by plan_missing_requirements_install.

Consider refactoring prefer_installed_dependencies to accept the already parsed requirement names or the install_plan to avoid these duplicate operations.

except Exception as preload_exc:
logger.info(
f"插件 {root_dir_name} 预加载已安装依赖失败,将继续常规导入: {preload_exc!s}"
)

try:
return __import__(path, fromlist=[module_str])
except (ModuleNotFoundError, ImportError) as import_exc:
if os.path.exists(requirements_path):
try:
logger.info(
f"插件 {root_dir_name} 导入失败,尝试从已安装依赖恢复: {import_exc!s}"
)
pip_installer.prefer_installed_dependencies(
requirements_path=requirements_path
)
module = __import__(path, fromlist=[module_str])
logger.info(
f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。"
)
return module
except Exception as recover_exc:
logger.info(
f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}"
)
if recovery_state.mode in {
ImportDependencyRecoveryMode.PRELOAD_AND_RECOVER,
ImportDependencyRecoveryMode.RECOVER_ON_FAILURE,
}:
recovered_module = self._try_import_from_installed_dependencies(
path,
module_str,
root_dir_name,
requirements_path,
import_exc,
)
if recovered_module is not None:
return recovered_module
elif (
recovery_state.mode is ImportDependencyRecoveryMode.REINSTALL_ON_FAILURE
):
assert recovery_state.install_plan is not None
logger.info(
"插件 %s 预检查检测到版本不匹配,跳过已安装依赖恢复: %s",
root_dir_name,
sorted(recovery_state.install_plan.version_mismatch_names),
)

await self._check_plugin_dept_update(target_plugin=root_dir_name)
return __import__(path, fromlist=[module_str])
Expand Down Expand Up @@ -788,6 +883,7 @@ async def load(
module_str=module_str,
root_dir_name=root_dir_name,
requirements_path=requirements_path,
reserved=reserved,
)
except Exception as e:
error_trace = traceback.format_exc()
Expand Down
21 changes: 12 additions & 9 deletions astrbot/core/utils/pip_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,7 @@ async def install(
package_name: str | None = None,
requirements_path: str | None = None,
mirror: str | None = None,
allow_target_upgrade: bool = True,
) -> None:
args, requested_requirements = self._build_pip_args(
package_name, requirements_path, mirror
Expand All @@ -995,15 +996,17 @@ async def install(
target_site_packages = get_astrbot_site_packages_path()
os.makedirs(target_site_packages, exist_ok=True)
_prepend_sys_path(target_site_packages)
args.extend(
[
"--target",
target_site_packages,
"--upgrade",
"--upgrade-strategy",
"only-if-needed",
]
)
# `allow_target_upgrade` only matters for packaged desktop installs that
# write into the shared `data/site-packages` target directory.
args.extend(["--target", target_site_packages])
if allow_target_upgrade:
args.extend(
[
"--upgrade",
"--upgrade-strategy",
"only-if-needed",
]
)

with self._core_constraints.constraints_file() as constraints_file_path:
if constraints_file_path:
Expand Down
34 changes: 30 additions & 4 deletions astrbot/core/utils/requirements_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,17 @@ class ParsedPackageInput:
requirement_names: frozenset[str]


@dataclass(frozen=True)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider simplifying the design by removing the separate MissingRequirementsAnalysis type and using a single, defaulted MissingRequirementsPlan for both analysis and planning stages.

You can collapse one layer without losing any functionality by reusing MissingRequirementsPlan for both the “analysis” and “plan” stages.

1. Remove MissingRequirementsAnalysis and reuse MissingRequirementsPlan

MissingRequirementsAnalysis and MissingRequirementsPlan carry overlapping data and you immediately rewrap one into the other. You can instead make MissingRequirementsPlan usable as an “analysis-only” result by giving install_lines a default:

@dataclass(frozen=True)
class MissingRequirementsPlan:
    missing_names: frozenset[str]
    install_lines: tuple[str, ...] = ()
    version_mismatch_names: frozenset[str] = frozenset()
    fallback_reason: str | None = None

Then change classify_missing_requirements_from_lines to return MissingRequirementsPlan directly:

def classify_missing_requirements_from_lines(
    requirement_lines: Sequence[str],
) -> MissingRequirementsPlan | None:
    required = list(iter_requirements(lines=requirement_lines))
    if not required:
        return MissingRequirementsPlan(missing_names=frozenset())

    installed = collect_installed_distribution_versions(get_requirement_check_paths())
    if installed is None:
        return None

    missing: set[str] = set()
    version_mismatch_names: set[str] = set()
    for name, specifier in required:
        installed_version = installed.get(name)
        if not installed_version:
            missing.add(name)
            continue
        if specifier and not _specifier_contains_version(specifier, installed_version):
            missing.add(name)
            version_mismatch_names.add(name)

    return MissingRequirementsPlan(
        missing_names=frozenset(missing),
        version_mismatch_names=frozenset(version_mismatch_names),
    )

Now MissingRequirementsAnalysis can be removed entirely.

2. Keep find_missing_requirements_from_lines as a thin compatibility wrapper

If you still need the old API, keep it but delegate to the enriched plan, avoiding an extra public “analysis” type:

def find_missing_requirements_from_lines(
    requirement_lines: Sequence[str],
) -> set[str] | None:
    plan = classify_missing_requirements_from_lines(requirement_lines)
    if plan is None:
        return None
    return set(plan.missing_names)

Callers that care only about names can continue using this; callers that need more detail can use classify_missing_requirements_from_lines and work with the single MissingRequirementsPlan type.

3. Simplify plan_missing_requirements_install using the unified type

With the unified plan/analysis type, you avoid unpacking/repacking:

def plan_missing_requirements_install(
    requirements_path: str,
) -> MissingRequirementsPlan | None:
    can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
        requirements_path
    )
    if not can_precheck or requirement_lines is None:
        return None

    analysis = classify_missing_requirements_from_lines(requirement_lines)
    if analysis is None:
        return None

    missing = analysis.missing_names
    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:
        logger.warning(
            "预检查缺失依赖成功,但无法映射到可安装 requirement 行,将回退到完整安装: %s -> %s",
            requirements_path,
            sorted(missing),
        )
        return MissingRequirementsPlan(
            missing_names=analysis.missing_names,
            version_mismatch_names=analysis.version_mismatch_names,
            install_lines=(),
            fallback_reason="unmapped missing requirement names",
        )

    return MissingRequirementsPlan(
        missing_names=analysis.missing_names,
        version_mismatch_names=analysis.version_mismatch_names,
        install_lines=install_lines,
    )

This keeps all current behavior (version_mismatch_names included), but reduces:

  • Number of public types (MissingRequirementsAnalysis removed).
  • Number of “stages” to understand (analysis and plan are the same structure).
  • API surface: you now have a simple progression from classify_missing_requirements_from_linesplan_missing_requirements_install, with an optional compatibility helper find_missing_requirements_from_lines.

class MissingRequirementsAnalysis:
missing_names: frozenset[str]
version_mismatch_names: frozenset[str] = frozenset()


@dataclass(frozen=True)
class MissingRequirementsPlan:
missing_names: frozenset[str]
install_lines: tuple[str, ...]
version_mismatch_names: frozenset[str] = frozenset()
fallback_reason: str | None = None


Expand Down Expand Up @@ -394,24 +401,39 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
def find_missing_requirements_from_lines(
requirement_lines: Sequence[str],
) -> set[str] | None:
analysis = classify_missing_requirements_from_lines(requirement_lines)
if analysis is None:
return None

return set(analysis.missing_names)


def classify_missing_requirements_from_lines(
requirement_lines: Sequence[str],
) -> MissingRequirementsAnalysis | None:
required = list(iter_requirements(lines=requirement_lines))
if not required:
return set()
return MissingRequirementsAnalysis(missing_names=frozenset())

installed = collect_installed_distribution_versions(get_requirement_check_paths())
if installed is None:
return None

missing: set[str] = set()
version_mismatch_names: set[str] = set()
for name, specifier in required:
installed_version = installed.get(name)
if not installed_version:
missing.add(name)
continue
if specifier and not _specifier_contains_version(specifier, installed_version):
missing.add(name)
version_mismatch_names.add(name)

return missing
return MissingRequirementsAnalysis(
missing_names=frozenset(missing),
version_mismatch_names=frozenset(version_mismatch_names),
)


def build_missing_requirements_install_lines(
Expand Down Expand Up @@ -449,9 +471,11 @@ def plan_missing_requirements_install(
if not can_precheck or requirement_lines is None:
return None

missing = find_missing_requirements_from_lines(requirement_lines)
if missing is None:
analysis = classify_missing_requirements_from_lines(requirement_lines)
if analysis is None:
return None
missing = analysis.missing_names
version_mismatch_names = analysis.version_mismatch_names

install_lines = build_missing_requirements_install_lines(
requirements_path,
Expand All @@ -468,12 +492,14 @@ def plan_missing_requirements_install(
)
return MissingRequirementsPlan(
missing_names=frozenset(missing),
version_mismatch_names=frozenset(version_mismatch_names),
install_lines=(),
fallback_reason="unmapped missing requirement names",
)

return MissingRequirementsPlan(
missing_names=frozenset(missing),
version_mismatch_names=frozenset(version_mismatch_names),
install_lines=install_lines,
)

Expand Down
2 changes: 1 addition & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def check_env() -> None:

site_packages_path = get_astrbot_site_packages_path()
if site_packages_path not in sys.path:
sys.path.insert(0, site_packages_path)
sys.path.append(site_packages_path)

os.makedirs(get_astrbot_config_path(), exist_ok=True)
os.makedirs(get_astrbot_plugin_path(), exist_ok=True)
Expand Down
42 changes: 42 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,48 @@ def test_check_env(monkeypatch):
check_env()


def test_check_env_appends_user_site_packages_after_runtime_paths(monkeypatch):
Comment on lines 60 to +63
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider also testing that check_env does not append a duplicate site_packages_path when it is already present

Since the implementation checks if site_packages_path not in sys.path before appending, a test that pre-populates sys.path with site_packages_path would verify that no duplicate entry is added and that this guard continues to work as intended.

Suggested change
check_env()
def test_check_env_appends_user_site_packages_after_runtime_paths(monkeypatch):
check_env()
def test_check_env_does_not_append_duplicate_user_site_packages(monkeypatch):
astrbot_root = "/tmp/astrbot-root"
site_packages_path = "/tmp/astrbot-site-packages"
original_sys_path = list(sys.path)
monkeypatch.setattr(sys, "version_info", _version_info(3, 12))
monkeypatch.setattr("main.get_astrbot_root", lambda: astrbot_root)
monkeypatch.setattr("main.get_astrbot_site_packages_path", lambda: site_packages_path)
monkeypatch.setattr("main.get_astrbot_config_path", lambda: "/tmp/config")
monkeypatch.setattr("main.get_astrbot_plugin_path", lambda: "/tmp/plugins")
monkeypatch.setattr("main.get_astrbot_temp_path", lambda: "/tmp/temp")
# Pre-populate sys.path with the user site-packages path to ensure it is not duplicated
sys.path = original_sys_path + [site_packages_path]
try:
check_env()
assert sys.path.count(site_packages_path) == 1
finally:
sys.path = original_sys_path
def test_check_env_appends_user_site_packages_after_runtime_paths(monkeypatch):

astrbot_root = "/tmp/astrbot-root"
site_packages_path = "/tmp/astrbot-site-packages"
original_sys_path = list(sys.path)

monkeypatch.setattr(sys, "version_info", _version_info(3, 12))
monkeypatch.setattr("main.get_astrbot_root", lambda: astrbot_root)
monkeypatch.setattr("main.get_astrbot_site_packages_path", lambda: site_packages_path)
monkeypatch.setattr("main.get_astrbot_config_path", lambda: "/tmp/config")
monkeypatch.setattr("main.get_astrbot_plugin_path", lambda: "/tmp/plugins")
monkeypatch.setattr("main.get_astrbot_temp_path", lambda: "/tmp/temp")
monkeypatch.setattr("main.get_astrbot_knowledge_base_path", lambda: "/tmp/kb")
monkeypatch.setattr(sys, "path", ["/runtime/lib", *original_sys_path])

with mock.patch("os.makedirs"):
check_env()

assert sys.path[0] == astrbot_root
assert sys.path[-1] == site_packages_path
assert sys.path.index(site_packages_path) > sys.path.index("/runtime/lib")


def test_check_env_does_not_append_duplicate_user_site_packages(monkeypatch):
astrbot_root = "/tmp/astrbot-root"
site_packages_path = "/tmp/astrbot-site-packages"
original_sys_path = list(sys.path)

monkeypatch.setattr(sys, "version_info", _version_info(3, 12))
monkeypatch.setattr("main.get_astrbot_root", lambda: astrbot_root)
monkeypatch.setattr("main.get_astrbot_site_packages_path", lambda: site_packages_path)
monkeypatch.setattr("main.get_astrbot_config_path", lambda: "/tmp/config")
monkeypatch.setattr("main.get_astrbot_plugin_path", lambda: "/tmp/plugins")
monkeypatch.setattr("main.get_astrbot_temp_path", lambda: "/tmp/temp")
monkeypatch.setattr("main.get_astrbot_knowledge_base_path", lambda: "/tmp/kb")
monkeypatch.setattr(sys, "path", [astrbot_root, *original_sys_path, site_packages_path])

with mock.patch("os.makedirs"):
check_env()

assert sys.path.count(site_packages_path) == 1


def test_version_info_comparisons():
"""Test _version_info comparison operators with tuples and other instances."""
v3_10 = _version_info(3, 10)
Expand Down
Loading
Loading