- Request (globe) on casting rows and loaded assets whose latest publish is not on this site, and Request Missing for all of them, through the new wtdcmds command wtd_request_revisions. Requested publishes show "requested, waiting for the download". - The version picker requests a version that is not on this site. - A 404 explains that the session is gone or wtdcmds is too old. - Fake server: car (vehicles) with a publish not on this site, wtd_request_revisions and /_test/download; 45 checks. - Tested live: car_paul modeling.blend v001 -> Requested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
"""Client of the Libreflow IPC server (kabaret.ipc) and its wtdcmds commands.
|
|
|
|
The server writes its address and token in ``config.LIBREFLOW_SERVER_FILE``.
|
|
The file is read again on every request: the port changes each time the
|
|
server restarts.
|
|
|
|
``run`` executes a request in a worker thread and hands the result back on
|
|
Blender's main thread, so the UI never waits on the network.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import queue
|
|
import threading
|
|
import traceback
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
import bpy
|
|
|
|
from .. import config
|
|
|
|
# Hints added to the server's message for the statuses the user can act on.
|
|
_STATUS_HINTS = {
|
|
401: "the server refused the token, try again",
|
|
404: "the Libreflow session is gone, or its wtdcmds extension is too old: update it and restart Libreflow",
|
|
504: "no answer within 10 s, the command may still complete in Libreflow",
|
|
}
|
|
# Statuses whose message replaces the server's.
|
|
_STATUS_MESSAGES = {400: "Several Libreflow sessions are connected: pick one in the Scene Manager panel"}
|
|
|
|
|
|
class LibreflowError(Exception):
|
|
"""A request failed. ``status`` is the HTTP status, None when the server was not reached."""
|
|
|
|
def __init__(self, message, status=None):
|
|
super().__init__(message)
|
|
self.status = status
|
|
|
|
|
|
class NotRunning(LibreflowError):
|
|
"""No IPC server: Libreflow is not running."""
|
|
|
|
|
|
def server_info():
|
|
path = os.path.expanduser(config.LIBREFLOW_SERVER_FILE)
|
|
try:
|
|
with open(path, encoding="utf-8") as fh:
|
|
return json.load(fh)
|
|
except FileNotFoundError:
|
|
raise NotRunning("Libreflow is not running") from None
|
|
except (OSError, ValueError) as exc:
|
|
raise LibreflowError(f"Cannot read {path}: {exc}") from None
|
|
|
|
|
|
def _request(method, path, body=None, params=None):
|
|
info = server_info()
|
|
url = f"http://{info['host']}:{info['port']}{path}"
|
|
if params:
|
|
url += "?" + urllib.parse.urlencode(params)
|
|
request = urllib.request.Request(
|
|
url,
|
|
data=None if body is None else json.dumps(body).encode(),
|
|
headers={"Authorization": f"Bearer {info['token']}", "Content-Type": "application/json"},
|
|
method=method,
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=config.LIBREFLOW_TIMEOUT) as response:
|
|
return json.loads(response.read() or b"null")
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
detail = json.loads(exc.read() or b"{}").get("detail", exc.reason)
|
|
except ValueError:
|
|
detail = exc.reason
|
|
hint = _STATUS_HINTS.get(exc.code)
|
|
message = _STATUS_MESSAGES.get(exc.code) or (f"{detail} ({hint})" if hint else str(detail))
|
|
raise LibreflowError(message, exc.code) from None
|
|
except (urllib.error.URLError, OSError) as exc:
|
|
raise NotRunning(f"Libreflow is not reachable ({getattr(exc, 'reason', exc)})") from None
|
|
|
|
|
|
def sessions():
|
|
"""{session_uid: {session_name, cluster_name, is_gui, current_oid}} of the connected sessions."""
|
|
return _request("GET", "/sessions") or {}
|
|
|
|
|
|
def call(cmd_name, session_uid=None, **kwargs):
|
|
"""Run a Flow command and return its result."""
|
|
params = {"session_uid": session_uid} if session_uid else None
|
|
return _request("POST", f"/cmds/Flow/{cmd_name}", {"kwargs": kwargs}, params)["result"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Background jobs
|
|
# --------------------------------------------------------------------------- #
|
|
_results = queue.Queue()
|
|
_jobs = {} # job id -> label, for the jobs not finished yet
|
|
_generation = 0 # bumped on file load: results of older jobs are dropped
|
|
_next_id = 0
|
|
|
|
|
|
def run(label, func, on_success, on_error):
|
|
"""Run ``func()`` in a thread, then ``on_success(result)`` or ``on_error(exception)`` on the main thread."""
|
|
global _next_id
|
|
_next_id += 1
|
|
job_id, generation = _next_id, _generation
|
|
_jobs[job_id] = label
|
|
|
|
def worker():
|
|
try:
|
|
_results.put((job_id, generation, on_success, func()))
|
|
except Exception as exc: # noqa: BLE001 - handed to on_error on the main thread
|
|
_results.put((job_id, generation, on_error, exc))
|
|
|
|
threading.Thread(target=worker, daemon=True).start()
|
|
if not bpy.app.timers.is_registered(_poll):
|
|
bpy.app.timers.register(_poll, first_interval=0.05)
|
|
|
|
|
|
def busy_label():
|
|
"""Label of a running job, or ""."""
|
|
return next(iter(_jobs.values()), "")
|
|
|
|
|
|
def reset():
|
|
"""Forget the running jobs (their results will be dropped), e.g. when another file is loaded."""
|
|
global _generation
|
|
_generation += 1
|
|
_jobs.clear()
|
|
|
|
|
|
def _poll():
|
|
while True:
|
|
try:
|
|
job_id, generation, callback, value = _results.get_nowait()
|
|
except queue.Empty:
|
|
break
|
|
if generation != _generation:
|
|
continue
|
|
_jobs.pop(job_id, None)
|
|
try:
|
|
callback(value)
|
|
except Exception: # noqa: BLE001 - keep the poller alive
|
|
traceback.print_exc()
|
|
for window in bpy.context.window_manager.windows:
|
|
for area in window.screen.areas:
|
|
if area.type == "VIEW_3D":
|
|
area.tag_redraw()
|
|
return 0.1 if _jobs else None
|