r2flow-cloud-client 0.1.0__tar.gz

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,20 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .mypy_cache/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .coverage
9
+ *.egg-info/
10
+ dist/
11
+ node_modules/
12
+ designer-web/dist/
13
+ frontend/dist/
14
+ .env
15
+ flow.json
16
+ flows/
17
+ *.log
18
+ .vscode/
19
+ .idea/
20
+ .DS_Store
@@ -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,28 @@
1
+ # r2flow-cloud-client
2
+
3
+ Thin Python client for the [r2flow-cloud](../README.md) orchestrator REST API.
4
+
5
+ ```bash
6
+ pip install r2flow-cloud-client
7
+ ```
8
+
9
+ ```python
10
+ from r2flow_cloud_client import R2FlowCloud
11
+
12
+ sc = R2FlowCloud("http://localhost:8000", token="r2f_...")
13
+
14
+ process = sc.create_process("hello", files={"main.py": 'print("hi")'})
15
+ agent = sc.list_agents()[0]
16
+ sc.deploy_process(process["id"], agent["id"])
17
+ run = sc.run_process(process["id"], agent["id"])
18
+ result = sc.wait_run(process["id"], run["id"], timeout_s=120)
19
+ print(result["status"])
20
+
21
+ # REFramework-style queues
22
+ sc.create_queue("invoices")
23
+ sc.add_queue_items("invoices", [{"file": "a.pdf"}, {"file": "b.pdf"}])
24
+ item = sc.claim_queue_item(agent["id"], "invoices", run["id"])
25
+ sc.complete_queue_item(agent["id"], item["id"], run["id"], status="success")
26
+ ```
27
+
28
+ Any HTTP client works too — the API is plain REST with Swagger at `/docs`.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "r2flow-cloud-client"
7
+ version = "0.1.0"
8
+ description = "Thin Python client for the r2flow-cloud orchestrator REST API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "2kurosss" }]
13
+ dependencies = ["httpx>=0.27"]
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=8", "pytest-asyncio>=1.0", "ruff>=0.12", "mypy>=1.18"]
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/r2flow_cloud_client"]
20
+
21
+ [tool.pytest.ini_options]
22
+ testpaths = ["tests"]
23
+
24
+ [tool.ruff]
25
+ line-length = 100
26
+ target-version = "py311"
27
+
28
+ [tool.mypy]
29
+ python_version = "3.11"
30
+ strict = true
31
+ # Thin JSON-over-HTTP wrapper: responses are Any by design.
32
+ disable_error_code = ["no-any-return"]
@@ -0,0 +1,5 @@
1
+ """Thin Python client for the r2flow-cloud orchestrator REST API."""
2
+
3
+ from r2flow_cloud_client.client import R2FlowCloud, R2FlowCloudError
4
+
5
+ __all__ = ["R2FlowCloud", "R2FlowCloudError"]
@@ -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,80 @@
1
+ """Client tests against a mocked transport (no live server needed)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from r2flow_cloud_client import R2FlowCloud, R2FlowCloudError
9
+
10
+
11
+ def _mock_app() -> httpx.MockTransport:
12
+ state = {"runs": [], "calls": []}
13
+
14
+ def handler(request: httpx.Request) -> httpx.Response:
15
+ state["calls"].append((request.method, request.url.path))
16
+ path = request.url.path
17
+ auth = request.headers.get("authorization", "")
18
+ if not auth.startswith("Bearer r2f_"):
19
+ return httpx.Response(401, json={"detail": "Not authenticated"})
20
+ if path == "/api/processes" and request.method == "POST":
21
+ return httpx.Response(
22
+ 201, json={"id": "p1", "name": "hello", "entry_point": "main.py"}
23
+ )
24
+ if path == "/api/agents" and request.method == "GET":
25
+ return httpx.Response(200, json=[{"id": "a1", "name": "ag", "url": "u"}])
26
+ if path.endswith("/deploy"):
27
+ return httpx.Response(201, json={"id": "d1"})
28
+ if path.endswith("/run"):
29
+ return httpx.Response(201, json={"id": "r1", "status": "pending"})
30
+ if path.endswith("/runs") and request.method == "GET":
31
+ return httpx.Response(
32
+ 200,
33
+ json=[{"id": "r1", "status": state.get("status", "completed")}],
34
+ )
35
+ if path == "/api/queues/invoices/items" and request.method == "POST":
36
+ return httpx.Response(201, json=[{"id": "q1", "status": "new", "attempts": 0}])
37
+ if path.endswith("/claim") and request.method == "POST":
38
+ return httpx.Response(
39
+ 200,
40
+ json={"item": {"id": "q1", "payload": {"file": "a.pdf"}, "attempts": 0}},
41
+ )
42
+ if request.method == "PATCH":
43
+ return httpx.Response(200, json={"id": "q1", "status": "success", "attempts": 1})
44
+ return httpx.Response(404, json={"detail": "Not Found"})
45
+
46
+ return httpx.MockTransport(handler)
47
+
48
+
49
+ @pytest.fixture()
50
+ def sc() -> R2FlowCloud:
51
+ client = R2FlowCloud("http://test", token="r2f_test")
52
+ client._client = httpx.Client(
53
+ transport=_mock_app(), base_url="http://test", headers={"Authorization": "Bearer r2f_test"}
54
+ )
55
+ return client
56
+
57
+
58
+ def test_full_process_flow(sc: R2FlowCloud) -> None:
59
+ process = sc.create_process("hello", files={"main.py": "print(1)"})
60
+ assert process["id"] == "p1"
61
+ agent = sc.list_agents()[0]
62
+ sc.deploy_process(process["id"], agent["id"])
63
+ run = sc.run_process(process["id"], agent["id"])
64
+ assert run["id"] == "r1"
65
+ finished = sc.wait_run(process["id"], run["id"], timeout_s=2)
66
+ assert finished["status"] == "completed"
67
+
68
+
69
+ def test_queue_flow(sc: R2FlowCloud) -> None:
70
+ items = sc.add_queue_items("invoices", [{"file": "a.pdf"}])
71
+ assert items[0]["id"] == "q1"
72
+ item = sc.claim_queue_item("a1", "invoices", "r1")
73
+ assert item is not None and item["payload"] == {"file": "a.pdf"}
74
+ state = sc.complete_queue_item("a1", item["id"], "r1", status="success")
75
+ assert state["status"] == "success"
76
+
77
+
78
+ def test_error_raises(sc: R2FlowCloud) -> None:
79
+ with pytest.raises(R2FlowCloudError):
80
+ sc.get_process("missing")