rugbe-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,48 @@
1
+ Metadata-Version: 2.3
2
+ Name: rugbe-client
3
+ Version: 0.1.0
4
+ Summary: Typed HTTP + SSE client (sync and async) for rugbe-agent-runtime
5
+ Requires-Dist: httpx>=0.27,<1.0
6
+ Requires-Dist: pydantic>=2.6,<3.0
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+
10
+ # rugbe-client (Python)
11
+
12
+ Typed HTTP + SSE client for rugbe-agent-runtime, sync and async. Backend subset of
13
+ `@bhanux/rugbe-client`: workflows, runs (streaming), sessions, files, skills, models,
14
+ me, node types, health.
15
+
16
+ ```python
17
+ from rugbe_client import RuntimeClient
18
+
19
+ with RuntimeClient(
20
+ "https://runtime.example.com",
21
+ get_auth_headers=lambda: {"Authorization": f"Bearer {key}"},
22
+ on_auth_error=lambda: refresh_key(), # optional: called once on 401
23
+ ) as rt:
24
+ session = rt.sessions.create(subject="user-42")
25
+ for event in rt.runs.stream(session.workflow_id, input={"message": "hi"}, view="chat"):
26
+ if event.type == "text":
27
+ print(event.content, end="")
28
+ ```
29
+
30
+ `AsyncRuntimeClient` has the same surface with `async`/`await` (`async for` on
31
+ `runs.stream`); its credential hooks may be coroutines.
32
+
33
+ - Errors: every non-2xx raises `RuntimeApiError` (`status`, `kind`, `issues`, `detail`).
34
+ `to_draft_conflict(err)` reads a lost-update 409 on `workflows.save_draft`.
35
+ - Only **401** triggers `on_auth_error`, once; a refreshed credential that is refused
36
+ again surfaces. 403 is never retried.
37
+ - Unknown stream event types arrive as `UnknownEvent` instead of raising.
38
+
39
+ ## Development
40
+
41
+ ```bash
42
+ uv sync
43
+ uv run pytest
44
+ uv run ruff check . --fix && uv run ruff format src tests
45
+ ```
46
+
47
+ `tests/test_conformance.py` compares model fields to
48
+ `packages/rugbe-client/openapi.json` (refresh with `bun run generate-types` there).
@@ -0,0 +1,39 @@
1
+ # rugbe-client (Python)
2
+
3
+ Typed HTTP + SSE client for rugbe-agent-runtime, sync and async. Backend subset of
4
+ `@bhanux/rugbe-client`: workflows, runs (streaming), sessions, files, skills, models,
5
+ me, node types, health.
6
+
7
+ ```python
8
+ from rugbe_client import RuntimeClient
9
+
10
+ with RuntimeClient(
11
+ "https://runtime.example.com",
12
+ get_auth_headers=lambda: {"Authorization": f"Bearer {key}"},
13
+ on_auth_error=lambda: refresh_key(), # optional: called once on 401
14
+ ) as rt:
15
+ session = rt.sessions.create(subject="user-42")
16
+ for event in rt.runs.stream(session.workflow_id, input={"message": "hi"}, view="chat"):
17
+ if event.type == "text":
18
+ print(event.content, end="")
19
+ ```
20
+
21
+ `AsyncRuntimeClient` has the same surface with `async`/`await` (`async for` on
22
+ `runs.stream`); its credential hooks may be coroutines.
23
+
24
+ - Errors: every non-2xx raises `RuntimeApiError` (`status`, `kind`, `issues`, `detail`).
25
+ `to_draft_conflict(err)` reads a lost-update 409 on `workflows.save_draft`.
26
+ - Only **401** triggers `on_auth_error`, once; a refreshed credential that is refused
27
+ again surfaces. 403 is never retried.
28
+ - Unknown stream event types arrive as `UnknownEvent` instead of raising.
29
+
30
+ ## Development
31
+
32
+ ```bash
33
+ uv sync
34
+ uv run pytest
35
+ uv run ruff check . --fix && uv run ruff format src tests
36
+ ```
37
+
38
+ `tests/test_conformance.py` compares model fields to
39
+ `packages/rugbe-client/openapi.json` (refresh with `bun run generate-types` there).
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "rugbe-client"
3
+ version = "0.1.0"
4
+ description = "Typed HTTP + SSE client (sync and async) for rugbe-agent-runtime"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "httpx>=0.27,<1.0",
9
+ "pydantic>=2.6,<3.0",
10
+ ]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "pytest>=8.0",
15
+ "pytest-asyncio>=0.24",
16
+ "respx>=0.21",
17
+ "ruff>=0.6",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.8.14,<0.9.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [tool.ruff]
25
+ line-length = 88
26
+
27
+ [tool.pytest.ini_options]
28
+ asyncio_mode = "auto"
@@ -0,0 +1,50 @@
1
+ """Typed HTTP + SSE client (sync and async) for rugbe-agent-runtime."""
2
+
3
+ from .client import AsyncRuntimeClient, RuntimeClient
4
+ from .errors import (
5
+ DraftConflict,
6
+ RuntimeApiError,
7
+ ValidationIssue,
8
+ to_draft_conflict,
9
+ )
10
+ from .events import RunStreamEvent, UnknownEvent, parse_event
11
+ from .models import (
12
+ Me,
13
+ ModelOption,
14
+ RunOut,
15
+ RunStatus,
16
+ RunStepOut,
17
+ RunSummary,
18
+ SessionOut,
19
+ Skill,
20
+ ValidationResult,
21
+ WorkflowFile,
22
+ WorkflowGraph,
23
+ WorkflowOut,
24
+ WorkflowSummary,
25
+ )
26
+
27
+ __all__ = [
28
+ "AsyncRuntimeClient",
29
+ "DraftConflict",
30
+ "Me",
31
+ "ModelOption",
32
+ "RunOut",
33
+ "RunStatus",
34
+ "RunStepOut",
35
+ "RunStreamEvent",
36
+ "RunSummary",
37
+ "RuntimeApiError",
38
+ "RuntimeClient",
39
+ "SessionOut",
40
+ "Skill",
41
+ "UnknownEvent",
42
+ "ValidationIssue",
43
+ "ValidationResult",
44
+ "WorkflowFile",
45
+ "WorkflowGraph",
46
+ "WorkflowOut",
47
+ "WorkflowSummary",
48
+ "parse_event",
49
+ "to_draft_conflict",
50
+ ]
@@ -0,0 +1,408 @@
1
+ """Every endpoint as a :class:`Call` builder, shared by the sync and async clients.
2
+
3
+ Keeping the path/body/parse knowledge here means the two clients are two-line
4
+ wrappers that cannot drift from each other.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import mimetypes
10
+ import os
11
+ from typing import Any, BinaryIO
12
+ from urllib.parse import quote
13
+
14
+ from pydantic import BaseModel, TypeAdapter
15
+
16
+ from ._transport import Call
17
+ from .models import (
18
+ ElicitationReceipt,
19
+ Me,
20
+ ModelOption,
21
+ RunOut,
22
+ RunReceipt,
23
+ RunStepOut,
24
+ RunSummary,
25
+ RuntimeReadiness,
26
+ SessionOut,
27
+ Skill,
28
+ SkillFile,
29
+ SkillUpload,
30
+ ValidationResult,
31
+ WorkflowFile,
32
+ WorkflowGraph,
33
+ WorkflowOut,
34
+ WorkflowSummary,
35
+ )
36
+
37
+ FileInput = str | os.PathLike[str] | bytes | BinaryIO
38
+
39
+
40
+ def _model(cls: type[BaseModel]) -> Any:
41
+ return cls.model_validate
42
+
43
+
44
+ def _list(cls: type[BaseModel]) -> Any:
45
+ adapter = TypeAdapter(list[cls]) # type: ignore[valid-type]
46
+ return adapter.validate_python
47
+
48
+
49
+ def _dump(value: BaseModel | dict[str, Any]) -> dict[str, Any]:
50
+ if isinstance(value, BaseModel):
51
+ return value.model_dump(mode="json", exclude_none=True)
52
+ return value
53
+
54
+
55
+ def _seg(value: str) -> str:
56
+ return quote(value, safe="")
57
+
58
+
59
+ def _upload(file: FileInput, name: str | None) -> tuple[str, Any, str]:
60
+ if isinstance(file, (str, os.PathLike)):
61
+ path = os.fspath(file)
62
+ with open(path, "rb") as fh:
63
+ content = fh.read()
64
+ name = name or os.path.basename(path)
65
+ elif isinstance(file, bytes):
66
+ content = file
67
+ if name is None:
68
+ raise ValueError("name is required when uploading raw bytes")
69
+ else:
70
+ content = file.read()
71
+ name = name or os.path.basename(getattr(file, "name", "") or "") or None
72
+ if name is None:
73
+ raise ValueError("name is required for a file object without a name")
74
+ mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
75
+ return name, content, mime
76
+
77
+
78
+ # -- workflows ---------------------------------------------------------------
79
+
80
+
81
+ def workflows_list() -> Call:
82
+ return Call("GET", "/v1/workflows", parse=_list(WorkflowSummary))
83
+
84
+
85
+ def workflows_get(workflow_id: str) -> Call:
86
+ return Call("GET", f"/v1/workflows/{_seg(workflow_id)}", parse=_model(WorkflowOut))
87
+
88
+
89
+ def workflows_create(
90
+ name: str | None, description: str | None, graph: WorkflowGraph | None
91
+ ) -> Call:
92
+ body: dict[str, Any] = {}
93
+ if name is not None:
94
+ body["name"] = name
95
+ if description is not None:
96
+ body["description"] = description
97
+ if graph is not None:
98
+ body["graph"] = _dump(graph)
99
+ return Call("POST", "/v1/workflows", body=body, parse=_model(WorkflowOut))
100
+
101
+
102
+ def workflows_update(
103
+ workflow_id: str, name: str | None, description: str | None
104
+ ) -> Call:
105
+ body = {
106
+ k: v
107
+ for k, v in {"name": name, "description": description}.items()
108
+ if v is not None
109
+ }
110
+ return Call(
111
+ "PATCH",
112
+ f"/v1/workflows/{_seg(workflow_id)}",
113
+ body=body,
114
+ parse=_model(WorkflowOut),
115
+ )
116
+
117
+
118
+ def workflows_delete(workflow_id: str) -> Call:
119
+ return Call("DELETE", f"/v1/workflows/{_seg(workflow_id)}")
120
+
121
+
122
+ def workflows_save_draft(workflow_id: str, graph: WorkflowGraph, if_match: int) -> Call:
123
+ return Call(
124
+ "PUT",
125
+ f"/v1/workflows/{_seg(workflow_id)}/draft",
126
+ body={"graph": _dump(graph)},
127
+ headers={"If-Match": str(if_match)},
128
+ parse=_model(WorkflowOut),
129
+ )
130
+
131
+
132
+ def workflows_validate(workflow_id: str, graph: WorkflowGraph) -> Call:
133
+ return Call(
134
+ "POST",
135
+ f"/v1/workflows/{_seg(workflow_id)}/validate",
136
+ body={"graph": _dump(graph)},
137
+ parse=_model(ValidationResult),
138
+ )
139
+
140
+
141
+ def workflows_publish(workflow_id: str) -> Call:
142
+ return Call(
143
+ "POST", f"/v1/workflows/{_seg(workflow_id)}/publish", parse=_model(WorkflowOut)
144
+ )
145
+
146
+
147
+ def workflows_contract(workflow_id: str) -> Call:
148
+ return Call("GET", f"/v1/workflows/{_seg(workflow_id)}/contract")
149
+
150
+
151
+ def workflows_transcript(workflow_id: str, thread_id: str) -> Call:
152
+ return Call(
153
+ "GET",
154
+ f"/v1/workflows/{_seg(workflow_id)}/threads/{_seg(thread_id)}/transcript",
155
+ )
156
+
157
+
158
+ def workflows_publish_event(
159
+ workflow_id: str,
160
+ event_type: str,
161
+ data: Any = None,
162
+ *,
163
+ id: str | None = None,
164
+ subject: str | None = None,
165
+ draft: bool = False,
166
+ ) -> Call:
167
+ body: dict[str, Any] = {"event_type": event_type, "draft": draft}
168
+ if data is not None:
169
+ body["data"] = data
170
+ if id is not None:
171
+ body["id"] = id
172
+ if subject is not None:
173
+ body["subject"] = subject
174
+ return Call("POST", f"/v1/workflows/{_seg(workflow_id)}/events", body=body)
175
+
176
+
177
+ # -- runs --------------------------------------------------------------------
178
+
179
+
180
+ def runs_stream_path(workflow_id: str) -> str:
181
+ return f"/v1/workflows/{_seg(workflow_id)}/runs"
182
+
183
+
184
+ def runs_body(
185
+ *,
186
+ input: dict[str, Any] | None = None,
187
+ params: dict[str, Any] | None = None,
188
+ answers: dict[str, Any] | None = None,
189
+ approvals: list[dict[str, Any]] | None = None,
190
+ surface_action: dict[str, Any] | None = None,
191
+ thread_id: str | None = None,
192
+ turn_id: str | None = None,
193
+ globals: dict[str, Any] | None = None,
194
+ view: str | None = None,
195
+ ) -> dict[str, Any]:
196
+ fields = {
197
+ "input": input,
198
+ "params": params,
199
+ "answers": answers,
200
+ "approvals": approvals,
201
+ "surface_action": surface_action,
202
+ "thread_id": thread_id,
203
+ "turn_id": turn_id,
204
+ "globals": globals,
205
+ "view": view,
206
+ }
207
+ return {k: v for k, v in fields.items() if v is not None}
208
+
209
+
210
+ def runs_answer_elicitation(
211
+ workflow_id: str, elicitation_id: str, action: str, content: Any, thread_id: str
212
+ ) -> Call:
213
+ return Call(
214
+ "POST",
215
+ f"/v1/workflows/{_seg(workflow_id)}/elicitations/{_seg(elicitation_id)}",
216
+ body={"action": action, "content": content, "thread_id": thread_id},
217
+ parse=_model(ElicitationReceipt),
218
+ )
219
+
220
+
221
+ def runs_trigger(workflow_id: str, params: dict[str, Any] | None) -> Call:
222
+ return Call(
223
+ "POST",
224
+ f"/v1/workflows/{_seg(workflow_id)}/trigger",
225
+ body={"params": params or {}},
226
+ parse=_model(RunReceipt),
227
+ )
228
+
229
+
230
+ def _run_query(
231
+ limit: int | None, status: str | None, before: str | None
232
+ ) -> dict[str, Any]:
233
+ return {"limit": limit, "status": status, "before": before}
234
+
235
+
236
+ def runs_list(
237
+ workflow_id: str, limit: int | None, status: str | None, before: str | None
238
+ ) -> Call:
239
+ return Call(
240
+ "GET",
241
+ f"/v1/workflows/{_seg(workflow_id)}/runs",
242
+ params=_run_query(limit, status, before),
243
+ parse=_list(RunSummary),
244
+ )
245
+
246
+
247
+ def runs_get(run_id: str) -> Call:
248
+ return Call("GET", f"/v1/runs/{_seg(run_id)}", parse=_model(RunOut))
249
+
250
+
251
+ def runs_steps(run_id: str) -> Call:
252
+ return Call("GET", f"/v1/runs/{_seg(run_id)}/steps", parse=_list(RunStepOut))
253
+
254
+
255
+ def automation_list(
256
+ workflow_id: str, limit: int | None, status: str | None, before: str | None
257
+ ) -> Call:
258
+ return Call(
259
+ "GET",
260
+ f"/v1/workflows/{_seg(workflow_id)}/automation/runs",
261
+ params=_run_query(limit, status, before),
262
+ parse=_list(RunSummary),
263
+ )
264
+
265
+
266
+ def automation_get(workflow_id: str, run_id: str) -> Call:
267
+ return Call(
268
+ "GET",
269
+ f"/v1/workflows/{_seg(workflow_id)}/automation/runs/{_seg(run_id)}",
270
+ parse=_model(RunOut),
271
+ )
272
+
273
+
274
+ # -- sessions ----------------------------------------------------------------
275
+
276
+
277
+ def sessions_create(
278
+ *,
279
+ context: dict[str, Any] | None = None,
280
+ secrets: dict[str, str] | None = None,
281
+ subject: str | None = None,
282
+ tenant_id: str | None = None,
283
+ ttl_seconds: int | None = None,
284
+ max_runs: int | None = None,
285
+ resume_thread_id: str | None = None,
286
+ thread_key: str | None = None,
287
+ ) -> Call:
288
+ fields = {
289
+ "context": context,
290
+ "secrets": secrets,
291
+ "subject": subject,
292
+ "tenant_id": tenant_id,
293
+ "ttl_seconds": ttl_seconds,
294
+ "max_runs": max_runs,
295
+ "resume_thread_id": resume_thread_id,
296
+ "thread_key": thread_key,
297
+ }
298
+ body = {k: v for k, v in fields.items() if v is not None}
299
+ return Call("POST", "/v1/sessions", body=body, parse=_model(SessionOut))
300
+
301
+
302
+ def sessions_refresh() -> Call:
303
+ return Call("POST", "/v1/sessions/refresh", parse=_model(SessionOut))
304
+
305
+
306
+ def sessions_revoke(session_id: str) -> Call:
307
+ return Call("DELETE", f"/v1/sessions/{_seg(session_id)}")
308
+
309
+
310
+ # -- files -------------------------------------------------------------------
311
+
312
+
313
+ def files_list(workflow_id: str, thread_id: str | None) -> Call:
314
+ return Call(
315
+ "GET",
316
+ f"/v1/workflows/{_seg(workflow_id)}/files",
317
+ params={"thread_id": thread_id},
318
+ parse=_list(WorkflowFile),
319
+ )
320
+
321
+
322
+ def files_upload(
323
+ workflow_id: str, thread_id: str, file: FileInput, name: str | None
324
+ ) -> Call:
325
+ return Call(
326
+ "POST",
327
+ f"/v1/workflows/{_seg(workflow_id)}/files",
328
+ files={"file": _upload(file, name)},
329
+ data={"thread_id": thread_id},
330
+ parse=_model(WorkflowFile),
331
+ )
332
+
333
+
334
+ def files_get(file_id: str) -> Call:
335
+ return Call("GET", f"/v1/files/{_seg(file_id)}", parse=_model(WorkflowFile))
336
+
337
+
338
+ def files_content(file_id: str) -> Call:
339
+ return Call("GET", f"/v1/files/{_seg(file_id)}/content", raw=True)
340
+
341
+
342
+ def files_delete(file_id: str) -> Call:
343
+ return Call("DELETE", f"/v1/files/{_seg(file_id)}")
344
+
345
+
346
+ # -- skills ------------------------------------------------------------------
347
+
348
+
349
+ def skills_list(workflow_id: str) -> Call:
350
+ return Call("GET", f"/v1/workflows/{_seg(workflow_id)}/skills", parse=_list(Skill))
351
+
352
+
353
+ def skills_upload(
354
+ workflow_id: str, file: FileInput, name: str | None, allow_unknown_types: bool
355
+ ) -> Call:
356
+ return Call(
357
+ "POST",
358
+ f"/v1/workflows/{_seg(workflow_id)}/skills",
359
+ files={"file": _upload(file, name)},
360
+ data={"allow_unknown_types": "true"} if allow_unknown_types else {},
361
+ parse=_model(SkillUpload),
362
+ )
363
+
364
+
365
+ def skills_get(skill_id: str) -> Call:
366
+ return Call("GET", f"/v1/skills/{_seg(skill_id)}", parse=_model(Skill))
367
+
368
+
369
+ def _skill_files(value: Any) -> dict[str, SkillFile]:
370
+ return {k: SkillFile.model_validate(v) for k, v in value["files"].items()}
371
+
372
+
373
+ def skills_files(skill_id: str) -> Call:
374
+ return Call("GET", f"/v1/skills/{_seg(skill_id)}/files", parse=_skill_files)
375
+
376
+
377
+ def skills_file_content(skill_id: str, path: str) -> Call:
378
+ # ``path`` may hold slashes: the route takes the rest of the path.
379
+ return Call(
380
+ "GET", f"/v1/skills/{_seg(skill_id)}/files/{quote(path, safe='/')}", raw=True
381
+ )
382
+
383
+
384
+ def skills_delete(skill_id: str) -> Call:
385
+ return Call("DELETE", f"/v1/skills/{_seg(skill_id)}")
386
+
387
+
388
+ # -- small reads -------------------------------------------------------------
389
+
390
+
391
+ def me_get() -> Call:
392
+ return Call("GET", "/v1/me", parse=_model(Me))
393
+
394
+
395
+ def models_list() -> Call:
396
+ return Call("GET", "/v1/models", parse=_list(ModelOption))
397
+
398
+
399
+ def node_types_list() -> Call:
400
+ return Call("GET", "/v1/node-types")
401
+
402
+
403
+ def health_live() -> Call:
404
+ return Call("GET", "/health")
405
+
406
+
407
+ def health_ready() -> Call:
408
+ return Call("GET", "/health/ready", parse=_model(RuntimeReadiness))