feat(53-doctor-verdict-surface): add interpreter resolution, per-module dep checks, and verdict

- Add _read_plugin_data() helper to read plugin data.json for python_path override
- Add _resolve_plugin_interpreter() replicating plugin resolvePythonExecutable() logic
- Add _query_resolved_version() to run interpreter --version via subprocess
- Add _query_resolved_package() to run pip show via resolved interpreter
- Add _MODULE_MANIFEST with per-module metadata (requests, pymupdf, Pillow, PyYAML)
- Wire resolved interpreter path, version, package drift, and wrong-env detection into run_doctor()
- Replace flat required_modules with per-module checks including version info and specific repair commands
- Add PyYAML version check (warn if < 6.0)
- Add final verdict aggregation [OK]/[WARN]/[FAIL] with color coding and recommended next action
- Color disabled when output is piped (non-TTY)
- Update return value: 1 if any fail, 0 otherwise
This commit is contained in:
Research Assistant 2026-05-08 13:44:50 +08:00
parent 70767c413a
commit 6a571dc76c

View file

@ -4,6 +4,7 @@ import json
import logging
import os
import re
import subprocess
import sys
from json import JSONDecodeError
from pathlib import Path
@ -194,6 +195,146 @@ def check_wikilink_format(vault: Path, paths: dict, add_check) -> None:
)
# ---------------------------------------------------------------------------
# Module-level manifest for dependency checks
# ---------------------------------------------------------------------------
_MODULE_MANIFEST = [
{"import": "requests", "pip": "requests", "label": "requests"},
{"import": "pymupdf", "pip": "pymupdf", "label": "pymupdf"},
{"import": "PIL", "pip": "Pillow", "label": "Pillow"},
{"import": "yaml", "pip": "pyyaml>=6.0", "label": "PyYAML"},
]
# ---------------------------------------------------------------------------
# Plugin interpreter resolution helpers (Phase 53)
# ---------------------------------------------------------------------------
def _read_plugin_data(vault: Path) -> dict:
"""Read Obsidian plugin's data.json to get the user's python_path override.
Returns empty dict if file not found, invalid, or not a dict.
"""
plugin_data_path = vault / ".obsidian" / "plugins" / "paperforge" / "data.json"
if not plugin_data_path.exists():
return {}
try:
data = json.loads(plugin_data_path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return {}
return data
except (json.JSONDecodeError, OSError, Exception):
return {}
def _resolve_plugin_interpreter(vault: Path, plugin_data: dict) -> tuple[str, str, list[str]]:
"""Replicate the plugin's resolvePythonExecutable() logic in pure Python.
Detection order:
1. Manual override: plugin_data["python_path"] if set and on disk
2. Venv candidates: .paperforge-test-venv, .venv, venv (Scripts/ on Windows, bin/ on POSIX)
3. System candidates: py -3, python, python3 (tested via --version)
4. Fallback: python
Returns (interpreter_path, source, extra_args).
"""
# 1. Manual override
manual_path = (plugin_data.get("python_path") or "").strip()
if manual_path and os.path.exists(manual_path):
return (manual_path, "manual", [])
# 2. Venv candidates
if os.name == "nt":
venv_candidates = [
vault / ".paperforge-test-venv" / "Scripts" / "python.exe",
vault / ".venv" / "Scripts" / "python.exe",
vault / "venv" / "Scripts" / "python.exe",
]
else:
venv_candidates = [
vault / ".paperforge-test-venv" / "bin" / "python",
vault / ".venv" / "bin" / "python",
vault / "venv" / "bin" / "python",
]
for candidate in venv_candidates:
try:
if candidate.exists():
return (str(candidate), "auto-detected", [])
except (PermissionError, OSError):
continue
# 3. System candidates — find first that runs --version successfully
system_candidates = [
("py", ["-3"]),
("python", []),
("python3", []),
]
for path, extra in system_candidates:
try:
cmd = [path] + extra + ["--version"]
result = subprocess.run(cmd, capture_output=True, timeout=5, text=True)
if result.returncode == 0 and "Python" in (result.stdout or ""):
return (path, "auto-detected", extra)
except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError, OSError):
continue
# 4. Fallback
return ("python", "auto-detected", [])
def _query_resolved_version(
interp: str, extra_args: list[str]
) -> tuple[str | None, tuple[int, int, int] | None]:
"""Run interpreter --version, parse and return version info.
Returns (version_string, (major, minor, micro)) or (None, None) on failure.
"""
try:
cmd = [interp] + extra_args + ["--version"]
result = subprocess.run(cmd, capture_output=True, timeout=10, text=True)
if result.returncode != 0:
return (None, None)
output = (result.stdout or "").strip() or (result.stderr or "").strip()
match = re.search(r"Python (\d+)\.(\d+)(?:\.(\d+))?", output)
if match:
major = int(match.group(1))
minor = int(match.group(2))
micro = int(match.group(3) or "0")
return (output, (major, minor, micro))
return (output, None)
except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError, OSError):
return (None, None)
def _query_resolved_package(
interp: str, extra_args: list[str], package_name: str
) -> dict | None:
"""Run pip show for a package under the resolved interpreter.
Returns dict with keys Name, Version, Location, etc., or None if not found.
"""
try:
cmd = [interp] + extra_args + ["-m", "pip", "show", package_name]
result = subprocess.run(cmd, capture_output=True, timeout=15, text=True)
if result.returncode != 0:
return None
output = (result.stdout or "").strip()
if not output:
return None
info: dict[str, str] = {}
for line in output.splitlines():
if ":" in line:
key, value = line.split(":", 1)
info[key.strip()] = value.strip()
return info if info else None
except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError, OSError):
return None
def run_doctor(vault: Path, verbose: bool = False) -> int:
"""Validate PaperForge setup and report by category.
@ -207,6 +348,12 @@ def run_doctor(vault: Path, verbose: bool = False) -> int:
def add_check(category: str, status: str, message: str, fix: str = "") -> None:
checks.append((category, status, message, fix))
# --- Plugin-resolved interpreter (Phase 53: DOCTOR-01, DOCTOR-02) ---
plugin_data = _read_plugin_data(vault)
interp, source, extra_args = _resolve_plugin_interpreter(vault, plugin_data)
version_str, version_tuple = _query_resolved_version(interp, extra_args)
pf_pkg_info = _query_resolved_package(interp, extra_args, "paperforge")
sys_version = sys.version_info
if sys_version.major >= 3 and sys_version.minor >= 10:
add_check("Python 环境", "pass", f"Python {sys_version.major}.{sys_version.minor}.{sys_version.micro}")
@ -218,22 +365,73 @@ def run_doctor(vault: Path, verbose: bool = False) -> int:
"升级 Python 到 3.10 或更高版本",
)
required_modules = ["requests", "pymupdf", "PIL", "yaml"]
missing_modules = []
for mod in required_modules:
# --- Plugin-resolved interpreter checks ---
add_check("Python 环境 (插件)", "pass",
f"已解析解释器: {interp} (来源: {source})")
if version_tuple is not None and version_tuple >= (3, 10, 0):
add_check("Python 环境 (插件)", "pass",
f"Python {version_str}")
elif version_tuple is not None:
add_check("Python 环境 (插件)", "fail",
f"Python {version_str} (需要 3.10+)",
f"升级解释器 {interp} 到 Python 3.10 或更高版本")
else:
add_check("Python 环境 (插件)", "fail",
f"无法获取 {interp} 的 Python 版本",
f"验证 {interp} 是一个有效的 Python 解释器")
if pf_pkg_info is not None:
pkg_version = pf_pkg_info.get("Version", "?")
pkg_location = pf_pkg_info.get("Location", "?")
expected_version = __import__("paperforge").__version__
if pkg_version == expected_version:
add_check("PaperForge 包", "pass",
f"v{pkg_version} 已安装 -> {pkg_location}")
else:
add_check("PaperForge 包", "warn",
f"v{pkg_version} 已安装 (插件版本 v{expected_version}) - 版本不匹配",
f"运行: {interp} -m pip install --upgrade git+https://github.com/LLLin000/PaperForge.git")
else:
add_check("PaperForge 包", "fail",
f"PaperForge 未安装在 {interp}",
f"运行: {interp} -m pip install -e .")
# Wrong-environment detection
current_package_path = os.path.dirname(__import__("paperforge").__file__)
if pf_pkg_info is not None and pf_pkg_info.get("Location"):
loc = pf_pkg_info["Location"]
if loc and loc != current_package_path:
add_check("PaperForge 包", "warn",
f"包路径不一致: 已解析解释器 -> {loc} | 当前诊断进程 -> {current_package_path}",
"建议: 使用已解析解释器运行 doctor或统一 Python 环境")
# --- Per-module dependency checks (Phase 53: DOCTOR-03) ---
for mod_info in _MODULE_MANIFEST:
mod_name = mod_info["import"]
try:
__import__(mod)
mod = __import__(mod_name)
ver = getattr(mod, "__version__", None)
ver_str = f" ({ver})" if ver else ""
# PyYAML specific: warn if version < 6.0
if mod_name == "yaml" and ver:
try:
ver_parts = str(ver).split(".")
if int(ver_parts[0]) < 6:
add_check("Python 环境", "warn",
f"{mod_info['label']} {ver} (需要 >=6.0)",
f"运行: pip install {mod_info['pip']}")
continue
except (ValueError, IndexError):
pass
add_check("Python 环境", "pass",
f"{mod_info['label']} 已安装{ver_str}")
except ImportError:
missing_modules.append(mod)
if missing_modules:
add_check(
"Python 环境",
"fail",
f"缺少模块: {', '.join(missing_modules)}",
f"运行: pip install {' '.join(missing_modules)}",
)
elif sys_version.major >= 3 and sys_version.minor >= 10:
pass
add_check("Python 环境", "fail",
f"{mod_info['label']} 缺失",
f"运行: pip install {mod_info['pip']}")
if (vault / "paperforge.json").exists():
add_check("Vault 结构", "pass", "paperforge.json 存在")
@ -550,15 +748,71 @@ def run_doctor(vault: Path, verbose: bool = False) -> int:
fix_map.setdefault(category, [])
fix_map[category].append(fix)
# --- Verdict (Phase 53: DOCTOR-04) ---
has_fail = any(status == "fail" for _, status, _, _ in checks)
has_warn = any(status == "warn" for _, status, _, _ in checks)
if has_fail:
verdict = "FAIL"
elif has_warn:
verdict = "WARN"
else:
verdict = "OK"
# Disable color when output is piped
if sys.stdout.isatty():
GREEN = "\033[92m"
YELLOW = "\033[93m"
RED = "\033[91m"
RESET = "\033[0m"
else:
GREEN = YELLOW = RED = RESET = ""
# Determine recommended next action
fail_categories = set()
for cat, status, _, fix in checks:
if status == "fail" and fix:
fail_categories.add(cat)
if "PaperForge 包" in fail_categories and not bool(fail_categories - {"PaperForge 包"}):
next_action = "Recommended: run pip install via the resolved interpreter"
elif "Python 环境" in fail_categories or "Python 环境 (插件)" in fail_categories:
next_action = "Recommended: install/upgrade Python to 3.10+"
elif "Vault 结构" in fail_categories or "Config Migration" in fail_categories:
next_action = "Recommended: run `paperforge sync`"
elif "OCR 配置" in fail_categories:
next_action = "Recommended: configure PADDLEOCR_API_TOKEN in .env"
elif "BBT 导出" in fail_categories:
next_action = "Recommended: configure Better BibTeX auto-export"
elif "Zotero 链接" in fail_categories or "Path Resolution" in fail_categories:
next_action = "Recommended: create Zotero junction"
elif has_warn:
next_action = "Recommended: `paperforge doctor` indicates warnings - review above"
else:
next_action = "Recommended: `paperforge sync` to ensure index is current"
separator = "=" * 40
print(f"\n{separator}")
if verdict == "OK":
tag = f"{GREEN}[OK]{RESET}"
elif verdict == "WARN":
tag = f"{YELLOW}[WARN]{RESET}"
else:
tag = f"{RED}[FAIL]{RESET}"
print(f"{tag} 诊断结论")
print(f"{next_action}")
print(f"{separator}\n")
if fix_map:
print("\n修复步骤:")
print("修复步骤:")
for cat, fixes in fix_map.items():
for f in fixes:
print(f" - {cat}: {f}")
print()
return 1
print()
return 0
return 1 if has_fail else 0
def _is_junction(path: Path) -> bool: