Files
DemonGatanjieu-Anomalous_Mo…/api/version_manager.py
T
DemonGatanjieuandClaude Opus 5.5 2e62703923 Update Anomalous_TTS from GPT-SoVITS Settings
The version manager takes an optional Checkout, so the companion node pack is
handled like the plugin itself: api/tts_version.py finds it among the
custom_nodes folders by its node class and accepts only a git clone of the
official repository; releases are fetched only on "Check for updates", an
update checks out the newest stable tag and can be undone. A checkout without
the release tags (cloned before it, then pulled) is matched by ls-remote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:08:10 +08:00

404 lines
16 KiB
Python

"""Plugin version display, published-release listing and version switching.
The plugin is normally installed as a git clone (ComfyUI Manager), so the
installed version is read from git and switching checks out a published
release tag. Nothing runs automatically: every network call starts from a
button in the UI. Switching refuses when tracked files have local changes, and
only tags that are published releases (or at least exist on the remote) can be
checked out.
Every function acts on the plugin itself unless given a `Checkout`: the
companion node pack Anomalous_TTS is updated the same way (api/tts_version.py).
"""
import asyncio
import json
import os
import re
import shutil
import subprocess
import threading
import time
import urllib.request
from aiohttp import web
try:
from ..net_proxy import urlopen
except ImportError: # loaded outside the package (tests)
from net_proxy import urlopen
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATE_FILE = os.path.join(PLUGIN_DIR, ".anomalous_version.local.json")
# A version whose tree contains this file has the version panel; older ones do not.
SWITCHER_MARKER = "api/version_manager.py"
TAG_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$")
GITHUB_REMOTE = re.compile(r"github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$", re.IGNORECASE)
RELEASES_CACHE_SECONDS = 300
NETWORK_TIMEOUT = 10
FETCH_TIMEOUT = 180
_lock = threading.Lock()
_releases_cache = {"expires": 0, "data": None}
class Checkout:
"""Another git checkout to manage: its folder, where its last switch is noted, and the file
that marks versions with a version panel (None: no such notion)."""
def __init__(self, root, state_file, marker=None):
self.root = root
self.state_file = state_file
self.marker = marker
self.cache = {"expires": 0, "data": None}
def _root(repo):
return repo.root if repo else PLUGIN_DIR
class VersionError(Exception):
"""A user-facing failure with a stable code for the UI."""
def __init__(self, code, message="", **details):
super().__init__(message or code)
self.code = code
self.details = details
# ---------- git helpers ----------
def _git_executable():
found = shutil.which("git")
if not found:
raise VersionError("git_missing", "git executable not found")
return found
def _git(*args, timeout=30, check=True, repo=None):
kwargs = {}
if os.name == "nt":
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
try:
result = subprocess.run(
[_git_executable(), "-C", _root(repo), *args],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout, env=env, **kwargs,
)
except subprocess.TimeoutExpired:
raise VersionError("git_timeout", f"git {' '.join(args[:2])} timed out")
if check and result.returncode != 0:
raise VersionError("git_failed", (result.stderr or result.stdout).strip()[-500:])
return result
def _git_out(*args, **kwargs):
result = _git(*args, check=False, **kwargs)
return result.stdout.strip() if result.returncode == 0 else ""
def version_key(tag):
"""Sortable key for tags such as v1.4.2, v1.57-beta, v1.58.0-rc1 (stable after pre-release)."""
match = re.match(r"^v?(\d+(?:\.\d+)*)(.*)$", str(tag or ""))
if not match:
return ((), 0, str(tag))
numbers = tuple(int(part) for part in match.group(1).split("."))
suffix = match.group(2).lstrip("-_.")
return (numbers, 1 if not suffix else 0, suffix)
def current_state(repo=None):
"""Local only (no network): what is installed and whether it can be switched."""
if not os.path.isdir(os.path.join(_root(repo), ".git")) and _git_out("rev-parse", "--is-inside-work-tree", repo=repo) != "true":
return {"is_git": False}
commit = _git_out("rev-parse", "HEAD", repo=repo)
branch = _git_out("symbolic-ref", "--short", "-q", "HEAD", repo=repo) or None
exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD", repo=repo) or None
described = _git_out("describe", "--tags", "--always", repo=repo)
base_tag = _git_out("describe", "--tags", "--abbrev=0", repo=repo) or None
# Porcelain lines are "XY path"; keep the leading status column (no strip before slicing).
status = _git("status", "--porcelain", "--untracked-files=no", check=False, repo=repo).stdout
changed = [line[3:] for line in status.splitlines() if line.strip()]
return {
"is_git": True,
"commit": commit[:12],
"branch": branch,
"tag": exact_tag,
"base_tag": base_tag,
"label": exact_tag or described or commit[:12],
"dirty": bool(changed),
"changed_files": changed[:20],
"remote": _git_out("remote", "get-url", "origin", repo=repo) or None,
"previous": _read_state(repo).get("previous"),
}
# ---------- releases ----------
def _github_repo(remote_url):
match = GITHUB_REMOTE.search(remote_url or "")
return (match.group(1), match.group(2)) if match else None
def _fetch_github_releases(owner, repo):
url = f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=50"
request = urllib.request.Request(url, headers={
"Accept": "application/vnd.github+json",
"User-Agent": "Anomalous-Model-Browser",
})
with urlopen(request, timeout=NETWORK_TIMEOUT) as response:
payload = json.loads(response.read().decode("utf-8"))
releases = []
for item in payload if isinstance(payload, list) else []:
if item.get("draft") or not item.get("tag_name"):
continue
releases.append({
"tag": item["tag_name"],
"name": item.get("name") or item["tag_name"],
"published_at": item.get("published_at"),
"prerelease": bool(item.get("prerelease")),
"notes": (item.get("body") or "")[:8000],
"url": item.get("html_url"),
})
return releases
def _remote_tags(repo=None):
output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3, repo=repo)
tags = [line.split("refs/tags/", 1)[1] for line in output.splitlines() if "refs/tags/" in line]
return [{"tag": tag, "name": tag, "published_at": None, "prerelease": False, "notes": "", "url": None} for tag in tags]
def _remote_tag_at(commit, repo=None):
"""The remote tag on `commit`, for a checkout whose own tags were never fetched (a clone made
before the release, then updated by a pull of its branch)."""
output = _git_out("ls-remote", "--tags", "origin", timeout=NETWORK_TIMEOUT * 3, repo=repo)
for line in output.splitlines():
sha, _, ref = line.partition("\t")
if commit and sha.startswith(commit) and ref.startswith("refs/tags/"):
return ref[len("refs/tags/"):].removesuffix("^{}")
return None
def list_releases(force=False, repo=None):
"""Published releases, newest first. GitHub API first, `git ls-remote` (uses git's proxy) as fallback."""
state = current_state(repo)
if not state.get("is_git"):
raise VersionError("not_git", "The plugin is not a git checkout")
cache = repo.cache if repo else _releases_cache
now = time.monotonic()
if not force and cache["data"] and now < cache["expires"]:
releases, source = cache["data"]
else:
releases, source = None, None
github = _github_repo(state.get("remote"))
if github:
try:
releases, source = _fetch_github_releases(*github), "github"
except Exception:
releases = None
if releases is None:
releases, source = _remote_tags(repo), "git"
if not releases:
raise VersionError("offline", "Could not reach GitHub or the git remote")
releases.sort(key=lambda item: (item["published_at"] or "", version_key(item["tag"])), reverse=True)
if source == "git":
releases.sort(key=lambda item: version_key(item["tag"]), reverse=True)
cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source))
if not state.get("tag") and not state.get("dirty"):
state["tag"] = _remote_tag_at(state.get("commit"), repo)
current_key = version_key(state.get("tag") or state.get("base_tag"))
stable = [item for item in releases if not item["prerelease"]]
latest = stable[0]["tag"] if stable else None
result = []
for item in releases:
key = version_key(item["tag"])
entry = dict(item)
entry["current"] = item["tag"] == state.get("tag")
entry["latest"] = item["tag"] == latest
entry["direction"] = "same" if entry["current"] else ("newer" if key > current_key else "older")
result.append(entry)
return {"state": state, "releases": result, "latest": latest, "source": source}
# ---------- switching ----------
def _read_state(repo=None):
try:
with open(repo.state_file if repo else STATE_FILE, "r", encoding="utf-8") as handle:
data = json.load(handle)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def _write_state(data, repo=None):
path = repo.state_file if repo else STATE_FILE
temporary = path + ".tmp"
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
os.replace(temporary, path)
def _require_switchable(repo=None):
state = current_state(repo)
if not state.get("is_git"):
raise VersionError("not_git", "The plugin is not a git checkout")
if state["dirty"]:
raise VersionError("dirty", "Tracked files have local changes", files=state["changed_files"])
return state
def _known_tag(tag, repo=None):
if not TAG_NAME.match(str(tag or "")):
raise VersionError("bad_tag", "Invalid version name")
tags = {item["tag"] for item in list_releases(repo=repo)["releases"]}
if tag not in tags:
raise VersionError("unknown_tag", f"{tag} is not a published version")
return tag
def _fetch_tag(tag, repo=None):
_git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT, repo=repo)
def preview_switch(tag, repo=None):
"""Fetch the target and report what switching would mean, without changing anything."""
state = _require_switchable(repo)
_known_tag(tag, repo)
_fetch_tag(tag, repo)
marker = repo.marker if repo else SWITCHER_MARKER
has_switcher = not marker or _git("cat-file", "-e", f"refs/tags/{tag}:{marker}", check=False, repo=repo).returncode == 0
target_key = version_key(tag)
current_key = version_key(state.get("tag") or state.get("base_tag"))
return {
"tag": tag,
"direction": "same" if tag == state.get("tag") else ("newer" if target_key > current_key else "older"),
"has_switcher": has_switcher,
"from": state["label"],
"from_branch": state["branch"],
}
def switch_to(tag, repo=None):
with _lock:
state = _require_switchable(repo)
_known_tag(tag, repo)
_fetch_tag(tag, repo)
previous = {"commit": _git_out("rev-parse", "HEAD", repo=repo), "branch": state["branch"], "label": state["label"]}
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}", repo=repo)
_write_state({"previous": previous, "switched_at": time.time()}, repo)
return {"state": current_state(repo), "restart_required": True}
def _default_branch():
ref = _git_out("symbolic-ref", "--short", "refs/remotes/origin/HEAD")
if ref.startswith("origin/"):
return ref.split("/", 1)[1]
for name in ("main", "master"):
if _git("ls-remote", "--exit-code", "--heads", "origin", name, check=False, timeout=NETWORK_TIMEOUT * 3).returncode == 0:
return name
raise VersionError("no_default_branch", "Could not determine the default branch")
def return_to_latest():
"""Back to the default branch at its newest commit; the same path ComfyUI Manager's update takes."""
with _lock:
state = _require_switchable()
branch = _default_branch()
_git("fetch", "--no-tags", "origin", f"+refs/heads/{branch}:refs/remotes/origin/{branch}", timeout=FETCH_TIMEOUT)
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
if _git("rev-parse", "--verify", "--quiet", f"refs/heads/{branch}", check=False).returncode == 0:
_git("checkout", "--quiet", branch)
else:
_git("checkout", "--quiet", "-b", branch, f"origin/{branch}")
merge = _git("merge", "--ff-only", "--quiet", f"origin/{branch}", check=False)
if merge.returncode != 0:
raise VersionError("diverged", f"Local {branch} has commits that are not on the remote")
_write_state({"previous": previous, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
def undo_last_switch(repo=None):
with _lock:
previous = _read_state(repo).get("previous")
if not previous or not previous.get("commit"):
raise VersionError("no_previous", "There is no switch to undo")
state = _require_switchable(repo)
here = {"commit": _git_out("rev-parse", "HEAD", repo=repo), "branch": state["branch"], "label": state["label"]}
if previous.get("branch"):
# Going back to a branch means its current tip, even if it moved since (e.g. a Manager update).
_git("checkout", "--quiet", previous["branch"], repo=repo)
else:
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"], repo=repo)
_write_state({"previous": here, "switched_at": time.time()}, repo)
return {"state": current_state(repo), "restart_required": True}
# ---------- routes ----------
def error_response(error):
status = 409 if error.code in {"dirty", "diverged"} else 400
if error.code in {"offline", "git_timeout", "git_failed"}:
status = 502
return web.json_response({"success": False, "code": error.code, "error": str(error), **error.details}, status=status)
async def run(handler, *args):
try:
return web.json_response({"success": True, **(await asyncio.to_thread(handler, *args))})
except VersionError as error:
return error_response(error)
async def json_body(request):
try:
data = await request.json()
except Exception:
return {}
return data if isinstance(data, dict) else {}
async def api_get_version(request):
"""GET /anomalous/version - Installed version (local, no network)."""
try:
return web.json_response({"success": True, "state": await asyncio.to_thread(current_state)})
except VersionError as error:
return error_response(error)
async def api_get_releases(request):
"""GET /anomalous/version/releases?refresh=1 - Published releases (network, only when asked)."""
return await run(list_releases, request.query.get("refresh") == "1")
async def api_preview_switch(request):
"""POST /anomalous/version/preview {tag}"""
return await run(preview_switch, str((await json_body(request)).get("tag", "")))
async def api_switch_version(request):
"""POST /anomalous/version/switch {tag}"""
return await run(switch_to, str((await json_body(request)).get("tag", "")))
async def api_return_to_latest(request):
"""POST /anomalous/version/latest"""
return await run(return_to_latest)
async def api_undo_switch(request):
"""POST /anomalous/version/undo"""
return await run(undo_last_switch)
def register_routes(app):
app.router.add_get('/anomalous/version', api_get_version)
app.router.add_get('/anomalous/version/releases', api_get_releases)
app.router.add_post('/anomalous/version/preview', api_preview_switch)
app.router.add_post('/anomalous/version/switch', api_switch_version)
app.router.add_post('/anomalous/version/latest', api_return_to_latest)
app.router.add_post('/anomalous/version/undo', api_undo_switch)