ciaren-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.
- ciaren_client/__init__.py +65 -0
- ciaren_client/_async.py +597 -0
- ciaren_client/_sync.py +591 -0
- ciaren_client/_types.py +179 -0
- ciaren_client/py.typed +1 -0
- ciaren_client-0.1.0.dist-info/METADATA +81 -0
- ciaren_client-0.1.0.dist-info/RECORD +8 -0
- ciaren_client-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""ciaren-client — thin HTTP client for the Ciaren visual data and ML workflow server.
|
|
2
|
+
|
|
3
|
+
Sync usage::
|
|
4
|
+
|
|
5
|
+
from ciaren_client import Ciaren
|
|
6
|
+
|
|
7
|
+
client = Ciaren("http://localhost:8055", webhook_secret="my-secret")
|
|
8
|
+
run = client.trigger("flow-id")
|
|
9
|
+
print(run["status"])
|
|
10
|
+
|
|
11
|
+
Async usage::
|
|
12
|
+
|
|
13
|
+
from ciaren_client import AsyncCiaren
|
|
14
|
+
|
|
15
|
+
async with AsyncCiaren("http://localhost:8055", webhook_secret="my-secret") as client:
|
|
16
|
+
run = await client.trigger("flow-id")
|
|
17
|
+
async for line in client.stream_logs(run["id"]):
|
|
18
|
+
print(line)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from ciaren_client._async import AsyncCiaren
|
|
22
|
+
from ciaren_client._sync import Ciaren
|
|
23
|
+
from ciaren_client._types import (
|
|
24
|
+
AppSetting,
|
|
25
|
+
CodeExport,
|
|
26
|
+
Connection,
|
|
27
|
+
ConnectionTestResult,
|
|
28
|
+
Dataset,
|
|
29
|
+
DatasetVersion,
|
|
30
|
+
Flow,
|
|
31
|
+
FlowDocument,
|
|
32
|
+
FlowMigrationResult,
|
|
33
|
+
JsonDict,
|
|
34
|
+
KeyringAvailability,
|
|
35
|
+
KeyringSecretStatus,
|
|
36
|
+
Project,
|
|
37
|
+
Run,
|
|
38
|
+
RunStatus,
|
|
39
|
+
Schedule,
|
|
40
|
+
WebhookStatus,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__version__ = "0.1.0"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"AppSetting",
|
|
47
|
+
"AsyncCiaren",
|
|
48
|
+
"Ciaren",
|
|
49
|
+
"CodeExport",
|
|
50
|
+
"Connection",
|
|
51
|
+
"ConnectionTestResult",
|
|
52
|
+
"Dataset",
|
|
53
|
+
"DatasetVersion",
|
|
54
|
+
"Flow",
|
|
55
|
+
"FlowDocument",
|
|
56
|
+
"FlowMigrationResult",
|
|
57
|
+
"JsonDict",
|
|
58
|
+
"KeyringAvailability",
|
|
59
|
+
"KeyringSecretStatus",
|
|
60
|
+
"Project",
|
|
61
|
+
"Run",
|
|
62
|
+
"RunStatus",
|
|
63
|
+
"Schedule",
|
|
64
|
+
"WebhookStatus",
|
|
65
|
+
]
|
ciaren_client/_async.py
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
"""Async Ciaren client backed by httpx.AsyncClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ciaren_client._types import (
|
|
13
|
+
AppSetting,
|
|
14
|
+
CodeExport,
|
|
15
|
+
Connection,
|
|
16
|
+
ConnectionTestResult,
|
|
17
|
+
Dataset,
|
|
18
|
+
DatasetVersion,
|
|
19
|
+
Flow,
|
|
20
|
+
FlowMigrationResult,
|
|
21
|
+
JsonDict,
|
|
22
|
+
KeyringAvailability,
|
|
23
|
+
KeyringSecretStatus,
|
|
24
|
+
Project,
|
|
25
|
+
Run,
|
|
26
|
+
Schedule,
|
|
27
|
+
WebhookStatus,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AsyncCiaren:
|
|
32
|
+
"""Async client for the Ciaren API.
|
|
33
|
+
|
|
34
|
+
Can be used as an async context manager::
|
|
35
|
+
|
|
36
|
+
async with AsyncCiaren(base_url, webhook_secret=secret) as client:
|
|
37
|
+
run = await client.trigger(flow_id)
|
|
38
|
+
|
|
39
|
+
Or managed manually::
|
|
40
|
+
|
|
41
|
+
client = AsyncCiaren(base_url)
|
|
42
|
+
await client.aclose()
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
base_url: str,
|
|
48
|
+
*,
|
|
49
|
+
webhook_secret: str | None = None,
|
|
50
|
+
api_token: str | None = None,
|
|
51
|
+
timeout: float = 30.0,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._base_url = base_url.rstrip("/")
|
|
54
|
+
self._secret = webhook_secret
|
|
55
|
+
headers = {"Authorization": f"Bearer {api_token}"} if api_token else None
|
|
56
|
+
self._client = httpx.AsyncClient(base_url=self._base_url, timeout=timeout, headers=headers)
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Raw HTTP helpers
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
async def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
63
|
+
"""Send a raw request against the Ciaren server and raise on HTTP errors."""
|
|
64
|
+
r = await self._client.request(method, url, **kwargs)
|
|
65
|
+
r.raise_for_status()
|
|
66
|
+
return r
|
|
67
|
+
|
|
68
|
+
async def get(self, url: str, **kwargs: Any) -> Any:
|
|
69
|
+
return (await self.request("GET", url, **kwargs)).json()
|
|
70
|
+
|
|
71
|
+
async def post(self, url: str, **kwargs: Any) -> Any:
|
|
72
|
+
return (await self.request("POST", url, **kwargs)).json()
|
|
73
|
+
|
|
74
|
+
async def put(self, url: str, **kwargs: Any) -> Any:
|
|
75
|
+
return (await self.request("PUT", url, **kwargs)).json()
|
|
76
|
+
|
|
77
|
+
async def patch(self, url: str, **kwargs: Any) -> Any:
|
|
78
|
+
return (await self.request("PATCH", url, **kwargs)).json()
|
|
79
|
+
|
|
80
|
+
async def delete(self, url: str, **kwargs: Any) -> None:
|
|
81
|
+
await self.request("DELETE", url, **kwargs)
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Context manager
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
async def __aenter__(self) -> AsyncCiaren:
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
async def __aexit__(self, *args: object) -> None:
|
|
91
|
+
await self.aclose()
|
|
92
|
+
|
|
93
|
+
async def aclose(self) -> None:
|
|
94
|
+
await self._client.aclose()
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# Projects
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
async def list_projects(self) -> list[Project]:
|
|
101
|
+
return await self.get("/api/projects")
|
|
102
|
+
|
|
103
|
+
async def create_project(self, name: str, *, description: str | None = None, color: str = "violet") -> Project:
|
|
104
|
+
return await self.post("/api/projects", json={"name": name, "description": description, "color": color})
|
|
105
|
+
|
|
106
|
+
async def get_project(self, project_id: str) -> Project:
|
|
107
|
+
return await self.get(f"/api/projects/{project_id}")
|
|
108
|
+
|
|
109
|
+
async def update_project(self, project_id: str, **fields: Any) -> Project:
|
|
110
|
+
return await self.put(f"/api/projects/{project_id}", json=fields)
|
|
111
|
+
|
|
112
|
+
async def delete_project(self, project_id: str) -> None:
|
|
113
|
+
await self.delete(f"/api/projects/{project_id}")
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# Datasets
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
async def upload_dataset(self, path: str | Path, *, project_id: str | None = None) -> Dataset:
|
|
120
|
+
file_path = Path(path)
|
|
121
|
+
params = {"project_id": project_id} if project_id else None
|
|
122
|
+
with file_path.open("rb") as handle:
|
|
123
|
+
r = await self._client.post(
|
|
124
|
+
"/api/datasets/upload",
|
|
125
|
+
params=params,
|
|
126
|
+
files={"file": (file_path.name, handle, "application/octet-stream")},
|
|
127
|
+
)
|
|
128
|
+
r.raise_for_status()
|
|
129
|
+
return r.json()
|
|
130
|
+
|
|
131
|
+
async def list_datasets(self, *, project_id: str | None = None, include_deleted: bool = False) -> list[Dataset]:
|
|
132
|
+
params: dict[str, Any] = {"include_deleted": include_deleted}
|
|
133
|
+
if project_id:
|
|
134
|
+
params["project_id"] = project_id
|
|
135
|
+
return await self.get("/api/datasets", params=params)
|
|
136
|
+
|
|
137
|
+
async def get_dataset(self, dataset_id: str) -> Dataset:
|
|
138
|
+
return await self.get(f"/api/datasets/{dataset_id}")
|
|
139
|
+
|
|
140
|
+
async def update_dataset(self, dataset_id: str, **fields: Any) -> Dataset:
|
|
141
|
+
return await self.patch(f"/api/datasets/{dataset_id}", json=fields)
|
|
142
|
+
|
|
143
|
+
async def delete_dataset(self, dataset_id: str, *, purge: bool = False, force: bool = False) -> None:
|
|
144
|
+
await self.delete(f"/api/datasets/{dataset_id}", params={"purge": purge, "force": force})
|
|
145
|
+
|
|
146
|
+
async def restore_dataset(self, dataset_id: str) -> Dataset:
|
|
147
|
+
return await self.post(f"/api/datasets/{dataset_id}/restore")
|
|
148
|
+
|
|
149
|
+
async def purge_expired_datasets(self) -> dict[str, int]:
|
|
150
|
+
return await self.post("/api/datasets/purge-expired")
|
|
151
|
+
|
|
152
|
+
async def list_dataset_flows(self, dataset_id: str) -> list[Flow]:
|
|
153
|
+
return await self.get(f"/api/datasets/{dataset_id}/flows")
|
|
154
|
+
|
|
155
|
+
async def list_dataset_versions(
|
|
156
|
+
self, dataset_id: str, *, limit: int = 100, offset: int = 0
|
|
157
|
+
) -> list[DatasetVersion]:
|
|
158
|
+
return await self.get(f"/api/datasets/{dataset_id}/versions", params={"limit": limit, "offset": offset})
|
|
159
|
+
|
|
160
|
+
async def get_dataset_schema(self, dataset_id: str, *, version: int | None = None) -> Any:
|
|
161
|
+
return await self.get(f"/api/datasets/{dataset_id}/schema", params={"version": version} if version else None)
|
|
162
|
+
|
|
163
|
+
async def get_dataset_sample(self, dataset_id: str, *, version: int | None = None) -> Any:
|
|
164
|
+
return await self.get(f"/api/datasets/{dataset_id}/sample", params={"version": version} if version else None)
|
|
165
|
+
|
|
166
|
+
async def get_dataset_profile(self, dataset_id: str, *, version: int | None = None) -> Any:
|
|
167
|
+
return await self.get(f"/api/datasets/{dataset_id}/profile", params={"version": version} if version else None)
|
|
168
|
+
|
|
169
|
+
async def download_dataset_version(
|
|
170
|
+
self, dataset_id: str, version_number: int, path: str | Path | None = None
|
|
171
|
+
) -> bytes | Path:
|
|
172
|
+
r = await self.request("GET", f"/api/datasets/{dataset_id}/versions/{version_number}/download")
|
|
173
|
+
if path is None:
|
|
174
|
+
return r.content
|
|
175
|
+
target = Path(path)
|
|
176
|
+
target.write_bytes(r.content)
|
|
177
|
+
return target
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
# Flows
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
async def list_flows(self, *, project_id: str | None = None) -> list[Flow]:
|
|
184
|
+
return await self.get("/api/flows", params={"project_id": project_id} if project_id else None)
|
|
185
|
+
|
|
186
|
+
async def create_flow(
|
|
187
|
+
self,
|
|
188
|
+
name: str,
|
|
189
|
+
*,
|
|
190
|
+
description: str | None = None,
|
|
191
|
+
project_id: str | None = None,
|
|
192
|
+
graph_json: JsonDict | None = None,
|
|
193
|
+
) -> Flow:
|
|
194
|
+
return await self.post(
|
|
195
|
+
"/api/flows",
|
|
196
|
+
json={"name": name, "description": description, "project_id": project_id, "graph_json": graph_json or {}},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
async def import_flow(
|
|
200
|
+
self,
|
|
201
|
+
graph_json: JsonDict,
|
|
202
|
+
*,
|
|
203
|
+
format: str | None = None,
|
|
204
|
+
name: str | None = None,
|
|
205
|
+
description: str | None = None,
|
|
206
|
+
project_id: str | None = None,
|
|
207
|
+
) -> Flow:
|
|
208
|
+
return await self.post(
|
|
209
|
+
"/api/flows/import",
|
|
210
|
+
json={
|
|
211
|
+
"format": format,
|
|
212
|
+
"name": name,
|
|
213
|
+
"description": description,
|
|
214
|
+
"project_id": project_id,
|
|
215
|
+
"graph_json": graph_json,
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
async def duplicate_flow(self, flow_id: str, *, name: str | None = None) -> Flow:
|
|
220
|
+
return await self.post(f"/api/flows/{flow_id}/duplicate", params={"name": name} if name else None)
|
|
221
|
+
|
|
222
|
+
async def get_flow(self, flow_id: str) -> Flow:
|
|
223
|
+
return await self.get(f"/api/flows/{flow_id}")
|
|
224
|
+
|
|
225
|
+
async def update_flow(self, flow_id: str, **fields: Any) -> Flow:
|
|
226
|
+
return await self.put(f"/api/flows/{flow_id}", json=fields)
|
|
227
|
+
|
|
228
|
+
async def delete_flow(self, flow_id: str) -> None:
|
|
229
|
+
await self.delete(f"/api/flows/{flow_id}")
|
|
230
|
+
|
|
231
|
+
async def preview_flow(self, flow_id: str, **payload: Any) -> Any:
|
|
232
|
+
return await self.post(f"/api/flows/{flow_id}/preview", json=payload)
|
|
233
|
+
|
|
234
|
+
async def export_flow_python(self, flow_id: str, *, free_intermediates: bool = True) -> CodeExport:
|
|
235
|
+
return await self.post(f"/api/flows/{flow_id}/export/python", params={"free_intermediates": free_intermediates})
|
|
236
|
+
|
|
237
|
+
async def migrate_flow_document(self, document: JsonDict) -> FlowMigrationResult:
|
|
238
|
+
"""Migrate/validate a raw .flow document to the current schema version
|
|
239
|
+
without persisting or importing it."""
|
|
240
|
+
return await self.post("/api/flows/migrate-document", json={"document": document})
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
# Runs
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
async def list_runs(
|
|
247
|
+
self,
|
|
248
|
+
*,
|
|
249
|
+
flow_id: str | None = None,
|
|
250
|
+
project_id: str | None = None,
|
|
251
|
+
dataset_id: str | None = None,
|
|
252
|
+
schedule_id: str | None = None,
|
|
253
|
+
status: str | None = None,
|
|
254
|
+
started_after: str | datetime | None = None,
|
|
255
|
+
started_before: str | datetime | None = None,
|
|
256
|
+
sort_by: str = "created_at",
|
|
257
|
+
sort_order: str = "desc",
|
|
258
|
+
limit: int = 100,
|
|
259
|
+
offset: int = 0,
|
|
260
|
+
) -> list[Run]:
|
|
261
|
+
params: dict[str, Any] = {
|
|
262
|
+
"limit": limit,
|
|
263
|
+
"offset": offset,
|
|
264
|
+
"sort_by": sort_by,
|
|
265
|
+
"sort_order": sort_order,
|
|
266
|
+
}
|
|
267
|
+
if flow_id:
|
|
268
|
+
params["flow_id"] = flow_id
|
|
269
|
+
if project_id:
|
|
270
|
+
params["project_id"] = project_id
|
|
271
|
+
if dataset_id:
|
|
272
|
+
params["dataset_id"] = dataset_id
|
|
273
|
+
if schedule_id:
|
|
274
|
+
params["schedule_id"] = schedule_id
|
|
275
|
+
if status:
|
|
276
|
+
params["status"] = status
|
|
277
|
+
if started_after:
|
|
278
|
+
params["started_after"] = started_after.isoformat() if isinstance(started_after, datetime) else started_after
|
|
279
|
+
if started_before:
|
|
280
|
+
params["started_before"] = started_before.isoformat() if isinstance(started_before, datetime) else started_before
|
|
281
|
+
return await self.get("/api/runs", params=params)
|
|
282
|
+
|
|
283
|
+
async def create_run(
|
|
284
|
+
self,
|
|
285
|
+
flow_id: str,
|
|
286
|
+
*,
|
|
287
|
+
input_dataset_id: str | None = None,
|
|
288
|
+
engine: str | None = None,
|
|
289
|
+
timeout_seconds: int | None = None,
|
|
290
|
+
parameters: dict[str, Any] | None = None,
|
|
291
|
+
) -> Run:
|
|
292
|
+
return await self.post(
|
|
293
|
+
f"/api/flows/{flow_id}/runs",
|
|
294
|
+
json={
|
|
295
|
+
"input_dataset_id": input_dataset_id,
|
|
296
|
+
"engine": engine,
|
|
297
|
+
"timeout_seconds": timeout_seconds,
|
|
298
|
+
"parameters": parameters,
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
async def get_run(self, run_id: str) -> Run:
|
|
303
|
+
return await self.get(f"/api/runs/{run_id}")
|
|
304
|
+
|
|
305
|
+
async def cancel_run(self, run_id: str) -> dict[str, str]:
|
|
306
|
+
"""Stop a running run: cooperatively at the next node boundary (thread
|
|
307
|
+
mode) or by abandoning the worker (process mode)."""
|
|
308
|
+
return await self.post(f"/api/runs/{run_id}/cancel")
|
|
309
|
+
|
|
310
|
+
async def retry_run(self, run_id: str) -> Run:
|
|
311
|
+
"""Retry a run via POST /api/runs/{id}/retry.
|
|
312
|
+
|
|
313
|
+
Re-runs the same flow with the original run's config, creating a new
|
|
314
|
+
run with a new id. Returns the newly created run dict.
|
|
315
|
+
"""
|
|
316
|
+
return await self.post(f"/api/runs/{run_id}/retry")
|
|
317
|
+
|
|
318
|
+
async def download_run_output(self, run_id: str, node_id: str, path: str | Path | None = None) -> bytes | Path:
|
|
319
|
+
r = await self.request("GET", f"/api/runs/{run_id}/output", params={"node_id": node_id})
|
|
320
|
+
if path is None:
|
|
321
|
+
return r.content
|
|
322
|
+
target = Path(path)
|
|
323
|
+
target.write_bytes(r.content)
|
|
324
|
+
return target
|
|
325
|
+
|
|
326
|
+
# ------------------------------------------------------------------
|
|
327
|
+
# Schedules
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
async def create_schedule(self, flow_id: str, cron: str, **fields: Any) -> Schedule:
|
|
331
|
+
return await self.post(f"/api/flows/{flow_id}/schedules", json={"cron": cron, **fields})
|
|
332
|
+
|
|
333
|
+
async def list_flow_schedules(self, flow_id: str) -> list[Schedule]:
|
|
334
|
+
return await self.get(f"/api/flows/{flow_id}/schedules")
|
|
335
|
+
|
|
336
|
+
async def list_schedules(self, *, flow_id: str | None = None) -> list[Schedule]:
|
|
337
|
+
return await self.get("/api/schedules", params={"flow_id": flow_id} if flow_id else None)
|
|
338
|
+
|
|
339
|
+
async def get_schedule(self, schedule_id: str) -> Schedule:
|
|
340
|
+
return await self.get(f"/api/schedules/{schedule_id}")
|
|
341
|
+
|
|
342
|
+
async def update_schedule(self, schedule_id: str, **fields: Any) -> Schedule:
|
|
343
|
+
return await self.patch(f"/api/schedules/{schedule_id}", json=fields)
|
|
344
|
+
|
|
345
|
+
async def delete_schedule(self, schedule_id: str) -> None:
|
|
346
|
+
await self.delete(f"/api/schedules/{schedule_id}")
|
|
347
|
+
|
|
348
|
+
async def run_schedule_now(self, schedule_id: str) -> Run:
|
|
349
|
+
return await self.post(f"/api/schedules/{schedule_id}/run-now")
|
|
350
|
+
|
|
351
|
+
async def list_schedule_runs(self, schedule_id: str, *, limit: int = 50, offset: int = 0) -> list[Run]:
|
|
352
|
+
return await self.get(f"/api/schedules/{schedule_id}/runs", params={"limit": limit, "offset": offset})
|
|
353
|
+
|
|
354
|
+
# ------------------------------------------------------------------
|
|
355
|
+
# ML
|
|
356
|
+
# ------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
async def get_run_ml_metrics(self, run_id: str) -> list[dict[str, Any]]:
|
|
359
|
+
return await self.get(f"/api/runs/{run_id}/ml/metrics")
|
|
360
|
+
|
|
361
|
+
async def register_run_model(self, run_id: str, model_name: str, *, stage: str | None = None) -> dict[str, Any]:
|
|
362
|
+
return await self.post(f"/api/runs/{run_id}/ml/register", json={"model_name": model_name, "stage": stage})
|
|
363
|
+
|
|
364
|
+
async def list_flow_ml_experiments(self, flow_id: str) -> list[dict[str, Any]]:
|
|
365
|
+
return await self.get(f"/api/flows/{flow_id}/ml/experiments")
|
|
366
|
+
|
|
367
|
+
async def list_registered_models(self) -> list[dict[str, Any]]:
|
|
368
|
+
return await self.get("/api/ml/models")
|
|
369
|
+
|
|
370
|
+
async def list_model_catalog(self) -> list[dict[str, Any]]:
|
|
371
|
+
return await self.get("/api/ml/model-catalog")
|
|
372
|
+
|
|
373
|
+
async def set_model_alias(self, model_name: str, version: str | int, alias: str) -> dict[str, Any]:
|
|
374
|
+
return await self.post(f"/api/ml/models/{model_name}/alias", json={"version": version, "alias": alias})
|
|
375
|
+
|
|
376
|
+
async def clear_model_alias(self, model_name: str, alias: str) -> dict[str, Any]:
|
|
377
|
+
return (await self.request("DELETE", f"/api/ml/models/{model_name}/alias/{alias}")).json()
|
|
378
|
+
|
|
379
|
+
async def list_ml_experiments(self) -> list[dict[str, Any]]:
|
|
380
|
+
return await self.get("/api/ml/experiments")
|
|
381
|
+
|
|
382
|
+
async def list_ml_experiment_runs(self, experiment_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
|
|
383
|
+
return await self.get(f"/api/ml/experiments/{experiment_id}/runs", params={"limit": limit})
|
|
384
|
+
|
|
385
|
+
# ------------------------------------------------------------------
|
|
386
|
+
# Connections
|
|
387
|
+
# ------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
async def list_connections(self) -> list[Connection]:
|
|
390
|
+
return await self.get("/api/connections")
|
|
391
|
+
|
|
392
|
+
async def create_connection(self, **payload: Any) -> Connection:
|
|
393
|
+
return await self.post("/api/connections", json=payload)
|
|
394
|
+
|
|
395
|
+
async def list_connection_providers(self) -> list[dict[str, Any]]:
|
|
396
|
+
return await self.get("/api/connections/providers")
|
|
397
|
+
|
|
398
|
+
async def test_connection_config(self, **payload: Any) -> ConnectionTestResult:
|
|
399
|
+
return await self.post("/api/connections/test-config", json=payload)
|
|
400
|
+
|
|
401
|
+
async def keyring_availability(self) -> KeyringAvailability:
|
|
402
|
+
"""Whether this host has a usable OS keychain (headless servers have none)."""
|
|
403
|
+
return await self.get("/api/connections/keyring")
|
|
404
|
+
|
|
405
|
+
async def store_keyring_secret(
|
|
406
|
+
self, name: str, value: str, *, overwrite: bool = False
|
|
407
|
+
) -> KeyringSecretStatus:
|
|
408
|
+
"""Store a secret in the OS keychain, returning its ``keyring:NAME`` reference.
|
|
409
|
+
|
|
410
|
+
The value is written to the platform keychain and never persisted, returned,
|
|
411
|
+
or logged by Ciaren. Refused with 409 if the name is taken unless ``overwrite``.
|
|
412
|
+
"""
|
|
413
|
+
return await self.post(
|
|
414
|
+
"/api/connections/keyring", json={"name": name, "value": value, "overwrite": overwrite}
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
async def get_keyring_secret_status(self, name: str) -> KeyringSecretStatus:
|
|
418
|
+
"""Whether a keychain secret exists — never returns its value."""
|
|
419
|
+
return await self.get(f"/api/connections/keyring/{name}")
|
|
420
|
+
|
|
421
|
+
async def delete_keyring_secret(self, name: str) -> None:
|
|
422
|
+
await self.delete(f"/api/connections/keyring/{name}")
|
|
423
|
+
|
|
424
|
+
async def get_connection(self, connection_id: str) -> Connection:
|
|
425
|
+
return await self.get(f"/api/connections/{connection_id}")
|
|
426
|
+
|
|
427
|
+
async def update_connection(self, connection_id: str, **fields: Any) -> Connection:
|
|
428
|
+
return await self.patch(f"/api/connections/{connection_id}", json=fields)
|
|
429
|
+
|
|
430
|
+
async def delete_connection(self, connection_id: str, *, force: bool = False) -> None:
|
|
431
|
+
"""Delete a connection. Refused with 409 while flows still reference it,
|
|
432
|
+
unless ``force=True`` (those flows then fail at run time until repointed)."""
|
|
433
|
+
await self.delete(f"/api/connections/{connection_id}", params={"force": force})
|
|
434
|
+
|
|
435
|
+
async def test_connection(self, connection_id: str) -> ConnectionTestResult:
|
|
436
|
+
return await self.post(f"/api/connections/{connection_id}/test")
|
|
437
|
+
|
|
438
|
+
async def list_connection_tables(self, connection_id: str) -> list[dict[str, Any]]:
|
|
439
|
+
return await self.get(f"/api/connections/{connection_id}/tables")
|
|
440
|
+
|
|
441
|
+
async def list_connection_objects(self, connection_id: str, *, prefix: str = "") -> list[str]:
|
|
442
|
+
return await self.get(f"/api/connections/{connection_id}/objects", params={"prefix": prefix})
|
|
443
|
+
|
|
444
|
+
# ------------------------------------------------------------------
|
|
445
|
+
# Catalog / transformations / webhook settings
|
|
446
|
+
# ------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
async def list_catalog_nodes(self, *, category: str | None = None) -> list[dict[str, Any]]:
|
|
449
|
+
return await self.get("/api/catalog/nodes", params={"category": category} if category else None)
|
|
450
|
+
|
|
451
|
+
async def list_catalog_connectors(self) -> list[dict[str, Any]]:
|
|
452
|
+
return await self.get("/api/catalog/connectors")
|
|
453
|
+
|
|
454
|
+
async def list_catalog_exporters(self) -> list[dict[str, Any]]:
|
|
455
|
+
return await self.get("/api/catalog/exporters")
|
|
456
|
+
|
|
457
|
+
async def list_catalog_categories(self) -> list[dict[str, Any]]:
|
|
458
|
+
return await self.get("/api/catalog/categories")
|
|
459
|
+
|
|
460
|
+
async def list_transformations(self, *, include_ml: bool = True) -> Any:
|
|
461
|
+
return await self.get("/api/transformations", params={"include_ml": include_ml})
|
|
462
|
+
|
|
463
|
+
async def preview_transformation(self, **payload: Any) -> Any:
|
|
464
|
+
return await self.post("/api/transformations/preview", json=payload)
|
|
465
|
+
|
|
466
|
+
async def webhook_status(self) -> WebhookStatus:
|
|
467
|
+
return await self.get("/api/settings/webhook")
|
|
468
|
+
|
|
469
|
+
# ------------------------------------------------------------------
|
|
470
|
+
# Runtime app settings (Settings page allowlist)
|
|
471
|
+
# ------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
async def list_settings(self) -> list[AppSetting]:
|
|
474
|
+
return await self.get("/api/settings")
|
|
475
|
+
|
|
476
|
+
async def update_setting(self, key: str, value: int | str) -> AppSetting:
|
|
477
|
+
return await self.put(f"/api/settings/{key}", json={"value": value})
|
|
478
|
+
|
|
479
|
+
async def reset_setting(self, key: str) -> AppSetting:
|
|
480
|
+
"""Remove ``key``'s override, falling back to the environment/default."""
|
|
481
|
+
return (await self.request("DELETE", f"/api/settings/{key}")).json()
|
|
482
|
+
|
|
483
|
+
# ------------------------------------------------------------------
|
|
484
|
+
# Plugins / marketplace
|
|
485
|
+
# ------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
async def list_plugins(self) -> list[dict[str, Any]]:
|
|
488
|
+
return await self.get("/api/plugins")
|
|
489
|
+
|
|
490
|
+
async def plugin_diagnostics(self) -> dict[str, Any]:
|
|
491
|
+
return await self.get("/api/plugins/diagnostics")
|
|
492
|
+
|
|
493
|
+
async def install_plugin(self, path: str | Path, *, require_trusted: bool | None = None) -> dict[str, Any]:
|
|
494
|
+
package_path = Path(path)
|
|
495
|
+
data = {"require_trusted": str(require_trusted).lower()} if require_trusted is not None else None
|
|
496
|
+
with package_path.open("rb") as handle:
|
|
497
|
+
r = await self._client.post(
|
|
498
|
+
"/api/plugins/install",
|
|
499
|
+
data=data,
|
|
500
|
+
files={"file": (package_path.name, handle, "application/octet-stream")},
|
|
501
|
+
)
|
|
502
|
+
r.raise_for_status()
|
|
503
|
+
return r.json()
|
|
504
|
+
|
|
505
|
+
async def get_plugin_license(self, plugin_id: str) -> dict[str, Any]:
|
|
506
|
+
return await self.get(f"/api/plugins/{plugin_id}/license")
|
|
507
|
+
|
|
508
|
+
async def activate_plugin_license(self, plugin_id: str, token: dict[str, Any]) -> dict[str, Any]:
|
|
509
|
+
"""Activate a license: send the pasted/downloaded token JSON (marketplace wire
|
|
510
|
+
format). The server vets it against the trusted issuer keys before caching."""
|
|
511
|
+
return await self.post(f"/api/plugins/{plugin_id}/license", json=token)
|
|
512
|
+
|
|
513
|
+
async def remove_plugin_license(self, plugin_id: str) -> dict[str, Any]:
|
|
514
|
+
r = await self.request("DELETE", f"/api/plugins/{plugin_id}/license")
|
|
515
|
+
return r.json()
|
|
516
|
+
|
|
517
|
+
async def enable_plugin(self, plugin_id: str) -> dict[str, Any]:
|
|
518
|
+
return await self.post(f"/api/plugins/{plugin_id}/enable")
|
|
519
|
+
|
|
520
|
+
async def disable_plugin(self, plugin_id: str) -> dict[str, Any]:
|
|
521
|
+
return await self.post(f"/api/plugins/{plugin_id}/disable")
|
|
522
|
+
|
|
523
|
+
async def uninstall_plugin(self, plugin_id: str) -> dict[str, Any]:
|
|
524
|
+
r = await self.request("DELETE", f"/api/plugins/{plugin_id}")
|
|
525
|
+
return r.json()
|
|
526
|
+
|
|
527
|
+
async def grant_plugin_permissions(self, plugin_id: str, permissions: list[str] | None = None) -> dict[str, Any]:
|
|
528
|
+
return await self.post(f"/api/plugins/{plugin_id}/grant", json={"permissions": permissions or []})
|
|
529
|
+
|
|
530
|
+
async def revoke_plugin_permissions(self, plugin_id: str, permissions: list[str] | None = None) -> dict[str, Any]:
|
|
531
|
+
return await self.post(f"/api/plugins/{plugin_id}/revoke", json={"permissions": permissions or []})
|
|
532
|
+
|
|
533
|
+
async def list_marketplace(self) -> dict[str, Any]:
|
|
534
|
+
return await self.get("/api/marketplace")
|
|
535
|
+
|
|
536
|
+
async def install_marketplace_plugin(self, plugin_id: str) -> dict[str, Any]:
|
|
537
|
+
return await self.post(f"/api/marketplace/{plugin_id}/install")
|
|
538
|
+
|
|
539
|
+
# ------------------------------------------------------------------
|
|
540
|
+
# Webhook trigger
|
|
541
|
+
# ------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
def _trigger_headers(self) -> dict[str, str]:
|
|
544
|
+
if self._secret is None:
|
|
545
|
+
raise ValueError(
|
|
546
|
+
"webhook_secret is required to call trigger(). "
|
|
547
|
+
"Pass it when constructing AsyncCiaren(webhook_secret=...)."
|
|
548
|
+
)
|
|
549
|
+
return {"X-Ciaren-Secret": self._secret}
|
|
550
|
+
|
|
551
|
+
async def trigger(
|
|
552
|
+
self,
|
|
553
|
+
flow_id: str,
|
|
554
|
+
*,
|
|
555
|
+
engine: str | None = None,
|
|
556
|
+
parameters: dict[str, Any] | None = None,
|
|
557
|
+
) -> Run:
|
|
558
|
+
"""Trigger a flow run via the webhook endpoint.
|
|
559
|
+
|
|
560
|
+
Returns the completed run dict (blocks until the server finishes).
|
|
561
|
+
"""
|
|
562
|
+
body: dict[str, Any] = {}
|
|
563
|
+
if engine:
|
|
564
|
+
body["engine"] = engine
|
|
565
|
+
if parameters:
|
|
566
|
+
body["parameters"] = parameters
|
|
567
|
+
r = await self._client.post(
|
|
568
|
+
f"/api/flows/{flow_id}/trigger",
|
|
569
|
+
json=body or None,
|
|
570
|
+
headers=self._trigger_headers(),
|
|
571
|
+
)
|
|
572
|
+
r.raise_for_status()
|
|
573
|
+
return r.json()
|
|
574
|
+
|
|
575
|
+
# ------------------------------------------------------------------
|
|
576
|
+
# SSE log streaming
|
|
577
|
+
# ------------------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
async def stream_logs(self, run_id: str) -> AsyncGenerator[dict[str, Any], None]:
|
|
580
|
+
"""Yield log entry dicts from GET /api/runs/{id}/logs/stream (SSE).
|
|
581
|
+
|
|
582
|
+
Yields each ``data:`` event payload as a dict. Stops after the
|
|
583
|
+
``event: done`` frame arrives (that frame itself is not yielded).
|
|
584
|
+
"""
|
|
585
|
+
async with self._client.stream("GET", f"/api/runs/{run_id}/logs/stream") as response:
|
|
586
|
+
response.raise_for_status()
|
|
587
|
+
async for line in response.aiter_lines():
|
|
588
|
+
if line.startswith("data:"):
|
|
589
|
+
import json
|
|
590
|
+
|
|
591
|
+
payload = line[len("data:"):].strip()
|
|
592
|
+
try:
|
|
593
|
+
yield json.loads(payload)
|
|
594
|
+
except Exception:
|
|
595
|
+
yield {"raw": payload}
|
|
596
|
+
elif line.startswith("event: done"):
|
|
597
|
+
return
|