onwAMD / onw /update.py
ryugyosoft's picture
onw AMD 0.1.0: LLMs on the AMD Ryzen AI NPU through FastFlowLM
d0bf6e2 verified
Raw History Blame Contribute Delete
6.75 kB
"""Updates of onw AMD itself from Hugging Face (REPO below).
latest() newest released version (from the repo's v* tags), or None when offline
notes(since) the CHANGELOG.md sections newer than `since`
can_update() only installs made by the one-line installer (%LOCALAPPDATA%\\onw-amd, ~/onw-amd, or a folder with the
.onw-installed marker) are updated in place; a development checkout is never touched
apply(tag) download that version into a staging folder first (a broken download leaves the current install as
it is), then replace the files, and run pip only when pyproject.toml changed
Restarting afterwards is the tray's job (onw.tray): it stops the server, starts a fresh tray through onw.relaunch and
reloads the model that was running.
"""
import json, os, re, shutil, subprocess, sys, time, urllib.request
from . import __version__
from .i18n import T
REPO = "ryugyosoft/onwAMD"
def vtuple(v):
return tuple(int(x) for x in re.findall(r"\d+", v or "")[:4]) or (0,)
def _get(url, tries=4, timeout=30):
req = urllib.request.Request(url, headers={"User-Agent": f"onw-amd/{__version__}"})
for i in range(tries):
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read()
except Exception:
if i == tries - 1:
raise
time.sleep(2 * (i + 1))
def latest():
"""(version string like "0.8.1", tag) of the newest v* tag on the hub, or None."""
try:
refs = json.loads(_get(f"https://huggingface.co/api/models/{REPO}/refs", tries=2, timeout=15))
except Exception:
return None
tags = [t["name"] for t in refs.get("tags", []) if re.fullmatch(r"v\d+(\.\d+)*", t.get("name", ""))]
if not tags:
return None
tag = max(tags, key=vtuple)
return tag[1:], tag
def newer(v, than=__version__):
return vtuple(v) > vtuple(than)
def notes(since=__version__, tag="main", lang="ja"):
"""The changelog entries for versions newer than `since` (markdown), or ""."""
name = "CHANGELOG.md" if lang == "ja" else "CHANGELOG_en.md"
try:
text = _get(f"https://huggingface.co/{REPO}/resolve/{tag}/{name}", tries=2, timeout=15).decode("utf8")
except Exception:
return ""
out = []
for sec in re.split(r"(?m)^## ", text)[1:]:
m = re.match(r"v(\d+(?:\.\d+)*)", sec)
if m and newer(m.group(1), since):
out.append("## " + sec.strip())
return "\n\n".join(out)
def install_dir():
"""The folder onw runs from (the editable install), or None if it is not an installer-made install."""
d = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
home = os.path.join(os.environ.get("LOCALAPPDATA", ""), "onw-amd") if os.name == "nt" else os.path.expanduser("~/onw-amd")
ok = os.path.exists(os.path.join(d, ".onw-installed")) or os.path.normcase(os.path.abspath(home)) == os.path.normcase(d)
if not ok or os.path.isdir(os.path.join(d, ".git")) or not os.path.exists(os.path.join(d, "pyproject.toml")):
return None
return d
def can_update():
return install_dir() is not None
def apply(tag, progress=lambda text: None):
"""Install version `tag` over the current install. Returns the new version string."""
d = install_dir()
if d is None:
raise RuntimeError("this onw AMD is not an installer-made install (update it with git / hf instead)")
info = json.loads(_get(f"https://huggingface.co/api/models/{REPO}/revision/{tag}"))
files = [s["rfilename"] for s in info["siblings"] if s["rfilename"] != ".gitattributes"]
stage = os.path.join(d, ".update")
shutil.rmtree(stage, ignore_errors=True)
def fetch(f):
data = _get(f"https://huggingface.co/{REPO}/resolve/{tag}/{f}")
if f.endswith((".sh", ".py", ".toml", ".txt", ".md", ".html")):
data = data.replace(b"\r\n", b"\n") # scripts keep Unix line endings on every platform
if not data and f.endswith((".py", ".sh", ".bat", ".toml")) and not f.endswith("__init__.py"):
raise RuntimeError(f"{f} came down empty; update cancelled")
out = os.path.join(stage, f)
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "wb") as fh:
fh.write(data)
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(8) as ex: # 8 at a time: a slow connection costs far less than one by one
jobs = [ex.submit(fetch, f) for f in files]
for i, j in enumerate(as_completed(jobs)):
j.result()
progress(T("ダウンロード中 {0}/{1}", i + 1, len(files)))
old_toml = open(os.path.join(d, "pyproject.toml"), "rb").read()
new_toml = open(os.path.join(stage, "pyproject.toml"), "rb").read()
progress(T("入れ替え中"))
for f in files: # replace file by file (a running Python keeps its loaded code)
dst = os.path.join(d, f)
os.makedirs(os.path.dirname(dst), exist_ok=True)
for i in range(10): # Windows: a file being read right now (the page being served)
try: # cannot be replaced for that moment
os.replace(os.path.join(stage, f), dst)
break
except PermissionError:
if i == 9:
raise
time.sleep(0.5)
# modules the new version no longer has: stale code must not be imported by accident
keep = {os.path.normcase(os.path.join(d, f)) for f in files}
for root, dirs, names in os.walk(os.path.join(d, "onw")):
dirs[:] = [x for x in dirs if x != "__pycache__"]
for n in names:
p = os.path.join(root, n)
if n.endswith(".py") and os.path.normcase(p) not in keep:
try:
os.remove(p)
except OSError:
pass
shutil.rmtree(stage, ignore_errors=True)
open(os.path.join(d, ".onw-installed"), "w").write(tag + "\n")
if new_toml != old_toml: # new or changed dependencies
progress(T("パッケージを更新中(数分かかることがあります)"))
kw = {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {}
r = subprocess.run([sys.executable, "-m", "pip", "install", "-e", ".[tray]"],
cwd=d, capture_output=True, text=True, encoding="utf8", errors="replace", **kw)
if r.returncode != 0:
raise RuntimeError("pip failed: " + " ".join(r.stdout.split()[-40:]))
return tag.lstrip("v")