r2flow-cloud-client 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.
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Thin Python client for the r2flow-cloud orchestrator REST API.
|
|
2
|
+
|
|
3
|
+
Works with any r2flow-cloud >= 0.1 install:
|
|
4
|
+
|
|
5
|
+
from r2flow_cloud_client import R2FlowCloud
|
|
6
|
+
|
|
7
|
+
sc = R2FlowCloud("http://localhost:8000", token="r2f_...")
|
|
8
|
+
process = sc.create_process("hello", files={"main.py": 'print("hi")'})
|
|
9
|
+
agent = sc.list_agents()[0]
|
|
10
|
+
sc.deploy_process(process["id"], agent["id"])
|
|
11
|
+
run = sc.run_process(process["id"], agent["id"])
|
|
12
|
+
sc.wait_run(process["id"], run["id"]) # blocks until terminal
|
|
13
|
+
|
|
14
|
+
Any HTTP client works too — the API is plain REST; this wrapper only saves
|
|
15
|
+
boilerplate. ``httpx`` (sync) is the only dependency.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
__all__ = ["R2FlowCloud", "R2FlowCloudError"]
|
|
26
|
+
|
|
27
|
+
_TERMINAL_STATUSES = {"completed", "failed", "stopped", "system_failed"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class R2FlowCloudError(RuntimeError):
|
|
31
|
+
"""Non-2xx response from the orchestrator."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
34
|
+
super().__init__(f"API error {status_code}: {detail}")
|
|
35
|
+
self.status_code = status_code
|
|
36
|
+
self.detail = detail
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class R2FlowCloud:
|
|
40
|
+
"""Sync client for the r2flow-cloud REST API (v1, paths ``/api/...``)."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
base_url: str = "http://localhost:8000",
|
|
45
|
+
token: str | None = None,
|
|
46
|
+
timeout: float = 30.0,
|
|
47
|
+
) -> None:
|
|
48
|
+
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
49
|
+
self._client = httpx.Client(base_url=base_url.rstrip("/"), headers=headers, timeout=timeout)
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------- internals
|
|
52
|
+
|
|
53
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
54
|
+
res = self._client.request(method, path, **kwargs)
|
|
55
|
+
if res.status_code >= 400:
|
|
56
|
+
detail = res.text
|
|
57
|
+
try:
|
|
58
|
+
detail = res.json().get("detail", res.text)
|
|
59
|
+
except ValueError:
|
|
60
|
+
pass
|
|
61
|
+
raise R2FlowCloudError(res.status_code, detail)
|
|
62
|
+
if res.status_code == 204 or not res.content:
|
|
63
|
+
return None
|
|
64
|
+
return res.json()
|
|
65
|
+
|
|
66
|
+
def _get(self, path: str, **kwargs: Any) -> Any:
|
|
67
|
+
return self._request("GET", path, **kwargs)
|
|
68
|
+
|
|
69
|
+
def _post(self, path: str, **kwargs: Any) -> Any:
|
|
70
|
+
return self._request("POST", path, **kwargs)
|
|
71
|
+
|
|
72
|
+
def _put(self, path: str, **kwargs: Any) -> Any:
|
|
73
|
+
return self._request("PUT", path, **kwargs)
|
|
74
|
+
|
|
75
|
+
def _delete(self, path: str) -> None:
|
|
76
|
+
self._request("DELETE", path)
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------- processes
|
|
79
|
+
|
|
80
|
+
def list_processes(self) -> list[dict[str, Any]]:
|
|
81
|
+
return self._get("/api/processes")
|
|
82
|
+
|
|
83
|
+
def create_process(
|
|
84
|
+
self,
|
|
85
|
+
name: str,
|
|
86
|
+
files: dict[str, str],
|
|
87
|
+
entry_point: str = "main.py",
|
|
88
|
+
description: str | None = None,
|
|
89
|
+
requirements: list[str] | None = None,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
payload: dict[str, Any] = {
|
|
92
|
+
"name": name,
|
|
93
|
+
"entry_point": entry_point,
|
|
94
|
+
"files": files,
|
|
95
|
+
"requirements": requirements or [],
|
|
96
|
+
}
|
|
97
|
+
if description:
|
|
98
|
+
payload["description"] = description
|
|
99
|
+
return self._post("/api/processes", json=payload)
|
|
100
|
+
|
|
101
|
+
def get_process(self, process_id: str) -> dict[str, Any]:
|
|
102
|
+
return self._get(f"/api/processes/{process_id}")
|
|
103
|
+
|
|
104
|
+
def update_process(
|
|
105
|
+
self,
|
|
106
|
+
process_id: str,
|
|
107
|
+
files: dict[str, str] | None = None,
|
|
108
|
+
entry_point: str | None = None,
|
|
109
|
+
requirements: list[str] | None = None,
|
|
110
|
+
description: str | None = None,
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
payload: dict[str, Any] = {}
|
|
113
|
+
if files is not None:
|
|
114
|
+
payload["files"] = files
|
|
115
|
+
if entry_point is not None:
|
|
116
|
+
payload["entry_point"] = entry_point
|
|
117
|
+
if requirements is not None:
|
|
118
|
+
payload["requirements"] = requirements
|
|
119
|
+
if description is not None:
|
|
120
|
+
payload["description"] = description
|
|
121
|
+
return self._put(f"/api/processes/{process_id}", json=payload)
|
|
122
|
+
|
|
123
|
+
def delete_process(self, process_id: str) -> None:
|
|
124
|
+
self._delete(f"/api/processes/{process_id}")
|
|
125
|
+
|
|
126
|
+
def deploy_process(self, process_id: str, agent_id: str) -> dict[str, Any]:
|
|
127
|
+
return self._post(
|
|
128
|
+
f"/api/processes/{process_id}/deploy", json={"agent_id": agent_id}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def run_process(self, process_id: str, agent_id: str) -> dict[str, Any]:
|
|
132
|
+
return self._post(
|
|
133
|
+
f"/api/processes/{process_id}/run", json={"agent_id": agent_id}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def stop_run(self, process_id: str, run_id: str) -> dict[str, Any]:
|
|
137
|
+
return self._post(f"/api/processes/{process_id}/stop", json={"run_id": run_id})
|
|
138
|
+
|
|
139
|
+
def list_runs(self, process_id: str, limit: int = 50) -> list[dict[str, Any]]:
|
|
140
|
+
return self._get(f"/api/processes/{process_id}/runs", params={"limit": limit})
|
|
141
|
+
|
|
142
|
+
def get_logs(self, process_id: str, limit: int = 200) -> list[dict[str, Any]]:
|
|
143
|
+
return self._get(f"/api/processes/{process_id}/logs", params={"limit": limit})
|
|
144
|
+
|
|
145
|
+
def wait_run(
|
|
146
|
+
self, process_id: str, run_id: str, timeout_s: float = 300.0, poll_s: float = 1.0
|
|
147
|
+
) -> dict[str, Any]:
|
|
148
|
+
"""Poll the run until it reaches a terminal status (or timeout)."""
|
|
149
|
+
deadline = time.monotonic() + timeout_s
|
|
150
|
+
while time.monotonic() < deadline:
|
|
151
|
+
run = self._get(f"/api/processes/{process_id}/runs", params={"limit": 50})
|
|
152
|
+
for r in run:
|
|
153
|
+
if r["id"] == run_id:
|
|
154
|
+
if str(r.get("status", "")).lower() in _TERMINAL_STATUSES:
|
|
155
|
+
return r
|
|
156
|
+
break
|
|
157
|
+
time.sleep(poll_s)
|
|
158
|
+
raise R2FlowCloudError(408, f"run {run_id} did not finish in {timeout_s}s")
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------- agents
|
|
161
|
+
|
|
162
|
+
def list_agents(self) -> list[dict[str, Any]]:
|
|
163
|
+
return self._get("/api/agents")
|
|
164
|
+
|
|
165
|
+
def register_agent(
|
|
166
|
+
self, name: str, url: str, join_token: str | None = None
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
headers = {"Authorization": f"Bearer {join_token}"} if join_token else None
|
|
169
|
+
return self._post(
|
|
170
|
+
"/api/agents",
|
|
171
|
+
json={"name": name, "url": url},
|
|
172
|
+
headers=headers,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def delete_agent(self, agent_id: str) -> None:
|
|
176
|
+
self._delete(f"/api/agents/{agent_id}")
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------- queues
|
|
179
|
+
|
|
180
|
+
def list_queues(self) -> list[dict[str, Any]]:
|
|
181
|
+
return self._get("/api/queues")
|
|
182
|
+
|
|
183
|
+
def create_queue(self, name: str, max_attempts: int = 3) -> dict[str, Any]:
|
|
184
|
+
return self._post("/api/queues", json={"name": name, "max_attempts": max_attempts})
|
|
185
|
+
|
|
186
|
+
def delete_queue(self, name: str) -> None:
|
|
187
|
+
self._delete(f"/api/queues/{name}")
|
|
188
|
+
|
|
189
|
+
def add_queue_items(
|
|
190
|
+
self, name: str, payloads: list[dict[str, Any]], idempotency_keys: list[str] | None = None
|
|
191
|
+
) -> list[dict[str, Any]]:
|
|
192
|
+
items = [
|
|
193
|
+
{"payload": p, **({"idempotency_key": k} if k else {})}
|
|
194
|
+
for p, k in zip(payloads, idempotency_keys or [], strict=False)
|
|
195
|
+
]
|
|
196
|
+
return self._post(f"/api/queues/{name}/items", json={"items": items})
|
|
197
|
+
|
|
198
|
+
def claim_queue_item(
|
|
199
|
+
self, agent_id: str, name: str, run_id: str, lease_seconds: int = 300
|
|
200
|
+
) -> dict[str, Any] | None:
|
|
201
|
+
"""Claim one item for a run; returns ``None`` when the queue is empty."""
|
|
202
|
+
res = self._post(
|
|
203
|
+
f"/api/agents/{agent_id}/queues/{name}/claim",
|
|
204
|
+
json={"run_id": run_id, "lease_seconds": lease_seconds},
|
|
205
|
+
)
|
|
206
|
+
return res.get("item")
|
|
207
|
+
|
|
208
|
+
def complete_queue_item(
|
|
209
|
+
self,
|
|
210
|
+
agent_id: str,
|
|
211
|
+
item_id: str,
|
|
212
|
+
run_id: str,
|
|
213
|
+
status: str = "success",
|
|
214
|
+
error: str | None = None,
|
|
215
|
+
result: dict[str, Any] | None = None,
|
|
216
|
+
) -> dict[str, Any]:
|
|
217
|
+
"""Complete an item; ``system_failed`` requeues it while attempts remain."""
|
|
218
|
+
payload: dict[str, Any] = {"run_id": run_id, "status": status}
|
|
219
|
+
if error:
|
|
220
|
+
payload["error"] = error
|
|
221
|
+
if result is not None:
|
|
222
|
+
payload["result"] = result
|
|
223
|
+
return self._request(
|
|
224
|
+
"PATCH",
|
|
225
|
+
f"/api/agents/{agent_id}/queue-items/{item_id}",
|
|
226
|
+
json=payload,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# -------------------------------------------------------------- triggers
|
|
230
|
+
|
|
231
|
+
def list_triggers(self) -> list[dict[str, Any]]:
|
|
232
|
+
return self._get("/api/triggers")
|
|
233
|
+
|
|
234
|
+
def create_trigger(
|
|
235
|
+
self,
|
|
236
|
+
name: str,
|
|
237
|
+
agent_id: str,
|
|
238
|
+
process_id: str,
|
|
239
|
+
run_at: str,
|
|
240
|
+
repeat: str = "once",
|
|
241
|
+
timezone: str = "Europe/Moscow",
|
|
242
|
+
) -> dict[str, Any]:
|
|
243
|
+
return self._post(
|
|
244
|
+
"/api/triggers",
|
|
245
|
+
json={
|
|
246
|
+
"name": name,
|
|
247
|
+
"agent_id": agent_id,
|
|
248
|
+
"process_id": process_id,
|
|
249
|
+
"run_at": run_at,
|
|
250
|
+
"repeat": repeat,
|
|
251
|
+
"timezone": timezone,
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def delete_trigger(self, trigger_id: str) -> None:
|
|
256
|
+
self._delete(f"/api/triggers/{trigger_id}")
|
|
257
|
+
|
|
258
|
+
# ---------------------------------------------------------------- tokens
|
|
259
|
+
|
|
260
|
+
def list_tokens(self) -> list[dict[str, Any]]:
|
|
261
|
+
return self._get("/api/tokens")
|
|
262
|
+
|
|
263
|
+
def create_token(self, name: str) -> dict[str, Any]:
|
|
264
|
+
return self._post("/api/tokens", json={"name": name})
|
|
265
|
+
|
|
266
|
+
def revoke_token(self, token_id: str) -> None:
|
|
267
|
+
self._delete(f"/api/tokens/{token_id}")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: r2flow-cloud-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Thin Python client for the r2flow-cloud orchestrator REST API
|
|
5
|
+
Author: 2kurosss
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: mypy>=1.18; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest-asyncio>=1.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
13
|
+
Requires-Dist: ruff>=0.12; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# r2flow-cloud-client
|
|
17
|
+
|
|
18
|
+
Thin Python client for the [r2flow-cloud](../README.md) orchestrator REST API.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install r2flow-cloud-client
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from r2flow_cloud_client import R2FlowCloud
|
|
26
|
+
|
|
27
|
+
sc = R2FlowCloud("http://localhost:8000", token="r2f_...")
|
|
28
|
+
|
|
29
|
+
process = sc.create_process("hello", files={"main.py": 'print("hi")'})
|
|
30
|
+
agent = sc.list_agents()[0]
|
|
31
|
+
sc.deploy_process(process["id"], agent["id"])
|
|
32
|
+
run = sc.run_process(process["id"], agent["id"])
|
|
33
|
+
result = sc.wait_run(process["id"], run["id"], timeout_s=120)
|
|
34
|
+
print(result["status"])
|
|
35
|
+
|
|
36
|
+
# REFramework-style queues
|
|
37
|
+
sc.create_queue("invoices")
|
|
38
|
+
sc.add_queue_items("invoices", [{"file": "a.pdf"}, {"file": "b.pdf"}])
|
|
39
|
+
item = sc.claim_queue_item(agent["id"], "invoices", run["id"])
|
|
40
|
+
sc.complete_queue_item(agent["id"], item["id"], run["id"], status="success")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Any HTTP client works too — the API is plain REST with Swagger at `/docs`.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
r2flow_cloud_client/__init__.py,sha256=7_nkPeOC6rvNVcO36cLJWuo6au0lTefZTnuu7HzhSek,186
|
|
2
|
+
r2flow_cloud_client/client.py,sha256=w-ekLSts1MhvWWbP-WRO9sML0PjRqPKJdo_07KjfyDE,9539
|
|
3
|
+
r2flow_cloud_client-0.1.0.dist-info/METADATA,sha256=zF_2wdovpczzq0EXPA8Flw_kt8G0PFtFZ8tI0U695xw,1355
|
|
4
|
+
r2flow_cloud_client-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
r2flow_cloud_client-0.1.0.dist-info/RECORD,,
|