314 lines
14 KiB
Python
314 lines
14 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""
|
||
|
|
WL Pipeline Client — Unity Pipeline 서버(HTTP + JSON)를 직접 호출하는 라이브러리 · CLI · 브라우저 제어판 프록시.
|
||
|
|
|
||
|
|
Unity CLI(`unity command …`)와 같은 백엔드(com.unity.pipeline · 127.0.0.1:78xx 에디터 / 79xx 런타임)를
|
||
|
|
표준 라이브러리만으로 호출한다. "사용자 지정 인터페이스"를 만들 때 이 파일을 import 하거나,
|
||
|
|
`serve` 모드로 control_panel.html 을 띄운다 (프록시가 토큰을 주입하므로 브라우저 쪽 CORS·토큰 처리가 없다).
|
||
|
|
|
||
|
|
CLI 예시 (Unity 프로젝트 루트에서):
|
||
|
|
python Tools/Pipeline/pipeline_client.py status # GET /api/status
|
||
|
|
python Tools/Pipeline/pipeline_client.py commands wl_ # 명령 검색 (compact)
|
||
|
|
python Tools/Pipeline/pipeline_client.py exec editor_status # POST /api/exec {"commandLine": …}
|
||
|
|
python Tools/Pipeline/pipeline_client.py exec "wl_timescale --scale 0.5"
|
||
|
|
python Tools/Pipeline/pipeline_client.py exec --runtime runtime_status # 런타임 서버(.unity-pipeline-runtime-port)
|
||
|
|
python Tools/Pipeline/pipeline_client.py serve # http://127.0.0.1:7700 제어판
|
||
|
|
|
||
|
|
환경 변수:
|
||
|
|
PIPELINE_PROJECT Unity 프로젝트 루트 (기본: 이 파일 기준 ../../)
|
||
|
|
PIPELINE_RUNTIME_DIR 런타임 포트 파일이 있는 폴더 (기본: 프로젝트 루트 = 에디터 Play 모드 · 빌드는 exe 옆)
|
||
|
|
"""
|
||
|
|
import argparse
|
||
|
|
import http.client
|
||
|
|
import http.server
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
import urllib.error
|
||
|
|
import urllib.parse
|
||
|
|
import urllib.request
|
||
|
|
import webbrowser
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
ROOT = Path(os.environ.get("PIPELINE_PROJECT") or Path(__file__).resolve().parents[2])
|
||
|
|
EDITOR_DESCRIPTOR = ROOT / "Library" / "Pipeline" / ".unity-pipeline-port"
|
||
|
|
RUNTIME_DESCRIPTOR_NAME = ".unity-pipeline-runtime-port"
|
||
|
|
PANEL_HTML = Path(__file__).resolve().parent / "control_panel.html"
|
||
|
|
DEFAULT_PANEL_PORT = 7700 # Pipeline 포트 범위(7800~7999) 바깥
|
||
|
|
|
||
|
|
|
||
|
|
class PipelineError(RuntimeError):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def descriptor_path(mode="editor", runtime_dir=None):
|
||
|
|
if mode == "editor":
|
||
|
|
return EDITOR_DESCRIPTOR
|
||
|
|
base = Path(runtime_dir or os.environ.get("PIPELINE_RUNTIME_DIR") or ROOT)
|
||
|
|
return base / RUNTIME_DESCRIPTOR_NAME
|
||
|
|
|
||
|
|
|
||
|
|
def read_descriptor(mode="editor", runtime_dir=None):
|
||
|
|
"""포트 파일(JSON)을 읽는다: port · evalToken · projectPath … 서버가 없으면 PipelineError."""
|
||
|
|
path = descriptor_path(mode, runtime_dir)
|
||
|
|
if not path.exists():
|
||
|
|
if mode == "editor":
|
||
|
|
hint = "에디터가 켜져 있고 Pipeline 서버가 기동됐는지 (unity status)"
|
||
|
|
else:
|
||
|
|
hint = ("Play 모드/개발 빌드가 실행 중이고 Project Settings > Pipeline > Runtime > "
|
||
|
|
"Enable In Builds 가 켜져 있는지")
|
||
|
|
raise PipelineError("%s 포트 파일이 없습니다: %s - %s 확인" % (mode, path, hint))
|
||
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
||
|
|
|
||
|
|
|
||
|
|
class PipelineClient:
|
||
|
|
"""Unity Pipeline HTTP API 클라이언트. refresh() 가 포트 파일을 다시 읽는다 (리로드 후 토큰 교체 대비)."""
|
||
|
|
|
||
|
|
def __init__(self, mode="editor", runtime_dir=None, timeout=60):
|
||
|
|
self.mode = mode
|
||
|
|
self.runtime_dir = runtime_dir
|
||
|
|
self.timeout = timeout
|
||
|
|
self.descriptor = None
|
||
|
|
self.refresh()
|
||
|
|
|
||
|
|
def refresh(self):
|
||
|
|
self.descriptor = read_descriptor(self.mode, self.runtime_dir)
|
||
|
|
self.port = self.descriptor["port"]
|
||
|
|
self.token = self.descriptor["evalToken"]
|
||
|
|
self.base = "http://127.0.0.1:%d" % self.port
|
||
|
|
return self.descriptor
|
||
|
|
|
||
|
|
# -- 저수준 -----------------------------------------------------------------
|
||
|
|
def request_raw(self, method, path, body=None):
|
||
|
|
"""(status_code, bytes, content_type). body 는 dict/list(JSON 직렬화) 또는 bytes."""
|
||
|
|
data = None
|
||
|
|
if body is not None:
|
||
|
|
data = body if isinstance(body, (bytes, bytearray)) else json.dumps(body).encode("utf-8")
|
||
|
|
req = urllib.request.Request(self.base + path, data=data, method=method)
|
||
|
|
req.add_header("Authorization", "Bearer " + self.token)
|
||
|
|
if data is not None:
|
||
|
|
req.add_header("Content-Type", "application/json")
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
||
|
|
return r.status, r.read(), r.headers.get("Content-Type", "application/json")
|
||
|
|
except urllib.error.HTTPError as e:
|
||
|
|
return e.code, e.read(), e.headers.get("Content-Type", "application/json")
|
||
|
|
except (urllib.error.URLError, http.client.HTTPException, ConnectionError, OSError) as e:
|
||
|
|
# 도메인 리로드 중에는 서버가 잠시 내려가 연결 거부/응답 없이 끊김이 정상 — 호출측에서 wait_ready() 후 재시도
|
||
|
|
reason = getattr(e, "reason", None) or e
|
||
|
|
raise PipelineError("%s%s 연결 실패: %s (서버가 내려갔거나 도메인 리로드 중 — wait_ready() 후 재시도)"
|
||
|
|
% (self.base, path, reason))
|
||
|
|
|
||
|
|
def wait_ready(self, timeout_s=180, interval_s=2.0):
|
||
|
|
"""포트 파일을 다시 읽으며 /api/status 가 'ready' 가 될 때까지 대기 (리컴파일·Play 전환 직후 사용)."""
|
||
|
|
import time
|
||
|
|
deadline = time.time() + timeout_s
|
||
|
|
last = None
|
||
|
|
while time.time() < deadline:
|
||
|
|
try:
|
||
|
|
self.refresh()
|
||
|
|
st = self.status()
|
||
|
|
last = st.get("status")
|
||
|
|
if last == "ready":
|
||
|
|
return st
|
||
|
|
except PipelineError as e:
|
||
|
|
last = str(e)
|
||
|
|
time.sleep(interval_s)
|
||
|
|
raise PipelineError("서버가 %ds 안에 ready 가 되지 않음 (마지막 상태: %s)" % (timeout_s, last))
|
||
|
|
|
||
|
|
def request(self, method, path, body=None):
|
||
|
|
status, payload, _ = self.request_raw(method, path, body)
|
||
|
|
try:
|
||
|
|
parsed = json.loads(payload or b"null")
|
||
|
|
except ValueError:
|
||
|
|
parsed = {"success": False, "error": payload.decode("utf-8", "replace")}
|
||
|
|
if isinstance(parsed, dict) and status >= 400:
|
||
|
|
parsed.setdefault("httpStatus", status)
|
||
|
|
return parsed
|
||
|
|
|
||
|
|
# -- 엔드포인트 ---------------------------------------------------------------
|
||
|
|
def status(self):
|
||
|
|
return self.request("GET", "/api/status")
|
||
|
|
|
||
|
|
def commands(self, query=None, detail="compact", tag=None):
|
||
|
|
q = {"detail": detail}
|
||
|
|
if query:
|
||
|
|
q["query"] = query
|
||
|
|
if tag:
|
||
|
|
q["tag"] = tag
|
||
|
|
return self.request("GET", "/api/commands?" + urllib.parse.urlencode(q))
|
||
|
|
|
||
|
|
def exec_line(self, command_line, job=False, verbose=False):
|
||
|
|
"""`unity command …` 와 같은 한 줄 명령을 그대로 보낸다 (서버가 토큰화·바인딩)."""
|
||
|
|
body = {"commandLine": command_line}
|
||
|
|
if job:
|
||
|
|
body["job"] = True
|
||
|
|
if verbose:
|
||
|
|
body["verbose"] = True
|
||
|
|
return self.request("POST", "/api/exec", body)
|
||
|
|
|
||
|
|
def exec(self, command, parameters=None, job=False, verbose=False):
|
||
|
|
"""구조화 호출: command + parameters(dict)."""
|
||
|
|
body = {"command": command, "parameters": parameters or {}}
|
||
|
|
if job:
|
||
|
|
body["job"] = True
|
||
|
|
if verbose:
|
||
|
|
body["verbose"] = True
|
||
|
|
return self.request("POST", "/api/exec", body)
|
||
|
|
|
||
|
|
def eval(self, code, timeout_ms=5000):
|
||
|
|
return self.exec("eval", {"code": code, "timeout": timeout_ms})
|
||
|
|
|
||
|
|
def progress(self):
|
||
|
|
return self.request("GET", "/api/progress")
|
||
|
|
|
||
|
|
def dialog(self):
|
||
|
|
return self.request("GET", "/api/dialog")
|
||
|
|
|
||
|
|
def job(self, job_id):
|
||
|
|
return self.request("GET", "/api/job?" + urllib.parse.urlencode({"id": job_id}))
|
||
|
|
|
||
|
|
def cancel_job(self, job_id):
|
||
|
|
return self.request("POST", "/api/job/cancel", {"id": job_id})
|
||
|
|
|
||
|
|
|
||
|
|
# -- 브라우저 제어판 (serve) -------------------------------------------------------
|
||
|
|
class PanelHandler(http.server.BaseHTTPRequestHandler):
|
||
|
|
"""/ -> control_panel.html · /api/* -> 에디터 서버 프록시 · /runtime/api/* -> 런타임 서버 프록시
|
||
|
|
· /panel/descriptor -> 연결 정보(토큰 제외)."""
|
||
|
|
|
||
|
|
runtime_dir = None
|
||
|
|
|
||
|
|
def log_message(self, fmt, *args): # 요청마다 찍히는 기본 로그 억제 (exec 만 표시)
|
||
|
|
line = args[0] if args else ""
|
||
|
|
if "/api/exec" in line:
|
||
|
|
sys.stderr.write("[panel] %s\n" % line)
|
||
|
|
|
||
|
|
def _send(self, code, payload, ctype="application/json; charset=utf-8"):
|
||
|
|
if not isinstance(payload, (bytes, bytearray)):
|
||
|
|
payload = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
|
|
self.send_response(code)
|
||
|
|
self.send_header("Content-Type", ctype)
|
||
|
|
self.send_header("Content-Length", str(len(payload)))
|
||
|
|
self.send_header("Cache-Control", "no-store")
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(payload)
|
||
|
|
|
||
|
|
def _descriptor_info(self, mode):
|
||
|
|
try:
|
||
|
|
d = read_descriptor(mode, self.runtime_dir)
|
||
|
|
except PipelineError as e:
|
||
|
|
return {"available": False, "error": str(e)}
|
||
|
|
info = {k: v for k, v in d.items() if k != "evalToken"}
|
||
|
|
info["available"] = True
|
||
|
|
return info
|
||
|
|
|
||
|
|
def do_GET(self):
|
||
|
|
url = urllib.parse.urlparse(self.path)
|
||
|
|
if url.path in ("/", "/index.html"):
|
||
|
|
if not PANEL_HTML.exists():
|
||
|
|
return self._send(500, {"error": "control_panel.html 없음: %s" % PANEL_HTML})
|
||
|
|
return self._send(200, PANEL_HTML.read_bytes(), "text/html; charset=utf-8")
|
||
|
|
if url.path == "/panel/descriptor":
|
||
|
|
return self._send(200, {"project": str(ROOT),
|
||
|
|
"editor": self._descriptor_info("editor"),
|
||
|
|
"runtime": self._descriptor_info("runtime")})
|
||
|
|
if url.path.startswith("/api/") or url.path.startswith("/runtime/api/"):
|
||
|
|
return self._proxy("GET")
|
||
|
|
return self._send(404, {"error": "not found"})
|
||
|
|
|
||
|
|
def do_POST(self):
|
||
|
|
url = urllib.parse.urlparse(self.path)
|
||
|
|
if url.path.startswith("/api/") or url.path.startswith("/runtime/api/"):
|
||
|
|
return self._proxy("POST")
|
||
|
|
return self._send(404, {"error": "not found"})
|
||
|
|
|
||
|
|
def _proxy(self, method):
|
||
|
|
mode = "runtime" if self.path.startswith("/runtime/") else "editor"
|
||
|
|
target = self.path[len("/runtime"):] if mode == "runtime" else self.path
|
||
|
|
body = None
|
||
|
|
if method == "POST":
|
||
|
|
length = int(self.headers.get("Content-Length") or 0)
|
||
|
|
body = self.rfile.read(length) if length else b"{}"
|
||
|
|
try:
|
||
|
|
client = PipelineClient(mode, self.runtime_dir, timeout=120) # 매 요청 새 토큰
|
||
|
|
status, payload, ctype = client.request_raw(method, target, body)
|
||
|
|
except PipelineError as e:
|
||
|
|
return self._send(503, {"success": False, "error": str(e), "mode": mode})
|
||
|
|
return self._send(status, payload, ctype)
|
||
|
|
|
||
|
|
|
||
|
|
def serve(port=DEFAULT_PANEL_PORT, runtime_dir=None, open_browser=True):
|
||
|
|
PanelHandler.runtime_dir = runtime_dir
|
||
|
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", port), PanelHandler)
|
||
|
|
url = "http://127.0.0.1:%d/" % port
|
||
|
|
print("[panel] WL Pipeline 제어판: %s (프로젝트: %s) Ctrl+C 로 종료" % (url, ROOT))
|
||
|
|
if open_browser:
|
||
|
|
webbrowser.open(url)
|
||
|
|
try:
|
||
|
|
server.serve_forever()
|
||
|
|
except KeyboardInterrupt:
|
||
|
|
print("\n[panel] 종료")
|
||
|
|
|
||
|
|
|
||
|
|
# -- CLI ------------------------------------------------------------------------
|
||
|
|
def _print(obj):
|
||
|
|
print(json.dumps(obj, ensure_ascii=False, indent=2))
|
||
|
|
|
||
|
|
|
||
|
|
def main(argv=None):
|
||
|
|
ap = argparse.ArgumentParser(description="Unity Pipeline HTTP 클라이언트 (WL)")
|
||
|
|
ap.add_argument("--runtime", action="store_true", help="런타임 서버(.unity-pipeline-runtime-port) 대상")
|
||
|
|
ap.add_argument("--runtime-dir", help="런타임 포트 파일 폴더 (기본: 프로젝트 루트)")
|
||
|
|
sub = ap.add_subparsers(dest="cmd")
|
||
|
|
sub.required = True
|
||
|
|
sub.add_parser("status", help="GET /api/status")
|
||
|
|
sub.add_parser("descriptor", help="포트 파일 내용 (토큰 마스킹)")
|
||
|
|
p = sub.add_parser("commands", help="GET /api/commands")
|
||
|
|
p.add_argument("query", nargs="?")
|
||
|
|
p.add_argument("--tag")
|
||
|
|
p.add_argument("--detail", default="compact", choices=["compact", "full"])
|
||
|
|
p = sub.add_parser("exec", help="POST /api/exec (한 줄 명령)")
|
||
|
|
p.add_argument("command_line", nargs="+", help='예: editor_status · "wl_timescale --scale 0.5"')
|
||
|
|
p.add_argument("--job", action="store_true", help="분리 작업으로 제출하고 job id 반환")
|
||
|
|
p.add_argument("--verbose", action="store_true")
|
||
|
|
p = sub.add_parser("eval", help="C# 한 줄 평가 (eval)")
|
||
|
|
p.add_argument("code")
|
||
|
|
p = sub.add_parser("serve", help="브라우저 제어판 + 프록시")
|
||
|
|
p.add_argument("--port", type=int, default=DEFAULT_PANEL_PORT)
|
||
|
|
p.add_argument("--no-browser", action="store_true")
|
||
|
|
args = ap.parse_args(argv)
|
||
|
|
|
||
|
|
mode = "runtime" if args.runtime else "editor"
|
||
|
|
try:
|
||
|
|
if args.cmd == "serve":
|
||
|
|
serve(args.port, args.runtime_dir, not args.no_browser)
|
||
|
|
return 0
|
||
|
|
if args.cmd == "descriptor":
|
||
|
|
d = read_descriptor(mode, args.runtime_dir)
|
||
|
|
d["evalToken"] = d.get("evalToken", "")[:4] + "...(masked)"
|
||
|
|
_print(d)
|
||
|
|
return 0
|
||
|
|
client = PipelineClient(mode, args.runtime_dir)
|
||
|
|
if args.cmd == "status":
|
||
|
|
_print(client.status())
|
||
|
|
elif args.cmd == "commands":
|
||
|
|
_print(client.commands(args.query, args.detail, args.tag))
|
||
|
|
elif args.cmd == "exec":
|
||
|
|
res = client.exec_line(" ".join(args.command_line), job=args.job, verbose=args.verbose)
|
||
|
|
_print(res)
|
||
|
|
return 0 if res.get("success") else 6
|
||
|
|
elif args.cmd == "eval":
|
||
|
|
res = client.eval(args.code)
|
||
|
|
_print(res)
|
||
|
|
return 0 if res.get("success") else 6
|
||
|
|
except PipelineError as e:
|
||
|
|
print("[pipeline] %s" % e, file=sys.stderr)
|
||
|
|
return 7
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main() or 0)
|