rathflow-cli 0.1.0__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.
- rathflow_cli/__init__.py +6 -0
- rathflow_cli/__main__.py +6 -0
- rathflow_cli/cli.py +161 -0
- rathflow_cli/commands/__init__.py +0 -0
- rathflow_cli/commands/_common.py +78 -0
- rathflow_cli/commands/admin.py +83 -0
- rathflow_cli/commands/agent.py +288 -0
- rathflow_cli/commands/api_cmd.py +75 -0
- rathflow_cli/commands/asset.py +449 -0
- rathflow_cli/commands/auth_cmd.py +160 -0
- rathflow_cli/commands/billing.py +71 -0
- rathflow_cli/commands/config_cmd.py +98 -0
- rathflow_cli/commands/memory.py +275 -0
- rathflow_cli/commands/org.py +70 -0
- rathflow_cli/commands/project.py +223 -0
- rathflow_cli/commands/sandbox.py +282 -0
- rathflow_cli/commands/session.py +237 -0
- rathflow_cli/commands/workflow.py +81 -0
- rathflow_cli/config.py +69 -0
- rathflow_cli/endpoints.py +207 -0
- rathflow_cli/errors.py +70 -0
- rathflow_cli/http.py +294 -0
- rathflow_cli/output.py +95 -0
- rathflow_cli/selftest.py +120 -0
- rathflow_cli/state.py +129 -0
- rathflow_cli-0.1.0.dist-info/METADATA +164 -0
- rathflow_cli-0.1.0.dist-info/RECORD +31 -0
- rathflow_cli-0.1.0.dist-info/WHEEL +5 -0
- rathflow_cli-0.1.0.dist-info/entry_points.txt +2 -0
- rathflow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- rathflow_cli-0.1.0.dist-info/top_level.txt +1 -0
rathflow_cli/http.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""HTTP 层:JWT 登录/续期 + 错误信封 + 流式解码。
|
|
2
|
+
|
|
3
|
+
契约依据:
|
|
4
|
+
- contracts/docs/error-envelope.md(错误信封与 HTTP 状态映射)
|
|
5
|
+
- contracts/docs/streaming.md §三(读取端最小实现要求,逐条落实)
|
|
6
|
+
- gateway/internal/gateway/cookiepost.go:「响应体 token 原样保留(SDK/CLI 走 Bearer)」
|
|
7
|
+
→ CLI 只读响应体,**不碰 cookie**;也因此天然豁免 CSRF 门。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from typing import Iterator
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from . import endpoints
|
|
19
|
+
from .errors import ApiError, UsageError, from_response
|
|
20
|
+
|
|
21
|
+
# 普通调用:读超时给足(上游 gateway 默认 10s,orchestrator 30s)
|
|
22
|
+
_TIMEOUT = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
|
|
23
|
+
# 流式:读超时关闭(沙箱命令可能长时间无输出仍存活)
|
|
24
|
+
_STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=10.0)
|
|
25
|
+
|
|
26
|
+
# 到期前多久就主动刷新(秒)
|
|
27
|
+
_REFRESH_MARGIN = 60
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Client:
|
|
31
|
+
"""一次进程内的 API 客户端(令牌变更经 persist 回调落盘)。"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url: str,
|
|
36
|
+
*,
|
|
37
|
+
project: str | None = None,
|
|
38
|
+
access_token: str | None = None,
|
|
39
|
+
refresh_token: str | None = None,
|
|
40
|
+
expires_at: float = 0.0,
|
|
41
|
+
persist=None,
|
|
42
|
+
):
|
|
43
|
+
self.base_url = base_url.rstrip("/")
|
|
44
|
+
self.project = project or None
|
|
45
|
+
self.access_token = access_token or None
|
|
46
|
+
self.refresh_token = refresh_token or None
|
|
47
|
+
self.expires_at = float(expires_at or 0)
|
|
48
|
+
self._persist = persist
|
|
49
|
+
# 客户端级超时取流式那份(read=None:沙箱里跑长命令时中途没输出也该活着);
|
|
50
|
+
# 普通调用在 _open 里逐次传 _TIMEOUT 盖掉它。
|
|
51
|
+
self._http = httpx.Client(follow_redirects=False, timeout=_STREAM_TIMEOUT)
|
|
52
|
+
|
|
53
|
+
def close(self) -> None:
|
|
54
|
+
self._http.close()
|
|
55
|
+
|
|
56
|
+
def __enter__(self) -> "Client":
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def __exit__(self, *exc) -> None:
|
|
60
|
+
self.close()
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------- 令牌
|
|
63
|
+
|
|
64
|
+
def _absorb_tokens(self, payload: dict) -> None:
|
|
65
|
+
"""从 LoginResponse 吸收令牌。protojson 把 int64 编成 JSON 字符串。"""
|
|
66
|
+
access = payload.get("accessToken") or payload.get("access_token")
|
|
67
|
+
refresh = payload.get("refreshToken") or payload.get("refresh_token")
|
|
68
|
+
if access:
|
|
69
|
+
self.access_token = access
|
|
70
|
+
if refresh:
|
|
71
|
+
self.refresh_token = refresh
|
|
72
|
+
ttl = payload.get("expiresInSeconds", payload.get("expires_in_seconds"))
|
|
73
|
+
try:
|
|
74
|
+
seconds = int(ttl) if ttl is not None else 0
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
seconds = 0
|
|
77
|
+
if seconds > 0:
|
|
78
|
+
self.expires_at = time.time() + seconds - _REFRESH_MARGIN
|
|
79
|
+
elif access:
|
|
80
|
+
self.expires_at = time.time() + 15 * 60 - _REFRESH_MARGIN
|
|
81
|
+
if self._persist:
|
|
82
|
+
self._persist(self)
|
|
83
|
+
|
|
84
|
+
def login(self, email: str, password: str) -> dict:
|
|
85
|
+
payload = self._call_raw(
|
|
86
|
+
"POST",
|
|
87
|
+
endpoints.render_path("auth.Login", {}),
|
|
88
|
+
body={"email": email, "password": password},
|
|
89
|
+
allow_retry=False,
|
|
90
|
+
)
|
|
91
|
+
self._absorb_tokens(payload)
|
|
92
|
+
return payload
|
|
93
|
+
|
|
94
|
+
def logout(self) -> None:
|
|
95
|
+
if self.access_token:
|
|
96
|
+
try:
|
|
97
|
+
self._call_raw("POST", endpoints.render_path("auth.Logout", {}), body={})
|
|
98
|
+
except ApiError:
|
|
99
|
+
pass # 幂等:服务端不可达也清本地
|
|
100
|
+
self.access_token = None
|
|
101
|
+
self.refresh_token = None
|
|
102
|
+
self.expires_at = 0.0
|
|
103
|
+
if self._persist:
|
|
104
|
+
self._persist(self)
|
|
105
|
+
|
|
106
|
+
def refresh(self) -> bool:
|
|
107
|
+
"""用 refresh token 换新令牌。成功返回 True。refresh 会轮换 → 必须存回。"""
|
|
108
|
+
if not self.refresh_token:
|
|
109
|
+
return False
|
|
110
|
+
try:
|
|
111
|
+
payload = self._call_raw(
|
|
112
|
+
"POST",
|
|
113
|
+
endpoints.render_path("auth.Refresh", {}),
|
|
114
|
+
body={"refreshToken": self.refresh_token},
|
|
115
|
+
allow_retry=False,
|
|
116
|
+
)
|
|
117
|
+
except ApiError:
|
|
118
|
+
return False
|
|
119
|
+
self._absorb_tokens(payload)
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
def ensure_token(self) -> None:
|
|
123
|
+
"""无令牌 → 报未登录;临期且有 refresh → 先刷新。"""
|
|
124
|
+
if not self.access_token:
|
|
125
|
+
raise UsageError("未登录:先跑 `rathflow auth login`")
|
|
126
|
+
if self.refresh_token and self.expires_at and time.time() >= self.expires_at:
|
|
127
|
+
self.refresh()
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------- 请求
|
|
130
|
+
|
|
131
|
+
def _headers(self) -> dict:
|
|
132
|
+
headers = {
|
|
133
|
+
"Accept": "application/json",
|
|
134
|
+
"User-Agent": "rathflow-cli/0.1.0",
|
|
135
|
+
}
|
|
136
|
+
if self.access_token:
|
|
137
|
+
headers["Authorization"] = f"Bearer {self.access_token}"
|
|
138
|
+
if self.project:
|
|
139
|
+
# 作用域解析:路径 {project_id} 优先,其次本头(gateway/internal/resolve)
|
|
140
|
+
headers["X-RathFlow-Project"] = self.project
|
|
141
|
+
return headers
|
|
142
|
+
|
|
143
|
+
def _send(self, method: str, path: str, *, params=None, body=None, streaming: bool = False):
|
|
144
|
+
"""带令牌发请求;401 → 刷新一次并重放。streaming 时响应未读体(调用方 close)。"""
|
|
145
|
+
self.ensure_token()
|
|
146
|
+
url = self.base_url + path
|
|
147
|
+
headers = self._headers()
|
|
148
|
+
for attempt in (0, 1):
|
|
149
|
+
try:
|
|
150
|
+
if streaming:
|
|
151
|
+
req = self._http.build_request(
|
|
152
|
+
method, url, headers=headers, params=params, json=body
|
|
153
|
+
)
|
|
154
|
+
resp = self._http.send(req, stream=True)
|
|
155
|
+
else:
|
|
156
|
+
resp = self._http.request(
|
|
157
|
+
method, url, headers=headers, params=params, json=body, timeout=_TIMEOUT
|
|
158
|
+
)
|
|
159
|
+
except httpx.HTTPError as exc:
|
|
160
|
+
raise ApiError(0, "UNAVAILABLE", f"无法连接 {self.base_url}:{exc}") from None
|
|
161
|
+
if resp.status_code == 401 and attempt == 0 and self.refresh():
|
|
162
|
+
resp.close()
|
|
163
|
+
continue
|
|
164
|
+
return resp
|
|
165
|
+
return resp # pragma: no cover — 循环必返回
|
|
166
|
+
|
|
167
|
+
def _send_unauthed(self, method: str, path: str, *, params=None, body=None):
|
|
168
|
+
"""免登录端点:不发令牌、不做 401 重放(否则登录/注册会自我递归)。"""
|
|
169
|
+
try:
|
|
170
|
+
return self._http.request(
|
|
171
|
+
method,
|
|
172
|
+
self.base_url + path,
|
|
173
|
+
headers=self._headers(),
|
|
174
|
+
params=params,
|
|
175
|
+
json=body,
|
|
176
|
+
timeout=_TIMEOUT,
|
|
177
|
+
)
|
|
178
|
+
except httpx.HTTPError as exc:
|
|
179
|
+
raise ApiError(0, "UNAVAILABLE", f"无法连接 {self.base_url}:{exc}") from None
|
|
180
|
+
|
|
181
|
+
def _call_raw(
|
|
182
|
+
self, method: str, path: str, *, params=None, body=None, allow_retry: bool = True
|
|
183
|
+
) -> dict:
|
|
184
|
+
"""非流式调用:返回解码后的 JSON(空体 → {})。"""
|
|
185
|
+
if allow_retry:
|
|
186
|
+
resp = self._send(method, path, params=params, body=body)
|
|
187
|
+
else:
|
|
188
|
+
resp = self._send_unauthed(method, path, params=params, body=body)
|
|
189
|
+
try:
|
|
190
|
+
raw = resp.read()
|
|
191
|
+
payload = _decode(raw) if raw else {}
|
|
192
|
+
if resp.status_code >= 400:
|
|
193
|
+
raise from_response(resp.status_code, payload)
|
|
194
|
+
return payload
|
|
195
|
+
finally:
|
|
196
|
+
resp.close()
|
|
197
|
+
|
|
198
|
+
def call(
|
|
199
|
+
self,
|
|
200
|
+
key: str | None = None,
|
|
201
|
+
*,
|
|
202
|
+
method: str | None = None,
|
|
203
|
+
path: str | None = None,
|
|
204
|
+
body: dict | None = None,
|
|
205
|
+
query: dict | None = None,
|
|
206
|
+
path_params: dict | None = None,
|
|
207
|
+
auth: bool = True,
|
|
208
|
+
) -> dict:
|
|
209
|
+
"""按端点 key(或显式 method/path)调用一次,返回响应 JSON。
|
|
210
|
+
|
|
211
|
+
auth=False 用于免登录端点(register / 公开读),不要求也不刷新令牌。
|
|
212
|
+
"""
|
|
213
|
+
method, path = _resolve(key, method, path, path_params)
|
|
214
|
+
return self._call_raw(
|
|
215
|
+
method, path, params=_clean(query), body=body, allow_retry=auth
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def stream(
|
|
219
|
+
self,
|
|
220
|
+
key: str | None = None,
|
|
221
|
+
*,
|
|
222
|
+
method: str | None = None,
|
|
223
|
+
path: str | None = None,
|
|
224
|
+
body: dict | None = None,
|
|
225
|
+
query: dict | None = None,
|
|
226
|
+
path_params: dict | None = None,
|
|
227
|
+
) -> Iterator[dict]:
|
|
228
|
+
"""流式调用:逐帧产出 `result` payload。
|
|
229
|
+
|
|
230
|
+
读取端纪律(streaming.md §三):按行切帧;只认 result/error 两键;
|
|
231
|
+
未知键拒绝该帧;error 帧即流终结并按信封报错。
|
|
232
|
+
"""
|
|
233
|
+
method, path = _resolve(key, method, path, path_params)
|
|
234
|
+
resp = self._send(method, path, params=_clean(query), body=body, streaming=True)
|
|
235
|
+
# 流式只认 GET/POST(其余方法本就不出流);此处不额外校验,交给服务端
|
|
236
|
+
try:
|
|
237
|
+
if resp.status_code >= 400:
|
|
238
|
+
payload = _decode(resp.read())
|
|
239
|
+
raise from_response(resp.status_code, payload)
|
|
240
|
+
for line in resp.iter_lines():
|
|
241
|
+
line = line.strip()
|
|
242
|
+
if not line:
|
|
243
|
+
continue
|
|
244
|
+
try:
|
|
245
|
+
frame = json.loads(line)
|
|
246
|
+
except json.JSONDecodeError:
|
|
247
|
+
raise ApiError(
|
|
248
|
+
resp.status_code, "UNKNOWN", "流中断:收到非 JSON 帧"
|
|
249
|
+
) from None
|
|
250
|
+
if not isinstance(frame, dict):
|
|
251
|
+
raise ApiError(resp.status_code, "UNKNOWN", "流中断:帧不是对象")
|
|
252
|
+
if set(frame) == {"result"}:
|
|
253
|
+
yield frame["result"]
|
|
254
|
+
elif set(frame) == {"error"}:
|
|
255
|
+
raise from_response(resp.status_code, frame)
|
|
256
|
+
else:
|
|
257
|
+
raise ApiError(
|
|
258
|
+
resp.status_code,
|
|
259
|
+
"UNKNOWN",
|
|
260
|
+
f"流帧含未知键 {sorted(frame)}(前后端契约漂移,拒绝该帧)",
|
|
261
|
+
)
|
|
262
|
+
finally:
|
|
263
|
+
resp.close()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _resolve(key, method, path, path_params) -> tuple[str, str]:
|
|
267
|
+
if key:
|
|
268
|
+
params = dict(path_params or {})
|
|
269
|
+
return endpoints.method_of(key), endpoints.render_path(key, params)
|
|
270
|
+
if not (method and path):
|
|
271
|
+
raise UsageError("需要端点 key,或显式 --method/--path")
|
|
272
|
+
return method.upper(), path
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _clean(query: dict | None) -> dict | None:
|
|
276
|
+
"""去掉 None 值;int64 类查询参数转字符串(protojson 惯例)。"""
|
|
277
|
+
if not query:
|
|
278
|
+
return None
|
|
279
|
+
out = {}
|
|
280
|
+
for k, v in query.items():
|
|
281
|
+
if v is None:
|
|
282
|
+
continue
|
|
283
|
+
out[k] = str(v).lower() if isinstance(v, bool) else v
|
|
284
|
+
return out or None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _decode(raw: bytes) -> dict:
|
|
288
|
+
if not raw:
|
|
289
|
+
return {}
|
|
290
|
+
try:
|
|
291
|
+
data = json.loads(raw)
|
|
292
|
+
except json.JSONDecodeError:
|
|
293
|
+
return {}
|
|
294
|
+
return data if isinstance(data, dict) else {"data": data}
|
rathflow_cli/output.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""输出渲染:默认人类可读,`--json` 原样透传(脚本消费的唯一稳定形态)。
|
|
2
|
+
|
|
3
|
+
`--json` **不重新编码**:protojson 已把 int64 编成字符串、Timestamp 编成 RFC3339、
|
|
4
|
+
bytes 编成 base64;重新编码会失真,故直接 json.dumps 透传。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
_MAX_CELL = 48
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get(obj, dotted: str):
|
|
16
|
+
cur = obj
|
|
17
|
+
for part in dotted.split("."):
|
|
18
|
+
if isinstance(cur, dict):
|
|
19
|
+
cur = cur.get(part)
|
|
20
|
+
else:
|
|
21
|
+
return None
|
|
22
|
+
if cur is None:
|
|
23
|
+
return None
|
|
24
|
+
return cur
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cell(value) -> str:
|
|
28
|
+
if value is None:
|
|
29
|
+
return ""
|
|
30
|
+
if isinstance(value, bool):
|
|
31
|
+
return "true" if value else "false"
|
|
32
|
+
if isinstance(value, (dict, list)):
|
|
33
|
+
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
34
|
+
else:
|
|
35
|
+
text = str(value)
|
|
36
|
+
text = text.replace("\n", " ")
|
|
37
|
+
if len(text) > _MAX_CELL:
|
|
38
|
+
text = text[: _MAX_CELL - 1] + "…"
|
|
39
|
+
return text
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def emit_json(payload) -> None:
|
|
43
|
+
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
|
|
44
|
+
sys.stdout.write("\n")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def emit_object(obj: dict, *, json_out: bool) -> None:
|
|
48
|
+
if json_out:
|
|
49
|
+
emit_json(obj)
|
|
50
|
+
return
|
|
51
|
+
if not isinstance(obj, dict):
|
|
52
|
+
print(obj)
|
|
53
|
+
return
|
|
54
|
+
width = max((len(k) for k in obj), default=0)
|
|
55
|
+
for key, value in obj.items():
|
|
56
|
+
if value is None or value == "" or value == [] or value == {}:
|
|
57
|
+
continue
|
|
58
|
+
print(f"{key.ljust(width)} {_cell(value)}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def emit_table(rows, columns, *, json_out: bool) -> None:
|
|
62
|
+
"""rows = 对象列表;columns = [(表头, 点分路径)]。"""
|
|
63
|
+
if json_out:
|
|
64
|
+
emit_json(rows)
|
|
65
|
+
return
|
|
66
|
+
rows = list(rows or [])
|
|
67
|
+
if not rows:
|
|
68
|
+
print("(空)")
|
|
69
|
+
return
|
|
70
|
+
headers = [h for h, _ in columns]
|
|
71
|
+
body = [[_cell(_get(r, path)) for _, path in columns] for r in rows]
|
|
72
|
+
widths = [
|
|
73
|
+
max(len(headers[i]), max((len(row[i]) for row in body), default=0))
|
|
74
|
+
for i in range(len(headers))
|
|
75
|
+
]
|
|
76
|
+
print(" ".join(headers[i].ljust(widths[i]) for i in range(len(headers))))
|
|
77
|
+
for row in body:
|
|
78
|
+
print(" ".join(row[i].ljust(widths[i]) for i in range(len(headers))))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def emit_items(payload, *, items_key: str, columns, json_out: bool) -> None:
|
|
82
|
+
"""分页响应:取 items_key 列表渲染;JSON 模式透传整包(保留 nextPageToken)。"""
|
|
83
|
+
if json_out:
|
|
84
|
+
emit_json(payload)
|
|
85
|
+
return
|
|
86
|
+
emit_table((payload or {}).get(items_key) or [], columns, json_out=False)
|
|
87
|
+
token = (payload or {}).get("nextPageToken")
|
|
88
|
+
if token:
|
|
89
|
+
print(f"\n(还有下一页:--page-token {token})")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def write_bytes(data: bytes, *, to_stderr: bool = False) -> None:
|
|
93
|
+
stream = sys.stderr.buffer if to_stderr else sys.stdout.buffer
|
|
94
|
+
stream.write(data)
|
|
95
|
+
stream.flush()
|
rathflow_cli/selftest.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""轻量自检(不联服务端):``python -m rathflow_cli.selftest``
|
|
2
|
+
|
|
3
|
+
查两件事,都是「装得上但会打错 URL」的那类毛病:
|
|
4
|
+
|
|
5
|
+
1. **命令实现里不写 URL 字面量** —— 所有请求必须走 ``endpoints.py`` 的端点 key;
|
|
6
|
+
2. **端点表与生成物一致** —— 生成物 ``endpoints.ts`` 由上游 proto 生成,本包的
|
|
7
|
+
``endpoints.py`` 手工同步;两者漂移就会打错 URL。生成物在本仓库中不存在
|
|
8
|
+
(它是上游 monorepo 的产物),此时这一项跳过,可用环境变量
|
|
9
|
+
``RATHFLOW_ENDPOINTS_TS`` 指向它来启用比对。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import endpoints
|
|
20
|
+
|
|
21
|
+
_COMMANDS = Path(__file__).resolve().parent / "commands"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _find_generated() -> Path | None:
|
|
25
|
+
"""定位上游生成的 endpoints.ts;找不到就返回 None(跳过比对)。"""
|
|
26
|
+
override = os.environ.get("RATHFLOW_ENDPOINTS_TS")
|
|
27
|
+
if override:
|
|
28
|
+
candidate = Path(override)
|
|
29
|
+
return candidate if candidate.exists() else None
|
|
30
|
+
for base in Path(__file__).resolve().parents:
|
|
31
|
+
candidate = base / "web" / "lib" / "api" / "generated" / "endpoints.ts"
|
|
32
|
+
if candidate.exists():
|
|
33
|
+
return candidate
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_GENERATED = _find_generated()
|
|
38
|
+
|
|
39
|
+
_ROW = re.compile(
|
|
40
|
+
r'\{ key: "(?P<key>[^"]+)", method: "(?P<method>\w+)", path: "(?P<path>[^"]+)",'
|
|
41
|
+
r" pathParams: \[(?P<pp>[^\]]*)\], multiParams: \[(?P<mp>[^\]]*)\],"
|
|
42
|
+
r".*?stream: (?P<stream>true|false),"
|
|
43
|
+
)
|
|
44
|
+
_URL_LITERAL = re.compile(r'"(?:/api/|/admin/api/)')
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main() -> int:
|
|
48
|
+
generated_problems, compared = _check_generated()
|
|
49
|
+
problems = generated_problems + _check_no_url_literals()
|
|
50
|
+
if problems:
|
|
51
|
+
print(f"自检未通过({len(problems)} 项):", file=sys.stderr)
|
|
52
|
+
for line in problems:
|
|
53
|
+
print(f" - {line}", file=sys.stderr)
|
|
54
|
+
return 1
|
|
55
|
+
total = len(endpoints.ENDPOINTS)
|
|
56
|
+
if compared:
|
|
57
|
+
print(f"自检通过:端点表与生成物一致({total} 个端点)")
|
|
58
|
+
else:
|
|
59
|
+
print(f"自检通过:命令层无 URL 字面量({total} 个端点;未找到生成物,跳过端点表比对)")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_generated() -> tuple[list[str], bool]:
|
|
64
|
+
"""比对端点表与上游生成物;返回 (问题列表, 是否真的比对了)。"""
|
|
65
|
+
if _GENERATED is None or not _GENERATED.exists():
|
|
66
|
+
return [], False
|
|
67
|
+
rows = {}
|
|
68
|
+
for line in _GENERATED.read_text(encoding="utf-8").splitlines():
|
|
69
|
+
m = _ROW.search(line)
|
|
70
|
+
if not m:
|
|
71
|
+
continue
|
|
72
|
+
rows[m["key"]] = {
|
|
73
|
+
"method": m["method"],
|
|
74
|
+
"path": m["path"],
|
|
75
|
+
"multi": _names(m["mp"]),
|
|
76
|
+
"stream": m["stream"] == "true",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
problems = []
|
|
80
|
+
missing = sorted(rows.keys() - endpoints.ENDPOINTS.keys())
|
|
81
|
+
extra = sorted(endpoints.ENDPOINTS.keys() - rows.keys())
|
|
82
|
+
if missing:
|
|
83
|
+
problems.append(f"endpoints.py 缺 {len(missing)} 个端点:{missing[:5]}…")
|
|
84
|
+
if extra:
|
|
85
|
+
problems.append(f"endpoints.py 多出 {len(extra)} 个端点:{extra[:5]}…")
|
|
86
|
+
|
|
87
|
+
for key in sorted(rows.keys() & endpoints.ENDPOINTS.keys()):
|
|
88
|
+
want, got = rows[key], endpoints.ENDPOINTS[key]
|
|
89
|
+
if (got[1], got[0]) != (want["path"], want["method"]):
|
|
90
|
+
problems.append(f"{key}: 路径/方法不一致 {got} vs {want['path']}/{want['method']}")
|
|
91
|
+
have_multi = set(endpoints.MULTI_PARAMS.get(key, frozenset()))
|
|
92
|
+
if have_multi != want["multi"]:
|
|
93
|
+
problems.append(f"{key}: multiParams 不一致 {sorted(have_multi)} vs {sorted(want['multi'])}")
|
|
94
|
+
if endpoints.is_streaming(key) != want["stream"]:
|
|
95
|
+
problems.append(f"{key}: stream 标记不一致 {endpoints.is_streaming(key)} vs {want['stream']}")
|
|
96
|
+
|
|
97
|
+
# 模板参数与 MULTI_PARAMS 声明必须对得上(多段参数一定出现在模板里)
|
|
98
|
+
for key in endpoints.ENDPOINTS:
|
|
99
|
+
declared = set(endpoints.MULTI_PARAMS.get(key, frozenset()))
|
|
100
|
+
unknown = declared - set(endpoints.path_params(key))
|
|
101
|
+
if unknown:
|
|
102
|
+
problems.append(f"{key}: MULTI_PARAMS 声明了模板里没有的参数 {sorted(unknown)}")
|
|
103
|
+
return problems, True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _check_no_url_literals() -> list[str]:
|
|
107
|
+
problems = []
|
|
108
|
+
for path in sorted(_COMMANDS.glob("*.py")):
|
|
109
|
+
for no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
110
|
+
if _URL_LITERAL.search(line) and not line.lstrip().startswith("#"):
|
|
111
|
+
problems.append(f"{path.name}:{no} 出现 URL 字面量(该走端点 key)")
|
|
112
|
+
return problems
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _names(raw: str) -> set[str]:
|
|
116
|
+
return set(re.findall(r'"([^"]+)"', raw))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
raise SystemExit(main())
|
rathflow_cli/state.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""进程内状态:合并「flag > 环境变量 > profile > 默认」并承载惰性 Client。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from . import config
|
|
6
|
+
from .http import Client
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class State:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
*,
|
|
13
|
+
profile_name: str | None = None,
|
|
14
|
+
base_url: str | None = None,
|
|
15
|
+
project: str | None = None,
|
|
16
|
+
json_out: bool = False,
|
|
17
|
+
quiet: bool = False,
|
|
18
|
+
):
|
|
19
|
+
self.data = config.load()
|
|
20
|
+
self.json_out = json_out
|
|
21
|
+
self.quiet = quiet
|
|
22
|
+
self._client: Client | None = None
|
|
23
|
+
self._flags = {"base_url": base_url, "project": project}
|
|
24
|
+
self.apply(profile_name=profile_name)
|
|
25
|
+
self.token_override = _env(config.ENV_TOKEN)
|
|
26
|
+
|
|
27
|
+
def apply(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
profile_name: str | None = None,
|
|
31
|
+
base_url: str | None = None,
|
|
32
|
+
project: str | None = None,
|
|
33
|
+
json_out: bool = False,
|
|
34
|
+
quiet: bool = False,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""合并一级选项。根回调先跑;子应用回调再跑一次(命令后写的选项)。
|
|
37
|
+
|
|
38
|
+
子应用层的显式选项优先;「优先级」仍是 flag > env > profile > 默认。
|
|
39
|
+
"""
|
|
40
|
+
if profile_name:
|
|
41
|
+
self.data["current"] = profile_name
|
|
42
|
+
if base_url:
|
|
43
|
+
self._flags["base_url"] = base_url
|
|
44
|
+
if project:
|
|
45
|
+
self._flags["project"] = project
|
|
46
|
+
self.json_out = self.json_out or json_out
|
|
47
|
+
self.quiet = self.quiet or quiet
|
|
48
|
+
|
|
49
|
+
prof = self.profile
|
|
50
|
+
self.base_url = (
|
|
51
|
+
self._flags.get("base_url")
|
|
52
|
+
or _env(config.ENV_BASE_URL)
|
|
53
|
+
or prof.get("base_url")
|
|
54
|
+
or config.DEFAULT_BASE_URL
|
|
55
|
+
)
|
|
56
|
+
self.project = self._flags.get("project") or _env(config.ENV_PROJECT) or prof.get("project")
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def profile_name(self) -> str:
|
|
60
|
+
return self.data["current"]
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def profile(self) -> dict:
|
|
64
|
+
return config.profile(self.data, self.profile_name)
|
|
65
|
+
|
|
66
|
+
def client(self) -> Client:
|
|
67
|
+
if self._client is None:
|
|
68
|
+
prof = self.profile
|
|
69
|
+
if self.token_override:
|
|
70
|
+
# CI 场景:直接用环境变量令牌,不落盘、不刷新
|
|
71
|
+
self._client = Client(
|
|
72
|
+
self.base_url, project=self.project, access_token=self.token_override
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
self._client = Client(
|
|
76
|
+
self.base_url,
|
|
77
|
+
project=self.project,
|
|
78
|
+
access_token=prof.get("access_token"),
|
|
79
|
+
refresh_token=prof.get("refresh_token"),
|
|
80
|
+
expires_at=prof.get("expires_at", 0),
|
|
81
|
+
persist=self._persist,
|
|
82
|
+
)
|
|
83
|
+
return self._client
|
|
84
|
+
|
|
85
|
+
def _persist(self, client: Client) -> None:
|
|
86
|
+
"""令牌变更落盘(refresh 会轮换 refresh token,必须存回)。"""
|
|
87
|
+
prof = self.profile
|
|
88
|
+
prof["access_token"] = client.access_token
|
|
89
|
+
prof["refresh_token"] = client.refresh_token
|
|
90
|
+
prof["expires_at"] = client.expires_at
|
|
91
|
+
config.save(self.data)
|
|
92
|
+
|
|
93
|
+
def remember_identity(self, login_payload: dict) -> None:
|
|
94
|
+
"""记下登录响应里的身份信息(whoami 的数据源)。"""
|
|
95
|
+
prof = self.profile
|
|
96
|
+
prof["user"] = {
|
|
97
|
+
"user_id": login_payload.get("userId"),
|
|
98
|
+
"org_id": login_payload.get("orgId"),
|
|
99
|
+
"is_platform_admin": bool(login_payload.get("isPlatformAdmin")),
|
|
100
|
+
}
|
|
101
|
+
if login_payload.get("defaultProjectId") and not prof.get("project"):
|
|
102
|
+
prof["project"] = login_payload["defaultProjectId"]
|
|
103
|
+
self.data["profiles"][self.profile_name] = prof
|
|
104
|
+
config.save(self.data)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _env(name: str) -> str | None:
|
|
108
|
+
import os
|
|
109
|
+
|
|
110
|
+
return os.environ.get(name) or None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# 当前进程的 State:根回调在派发子命令前写入,命令实现用 current() 取。
|
|
114
|
+
# 用模块级单例而非 click 的 ctx 链:typer 0.16 起自带的 click 已不再是独立包,
|
|
115
|
+
# 取「当前 Context」需要私有路径,跨版本不可靠。CLI 是单次进程,单例足够。
|
|
116
|
+
_CURRENT: State | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def install(state: State) -> None:
|
|
120
|
+
"""根回调调用(每个进程一次)。"""
|
|
121
|
+
global _CURRENT
|
|
122
|
+
_CURRENT = state
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def current() -> State:
|
|
126
|
+
"""取当前命令的 State(由根回调注入)。"""
|
|
127
|
+
if _CURRENT is None:
|
|
128
|
+
raise RuntimeError("State 未初始化(内部错误)")
|
|
129
|
+
return _CURRENT
|