mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
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:
parent
70767c413a
commit
6a571dc76c
1 changed files with 272 additions and 18 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue