ymai-codeflow 0.0.0-stage → 0.3.30
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.
- package/bin/cli.js +40 -0
- package/package.json +17 -4
- package/runtime/__init__.py +0 -0
- package/runtime/chain.py +162 -0
- package/runtime/config.py +94 -0
- package/runtime/conversation.py +220 -0
- package/runtime/daemon.py +557 -0
- package/runtime/frames.py +90 -0
- package/runtime/logging_util.py +34 -0
- package/runtime/packager.py +146 -0
- package/runtime/powercontext_client.py +131 -0
- package/runtime/providers/__init__.py +0 -0
- package/runtime/providers/base.py +81 -0
- package/runtime/providers/claude_code.py +226 -0
- package/runtime/providers/mock.py +42 -0
- package/runtime/providers/text_cli.py +106 -0
- package/runtime/store.py +126 -0
- package/runtime/wsclient.py +174 -0
- package/README.md +0 -3
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""文本型 CLI provider:Gemini / Codex / Cursor CLI。
|
|
2
|
+
|
|
3
|
+
与 claude 的 stream-json 协议不同,这三家 CLI 以纯文本 stdout 返回结果:
|
|
4
|
+
gemini gemini -p "<prompt>"
|
|
5
|
+
codex codex exec "<prompt>"
|
|
6
|
+
cursor cursor-agent -p "<prompt>"
|
|
7
|
+
stdout 全文作为该步骤摘要;exit 0 即成功。凭据走各家 CLI 的环境变量/本机配置
|
|
8
|
+
(GEMINI_API_KEY / OPENAI_API_KEY 等),不经 runtime 转手;HOME 保持调用方环境,
|
|
9
|
+
允许读本机已有的 CLI 登录态。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .base import RunContext, RunResult, RuntimeEvent, SdlcStep
|
|
21
|
+
|
|
22
|
+
KIND_DEFAULT_CMD = {"gemini": "gemini", "codex": "codex", "cursor": "cursor-agent"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TextCliProvider:
|
|
26
|
+
name = "text-cli"
|
|
27
|
+
|
|
28
|
+
def __init__(self, kind: str, cli_binary: str, timeout_sec: int = 1800):
|
|
29
|
+
if kind not in KIND_DEFAULT_CMD:
|
|
30
|
+
raise ValueError(f"unknown text-cli kind: {kind}")
|
|
31
|
+
self.kind = kind
|
|
32
|
+
self.name = kind
|
|
33
|
+
self.cli_binary = cli_binary
|
|
34
|
+
self.timeout_sec = timeout_sec
|
|
35
|
+
|
|
36
|
+
def _argv(self, prompt: str) -> list[str]:
|
|
37
|
+
if self.kind in ("gemini", "cursor"):
|
|
38
|
+
return [self.cli_binary, "-p", prompt]
|
|
39
|
+
return [self.cli_binary, "exec", prompt] # codex
|
|
40
|
+
|
|
41
|
+
def prepare(self, capsule, workdir: Path) -> None:
|
|
42
|
+
(workdir / "deliverables").mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
def _prompt(self, step: SdlcStep, capsule) -> str:
|
|
45
|
+
parts = [
|
|
46
|
+
f"# 工单\n{capsule.workitem_md}",
|
|
47
|
+
f"# 当前步骤({step.step_id},顺序 {step.step_order}):{step.name}",
|
|
48
|
+
f"目标:{step.goal}",
|
|
49
|
+
]
|
|
50
|
+
if step.deliverables:
|
|
51
|
+
parts.append("要求的产物:" + ", ".join(step.deliverables))
|
|
52
|
+
if step.done_gate:
|
|
53
|
+
parts.append(f"完成门:{step.done_gate}")
|
|
54
|
+
parts.append("完成后把产物写入 deliverables/ 目录。")
|
|
55
|
+
return "\n\n".join(parts)
|
|
56
|
+
|
|
57
|
+
def run(self, step: SdlcStep, ctx: RunContext) -> RunResult:
|
|
58
|
+
started = RuntimeEvent("step.started", step.step_order, step.step_id, step.name)
|
|
59
|
+
if ctx.emit_event:
|
|
60
|
+
ctx.emit_event(started)
|
|
61
|
+
|
|
62
|
+
argv = self._argv(self._prompt(step, ctx.capsule))
|
|
63
|
+
env = dict(os.environ)
|
|
64
|
+
# start_new_session:超时 killpg 不连坐 daemon(同 claude provider,DEF-009)
|
|
65
|
+
proc = subprocess.Popen(argv, cwd=str(ctx.workdir.root), env=env,
|
|
66
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
67
|
+
start_new_session=True)
|
|
68
|
+
|
|
69
|
+
def _kill_tree():
|
|
70
|
+
time.sleep(self.timeout_sec)
|
|
71
|
+
if proc.poll() is None:
|
|
72
|
+
try:
|
|
73
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
74
|
+
except (ProcessLookupError, PermissionError):
|
|
75
|
+
proc.kill()
|
|
76
|
+
|
|
77
|
+
watcher = threading.Thread(target=_kill_tree, daemon=True)
|
|
78
|
+
watcher.start()
|
|
79
|
+
timed_out = False
|
|
80
|
+
try:
|
|
81
|
+
out, err = proc.communicate(timeout=self.timeout_sec + 5)
|
|
82
|
+
except subprocess.TimeoutExpired:
|
|
83
|
+
timed_out = True
|
|
84
|
+
out, err = proc.communicate()
|
|
85
|
+
watcher.join(timeout=1)
|
|
86
|
+
|
|
87
|
+
text = (out or b"").decode("utf-8", "replace").strip()
|
|
88
|
+
if timed_out:
|
|
89
|
+
state, summary, error = "timed_out", "", f"{self.kind} timeout after {self.timeout_sec}s"
|
|
90
|
+
elif proc.returncode == 0:
|
|
91
|
+
state, summary, error = "completed", text or f"step {step.step_id} completed", ""
|
|
92
|
+
else:
|
|
93
|
+
state = "failed"
|
|
94
|
+
summary = f"{self.kind} exited with {proc.returncode}"
|
|
95
|
+
error = (err or b"").decode("utf-8", "replace")[-2000:] or summary
|
|
96
|
+
|
|
97
|
+
artifacts = sorted((ctx.workdir.root / "deliverables").glob("*"))
|
|
98
|
+
completed = RuntimeEvent("step.completed", step.step_order, step.step_id, step.name,
|
|
99
|
+
message=state)
|
|
100
|
+
if ctx.emit_event:
|
|
101
|
+
ctx.emit_event(completed)
|
|
102
|
+
return RunResult(state=state, summary=summary[:2000], error=error,
|
|
103
|
+
events=[started, completed], artifacts=artifacts, session_ref=None)
|
|
104
|
+
|
|
105
|
+
def cleanup(self, run_record: dict) -> None:
|
|
106
|
+
pass
|
package/runtime/store.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""SQLite-backed local state (runs/events/outbox/terminal)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
_SCHEMA = """
|
|
11
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
12
|
+
dispatch_id INTEGER PRIMARY KEY,
|
|
13
|
+
tenant_id TEXT NOT NULL, workitem_id TEXT NOT NULL, attempt INTEGER NOT NULL,
|
|
14
|
+
state TEXT NOT NULL, capsule_dir TEXT, provider TEXT NOT NULL,
|
|
15
|
+
session_ref TEXT, error TEXT,
|
|
16
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
dispatch_id INTEGER NOT NULL, ts TEXT NOT NULL, type TEXT NOT NULL,
|
|
21
|
+
step_order INTEGER, step_id TEXT, step_name TEXT, payload TEXT,
|
|
22
|
+
FOREIGN KEY (dispatch_id) REFERENCES runs(dispatch_id)
|
|
23
|
+
);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_events_dispatch ON events(dispatch_id, id);
|
|
25
|
+
CREATE TABLE IF NOT EXISTS outbox (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
dispatch_id INTEGER, frame_type TEXT NOT NULL, payload TEXT NOT NULL,
|
|
28
|
+
state TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL, sent_at TEXT
|
|
29
|
+
);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_state ON outbox(state, id);
|
|
31
|
+
CREATE TABLE IF NOT EXISTS terminal (
|
|
32
|
+
dispatch_id INTEGER PRIMARY KEY, success INTEGER NOT NULL, finished_at TEXT NOT NULL
|
|
33
|
+
);
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
ACTIVE_STATES = ("received", "preparing", "running")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _now() -> str:
|
|
40
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StateStore:
|
|
44
|
+
def __init__(self, path: str):
|
|
45
|
+
self.path = path
|
|
46
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
self.conn = sqlite3.connect(path, check_same_thread=False)
|
|
49
|
+
self.conn.row_factory = sqlite3.Row
|
|
50
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
51
|
+
self.conn.executescript(_SCHEMA)
|
|
52
|
+
self.conn.commit()
|
|
53
|
+
|
|
54
|
+
# ---- runs ----
|
|
55
|
+
def create_run(self, dispatch: dict, provider: str) -> bool:
|
|
56
|
+
"""Insert run row; returns False if dispatch already tracked."""
|
|
57
|
+
try:
|
|
58
|
+
with self._lock, self.conn:
|
|
59
|
+
self.conn.execute(
|
|
60
|
+
"INSERT INTO runs (dispatch_id, tenant_id, workitem_id, attempt, state, provider, created_at, updated_at)"
|
|
61
|
+
" VALUES (?,?,?,?,?,?,?,?)",
|
|
62
|
+
(int(dispatch["dispatchId"]), str(dispatch.get("tenantId")), str(dispatch.get("workitemId")),
|
|
63
|
+
int(dispatch.get("attempt", 1)), "received", provider, _now(), _now()))
|
|
64
|
+
return True
|
|
65
|
+
except sqlite3.IntegrityError:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
def run_state(self, dispatch_id: int) -> str | None:
|
|
69
|
+
row = self.conn.execute("SELECT state FROM runs WHERE dispatch_id=?", (dispatch_id,)).fetchone()
|
|
70
|
+
return row[0] if row else None
|
|
71
|
+
|
|
72
|
+
def update_run(self, dispatch_id: int, **fields) -> None:
|
|
73
|
+
if not fields:
|
|
74
|
+
return
|
|
75
|
+
sets = ", ".join(f"{k}=?" for k in fields)
|
|
76
|
+
vals = list(fields.values()) + [_now(), dispatch_id]
|
|
77
|
+
with self._lock, self.conn:
|
|
78
|
+
self.conn.execute(f"UPDATE runs SET {sets}, updated_at=? WHERE dispatch_id=?", vals)
|
|
79
|
+
|
|
80
|
+
def set_session(self, dispatch_id: int, session_ref: str) -> None:
|
|
81
|
+
self.update_run(dispatch_id, session_ref=session_ref)
|
|
82
|
+
|
|
83
|
+
def get_session(self, dispatch_id: int) -> str | None:
|
|
84
|
+
row = self.conn.execute("SELECT session_ref FROM runs WHERE dispatch_id=?", (dispatch_id,)).fetchone()
|
|
85
|
+
return row[0] if row else None
|
|
86
|
+
|
|
87
|
+
def active_runs(self) -> list[sqlite3.Row]:
|
|
88
|
+
q = ",".join("?" * len(ACTIVE_STATES))
|
|
89
|
+
return self.conn.execute(f"SELECT * FROM runs WHERE state IN ({q})", ACTIVE_STATES).fetchall()
|
|
90
|
+
|
|
91
|
+
# ---- events ----
|
|
92
|
+
def add_event(self, dispatch_id: int, type_: str, *, step_order=None, step_id=None,
|
|
93
|
+
step_name=None, payload: dict | None = None) -> None:
|
|
94
|
+
with self._lock, self.conn:
|
|
95
|
+
self.conn.execute(
|
|
96
|
+
"INSERT INTO events (dispatch_id, ts, type, step_order, step_id, step_name, payload) VALUES (?,?,?,?,?,?,?)",
|
|
97
|
+
(dispatch_id, _now(), type_, step_order, step_id, step_name,
|
|
98
|
+
json.dumps(payload, ensure_ascii=False) if payload else None))
|
|
99
|
+
|
|
100
|
+
# ---- outbox ----
|
|
101
|
+
def enqueue_frame(self, frame_type: str, payload: dict, dispatch_id: int | None = None) -> int:
|
|
102
|
+
with self._lock, self.conn:
|
|
103
|
+
cur = self.conn.execute(
|
|
104
|
+
"INSERT INTO outbox (dispatch_id, frame_type, payload, created_at) VALUES (?,?,?,?)",
|
|
105
|
+
(dispatch_id, frame_type, json.dumps(payload, ensure_ascii=False), _now()))
|
|
106
|
+
return cur.lastrowid # type: ignore[return-value]
|
|
107
|
+
|
|
108
|
+
def pending_frames(self) -> list[sqlite3.Row]:
|
|
109
|
+
return self.conn.execute(
|
|
110
|
+
"SELECT id, dispatch_id, frame_type, payload FROM outbox WHERE state='pending' ORDER BY id").fetchall()
|
|
111
|
+
|
|
112
|
+
def mark_sent(self, outbox_id: int) -> None:
|
|
113
|
+
with self._lock, self.conn:
|
|
114
|
+
self.conn.execute("UPDATE outbox SET state='sent', sent_at=? WHERE id=?", (_now(), outbox_id))
|
|
115
|
+
|
|
116
|
+
# ---- terminal / idempotency ----
|
|
117
|
+
def is_terminal(self, dispatch_id: int) -> bool:
|
|
118
|
+
row = self.conn.execute("SELECT 1 FROM terminal WHERE dispatch_id=?", (dispatch_id,)).fetchone()
|
|
119
|
+
return row is not None
|
|
120
|
+
|
|
121
|
+
def mark_terminal(self, dispatch_id: int, success: bool) -> None:
|
|
122
|
+
with self._lock, self.conn:
|
|
123
|
+
self.conn.execute("INSERT OR REPLACE INTO terminal (dispatch_id, success, finished_at) VALUES (?,?,?)",
|
|
124
|
+
(dispatch_id, 1 if success else 0, _now()))
|
|
125
|
+
self.conn.execute("UPDATE runs SET state=?, updated_at=? WHERE dispatch_id=?",
|
|
126
|
+
("completed" if success else "failed", _now(), dispatch_id))
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Minimal RFC6455 WebSocket client (text frames), stdlib only.
|
|
2
|
+
|
|
3
|
+
Implements the subset needed by the protocol: client->server masked text frames,
|
|
4
|
+
server->client unmasked text/binary/ping/close, client-initiated close.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import socket
|
|
13
|
+
import ssl
|
|
14
|
+
import struct
|
|
15
|
+
import threading
|
|
16
|
+
|
|
17
|
+
_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WsClosed(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WsIdleTimeout(WsClosed):
|
|
25
|
+
"""Read timeout with no inbound frames; the connection may still be alive."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WsClient:
|
|
30
|
+
def __init__(self, host: str, port: int, path: str, use_tls: bool = False,
|
|
31
|
+
extra_headers: dict | None = None, timeout: float = 60.0):
|
|
32
|
+
self.host, self.port, self.path = host, port, path
|
|
33
|
+
self.use_tls = use_tls
|
|
34
|
+
self.extra_headers = extra_headers or {}
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
self.sock: socket.socket | None = None
|
|
37
|
+
self._send_lock = threading.Lock()
|
|
38
|
+
self._leftover = b""
|
|
39
|
+
|
|
40
|
+
# ---- handshake -------------------------------------------------------
|
|
41
|
+
def connect(self) -> None:
|
|
42
|
+
raw = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
|
43
|
+
if self.use_tls:
|
|
44
|
+
ctx = ssl.create_default_context()
|
|
45
|
+
raw = ctx.wrap_socket(raw, server_hostname=self.host.split(":")[0])
|
|
46
|
+
key = base64.b64encode(os.urandom(16)).decode()
|
|
47
|
+
req = (
|
|
48
|
+
f"GET {self.path} HTTP/1.1\r\n"
|
|
49
|
+
f"Host: {self.host}\r\n"
|
|
50
|
+
"Upgrade: websocket\r\n"
|
|
51
|
+
"Connection: Upgrade\r\n"
|
|
52
|
+
f"Sec-WebSocket-Key: {key}\r\n"
|
|
53
|
+
"Sec-WebSocket-Version: 13\r\n"
|
|
54
|
+
)
|
|
55
|
+
for k, v in self.extra_headers.items():
|
|
56
|
+
req += f"{k}: {v}\r\n"
|
|
57
|
+
req += "\r\n"
|
|
58
|
+
raw.sendall(req.encode())
|
|
59
|
+
raw.settimeout(self.timeout)
|
|
60
|
+
status, headers = self._read_http_response(raw)
|
|
61
|
+
if status != 101:
|
|
62
|
+
raw.close()
|
|
63
|
+
raise WsClosed(f"handshake rejected: HTTP {status}: {headers.get('x-error', headers)}")
|
|
64
|
+
expect = base64.b64encode(hashlib.sha1((key + _GUID).encode()).digest()).decode()
|
|
65
|
+
if headers.get("sec-websocket-accept") != expect:
|
|
66
|
+
raw.close()
|
|
67
|
+
raise WsClosed("bad Sec-WebSocket-Accept")
|
|
68
|
+
self.sock = raw
|
|
69
|
+
|
|
70
|
+
def _read_http_response(self, sock: socket.socket) -> tuple[int, dict]:
|
|
71
|
+
data = b""
|
|
72
|
+
while b"\r\n\r\n" not in data:
|
|
73
|
+
chunk = sock.recv(4096)
|
|
74
|
+
if not chunk:
|
|
75
|
+
raise WsClosed("connection closed during handshake")
|
|
76
|
+
data += chunk
|
|
77
|
+
if len(data) > 65536:
|
|
78
|
+
raise WsClosed("handshake response too large")
|
|
79
|
+
head, _, rest = data.partition(b"\r\n\r\n")
|
|
80
|
+
lines = head.decode("latin-1").split("\r\n")
|
|
81
|
+
status = int(lines[0].split(" ")[1]) if len(lines[0].split(" ")) > 1 else 0
|
|
82
|
+
headers = {}
|
|
83
|
+
for line in lines[1:]:
|
|
84
|
+
if ":" in line:
|
|
85
|
+
k, v = line.split(":", 1)
|
|
86
|
+
headers[k.strip().lower()] = v.strip()
|
|
87
|
+
# stash leftover bytes for frame reader
|
|
88
|
+
self._leftover = rest
|
|
89
|
+
return status, headers
|
|
90
|
+
|
|
91
|
+
# ---- frames ----------------------------------------------------------
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _encode_frame(opcode: int, payload: bytes) -> bytes:
|
|
94
|
+
mask = os.urandom(4)
|
|
95
|
+
header = bytes([0x80 | opcode])
|
|
96
|
+
n = len(payload)
|
|
97
|
+
if n < 126:
|
|
98
|
+
header += bytes([0x80 | n])
|
|
99
|
+
elif n < 65536:
|
|
100
|
+
header += bytes([0x80 | 126]) + struct.pack(">H", n)
|
|
101
|
+
else:
|
|
102
|
+
header += bytes([0x80 | 127]) + struct.pack(">Q", n)
|
|
103
|
+
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
104
|
+
return header + mask + masked
|
|
105
|
+
|
|
106
|
+
def _recv_exact(self, n: int) -> bytes:
|
|
107
|
+
assert self.sock is not None
|
|
108
|
+
buf = bytearray(self._leftover)
|
|
109
|
+
self._leftover = b""
|
|
110
|
+
while len(buf) < n:
|
|
111
|
+
try:
|
|
112
|
+
chunk = self.sock.recv(n - len(buf))
|
|
113
|
+
except socket.timeout as e:
|
|
114
|
+
raise WsIdleTimeout("read idle timeout") from e
|
|
115
|
+
except OSError as e:
|
|
116
|
+
raise WsClosed(f"socket error: {e}") from e
|
|
117
|
+
if not chunk:
|
|
118
|
+
raise WsClosed("connection closed")
|
|
119
|
+
buf.extend(chunk)
|
|
120
|
+
return bytes(buf)
|
|
121
|
+
|
|
122
|
+
def recv_frame(self) -> tuple[int, bytes]:
|
|
123
|
+
"""Returns (opcode, payload). Auto-answers ping; raises WsClosed on close."""
|
|
124
|
+
if self.sock is None:
|
|
125
|
+
raise WsClosed("not connected")
|
|
126
|
+
b1, b2 = self._recv_exact(2)
|
|
127
|
+
opcode = b1 & 0x0F
|
|
128
|
+
masked = b2 & 0x80
|
|
129
|
+
n = b2 & 0x7F
|
|
130
|
+
if n == 126:
|
|
131
|
+
(n,) = struct.unpack(">H", self._recv_exact(2))
|
|
132
|
+
elif n == 127:
|
|
133
|
+
(n,) = struct.unpack(">Q", self._recv_exact(8))
|
|
134
|
+
mask = self._recv_exact(4) if masked else None
|
|
135
|
+
payload = self._recv_exact(n) if n else b""
|
|
136
|
+
if mask:
|
|
137
|
+
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
138
|
+
if opcode == 0x9: # ping -> pong
|
|
139
|
+
self._send_raw(0xA, payload)
|
|
140
|
+
return self.recv_frame()
|
|
141
|
+
if opcode == 0x8:
|
|
142
|
+
raise WsClosed(f"server closed: {payload[:120]!r}")
|
|
143
|
+
if opcode in (0x1, 0x2, 0x0):
|
|
144
|
+
return opcode, payload
|
|
145
|
+
return self.recv_frame() # ignore unknown (e.g. pong)
|
|
146
|
+
|
|
147
|
+
def _send_raw(self, opcode: int, payload: bytes) -> None:
|
|
148
|
+
if self.sock is None:
|
|
149
|
+
raise WsClosed("not connected")
|
|
150
|
+
with self._send_lock:
|
|
151
|
+
self.sock.sendall(self._encode_frame(opcode, payload))
|
|
152
|
+
|
|
153
|
+
def send_text(self, text: str) -> None:
|
|
154
|
+
self._send_raw(0x1, text.encode())
|
|
155
|
+
|
|
156
|
+
def send_json(self, obj: dict) -> None:
|
|
157
|
+
self.send_text(json.dumps(obj, ensure_ascii=False))
|
|
158
|
+
|
|
159
|
+
def recv_json(self) -> dict:
|
|
160
|
+
while True:
|
|
161
|
+
_, payload = self.recv_frame()
|
|
162
|
+
return json.loads(payload.decode())
|
|
163
|
+
|
|
164
|
+
def close(self) -> None:
|
|
165
|
+
if self.sock is not None:
|
|
166
|
+
try:
|
|
167
|
+
self._send_raw(0x8, struct.pack(">H", 1000))
|
|
168
|
+
except OSError:
|
|
169
|
+
pass
|
|
170
|
+
try:
|
|
171
|
+
self.sock.close()
|
|
172
|
+
except OSError:
|
|
173
|
+
pass
|
|
174
|
+
self.sock = None
|
package/README.md
DELETED