Files
mclaughlinrosemary927-Comfy…/node_installer.py
T

327 lines
13 KiB
Python

from __future__ import annotations
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import urlparse
TE_MARKET_URL = "https://extension-list.oystermercury.top/comfyui/custom-node-list.json"
COMFY_MANAGER_NODE_MAP_URL = "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/extension-node-map.json"
PROTECTED_DEPENDENCIES = {
"torch",
"torchvision",
"torchaudio",
"xformers",
"triton",
"onnxruntime",
"onnxruntime-gpu",
}
FRONTEND_ONLY_NODE_HINTS = ("note", "markdown", "注释", "comment", "reroute")
def missing_node_types(node_types: Iterable[Any], registered: Iterable[str]) -> list[str]:
known = set(registered)
return sorted(
{
str(node_type).strip()
for node_type in node_types
if isinstance(node_type, str)
and node_type.strip()
and node_type not in known
and not any(hint in node_type.lower() for hint in FRONTEND_ONLY_NODE_HINTS)
},
key=str.lower,
)
def normalize_repo_url(value: str) -> str:
url = value.strip().rstrip("/")
if url.endswith(".git"):
url = url[:-4]
return url.lower()
def allowed_repo_url(value: str) -> bool:
parsed = urlparse(value)
return parsed.scheme == "https" and parsed.hostname == "github.com" and len(parsed.path.strip("/").split("/")) >= 2
def market_candidates(
entries: Iterable[dict[str, Any]], node_type: str, node_map: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
mapped_repositories = {
normalize_repo_url(repo_url)
for repo_url, value in (node_map or {}).items()
if isinstance(value, list) and value and isinstance(value[0], list) and node_type in value[0]
}
candidates = []
for entry in entries:
files = entry.get("files") or []
repo_url = next((str(item) for item in files if allowed_repo_url(str(item))), "")
if entry.get("install_type", "git-clone") != "git-clone" or not repo_url:
continue
confidence = 0.0
reason = ""
if node_type in (entry.get("preemptions") or []):
confidence, reason = 1.0, "official_node_mapping"
elif normalize_repo_url(repo_url) in mapped_repositories:
confidence, reason = 0.99, "comfy_manager_node_mapping"
else:
pattern = entry.get("nodename_pattern")
if pattern:
try:
if re.search(str(pattern), node_type):
confidence, reason = 0.96, "official_node_pattern"
except re.error:
pass
if not confidence:
continue
candidates.append(
{
"id": str(entry.get("id") or normalize_repo_url(repo_url).rsplit("/", 1)[-1]),
"title": str(entry.get("title") or entry.get("id") or repo_url),
"author": str(entry.get("author") or ""),
"description": str(entry.get("description") or ""),
"repo_url": repo_url,
"confidence": confidence,
"reason": reason,
"installable": True,
"pip": entry.get("pip") or [],
"apt_dependency": entry.get("apt_dependency") or [],
}
)
return sorted(candidates, key=lambda item: (-item["confidence"], item["title"].lower()))
def _comfy_root(folder_paths_module: Any) -> Path:
module_file = getattr(folder_paths_module, "__file__", None)
if module_file:
return Path(module_file).resolve().parent
return Path(folder_paths_module.models_dir).resolve().parent
def _launcher_preferences(root: Path) -> dict[str, Any]:
path = root / ".launcher" / "preference.json"
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _resolve_tool(root: Path, configured: str | None, fallback: str) -> str:
if configured:
path = (root / configured).resolve()
if path.exists():
return str(path)
return fallback
def _resolve_python(root: Path, configured: str | None) -> str:
candidates = [
(root / configured).resolve() if configured else None,
Path(sys.executable).resolve(),
(root.parent / "python_embeded" / "python.exe").resolve(),
(root / "venv" / "Scripts" / "python.exe").resolve(),
]
portable_root = root.parent.resolve()
for candidate in candidates:
if candidate and candidate.exists() and (portable_root == candidate.parent or portable_root in candidate.parents):
return str(candidate)
return sys.executable
def _install_environment(root: Path) -> tuple[str, str, dict[str, str]]:
preference = _launcher_preferences(root)
expert = preference.get("expert_settings") or {}
network = preference.get("network_preference") or {}
python = _resolve_python(root, expert.get("python_path_override"))
git = _resolve_tool(root, expert.get("git_path_override"), shutil.which("git") or "git")
env = os.environ.copy()
proxy = str(network.get("proxy_address") or "").strip()
if proxy and not _proxy_available(proxy):
proxy = _windows_proxy()
if proxy and network.get("proxy_env"):
env.update({"HTTP_PROXY": proxy, "HTTPS_PROXY": proxy, "http_proxy": proxy, "https_proxy": proxy})
return python, git, env
def _proxy_available(value: str) -> bool:
parsed = urlparse(value if "://" in value else f"http://{value}")
try:
with socket.create_connection((parsed.hostname or "", parsed.port or 80), timeout=1):
return True
except OSError:
return False
def _windows_proxy() -> str:
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Internet Settings") as key:
enabled = winreg.QueryValueEx(key, "ProxyEnable")[0]
server = str(winreg.QueryValueEx(key, "ProxyServer")[0])
return f"http://{server}" if enabled and server else ""
except (ImportError, OSError):
return ""
def _run(
command: list[str], cwd: Path, env: dict[str, str], timeout: int = 900, allow_failure: bool = False
) -> str:
result = subprocess.run(
command,
cwd=cwd,
env=env,
text=True,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=timeout,
check=False,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
if result.returncode and not allow_failure:
raise RuntimeError(result.stdout.strip() or f"Command failed: {command[0]}")
return result.stdout.strip()
def _requirement_files(repo: Path) -> list[Path]:
result = []
queue = [repo / "requirements.txt"]
visited = set()
while queue:
path = queue.pop(0).resolve()
if path in visited or not path.is_file() or repo.resolve() not in path.parents:
continue
visited.add(path)
result.append(path)
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = raw.strip()
if line.startswith(("-r ", "--requirement ")):
nested = line.split(maxsplit=1)[1].strip()
queue.append(path.parent / nested)
return result
def dependency_conflicts(repo: Path) -> list[str]:
conflicts = []
for path in _requirement_files(repo):
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = raw.strip()
if not line or line.startswith("#") or line.startswith(("-r ", "--requirement ")):
continue
if line.startswith("-") or ("://" in line and not line.startswith("git+https://github.com/")):
conflicts.append(f"不安全或无法预检的依赖声明: {line}")
continue
package = re.split(r"[\s\[<>=!~;]", line, maxsplit=1)[0].lower().replace("_", "-")
if package in PROTECTED_DEPENDENCIES and re.search(r"[<>=!~]", line):
conflicts.append(f"插件要求修改核心运行依赖: {line}")
return conflicts
def declared_dependency_conflicts(requirements: Iterable[Any]) -> list[str]:
conflicts = []
for value in requirements:
line = str(value).strip()
if not line:
continue
if line.startswith("-") or ("://" in line and not line.startswith("git+https://github.com/")):
conflicts.append(f"不安全或无法预检的市场依赖声明: {line}")
continue
package = re.split(r"[\s\[<>=!~;]", line, maxsplit=1)[0].lower().replace("_", "-")
if package in PROTECTED_DEPENDENCIES and re.search(r"[<>=!~]", line):
conflicts.append(f"插件要求修改核心运行依赖: {line}")
return conflicts
def _existing_repo(custom_nodes: Path, repo_url: str, git: str, env: dict[str, str]) -> Path | None:
wanted = normalize_repo_url(repo_url)
for directory in custom_nodes.iterdir():
if not directory.is_dir() or not (directory / ".git").exists():
continue
try:
remote = _run([git, "remote", "get-url", "origin"], directory, env, timeout=30)
except RuntimeError:
continue
if normalize_repo_url(remote) == wanted:
return directory
return None
def install_market_plugin(folder_paths_module: Any, candidate: dict[str, Any]) -> dict[str, Any]:
repo_url = str(candidate.get("repo_url") or "")
if not candidate.get("installable") or not allowed_repo_url(repo_url):
raise RuntimeError("只允许安装 TE 官方插件市场中经过节点名精确匹配的 GitHub 插件")
root = _comfy_root(folder_paths_module)
custom_nodes = root / "custom_nodes"
python, git, env = _install_environment(root)
existing = _existing_repo(custom_nodes, repo_url, git, env)
installed_path = existing
action = "updated" if existing else "installed"
temp_path: Path | None = None
try:
if existing:
_run([git, "pull", "--ff-only"], existing, env)
repo = existing
else:
repo_name = re.sub(r"[^A-Za-z0-9_.-]+", "-", urlparse(repo_url).path.rstrip("/").rsplit("/", 1)[-1])
target = custom_nodes / repo_name
if target.exists():
raise RuntimeError(f"目标目录已存在但不是同一仓库: {target.name}")
temp_path = Path(tempfile.mkdtemp(prefix=".findmodels-install-", dir=custom_nodes))
_run([git, "clone", "--depth", "1", repo_url, str(temp_path)], custom_nodes, env)
repo = temp_path
declared = candidate.get("pip") or []
conflicts = dependency_conflicts(repo) + declared_dependency_conflicts(declared)
if candidate.get("apt_dependency"):
conflicts.append("插件需要系统级 apt 依赖,Windows TE 环境无法安全自动安装")
if conflicts:
raise RuntimeError("\n".join(conflicts))
requirements = _requirement_files(repo)
conflicts_before = set(
_run([python, "-m", "pip", "check"], repo, env, allow_failure=True).splitlines()
)
for requirement in requirements:
_run([python, "-m", "pip", "install", "--dry-run", "-r", str(requirement)], repo, env)
for requirement in requirements:
_run([python, "-m", "pip", "install", "-r", str(requirement)], repo, env)
if declared:
_run([python, "-m", "pip", "install", "--dry-run", *map(str, declared)], repo, env)
_run([python, "-m", "pip", "install", *map(str, declared)], repo, env)
if (repo / "install.py").is_file():
_run([python, "install.py"], repo, env)
conflicts_after = set(
_run([python, "-m", "pip", "check"], repo, env, allow_failure=True).splitlines()
)
new_conflicts = sorted(conflicts_after - conflicts_before)
if temp_path:
temp_path.replace(target)
installed_path = target
temp_path = None
return {
"action": action,
"title": candidate.get("title"),
"path": str(installed_path),
"dependencies": len(requirements),
"existing_conflicts": sorted(conflicts_before),
"new_conflicts": new_conflicts,
"pip_check": "No new broken requirements found." if not new_conflicts else "\n".join(new_conflicts),
"restart_required": True,
}
finally:
if temp_path and temp_path.exists():
shutil.rmtree(temp_path, ignore_errors=True)