Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"dotenv>=0.9.9",
"httpx>=0.28.1",
"jinja2>=3.1.6",
"packaging>=24.0",
"pip>=25.2",
"pydantic>=2.11.5",
"pydantic-settings>=2.10.1",
Expand Down
27 changes: 27 additions & 0 deletions src/langbot_plugin/entities/io/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,33 @@ def __str__(self):
return self.message


class DependencyInstallError(Exception):
"""Dependency installation failed."""

def __init__(self, package: str, returncode: int, stderr: str):
self.package = package
self.returncode = returncode
self.stderr = stderr
super().__init__(f"Failed to install {package}: {stderr}")

def __str__(self):
return f"Failed to install {self.package}: {self.stderr}"


class DependencyVerificationError(Exception):
"""Dependency verification failed."""

def __init__(self, missing: list[str], version_mismatch: list[str] = None):
self.missing = missing
self.version_mismatch = version_mismatch or []
super().__init__(
f"Missing dependencies: {missing}, Version mismatch: {self.version_mismatch}"
)

def __str__(self):
return f"Missing dependencies: {self.missing}, Version mismatch: {self.version_mismatch}"


class ActionCallError(Exception):
"""The action call failed."""

Expand Down
191 changes: 191 additions & 0 deletions src/langbot_plugin/runtime/helper/pkgmgr.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import asyncio
import importlib.metadata
import importlib.util
import os
import re
import subprocess
import sys

from packaging.requirements import Requirement
from pip._internal import main as pipmain


Expand Down Expand Up @@ -139,3 +142,191 @@ def _parse_downloaded_bytes(output: str) -> int:
else:
total += int(val)
return total


_dist_to_packages: dict[str, set[str]] | None = None


def _is_distribution_installed(pkg_name: str) -> bool:
"""Check whether a pip distribution is installed via its metadata.

This is the authoritative check, regardless of whether the pip
package name matches the actual Python module name.
"""
candidates = {pkg_name, pkg_name.replace("-", "_"), pkg_name.replace("_", "-")}
for name in candidates:
try:
importlib.metadata.distribution(name)
return True
except importlib.metadata.PackageNotFoundError:
continue
return False


def _resolve_import_names(pkg_name: str) -> set[str]:
"""Resolve top-level Python import names for a pip distribution.

Many pip package names differ from their importable module name.
E.g. yiri-mirai -> mirai, PyYAML -> yaml, Pillow -> PIL.
Uses importlib.metadata.packages_distributions() reverse mapping.
"""
global _dist_to_packages

if _dist_to_packages is None:
_dist_to_packages = {}
try:
for top_pkg, dist_names in importlib.metadata.packages_distributions().items():
for dist_name in dist_names:
key = dist_name.lower().replace("_", "-")
_dist_to_packages.setdefault(key, set()).add(top_pkg)
except Exception:
pass

key = pkg_name.lower().replace("_", "-")
names = _dist_to_packages.get(key, set()).copy()
names.add(pkg_name.replace("-", "_"))
return names


def _check_dependency_installed(dep_spec: str) -> bool:
"""Check whether a dependency requirement is fully satisfied.

Returns True only when:
1. Environment markers do not apply → treat as satisfied (skip)
2. Distribution is installed AND version satisfies the specifier
3. Package is importable (fallback for name-mismatch cases,
e.g. yiri-mirai → mirai, PyYAML → yaml)

Returns False when the installed version does not meet the specifier
or the package is not installed at all.
"""
try:
req = Requirement(dep_spec)
except Exception:
# parse_requirements() already filters comments, empty lines, and
# option lines (-r / --index-url). If Requirement() still fails, the
# input is genuinely malformed and no heuristic name extraction will
# be reliable. Return False so pip gets a chance to install it.
return False

if req.marker and not req.marker.evaluate():
return True

pkg_name = req.name

if _is_distribution_installed(pkg_name):
if req.url:
# URL requirements (e.g. ``foo @ https://...``) cannot be
# reliably verified from Python — the installed distribution
# may have come from a different source or version. Reading
# pip's internal direct_url.json (PEP 610) is fragile and
# couples us to pip implementation details. Returning True
# would risk silently keeping a mismatched version.
#
# Instead, return False so pip decides. If the URL points to
# an already-satisfied version, pip will output "Requirement
# already satisfied" and exit without downloading — the
# overhead is a single subprocess spawn.
return False
if not req.specifier:
return True
try:
installed_version = importlib.metadata.version(pkg_name)
return installed_version in req.specifier
except importlib.metadata.PackageNotFoundError:
return False

for import_name in _resolve_import_names(pkg_name):
if importlib.util.find_spec(import_name) is not None:
return True

return False


