Files
theonetwoone a853a17ec6 Restructure repo with extension at root
Moves all node files/presets/workflow to repository root so cloning into ComfyUI custom_nodes works without an extra subfolder.

Made-with: Cursor
2026-04-28 18:46:55 +02:00

300 lines
9.4 KiB
Python

"""
Resolve the best-matching llama-cpp-python wheel from GitHub Releases (no extra deps).
"""
from __future__ import annotations
import json
import os
import re
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any, Iterator, Optional
from packaging.utils import InvalidWheelFilename, parse_wheel_filename
from packaging.version import InvalidVersion, Version
from .env_probe import InstallProfile
GITHUB_API = "https://api.github.com"
DEFAULT_REPO = "JamePeng/llama-cpp-python"
UA = "llm-comfy-multimodal-wheel-resolver/1.0"
@dataclass(frozen=True)
class ResolvedWheel:
filename: str
url: str
score: float
reason: str
package_version: str
def _cu_to_int(tag: Optional[str]) -> Optional[int]:
if not tag or not tag.startswith("cu"):
return None
rest = tag[2:]
if not rest.isdigit():
return None
return int(rest)
def _infer_cuda_tokens_from_text(name: str) -> list[str]:
lower = name.lower()
return list(dict.fromkeys(re.findall(r"cu\d{3,4}", lower)))
def _wheel_interpreter_tags_ok(filename: str, profile: InstallProfile) -> bool:
"""True if wheel supports this CPython version (e.g. cp312)."""
want = profile.python_cp_tag.lower()
try:
_dist, _ver, _build, tags = parse_wheel_filename(filename)
for t in tags:
inter = (getattr(t, "interpreter", "") or "").lower()
if inter == want or inter.startswith("py3") or inter == "py2.py3":
return True
return False
except (InvalidWheelFilename, ValueError):
pass
# Legacy / odd filenames: require cpXYZ substring
return want in filename.lower()
def _wheel_platform_ok(filename: str, profile: InstallProfile) -> bool:
try:
_dist, _ver, _build, tags = parse_wheel_filename(filename)
plat_strings = {getattr(t, "platform", "") for t in tags}
plat_strings.discard("")
if plat_strings & profile.platform_strings:
return True
except (InvalidWheelFilename, ValueError):
pass
fn_lower = filename.lower()
for p in profile.platform_strings:
if p.lower() in fn_lower:
return True
return False
def _parse_wheel_version_string(filename: str) -> str:
try:
_dist, ver, _build, _tags = parse_wheel_filename(filename)
return str(ver)
except Exception:
m = re.match(r"^[Ll]lama[_-]?cpp[_-]?python-([^-]+(?:-[^-]+)?)", filename)
return m.group(1) if m else "0"
def _score_wheel(filename: str, profile: InstallProfile) -> tuple[float, str]:
"""
Higher is better. Filters incompatible wheels via very low scores (caller skips < 0).
"""
if not filename.endswith(".whl"):
return -1.0, "not a .whl"
if not _wheel_interpreter_tags_ok(filename, profile):
return -1.0, f"no {profile.python_cp_tag} tag"
if not _wheel_platform_ok(filename, profile):
return -1.0, "platform mismatch"
tokens = _infer_cuda_tokens_from_text(filename)
has_cuda_token = bool(tokens)
pref = profile.cuda_hint_tag
pref_i = _cu_to_int(pref)
parts: list[str] = []
if profile.prefer_cuda:
if has_cuda_token:
best_cuda = 0.0
for tok in tokens:
ti = _cu_to_int(tok)
if pref_i is not None and ti is not None:
dist = abs(ti - pref_i)
s = 100.0 - min(dist, 50.0) * 0.8
elif ti is not None:
s = 70.0
else:
s = 50.0
best_cuda = max(best_cuda, s)
cuda_score = best_cuda
parts.append(f"CUDA tokens {tokens} score~{cuda_score:.1f} vs pref {pref!r}")
else:
cuda_score = 55.0
parts.append("no cuNNN in name (may still be CUDA build); score 55")
else:
if has_cuda_token:
cuda_score = 40.0
parts.append("CUDA wheel on CPU-only profile; score 40")
else:
cuda_score = 90.0
parts.append("non-CUDA naming; score 90")
ver_s = _parse_wheel_version_string(filename)
try:
pv = Version(ver_s)
ver_bonus = min(float(pv.major) * 10 + float(pv.minor), 500.0) * 0.01
except (InvalidVersion, ValueError):
ver_bonus = 0.0
parts.append(f"version {ver_s!r} bonus {ver_bonus:.2f}")
if "avx2" in filename.lower():
parts.append("AVX2 hint +2")
cuda_score += 2.0
elif "basic" in filename.lower():
parts.append("Basic +0")
total = cuda_score + ver_bonus
return total, "; ".join(parts)
def iter_release_wheel_assets(
repo: str,
timeout: float = 45.0,
max_pages: int = 15,
) -> Iterator[dict[str, Any]]:
"""Yield GitHub release asset dicts with name, browser_download_url for .whl files."""
headers = {
"User-Agent": UA,
"Accept": "application/vnd.github+json",
}
ght = (os.environ.get("GITHUB_TOKEN") or "").strip()
if ght:
headers["Authorization"] = f"Bearer {ght}"
for page in range(1, max_pages + 1):
url = f"{GITHUB_API}/repos/{repo}/releases?page={page}&per_page=100"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode("utf-8")
except urllib.error.HTTPError as e:
if e.code == 403:
raise RuntimeError(
"GitHub API returned 403 (rate limit). Retry later or set "
"GITHUB_TOKEN for higher limits."
) from e
if e.code == 404:
raise ValueError(f"GitHub repo not found or not visible: {repo}") from e
raise RuntimeError(f"GitHub API HTTP {e.code}: {e.reason}") from e
except urllib.error.URLError as e:
raise RuntimeError(f"network error fetching releases: {e}") from e
page_data = json.loads(body)
if not isinstance(page_data, list) or not page_data:
break
for release in page_data:
assets = release.get("assets") or []
for a in assets:
name = (a.get("name") or "").strip()
if name.endswith(".whl"):
yield a
if len(page_data) < 100:
break
def resolve_best_wheel(
profile: InstallProfile,
repo: str = DEFAULT_REPO,
timeout: float = 45.0,
) -> tuple[Optional[ResolvedWheel], list[ResolvedWheel]]:
"""
Returns (best, top_alternatives_sorted[:5] excluding best).
"""
ranked: list[ResolvedWheel] = []
seen: set[str] = set()
try:
for asset in iter_release_wheel_assets(repo, timeout=timeout):
fn = (asset.get("name") or "").strip()
url = (asset.get("browser_download_url") or "").strip()
if not fn or not url or fn in seen:
continue
seen.add(fn)
score, reason = _score_wheel(fn, profile)
if score < 0:
continue
ver_s = _parse_wheel_version_string(fn)
ranked.append(
ResolvedWheel(
filename=fn,
url=url,
score=score,
reason=reason,
package_version=ver_s,
)
)
except RuntimeError:
raise
except ValueError:
raise
def _rank_sort_key(r: ResolvedWheel) -> tuple:
try:
v = Version(r.package_version)
ver_key = (-v.major, -v.minor, -v.micro)
except Exception:
ver_key = (0, 0, 0)
return (-r.score, ver_key, r.filename)
ranked.sort(key=_rank_sort_key)
if not ranked:
return None, []
best = ranked[0]
alts = [r for r in ranked[1:6]]
return best, alts
def format_resolution_report(
python_exe: str,
profile: InstallProfile,
repo: str,
best: Optional[ResolvedWheel],
alternatives: list[ResolvedWheel],
) -> str:
lines = [
"=== llama-cpp-python - auto-resolved wheel (GitHub) ===",
"",
f"github_repo: {repo}",
f"python_executable:\n {python_exe}",
f"match_profile: cp={profile.python_cp_tag!r} "
f"platforms={sorted(profile.platform_strings)!r} "
f"prefer_cuda={profile.prefer_cuda} cuda_hint={profile.cuda_hint_tag!r}",
"",
]
if best is None:
lines.append("No compatible .whl found (check repo, network, or Python/OS version).")
lines.append("")
lines.append(
"Tip: browse releases manually - "
f"https://github.com/{repo}/releases"
)
return "\n".join(lines)
lines.extend(
[
f"best_match: {best.filename}",
f"score: {best.score:.2f}",
f"detail: {best.reason}",
f"download_url:\n {best.url}",
"",
"-- pip (same environment as Comfy) --",
f' "{python_exe}" -m pip uninstall -y llama-cpp-python',
f' "{python_exe}" -m pip install "{best.url}"',
"",
]
)
if alternatives:
lines.append("alternatives (next best):")
for a in alternatives:
lines.append(f" score={a.score:.2f} {a.filename}")
lines.append("")
lines.append(
"After install, verify Qwen3-VL support:\n"
f' "{python_exe}" -c "from llama_cpp.llama_chat_format import Qwen3VLChatHandler; print(Qwen3VLChatHandler)"'
)
return "\n".join(lines)