unit3dprep 1.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unit3dprep/__init__.py +0 -0
- unit3dprep/cli.py +256 -0
- unit3dprep/core.py +556 -0
- unit3dprep/i18n.py +151 -0
- unit3dprep/media.py +278 -0
- unit3dprep/upload.py +146 -0
- unit3dprep/web/__init__.py +0 -0
- unit3dprep/web/_env.py +40 -0
- unit3dprep/web/api/__init__.py +1 -0
- unit3dprep/web/api/auth.py +36 -0
- unit3dprep/web/api/fs.py +64 -0
- unit3dprep/web/api/library.py +503 -0
- unit3dprep/web/api/logs.py +42 -0
- unit3dprep/web/api/queue.py +54 -0
- unit3dprep/web/api/quickupload.py +153 -0
- unit3dprep/web/api/search.py +40 -0
- unit3dprep/web/api/settings.py +77 -0
- unit3dprep/web/api/tmdb.py +77 -0
- unit3dprep/web/api/trackers.py +30 -0
- unit3dprep/web/api/uploaded.py +26 -0
- unit3dprep/web/api/version.py +754 -0
- unit3dprep/web/api/webup.py +59 -0
- unit3dprep/web/api/wizard.py +512 -0
- unit3dprep/web/app.py +201 -0
- unit3dprep/web/auth.py +41 -0
- unit3dprep/web/clients.py +167 -0
- unit3dprep/web/config.py +932 -0
- unit3dprep/web/db.py +184 -0
- unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
- unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
- unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
- unit3dprep/web/dist/assets/index-BizNr_oP.js +255 -0
- unit3dprep/web/dist/assets/index-DChRHChM.css +1 -0
- unit3dprep/web/dist/index.html +14 -0
- unit3dprep/web/duplicate_check.py +92 -0
- unit3dprep/web/lang_cache.py +118 -0
- unit3dprep/web/logbuf.py +164 -0
- unit3dprep/web/tmdb_cache.py +116 -0
- unit3dprep/web/trackers.py +177 -0
- unit3dprep/web/webup_client.py +190 -0
- unit3dprep/web/webup_job_fix.py +231 -0
- unit3dprep/web/webup_logclass.py +107 -0
- unit3dprep/web/webup_orchestrator.py +796 -0
- unit3dprep/web/webup_ws.py +141 -0
- unit3dprep-1.0.4.dist-info/METADATA +819 -0
- unit3dprep-1.0.4.dist-info/RECORD +50 -0
- unit3dprep-1.0.4.dist-info/WHEEL +5 -0
- unit3dprep-1.0.4.dist-info/entry_points.txt +3 -0
- unit3dprep-1.0.4.dist-info/licenses/LICENSE +674 -0
- unit3dprep-1.0.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
"""Versioning + auto-update endpoints.
|
|
2
|
+
|
|
3
|
+
- /api/version/info → cached {app, webup} current/latest/newer
|
|
4
|
+
- /api/version/refresh → force-refresh cache
|
|
5
|
+
- /api/version/changelog → GitHub release body for a given app version
|
|
6
|
+
- /api/version/update/webup/stream → SSE: git pull + pip install --upgrade Unit3DwebUp + systemctl restart unit3dwebup.service
|
|
7
|
+
- /api/version/update/app/stream → SSE: git pull + pip install -e . + systemctl restart
|
|
8
|
+
|
|
9
|
+
Current app version: importlib.metadata (authoritative) with pyproject fallback.
|
|
10
|
+
Remote: GitHub releases/latest. 10-min in-memory cache, errors swallowed
|
|
11
|
+
(latest=None). Subprocess output streamed line-by-line.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, AsyncGenerator
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
27
|
+
from fastapi.responses import JSONResponse
|
|
28
|
+
from sse_starlette.sse import EventSourceResponse
|
|
29
|
+
|
|
30
|
+
from ..config import runtime_setting
|
|
31
|
+
from .._env import env as _env
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from packaging.version import InvalidVersion, Version
|
|
35
|
+
except ImportError:
|
|
36
|
+
Version = None
|
|
37
|
+
InvalidVersion = Exception
|
|
38
|
+
|
|
39
|
+
router = APIRouter(prefix="/api", tags=["version"])
|
|
40
|
+
|
|
41
|
+
GITHUB_REPO = _env("U3DP_GITHUB_REPO", "ITA_GITHUB_REPO", "davidesidoti/unit3dprep") or "davidesidoti/unit3dprep"
|
|
42
|
+
WEBUP_GITHUB_REPO = "31December99/Unit3DWebUp"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _systemd_unit() -> str:
|
|
46
|
+
"""Runtime-resolved systemd unit name. Reads from env → shared .env → default."""
|
|
47
|
+
return runtime_setting("U3DP_SYSTEMD_UNIT", "unit3dprep-web.service")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _webup_systemd_unit() -> str:
|
|
51
|
+
return runtime_setting("WEBUP_SYSTEMD_UNIT", "unit3dwebup.service")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _webup_repo_path() -> Path:
|
|
55
|
+
return Path(runtime_setting("WEBUP_REPO_PATH", str(Path.home() / "dev" / "Unit3DWebUp"))).expanduser()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _webup_python() -> str:
|
|
59
|
+
"""Resolve the python interpreter that has Unit3DwebUp installed.
|
|
60
|
+
|
|
61
|
+
Precedence: WEBUP_VENV_BIN → <WEBUP_REPO_PATH>/.venv/bin → sys.executable
|
|
62
|
+
(the running unit3dprep interpreter — works when both packages share a
|
|
63
|
+
single venv, which is the canonical PyPI install layout).
|
|
64
|
+
"""
|
|
65
|
+
explicit = runtime_setting("WEBUP_VENV_BIN", "")
|
|
66
|
+
if explicit:
|
|
67
|
+
return str(Path(explicit).expanduser() / "python")
|
|
68
|
+
legacy = _webup_repo_path() / ".venv" / "bin" / "python"
|
|
69
|
+
if legacy.exists():
|
|
70
|
+
return str(legacy)
|
|
71
|
+
return sys.executable
|
|
72
|
+
USER_AGENT = "unit3dprep/version-check"
|
|
73
|
+
|
|
74
|
+
_CACHE_TTL = 600.0
|
|
75
|
+
_CHANGELOG_TTL = 3600.0
|
|
76
|
+
_cache: dict[str, Any] = {"at": 0.0, "data": None}
|
|
77
|
+
_changelog_cache: dict[str, tuple[float, dict]] = {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------- current --
|
|
81
|
+
|
|
82
|
+
def _current_app_version() -> str:
|
|
83
|
+
# In git-checkout installs, pyproject.toml at the repo root is the authoritative
|
|
84
|
+
# source — `git pull` updates it atomically. importlib.metadata can return stale
|
|
85
|
+
# values when an orphan dist-info/egg-info shadows the fresh install.
|
|
86
|
+
if _repo_root() is not None:
|
|
87
|
+
try:
|
|
88
|
+
import tomllib
|
|
89
|
+
pyproject = _repo_root() / "pyproject.toml"
|
|
90
|
+
v = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["version"]
|
|
91
|
+
if v:
|
|
92
|
+
return v
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
try:
|
|
96
|
+
from importlib.metadata import version
|
|
97
|
+
return version("unit3dprep")
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
try:
|
|
101
|
+
import tomllib
|
|
102
|
+
pyproject = Path(__file__).resolve().parents[3] / "pyproject.toml"
|
|
103
|
+
return tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["version"]
|
|
104
|
+
except Exception:
|
|
105
|
+
return "0.0.0"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _current_webup_version() -> str | None:
|
|
109
|
+
"""Read VERSION from the running Unit3DWebUp instance via /setting."""
|
|
110
|
+
try:
|
|
111
|
+
from ..webup_client import get_client
|
|
112
|
+
h = await get_client().health(force=False)
|
|
113
|
+
if h.get("ok"):
|
|
114
|
+
return h.get("version") or None
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _current_webup_repo_version() -> str | None:
|
|
121
|
+
"""Best-effort: parse UNIT3DWEBUP__VERSION from .env(example) at the cloned repo."""
|
|
122
|
+
repo = _webup_repo_path()
|
|
123
|
+
for fname in (".env", ".env(example)"):
|
|
124
|
+
f = repo / fname
|
|
125
|
+
if not f.exists():
|
|
126
|
+
continue
|
|
127
|
+
try:
|
|
128
|
+
for line in f.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
129
|
+
if line.startswith("UNIT3DWEBUP__VERSION="):
|
|
130
|
+
return line.split("=", 1)[1].strip()
|
|
131
|
+
except Exception:
|
|
132
|
+
continue
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _current_webup_pip_version() -> str | None:
|
|
137
|
+
"""Read installed Unit3DwebUp version from the webup venv's site-packages.
|
|
138
|
+
|
|
139
|
+
Webup 0.0.x is distributed via PyPI and no longer exposes a VERSION key in
|
|
140
|
+
/setting nor in .env(example), so importlib.metadata against the webup venv
|
|
141
|
+
is the canonical source.
|
|
142
|
+
"""
|
|
143
|
+
py = _webup_python()
|
|
144
|
+
if not Path(py).exists():
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
r = subprocess.run(
|
|
148
|
+
[py, "-c", "import importlib.metadata as m; print(m.version('Unit3DwebUp'))"],
|
|
149
|
+
capture_output=True, text=True, timeout=5,
|
|
150
|
+
)
|
|
151
|
+
if r.returncode != 0:
|
|
152
|
+
return None
|
|
153
|
+
v = (r.stdout or "").strip()
|
|
154
|
+
return v or None
|
|
155
|
+
except Exception:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------- remote --
|
|
160
|
+
|
|
161
|
+
async def _fetch_github_latest(client: httpx.AsyncClient) -> dict | None:
|
|
162
|
+
try:
|
|
163
|
+
r = await client.get(
|
|
164
|
+
f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
|
|
165
|
+
headers={"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT},
|
|
166
|
+
timeout=10.0,
|
|
167
|
+
follow_redirects=True,
|
|
168
|
+
)
|
|
169
|
+
if r.status_code == 404:
|
|
170
|
+
return None
|
|
171
|
+
r.raise_for_status()
|
|
172
|
+
j = r.json()
|
|
173
|
+
tag = (j.get("tag_name") or "").lstrip("v")
|
|
174
|
+
if not tag:
|
|
175
|
+
return None
|
|
176
|
+
return {
|
|
177
|
+
"version": tag,
|
|
178
|
+
"body": j.get("body") or "",
|
|
179
|
+
"html_url": j.get("html_url") or "",
|
|
180
|
+
"published_at": j.get("published_at") or "",
|
|
181
|
+
"name": j.get("name") or tag,
|
|
182
|
+
}
|
|
183
|
+
except Exception:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _fetch_webup_latest(client: httpx.AsyncClient) -> str | None:
|
|
188
|
+
try:
|
|
189
|
+
r = await client.get(
|
|
190
|
+
f"https://api.github.com/repos/{WEBUP_GITHUB_REPO}/releases/latest",
|
|
191
|
+
headers={"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT},
|
|
192
|
+
timeout=10.0,
|
|
193
|
+
follow_redirects=True,
|
|
194
|
+
)
|
|
195
|
+
if r.status_code == 404:
|
|
196
|
+
# No release published — fall back to default branch HEAD via tags
|
|
197
|
+
r2 = await client.get(
|
|
198
|
+
f"https://api.github.com/repos/{WEBUP_GITHUB_REPO}/tags",
|
|
199
|
+
headers={"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT},
|
|
200
|
+
timeout=10.0,
|
|
201
|
+
follow_redirects=True,
|
|
202
|
+
)
|
|
203
|
+
r2.raise_for_status()
|
|
204
|
+
tags = r2.json() or []
|
|
205
|
+
if tags:
|
|
206
|
+
return (tags[0].get("name") or "").lstrip("v") or None
|
|
207
|
+
return None
|
|
208
|
+
r.raise_for_status()
|
|
209
|
+
return (r.json().get("tag_name") or "").lstrip("v") or None
|
|
210
|
+
except Exception:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _is_newer(current: str | None, latest: str | None) -> bool:
|
|
215
|
+
if not current or not latest:
|
|
216
|
+
return False
|
|
217
|
+
if Version is None:
|
|
218
|
+
return current != latest
|
|
219
|
+
try:
|
|
220
|
+
return Version(current) < Version(latest)
|
|
221
|
+
except InvalidVersion:
|
|
222
|
+
return current != latest
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def _compute_info() -> dict:
|
|
226
|
+
async with httpx.AsyncClient() as client:
|
|
227
|
+
release, webup_latest = await asyncio.gather(
|
|
228
|
+
_fetch_github_latest(client),
|
|
229
|
+
_fetch_webup_latest(client),
|
|
230
|
+
)
|
|
231
|
+
app_current = _current_app_version()
|
|
232
|
+
app_latest = release["version"] if release else None
|
|
233
|
+
webup_current = (
|
|
234
|
+
await _current_webup_version()
|
|
235
|
+
or _current_webup_pip_version()
|
|
236
|
+
or _current_webup_repo_version()
|
|
237
|
+
)
|
|
238
|
+
return {
|
|
239
|
+
"app": {
|
|
240
|
+
"current": app_current,
|
|
241
|
+
"latest": app_latest,
|
|
242
|
+
"newer": _is_newer(app_current, app_latest),
|
|
243
|
+
"release": release,
|
|
244
|
+
},
|
|
245
|
+
"webup": {
|
|
246
|
+
"current": webup_current,
|
|
247
|
+
"latest": webup_latest,
|
|
248
|
+
"newer": _is_newer(webup_current, webup_latest),
|
|
249
|
+
"installed": webup_current is not None,
|
|
250
|
+
"repo_path": str(_webup_repo_path()),
|
|
251
|
+
},
|
|
252
|
+
"can_update_app": _systemd_available(),
|
|
253
|
+
"can_update_webup": _webup_can_update(),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _webup_can_update() -> bool:
|
|
258
|
+
"""True iff we have a python interpreter able to `pip install --upgrade
|
|
259
|
+
Unit3DwebUp` AND a reachable systemd user unit for the webup service.
|
|
260
|
+
|
|
261
|
+
The legacy git-clone path (`<WEBUP_REPO_PATH>/.git`) is no longer required
|
|
262
|
+
— Unit3DwebUp 0.0.x is distributed via PyPI, so a pip install is the
|
|
263
|
+
canonical update path. If a checkout exists we'll still `git pull` it
|
|
264
|
+
before pip-installing, but its absence is not a failure.
|
|
265
|
+
"""
|
|
266
|
+
if not Path(_webup_python()).exists():
|
|
267
|
+
return False
|
|
268
|
+
if not shutil.which("systemctl"):
|
|
269
|
+
return False
|
|
270
|
+
try:
|
|
271
|
+
r = subprocess.run(
|
|
272
|
+
["systemctl", "--user", "cat", _webup_systemd_unit()],
|
|
273
|
+
capture_output=True, text=True, timeout=5,
|
|
274
|
+
)
|
|
275
|
+
return r.returncode == 0
|
|
276
|
+
except Exception:
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _systemd_available() -> bool:
|
|
281
|
+
"""True iff the systemd unit file exists and is loadable.
|
|
282
|
+
|
|
283
|
+
Uses `systemctl --user cat` which returns 0 for any valid state
|
|
284
|
+
(enabled, linked, static, disabled) and non-zero only when the unit
|
|
285
|
+
cannot be found. Previous `is-enabled` check incorrectly rejected
|
|
286
|
+
symlinked (`linked`) units.
|
|
287
|
+
"""
|
|
288
|
+
if not shutil.which("systemctl"):
|
|
289
|
+
return False
|
|
290
|
+
try:
|
|
291
|
+
r = subprocess.run(
|
|
292
|
+
["systemctl", "--user", "cat", _systemd_unit()],
|
|
293
|
+
capture_output=True, text=True, timeout=5,
|
|
294
|
+
)
|
|
295
|
+
return r.returncode == 0
|
|
296
|
+
except Exception:
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _install_mode() -> str:
|
|
301
|
+
"""Returns 'git' if the package was installed from a git checkout
|
|
302
|
+
(has a reachable `.git` at repo root) or 'pip' otherwise (e.g. installed
|
|
303
|
+
via `pip install git+https://...@tag`, which drops the `.git` folder).
|
|
304
|
+
"""
|
|
305
|
+
if _repo_root() is not None:
|
|
306
|
+
return "git"
|
|
307
|
+
return "pip"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def _get_info(force: bool = False) -> dict:
|
|
311
|
+
now = time.time()
|
|
312
|
+
if not force and _cache["data"] and (now - _cache["at"]) < _CACHE_TTL:
|
|
313
|
+
return _cache["data"]
|
|
314
|
+
data = await _compute_info()
|
|
315
|
+
_cache["at"] = now
|
|
316
|
+
_cache["data"] = data
|
|
317
|
+
return data
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------- routes --
|
|
321
|
+
|
|
322
|
+
@router.get("/version/info")
|
|
323
|
+
async def info():
|
|
324
|
+
return await _get_info()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@router.post("/version/refresh")
|
|
328
|
+
async def refresh():
|
|
329
|
+
return await _get_info(force=True)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@router.get("/version/changelog")
|
|
333
|
+
async def changelog(v: str = Query(..., min_length=1, max_length=64)):
|
|
334
|
+
v = v.strip().lstrip("v")
|
|
335
|
+
now = time.time()
|
|
336
|
+
cached = _changelog_cache.get(v)
|
|
337
|
+
if cached and (now - cached[0]) < _CHANGELOG_TTL:
|
|
338
|
+
return cached[1]
|
|
339
|
+
async with httpx.AsyncClient() as client:
|
|
340
|
+
try:
|
|
341
|
+
r = await client.get(
|
|
342
|
+
f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{v}",
|
|
343
|
+
headers={"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT},
|
|
344
|
+
timeout=10.0,
|
|
345
|
+
follow_redirects=True,
|
|
346
|
+
)
|
|
347
|
+
if r.status_code == 404:
|
|
348
|
+
r = await client.get(
|
|
349
|
+
f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/{v}",
|
|
350
|
+
headers={"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT},
|
|
351
|
+
timeout=10.0,
|
|
352
|
+
follow_redirects=True,
|
|
353
|
+
)
|
|
354
|
+
r.raise_for_status()
|
|
355
|
+
j = r.json()
|
|
356
|
+
out = {
|
|
357
|
+
"version": v,
|
|
358
|
+
"name": j.get("name") or v,
|
|
359
|
+
"body": j.get("body") or "",
|
|
360
|
+
"html_url": j.get("html_url") or "",
|
|
361
|
+
"published_at": j.get("published_at") or "",
|
|
362
|
+
}
|
|
363
|
+
except Exception as exc:
|
|
364
|
+
raise HTTPException(404, f"Release not found: {exc}")
|
|
365
|
+
_changelog_cache[v] = (now, out)
|
|
366
|
+
return out
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------- update --
|
|
370
|
+
|
|
371
|
+
def _sse(event: str, payload: Any) -> dict:
|
|
372
|
+
return {"event": event, "data": json.dumps(payload) if not isinstance(payload, str) else payload}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
async def _stream_subprocess(argv: list[str], cwd: Path | None = None) -> AsyncGenerator[dict, None]:
|
|
376
|
+
yield _sse("log", f"$ {' '.join(argv)}")
|
|
377
|
+
try:
|
|
378
|
+
proc = await asyncio.create_subprocess_exec(
|
|
379
|
+
*argv,
|
|
380
|
+
stdout=asyncio.subprocess.PIPE,
|
|
381
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
382
|
+
cwd=str(cwd) if cwd else None,
|
|
383
|
+
)
|
|
384
|
+
except FileNotFoundError as exc:
|
|
385
|
+
yield _sse("error", {"message": f"command not found: {exc}"})
|
|
386
|
+
return
|
|
387
|
+
assert proc.stdout is not None
|
|
388
|
+
while True:
|
|
389
|
+
line = await proc.stdout.readline()
|
|
390
|
+
if not line:
|
|
391
|
+
break
|
|
392
|
+
try:
|
|
393
|
+
text = line.decode("utf-8", errors="replace").rstrip()
|
|
394
|
+
except Exception:
|
|
395
|
+
text = repr(line)
|
|
396
|
+
if text:
|
|
397
|
+
yield _sse("log", text)
|
|
398
|
+
code = await proc.wait()
|
|
399
|
+
yield _sse("exit", {"code": code})
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@router.get("/version/update/webup/stream")
|
|
403
|
+
async def update_webup():
|
|
404
|
+
"""Update Unit3DWebUp: git pull → pip install → systemctl restart."""
|
|
405
|
+
async def gen_inner() -> AsyncGenerator[dict, None]:
|
|
406
|
+
before = (
|
|
407
|
+
await _current_webup_version()
|
|
408
|
+
or _current_webup_pip_version()
|
|
409
|
+
or _current_webup_repo_version()
|
|
410
|
+
)
|
|
411
|
+
yield _sse("start", {"target": "webup", "current": before})
|
|
412
|
+
|
|
413
|
+
if not _webup_can_update():
|
|
414
|
+
py = _webup_python()
|
|
415
|
+
if not Path(py).exists():
|
|
416
|
+
yield _sse("error", {"message": f"webup python not found at {py} (set WEBUP_VENV_BIN)"})
|
|
417
|
+
else:
|
|
418
|
+
yield _sse("error", {"message": f"webup systemd unit '{_webup_systemd_unit()}' not available"})
|
|
419
|
+
yield _sse("done", {"ok": False})
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
repo = _webup_repo_path()
|
|
423
|
+
# Optional: if a git checkout exists at WEBUP_REPO_PATH, refresh it first.
|
|
424
|
+
# PyPI install does not require this; we only do it for users who run
|
|
425
|
+
# webup from a local checkout (legacy / dev setups).
|
|
426
|
+
if (repo / ".git").exists():
|
|
427
|
+
for argv in (
|
|
428
|
+
["git", "fetch", "--all"],
|
|
429
|
+
["git", "pull", "--ff-only"],
|
|
430
|
+
):
|
|
431
|
+
async for ev in _stream_subprocess(argv, cwd=repo):
|
|
432
|
+
if ev["event"] == "exit":
|
|
433
|
+
if json.loads(ev["data"])["code"] != 0:
|
|
434
|
+
yield _sse("error", {"message": f"{argv[0]} {argv[1]} failed"})
|
|
435
|
+
yield _sse("done", {"ok": False})
|
|
436
|
+
return
|
|
437
|
+
break
|
|
438
|
+
yield ev
|
|
439
|
+
|
|
440
|
+
py = _webup_python()
|
|
441
|
+
pip_cwd = repo if repo.exists() else None
|
|
442
|
+
async for ev in _stream_subprocess(
|
|
443
|
+
[py, "-m", "pip", "install", "--upgrade", "Unit3DwebUp"], cwd=pip_cwd
|
|
444
|
+
):
|
|
445
|
+
if ev["event"] == "exit":
|
|
446
|
+
if json.loads(ev["data"])["code"] != 0:
|
|
447
|
+
yield _sse("error", {"message": "pip install failed"})
|
|
448
|
+
yield _sse("done", {"ok": False})
|
|
449
|
+
return
|
|
450
|
+
break
|
|
451
|
+
yield ev
|
|
452
|
+
|
|
453
|
+
# Restart webup service via systemd-run timer (fire-and-forget;
|
|
454
|
+
# outside our cgroup, won't be killed when this request finishes).
|
|
455
|
+
unit = _webup_systemd_unit()
|
|
456
|
+
if shutil.which("systemd-run"):
|
|
457
|
+
try:
|
|
458
|
+
subprocess.run(
|
|
459
|
+
[
|
|
460
|
+
"systemd-run", "--user", "--on-active=2s",
|
|
461
|
+
"--unit", f"webup-restart-{int(time.time())}",
|
|
462
|
+
"systemctl", "--user", "restart", unit,
|
|
463
|
+
],
|
|
464
|
+
stdin=subprocess.DEVNULL,
|
|
465
|
+
stdout=subprocess.DEVNULL,
|
|
466
|
+
stderr=subprocess.PIPE,
|
|
467
|
+
timeout=10,
|
|
468
|
+
check=True,
|
|
469
|
+
)
|
|
470
|
+
yield _sse("log", f"restart scheduled for {unit} (2s)")
|
|
471
|
+
except Exception as exc:
|
|
472
|
+
yield _sse("log", f"systemd-run failed, fallback Popen: {exc}")
|
|
473
|
+
try:
|
|
474
|
+
subprocess.Popen(
|
|
475
|
+
["systemctl", "--user", "restart", unit],
|
|
476
|
+
stdin=subprocess.DEVNULL,
|
|
477
|
+
stdout=subprocess.DEVNULL,
|
|
478
|
+
stderr=subprocess.DEVNULL,
|
|
479
|
+
start_new_session=True,
|
|
480
|
+
)
|
|
481
|
+
except Exception as exc2:
|
|
482
|
+
yield _sse("error", {"message": f"failed to spawn systemctl: {exc2}"})
|
|
483
|
+
|
|
484
|
+
# Invalidate cache so the next /version/info reflects the new version.
|
|
485
|
+
_cache["data"] = None
|
|
486
|
+
_cache["at"] = 0.0
|
|
487
|
+
# webup HTTP probably down right now (just restarted) — fall back to
|
|
488
|
+
# the pip-installed version, which is updated synchronously above.
|
|
489
|
+
after = _current_webup_pip_version() or _current_webup_repo_version()
|
|
490
|
+
yield _sse("done", {"ok": True, "target": "webup", "from": before, "to": after})
|
|
491
|
+
|
|
492
|
+
async def gen() -> AsyncGenerator[dict, None]:
|
|
493
|
+
try:
|
|
494
|
+
async for ev in gen_inner():
|
|
495
|
+
yield ev
|
|
496
|
+
except Exception as exc:
|
|
497
|
+
import traceback
|
|
498
|
+
tb = traceback.format_exc()
|
|
499
|
+
yield _sse("error", {"message": f"unexpected error: {exc!r}", "traceback": tb})
|
|
500
|
+
yield _sse("done", {"ok": False})
|
|
501
|
+
|
|
502
|
+
return EventSourceResponse(gen())
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
async def _git(argv: list[str], cwd: Path) -> tuple[int, str]:
|
|
506
|
+
try:
|
|
507
|
+
proc = await asyncio.create_subprocess_exec(
|
|
508
|
+
"git", *argv,
|
|
509
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
|
510
|
+
cwd=str(cwd),
|
|
511
|
+
)
|
|
512
|
+
except FileNotFoundError:
|
|
513
|
+
return 127, "git executable not found in PATH"
|
|
514
|
+
except Exception as exc:
|
|
515
|
+
return 1, f"failed to spawn git: {exc!r}"
|
|
516
|
+
out, _ = await proc.communicate()
|
|
517
|
+
return proc.returncode or 0, out.decode("utf-8", errors="replace").strip()
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _repo_root() -> Path | None:
|
|
521
|
+
candidate = Path(__file__).resolve().parents[3]
|
|
522
|
+
if (candidate / ".git").exists():
|
|
523
|
+
return candidate
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@router.get("/version/update/app/stream")
|
|
528
|
+
async def update_app():
|
|
529
|
+
async def gen() -> AsyncGenerator[dict, None]:
|
|
530
|
+
try:
|
|
531
|
+
before = _current_app_version()
|
|
532
|
+
mode = _install_mode()
|
|
533
|
+
yield _sse("start", {"target": "app", "current": before, "mode": mode})
|
|
534
|
+
|
|
535
|
+
if not _systemd_available():
|
|
536
|
+
yield _sse("error", {"message": f"systemd unit '{_systemd_unit()}' not available"})
|
|
537
|
+
yield _sse("done", {"ok": False})
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
if mode == "git":
|
|
541
|
+
async for ev in _update_app_from_git():
|
|
542
|
+
yield ev
|
|
543
|
+
data = ev.get("data") or ""
|
|
544
|
+
if ev["event"] == "done":
|
|
545
|
+
try:
|
|
546
|
+
if not json.loads(data).get("ok"):
|
|
547
|
+
return
|
|
548
|
+
except Exception:
|
|
549
|
+
return
|
|
550
|
+
break
|
|
551
|
+
else:
|
|
552
|
+
async for ev in _update_app_from_pip():
|
|
553
|
+
yield ev
|
|
554
|
+
data = ev.get("data") or ""
|
|
555
|
+
if ev["event"] == "done":
|
|
556
|
+
try:
|
|
557
|
+
if not json.loads(data).get("ok"):
|
|
558
|
+
return
|
|
559
|
+
except Exception:
|
|
560
|
+
return
|
|
561
|
+
break
|
|
562
|
+
except Exception as exc:
|
|
563
|
+
# Surface unexpected errors as proper SSE events instead of an
|
|
564
|
+
# abrupt stream close, which the frontend would render as
|
|
565
|
+
# "(stream closed)" with no actionable diagnostic.
|
|
566
|
+
import traceback
|
|
567
|
+
tb = traceback.format_exc()
|
|
568
|
+
yield _sse("error", {"message": f"unexpected error: {exc!r}", "traceback": tb})
|
|
569
|
+
yield _sse("done", {"ok": False})
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
await asyncio.sleep(1.5)
|
|
573
|
+
|
|
574
|
+
# Schedule the restart via a transient systemd-run timer so the restart
|
|
575
|
+
# command lives OUTSIDE this service's cgroup. A plain `Popen(...,
|
|
576
|
+
# start_new_session=True)` child stays in the parent unit's cgroup and
|
|
577
|
+
# gets killed together with it when systemd stops the service.
|
|
578
|
+
restart_scheduled = False
|
|
579
|
+
if shutil.which("systemd-run"):
|
|
580
|
+
try:
|
|
581
|
+
subprocess.run(
|
|
582
|
+
[
|
|
583
|
+
"systemd-run", "--user", "--on-active=3s",
|
|
584
|
+
"--unit", f"unit3dprep-restart-{int(time.time())}",
|
|
585
|
+
"systemctl", "--user", "restart", _systemd_unit(),
|
|
586
|
+
],
|
|
587
|
+
stdin=subprocess.DEVNULL,
|
|
588
|
+
stdout=subprocess.DEVNULL,
|
|
589
|
+
stderr=subprocess.PIPE,
|
|
590
|
+
timeout=10,
|
|
591
|
+
check=True,
|
|
592
|
+
)
|
|
593
|
+
restart_scheduled = True
|
|
594
|
+
yield _sse("log", "restart scheduled via systemd-run (3s)")
|
|
595
|
+
except Exception as exc:
|
|
596
|
+
yield _sse("log", f"systemd-run failed, falling back: {exc}")
|
|
597
|
+
|
|
598
|
+
if not restart_scheduled:
|
|
599
|
+
try:
|
|
600
|
+
subprocess.Popen(
|
|
601
|
+
["systemctl", "--user", "restart", _systemd_unit()],
|
|
602
|
+
stdin=subprocess.DEVNULL,
|
|
603
|
+
stdout=subprocess.DEVNULL,
|
|
604
|
+
stderr=subprocess.DEVNULL,
|
|
605
|
+
start_new_session=True,
|
|
606
|
+
)
|
|
607
|
+
except Exception as exc:
|
|
608
|
+
yield _sse("error", {"message": f"failed to spawn systemctl: {exc}"})
|
|
609
|
+
|
|
610
|
+
return EventSourceResponse(gen())
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
async def _clean_stale_metadata(repo: Path) -> AsyncGenerator[dict, None]:
|
|
614
|
+
"""Remove stale egg-info (source tree) and orphan dist-info (site-packages)
|
|
615
|
+
that can shadow the fresh install and confuse importlib.metadata.
|
|
616
|
+
"""
|
|
617
|
+
for egg_name in ("unit3dprep.egg-info", "itatorrents.egg-info"):
|
|
618
|
+
egg_info = repo / egg_name
|
|
619
|
+
if egg_info.exists():
|
|
620
|
+
yield _sse("log", f"removing stale egg-info: {egg_info}")
|
|
621
|
+
shutil.rmtree(egg_info, ignore_errors=True)
|
|
622
|
+
|
|
623
|
+
try:
|
|
624
|
+
import site
|
|
625
|
+
roots: list[Path] = []
|
|
626
|
+
for p in [site.getusersitepackages(), *site.getsitepackages()]:
|
|
627
|
+
rp = Path(p)
|
|
628
|
+
if rp.exists() and rp not in roots:
|
|
629
|
+
roots.append(rp)
|
|
630
|
+
for root in roots:
|
|
631
|
+
for pkg in ("unit3dprep", "itatorrents"):
|
|
632
|
+
for d in root.glob(f"{pkg}-*.dist-info"):
|
|
633
|
+
yield _sse("log", f"removing dist-info: {d}")
|
|
634
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
635
|
+
for pth in root.glob(f"__editable__.{pkg}-*.pth"):
|
|
636
|
+
yield _sse("log", f"removing editable pth: {pth}")
|
|
637
|
+
try:
|
|
638
|
+
pth.unlink()
|
|
639
|
+
except Exception:
|
|
640
|
+
pass
|
|
641
|
+
except Exception as exc:
|
|
642
|
+
yield _sse("log", f"cleanup warning: {exc}")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
async def _update_app_from_git() -> AsyncGenerator[dict, None]:
|
|
646
|
+
before = _current_app_version()
|
|
647
|
+
repo = _repo_root()
|
|
648
|
+
assert repo is not None
|
|
649
|
+
yield _sse("log", f"install mode: git checkout at {repo}")
|
|
650
|
+
|
|
651
|
+
code, branch = await _git(["rev-parse", "--abbrev-ref", "HEAD"], repo)
|
|
652
|
+
if code != 0:
|
|
653
|
+
yield _sse("error", {"message": f"git failed: {branch}"})
|
|
654
|
+
yield _sse("done", {"ok": False})
|
|
655
|
+
return
|
|
656
|
+
if branch != "main":
|
|
657
|
+
yield _sse("error", {"message": f"refuse to update: on branch '{branch}', expected 'main'"})
|
|
658
|
+
yield _sse("done", {"ok": False})
|
|
659
|
+
return
|
|
660
|
+
|
|
661
|
+
code, dirty = await _git(["status", "--porcelain"], repo)
|
|
662
|
+
if code != 0:
|
|
663
|
+
yield _sse("error", {"message": f"git status failed: {dirty}"})
|
|
664
|
+
yield _sse("done", {"ok": False})
|
|
665
|
+
return
|
|
666
|
+
if dirty:
|
|
667
|
+
yield _sse("error", {"message": "working tree has uncommitted changes — refuse to update"})
|
|
668
|
+
yield _sse("done", {"ok": False})
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
# Fetch + pull first, THEN clean metadata so pip install creates fresh
|
|
672
|
+
# dist-info without any leftovers shadowing it.
|
|
673
|
+
for argv in (
|
|
674
|
+
["git", "fetch", "origin", "main"],
|
|
675
|
+
["git", "pull", "--ff-only", "origin", "main"],
|
|
676
|
+
):
|
|
677
|
+
async for ev in _stream_subprocess(argv, cwd=repo):
|
|
678
|
+
if ev["event"] == "exit":
|
|
679
|
+
if json.loads(ev["data"])["code"] != 0:
|
|
680
|
+
yield _sse("error", {"message": f"{argv[0]} failed"})
|
|
681
|
+
yield _sse("done", {"ok": False})
|
|
682
|
+
return
|
|
683
|
+
break
|
|
684
|
+
yield ev
|
|
685
|
+
|
|
686
|
+
# Uninstall in a loop (pip doesn't remove orphan dist-info reliably).
|
|
687
|
+
# Target both the new name and the legacy one for mid-rename upgrades.
|
|
688
|
+
for pkg_name in ("unit3dprep", "itatorrents"):
|
|
689
|
+
for _ in range(3):
|
|
690
|
+
done_uninstall = False
|
|
691
|
+
async for ev in _stream_subprocess(
|
|
692
|
+
[sys.executable, "-m", "pip", "uninstall", "-y", pkg_name], cwd=repo
|
|
693
|
+
):
|
|
694
|
+
if ev["event"] == "exit":
|
|
695
|
+
done_uninstall = True
|
|
696
|
+
break
|
|
697
|
+
yield ev
|
|
698
|
+
if done_uninstall:
|
|
699
|
+
break
|
|
700
|
+
|
|
701
|
+
async for ev in _clean_stale_metadata(repo):
|
|
702
|
+
yield ev
|
|
703
|
+
|
|
704
|
+
async for ev in _stream_subprocess(
|
|
705
|
+
[sys.executable, "-m", "pip", "install", "-e", "."], cwd=repo
|
|
706
|
+
):
|
|
707
|
+
if ev["event"] == "exit":
|
|
708
|
+
if json.loads(ev["data"])["code"] != 0:
|
|
709
|
+
yield _sse("error", {"message": "pip install failed"})
|
|
710
|
+
yield _sse("done", {"ok": False})
|
|
711
|
+
return
|
|
712
|
+
break
|
|
713
|
+
yield ev
|
|
714
|
+
|
|
715
|
+
after = _current_app_version()
|
|
716
|
+
# Invalidate /version/info cache so post-reload poll re-computes against
|
|
717
|
+
# the freshly installed version — otherwise the old process (if still
|
|
718
|
+
# answering during the restart race) returns stale {newer: true}.
|
|
719
|
+
_cache["data"] = None
|
|
720
|
+
_cache["at"] = 0.0
|
|
721
|
+
yield _sse("log", f"restarting systemd unit {_systemd_unit()}…")
|
|
722
|
+
yield _sse("done", {"ok": True, "target": "app", "from": before, "to": after, "mode": "git"})
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
async def _update_app_from_pip() -> AsyncGenerator[dict, None]:
|
|
726
|
+
before = _current_app_version()
|
|
727
|
+
yield _sse("log", "install mode: pip — will upgrade from PyPI")
|
|
728
|
+
|
|
729
|
+
info = await _get_info(force=True)
|
|
730
|
+
latest = (info.get("app") or {}).get("latest")
|
|
731
|
+
if not latest:
|
|
732
|
+
yield _sse("error", {"message": "cannot determine latest release (network issue or no GitHub release yet)"})
|
|
733
|
+
yield _sse("done", {"ok": False})
|
|
734
|
+
return
|
|
735
|
+
|
|
736
|
+
# Published on PyPI as `unit3dprep`; pin to the version advertised by the
|
|
737
|
+
# GitHub release so we install exactly what's expected (CI publishes the
|
|
738
|
+
# wheel to PyPI on the same tag).
|
|
739
|
+
async for ev in _stream_subprocess(
|
|
740
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", f"unit3dprep=={latest}"]
|
|
741
|
+
):
|
|
742
|
+
if ev["event"] == "exit":
|
|
743
|
+
if json.loads(ev["data"])["code"] != 0:
|
|
744
|
+
yield _sse("error", {"message": "pip install failed (PyPI may not have this version yet — retry shortly)"})
|
|
745
|
+
yield _sse("done", {"ok": False})
|
|
746
|
+
return
|
|
747
|
+
break
|
|
748
|
+
yield ev
|
|
749
|
+
|
|
750
|
+
after = _current_app_version()
|
|
751
|
+
_cache["data"] = None
|
|
752
|
+
_cache["at"] = 0.0
|
|
753
|
+
yield _sse("log", f"restarting systemd unit {_systemd_unit()}…")
|
|
754
|
+
yield _sse("done", {"ok": True, "target": "app", "from": before, "to": after, "mode": "pip"})
|