async def verify_dependencies(deps: list[str]) -> list[str]:
"""Verify installed dependencies are actually importable.

Returns:
List of dependency specs that failed verification.
"""
missing = []
for dep in deps:
if _check_dependency_installed(dep):
continue
# _check_dependency_installed always returns False for URL
# requirements (preferring pip to decide). After pip has run,
# distribution existence is sufficient verification.
try:
req = Requirement(dep)
if req.url and _is_distribution_installed(req.name):
continue
except Exception:
pass
missing.append(dep)
return missing


async def install_with_retry(
package: str,
max_retries: int = 3,
retry_delay: float = 1.0,
extra_params: list | None = None,
) -> tuple[int, int, str]:
"""Install a package with retry on failure.

Args:
package: Package name to install.
max_retries: Maximum number of retry attempts.
retry_delay: Delay between retries in seconds.
extra_params: Additional pip parameters.

Returns:
(returncode, downloaded_bytes, error_message)
"""
last_error = ""
for attempt in range(max_retries):
returncode, downloaded_bytes, output = await install_single_async(
package, extra_params
)
if returncode == 0:
return returncode, downloaded_bytes, ""

last_error = (
f"Attempt {attempt + 1}/{max_retries} failed with code {returncode}"
)
if output.strip():
last_error += f"\n{output.strip()}"

if attempt < max_retries - 1:
await asyncio.sleep(retry_delay)

return returncode, 0, last_error


async def precheck_dependencies(requirements_file: str) -> dict:
"""Pre-check dependency status before installation.

Returns:
{
'deps': list[str],
'already_installed': list[str],
'to_install': list[str],
'conflicts': list[str],
}
"""
deps = parse_requirements(requirements_file)
already_installed = []
to_install = []

for dep in deps:
if _check_dependency_installed(dep):
already_installed.append(dep)
else:
to_install.append(dep)

return {
"deps": deps,
"already_installed": already_installed,
"to_install": to_install,
"conflicts": [],
}
59 changes: 47 additions & 12 deletions src/langbot_plugin/runtime/plugin/mgr.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,50 +310,85 @@ async def install_plugin(
yield {"current_action": "installing dependencies"}
requirements_file = os.path.join(plugin_path, "requirements.txt")
if os.path.exists(requirements_file):
deps = pkgmgr_helper.parse_requirements(requirements_file)
precheck_result = await pkgmgr_helper.precheck_dependencies(
requirements_file
)
deps = precheck_result["deps"]
to_install = precheck_result["to_install"]
already_installed = precheck_result["already_installed"]

logger.info(
f"Dependency precheck: {len(already_installed)} already installed, "
f"{len(to_install)} to install"
)

total_deps = len(deps)
total_downloaded = 0
start_time = time.time()
failed_deps = []
already_installed_count = len(already_installed)
to_install_count = len(to_install)

for i, dep in enumerate(deps):
for i, dep in enumerate(to_install):
elapsed = time.time() - start_time
yield {
"current_action": "installing dependencies",
"metadata": {
"deps_total": total_deps,
"deps_installed": i,
"deps_remaining": total_deps - i,
"deps_installed": already_installed_count + i,
"deps_remaining": to_install_count - i,
"current_dep": dep,
"deps_downloaded_size": total_downloaded,
"deps_speed": total_downloaded / elapsed if elapsed > 0 else 0,
"already_installed": already_installed_count,
"to_install": to_install_count,
},
}

returncode, downloaded_bytes, output = (
await pkgmgr_helper.install_single_async(dep)
returncode, downloaded_bytes, error_msg = (
await pkgmgr_helper.install_with_retry(dep, max_retries=3)
)
total_downloaded += downloaded_bytes

if returncode != 0:
error_message = f"Failed to install dependency: {dep}"
if output:
error_message = f"{error_message}\n{output.strip()}"
logger.error(error_message)
raise RuntimeError(error_message)
logger.error(
f"Failed to install dependency after retries: {dep}, "
f"error: {error_msg}"
)
failed_deps.append(dep)

missing_deps = await pkgmgr_helper.verify_dependencies(deps)
if missing_deps:
logger.warning(
f"Dependency verification failed, missing: {missing_deps}"
)
for dep in missing_deps:
if dep not in failed_deps:
failed_deps.append(dep)

elapsed = time.time() - start_time
yield {
"current_action": "installing dependencies",
"metadata": {
"deps_total": total_deps,
"deps_installed": total_deps,
"deps_installed": total_deps - len(failed_deps),
"deps_remaining": 0,
"deps_failed": len(failed_deps),
"failed_deps": failed_deps,
"current_dep": "",
"deps_downloaded_size": total_downloaded,
"deps_speed": total_downloaded / elapsed if elapsed > 0 else 0,
},
}

if failed_deps:
error_message = (
f"Plugin {plugin_author}/{plugin_name} has {len(failed_deps)} "
f"failed dependencies: {failed_deps}"
)
logger.error(error_message)
raise RuntimeError(error_message)

# initialize plugin settings
yield {"current_action": "initializing plugin settings"}
await self.context.control_handler.call_action(
Expand Down