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.
@@ -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
+ ]
@@ -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