Project_WL/Tools/Pipeline/pipeline_client.py

314 lines
14 KiB
Python
Raw Normal View History

#!/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)