"""插件 Python 依赖清单的选择和解析。""" from __future__ import annotations import tomllib from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from packaging.requirements import Requirement from app.runtime.log import logger PYPROJECT_FILENAME = "pyproject.toml" REQUIREMENTS_FILENAME = "requirements.txt" DEPENDENCY_MANIFEST_PRIORITY = ( PYPROJECT_FILENAME, REQUIREMENTS_FILENAME, ) DEPENDENCY_MANIFEST_FILENAMES = frozenset( DEPENDENCY_MANIFEST_PRIORITY ) class PluginDependencyManifestError(ValueError): """表示生效的现代依赖清单无法安全消费。""" @dataclass(frozen=True) class PluginDependencyManifest: """保存插件当前生效的依赖清单及其已解析依赖。""" path: Path dependencies: tuple[Requirement, ...] def select_dependency_manifest(plugin_dir: Path) -> Path | None: """按现代清单优先级返回插件当前生效的依赖文件。""" pyproject_file = plugin_dir / PYPROJECT_FILENAME if pyproject_file.is_file(): return pyproject_file requirements_file = plugin_dir / REQUIREMENTS_FILENAME if requirements_file.is_file(): return requirements_file return None def dependency_manifest_status(event_path: Path) -> bool | None: """判断文件事件是否改变生效清单,非清单文件返回 None。""" if event_path.name not in DEPENDENCY_MANIFEST_FILENAMES: return None active_manifest = select_dependency_manifest(event_path.parent) if event_path.is_file(): return active_manifest == event_path if active_manifest is None: return True return DEPENDENCY_MANIFEST_PRIORITY.index( event_path.name ) < DEPENDENCY_MANIFEST_PRIORITY.index(active_manifest.name) def load_dependency_manifest( plugin_dir: Path, ) -> PluginDependencyManifest | None: """读取插件当前生效的依赖清单,现代清单无效时拒绝回退。""" manifest_path = select_dependency_manifest(plugin_dir) if manifest_path is None: return None return load_dependency_file(manifest_path) def load_dependency_file(path: Path) -> PluginDependencyManifest: """读取指定依赖文件,pyproject 严格校验,其余文件保持旧格式兼容。""" if path.name == PYPROJECT_FILENAME: dependencies = _load_pyproject_dependencies(path) else: dependencies = _load_requirements_dependencies(path) return PluginDependencyManifest( path=path, dependencies=dependencies, ) def _load_pyproject_dependencies(path: Path) -> tuple[Requirement, ...]: """严格读取 PEP 621 ``project.dependencies``。""" try: with path.open("rb") as file: document = tomllib.load(file) except (OSError, tomllib.TOMLDecodeError) as err: raise PluginDependencyManifestError( f"插件依赖清单 {path.name} 无法解析:{err}" ) from err project = document.get("project") if not isinstance(project, Mapping): raise PluginDependencyManifestError( f"插件依赖清单 {path.name} 缺少 [project] 表" ) name = project.get("name") if not isinstance(name, str) or not name.strip(): raise PluginDependencyManifestError( f"插件依赖清单 {path.name} 的 project.name 必须是非空字符串" ) dynamic = project.get("dynamic", []) if not isinstance(dynamic, list) or not all( isinstance(item, str) for item in dynamic ): raise PluginDependencyManifestError( f"插件依赖清单 {path.name} 的 project.dynamic 必须是字符串数组" ) if "dependencies" in dynamic: raise PluginDependencyManifestError( f"插件依赖清单 {path.name} 不支持动态 dependencies" ) version = project.get("version") if "version" in dynamic: if version is not None: raise PluginDependencyManifestError( f"插件依赖清单 {path.name} 不能同时静态和动态声明 version" ) elif not isinstance(version, str) or not version.strip(): raise PluginDependencyManifestError( f"插件依赖清单 {path.name} 必须声明非空 project.version," "或将 version 加入 project.dynamic" ) raw_dependencies = project.get("dependencies", []) if not isinstance(raw_dependencies, list) or not all( isinstance(item, str) for item in raw_dependencies ): raise PluginDependencyManifestError( f"插件依赖清单 {path.name} 的 project.dependencies 必须是字符串数组" ) dependencies: list[Requirement] = [] for item in raw_dependencies: try: dependencies.append(Requirement(item)) except Exception as err: raise PluginDependencyManifestError( f"插件依赖清单 {path.name} 包含无效依赖项 {item!r}:{err}" ) from err return tuple(dependencies) def _load_requirements_dependencies(path: Path) -> tuple[Requirement, ...]: """按旧行为逐行读取 requirements,忽略无法解析的兼容内容。""" dependencies: list[Requirement] = [] try: lines = path.read_text(encoding="utf-8", errors="replace").splitlines() except OSError as err: logger.error(f"解析 requirements.txt 时发生错误:{err}") return () for line in lines: line = line.strip() if not line or line.startswith("#"): continue try: dependencies.append(Requirement(line)) except Exception as err: logger.debug(f"无法解析依赖项 '{line}':{err}") return tuple(dependencies)