witrium 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.
- witrium/__init__.py +20 -0
- witrium/client.py +765 -0
- witrium/py.typed +2 -0
- witrium-0.1.0.dist-info/METADATA +55 -0
- witrium-0.1.0.dist-info/RECORD +8 -0
- witrium-0.1.0.dist-info/WHEEL +5 -0
- witrium-0.1.0.dist-info/licenses/LICENSE +21 -0
- witrium-0.1.0.dist-info/top_level.txt +1 -0
witrium/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from witrium.client import (
|
|
2
|
+
WitriumClient,
|
|
3
|
+
SyncWitriumClient,
|
|
4
|
+
AsyncWitriumClient,
|
|
5
|
+
WorkflowRunStatus,
|
|
6
|
+
WitriumClientException,
|
|
7
|
+
AgentExecutionStatus,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"WitriumClient",
|
|
14
|
+
"SyncWitriumClient",
|
|
15
|
+
"AsyncWitriumClient",
|
|
16
|
+
"WorkflowRunStatus",
|
|
17
|
+
"WitriumClientException",
|
|
18
|
+
"AgentExecutionStatus",
|
|
19
|
+
"__version__",
|
|
20
|
+
]
|
witrium/client.py
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import httpx
|
|
5
|
+
from typing import Dict, List, Optional, Any, Union, Callable
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from tenacity import retry, stop_after_delay, wait_fixed, retry_if_result
|
|
8
|
+
|
|
9
|
+
# Setup logger
|
|
10
|
+
logger = logging.getLogger("witrium_client")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WitriumClientException(Exception):
|
|
14
|
+
"""Base exception for Witrium Client errors."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WorkflowRunExecuteSchema(BaseModel):
|
|
20
|
+
args: Optional[dict[str, str | int | float]] = None
|
|
21
|
+
use_states: Optional[List[str]] = None
|
|
22
|
+
preserve_state: Optional[str] = None
|
|
23
|
+
no_intelligence: bool = False
|
|
24
|
+
record_session: bool = False
|
|
25
|
+
keep_session_alive: bool = False
|
|
26
|
+
use_existing_session: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WorkflowRunSubmittedSchema(BaseModel):
|
|
30
|
+
workflow_id: str
|
|
31
|
+
run_id: str
|
|
32
|
+
status: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AgentExecutionSchema(BaseModel):
|
|
36
|
+
status: str
|
|
37
|
+
instruction_order: int
|
|
38
|
+
instruction: str
|
|
39
|
+
result: Optional[dict | list] = None
|
|
40
|
+
result_format: Optional[str] = None
|
|
41
|
+
error_message: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WorkflowRunExecutionSchema(BaseModel):
|
|
45
|
+
instruction_id: str
|
|
46
|
+
instruction: str
|
|
47
|
+
result: Optional[dict | list] = None
|
|
48
|
+
status: str
|
|
49
|
+
error_message: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WorkflowRunResultsSchema(BaseModel):
|
|
53
|
+
workflow_id: str
|
|
54
|
+
run_id: str
|
|
55
|
+
status: str
|
|
56
|
+
started_at: Optional[str] = None
|
|
57
|
+
completed_at: Optional[str] = None
|
|
58
|
+
message: Optional[str] = None
|
|
59
|
+
executions: List[AgentExecutionSchema] = None
|
|
60
|
+
result: Optional[dict | list] = None
|
|
61
|
+
result_format: Optional[str] = None
|
|
62
|
+
error_message: Optional[str] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class WorkflowSchema(BaseModel):
|
|
66
|
+
uuid: str
|
|
67
|
+
name: str
|
|
68
|
+
description: Optional[str] = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class WorkflowRunSchema(BaseModel):
|
|
72
|
+
uuid: str
|
|
73
|
+
session_id: str
|
|
74
|
+
workflow: WorkflowSchema
|
|
75
|
+
run_type: str
|
|
76
|
+
triggered_by: str
|
|
77
|
+
status: str
|
|
78
|
+
session_active: bool
|
|
79
|
+
started_at: Optional[str] = None
|
|
80
|
+
completed_at: Optional[str] = None
|
|
81
|
+
error_message: Optional[str] = None
|
|
82
|
+
executions: List[WorkflowRunExecutionSchema] = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class WorkflowRunStatus:
|
|
86
|
+
"""Constants for workflow run statuses."""
|
|
87
|
+
|
|
88
|
+
PENDING = "P"
|
|
89
|
+
RUNNING = "R"
|
|
90
|
+
COMPLETED = "C"
|
|
91
|
+
FAILED = "F"
|
|
92
|
+
CANCELLED = "X"
|
|
93
|
+
|
|
94
|
+
# Terminal statuses that should stop polling
|
|
95
|
+
TERMINAL_STATUSES = [COMPLETED, FAILED, CANCELLED]
|
|
96
|
+
|
|
97
|
+
# Reverse mapping for human-readable status names
|
|
98
|
+
STATUS_NAMES = {
|
|
99
|
+
PENDING: "pending",
|
|
100
|
+
RUNNING: "running",
|
|
101
|
+
COMPLETED: "completed",
|
|
102
|
+
FAILED: "failed",
|
|
103
|
+
CANCELLED: "cancelled",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def get_status_name(cls, status_code: str) -> str:
|
|
108
|
+
"""Get human-readable status name from status code."""
|
|
109
|
+
return cls.STATUS_NAMES.get(status_code, status_code)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AgentExecutionStatus:
|
|
113
|
+
"""Constants for agent execution statuses."""
|
|
114
|
+
|
|
115
|
+
PENDING = "P"
|
|
116
|
+
RUNNING = "R"
|
|
117
|
+
COMPLETED = "C"
|
|
118
|
+
FAILED = "F"
|
|
119
|
+
CANCELLED = "X"
|
|
120
|
+
|
|
121
|
+
STATUS_NAMES = {
|
|
122
|
+
PENDING: "pending",
|
|
123
|
+
RUNNING: "running",
|
|
124
|
+
COMPLETED: "completed",
|
|
125
|
+
FAILED: "failed",
|
|
126
|
+
CANCELLED: "cancelled",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def get_status_name(cls, status_code: str) -> str:
|
|
131
|
+
"""Get human-readable status name from status code."""
|
|
132
|
+
return cls.STATUS_NAMES.get(status_code, status_code)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class WitriumClient:
|
|
136
|
+
"""
|
|
137
|
+
Base class for Witrium API Client.
|
|
138
|
+
Not meant to be used directly - use SyncWitriumClient or AsyncWitriumClient.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self, base_url: str, api_token: str, timeout: int = 60, verify_ssl: bool = True
|
|
143
|
+
):
|
|
144
|
+
"""
|
|
145
|
+
Initialize the Witrium client.
|
|
146
|
+
Args:
|
|
147
|
+
api_token: The API token for authentication.
|
|
148
|
+
"""
|
|
149
|
+
self.base_url = base_url.rstrip("/")
|
|
150
|
+
self.api_token = api_token
|
|
151
|
+
self.timeout = timeout
|
|
152
|
+
self.verify_ssl = verify_ssl
|
|
153
|
+
self._headers = {"X-Witrium-Key": api_token, "Content-Type": "application/json"}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class SyncWitriumClient(WitriumClient):
|
|
157
|
+
"""Synchronous Witrium API client."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, api_token: str, timeout: int = 60, verify_ssl: bool = True):
|
|
160
|
+
"""Initialize the synchronous client."""
|
|
161
|
+
super().__init__(
|
|
162
|
+
"https://api.witrium.com", api_token, timeout, verify_ssl
|
|
163
|
+
)
|
|
164
|
+
self._client = httpx.Client(
|
|
165
|
+
timeout=self.timeout, verify=self.verify_ssl, headers=self._headers
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def close(self):
|
|
169
|
+
"""Close the underlying HTTP client."""
|
|
170
|
+
if self._client:
|
|
171
|
+
self._client.close()
|
|
172
|
+
|
|
173
|
+
def __enter__(self):
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
177
|
+
self.close()
|
|
178
|
+
|
|
179
|
+
def run_workflow(
|
|
180
|
+
self,
|
|
181
|
+
workflow_id: str,
|
|
182
|
+
args: Optional[Dict[str, Union[str, int, float]]] = None,
|
|
183
|
+
use_states: Optional[List[str]] = None,
|
|
184
|
+
preserve_state: Optional[str] = None,
|
|
185
|
+
no_intelligence: bool = False,
|
|
186
|
+
record_session: Optional[bool] = False,
|
|
187
|
+
keep_session_alive: bool = False,
|
|
188
|
+
use_existing_session: Optional[str] = None,
|
|
189
|
+
) -> WorkflowRunSubmittedSchema:
|
|
190
|
+
"""
|
|
191
|
+
Run a workflow by ID.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
workflow_id: The ID of the workflow to run.
|
|
195
|
+
args: Optional arguments to pass to the workflow.
|
|
196
|
+
use_states: Optional list of state names to use.
|
|
197
|
+
preserve_state: Optional state name to preserve.
|
|
198
|
+
no_intelligence: Whether to run without AI intelligence.
|
|
199
|
+
record_session: Whether to record the session.
|
|
200
|
+
keep_session_alive: Whether to keep the session alive.
|
|
201
|
+
use_existing_session: The ID of the existing session to use.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Dict containing workflow_id, run_id and status.
|
|
205
|
+
"""
|
|
206
|
+
url = f"{self.base_url}/v1/workflows/{workflow_id}/run"
|
|
207
|
+
payload = {
|
|
208
|
+
"args": args,
|
|
209
|
+
"use_states": use_states,
|
|
210
|
+
"preserve_state": preserve_state,
|
|
211
|
+
"no_intelligence": no_intelligence,
|
|
212
|
+
"record_session": record_session,
|
|
213
|
+
"keep_session_alive": keep_session_alive,
|
|
214
|
+
"use_existing_session": use_existing_session,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
response = self._client.post(url, json=payload)
|
|
219
|
+
response.raise_for_status()
|
|
220
|
+
return WorkflowRunSubmittedSchema.model_validate(response.json())
|
|
221
|
+
except httpx.HTTPStatusError as e:
|
|
222
|
+
error_detail = self._extract_error_detail(e.response)
|
|
223
|
+
raise WitriumClientException(
|
|
224
|
+
f"Error running workflow: {error_detail} (Status code: {e.response.status_code})"
|
|
225
|
+
)
|
|
226
|
+
except Exception as e:
|
|
227
|
+
raise WitriumClientException(f"Error running workflow: {str(e)}")
|
|
228
|
+
|
|
229
|
+
def get_workflow_results(self, run_id: str) -> WorkflowRunResultsSchema:
|
|
230
|
+
"""
|
|
231
|
+
Get workflow run results.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
run_id: The ID of the workflow run.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Dict containing the workflow run results.
|
|
238
|
+
"""
|
|
239
|
+
url = f"{self.base_url}/v1/runs/{run_id}/results"
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
response = self._client.get(url)
|
|
243
|
+
response.raise_for_status()
|
|
244
|
+
return WorkflowRunResultsSchema.model_validate(response.json())
|
|
245
|
+
except httpx.HTTPStatusError as e:
|
|
246
|
+
error_detail = self._extract_error_detail(e.response)
|
|
247
|
+
raise WitriumClientException(
|
|
248
|
+
f"Error getting workflow results: {error_detail} (Status code: {e.response.status_code})"
|
|
249
|
+
)
|
|
250
|
+
except Exception as e:
|
|
251
|
+
raise WitriumClientException(f"Error getting workflow results: {str(e)}")
|
|
252
|
+
|
|
253
|
+
def run_workflow_and_wait(
|
|
254
|
+
self,
|
|
255
|
+
workflow_id: str,
|
|
256
|
+
args: Optional[Dict[str, Union[str, int, float]]] = None,
|
|
257
|
+
use_states: Optional[List[str]] = None,
|
|
258
|
+
preserve_state: Optional[str] = None,
|
|
259
|
+
no_intelligence: bool = False,
|
|
260
|
+
polling_interval: int = 5,
|
|
261
|
+
timeout: int = 300,
|
|
262
|
+
return_intermediate_results: bool = False,
|
|
263
|
+
on_progress: Optional[Callable[[WorkflowRunResultsSchema], Any]] = None,
|
|
264
|
+
) -> Union[WorkflowRunResultsSchema, List[WorkflowRunResultsSchema]]:
|
|
265
|
+
"""
|
|
266
|
+
Run a workflow and wait for results by polling until completion.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
workflow_id: The ID of the workflow to run.
|
|
270
|
+
args: Optional arguments to pass to the workflow.
|
|
271
|
+
use_states: Optional list of session IDs to use.
|
|
272
|
+
preserve_state: Optional session ID to preserve.
|
|
273
|
+
no_intelligence: Whether to run without AI intelligence.
|
|
274
|
+
polling_interval: Seconds to wait between polling attempts.
|
|
275
|
+
timeout: Maximum seconds to poll before timing out.
|
|
276
|
+
return_intermediate_results: If True, returns a list of all polled results.
|
|
277
|
+
on_progress: Optional callback function that receives each intermediate result.
|
|
278
|
+
This is called on each polling iteration with the current results.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Dict containing the final workflow run results, or if return_intermediate_results=True,
|
|
282
|
+
a list of all polled result dictionaries.
|
|
283
|
+
"""
|
|
284
|
+
# Run the workflow
|
|
285
|
+
run_response = self.run_workflow(
|
|
286
|
+
workflow_id=workflow_id,
|
|
287
|
+
args=args,
|
|
288
|
+
use_states=use_states,
|
|
289
|
+
preserve_state=preserve_state,
|
|
290
|
+
no_intelligence=no_intelligence,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
run_id = run_response.run_id
|
|
294
|
+
start_time = time.time()
|
|
295
|
+
intermediate_results = []
|
|
296
|
+
|
|
297
|
+
# Poll for results
|
|
298
|
+
while time.time() - start_time < timeout:
|
|
299
|
+
results = self.get_workflow_results(run_id)
|
|
300
|
+
|
|
301
|
+
# Store intermediate results if requested
|
|
302
|
+
if return_intermediate_results:
|
|
303
|
+
intermediate_results.append(results)
|
|
304
|
+
|
|
305
|
+
# Call progress callback if provided
|
|
306
|
+
if on_progress:
|
|
307
|
+
on_progress(results)
|
|
308
|
+
|
|
309
|
+
# Check if workflow has completed
|
|
310
|
+
if results.status in WorkflowRunStatus.TERMINAL_STATUSES:
|
|
311
|
+
return intermediate_results if return_intermediate_results else results
|
|
312
|
+
|
|
313
|
+
# Wait before polling again
|
|
314
|
+
time.sleep(polling_interval)
|
|
315
|
+
|
|
316
|
+
raise WitriumClientException(
|
|
317
|
+
f"Workflow execution timed out after {timeout} seconds"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def wait_until_state(
|
|
321
|
+
self,
|
|
322
|
+
run_id: str,
|
|
323
|
+
target_status: str,
|
|
324
|
+
all_instructions_executed: bool = False,
|
|
325
|
+
min_wait_time: int = 0,
|
|
326
|
+
polling_interval: int = 2,
|
|
327
|
+
timeout: int = 60,
|
|
328
|
+
) -> WorkflowRunResultsSchema:
|
|
329
|
+
"""
|
|
330
|
+
Wait for a workflow run to reach a specific status by polling.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
run_id: The ID of the workflow run to wait for.
|
|
334
|
+
target_status: The status to wait for (e.g., WorkflowRunStatus.RUNNING).
|
|
335
|
+
all_instructions_executed: If True, also wait for all executions to be completed.
|
|
336
|
+
min_wait_time: Minimum time in seconds to wait before starting polling. Useful when you know approximately how long the workflow will take.
|
|
337
|
+
polling_interval: Seconds to wait between polling attempts.
|
|
338
|
+
timeout: Maximum seconds to poll before timing out.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
WorkflowRunResultsSchema when the target status is reached.
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
WitriumClientException: If timeout is reached or workflow reaches an unexpected terminal status.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
# Wait for minimum time before starting to poll
|
|
348
|
+
if min_wait_time > 0:
|
|
349
|
+
time.sleep(min_wait_time)
|
|
350
|
+
|
|
351
|
+
def _check_all_executions_completed(results: WorkflowRunResultsSchema) -> bool:
|
|
352
|
+
"""Check if all executions have completed status."""
|
|
353
|
+
if not results.executions:
|
|
354
|
+
return False
|
|
355
|
+
return results.executions[-1].status == AgentExecutionStatus.COMPLETED
|
|
356
|
+
|
|
357
|
+
def _should_continue_polling(results: WorkflowRunResultsSchema) -> bool:
|
|
358
|
+
"""Determine if we should continue polling based on target status and execution completion."""
|
|
359
|
+
status_not_reached = results.status != target_status
|
|
360
|
+
terminal_status_reached = (
|
|
361
|
+
results.status in WorkflowRunStatus.TERMINAL_STATUSES
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# If we've reached a terminal status but it's not our target, stop retrying
|
|
365
|
+
if terminal_status_reached and status_not_reached:
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
# If target status is not reached, continue polling
|
|
369
|
+
if status_not_reached:
|
|
370
|
+
return True
|
|
371
|
+
|
|
372
|
+
# If target status is reached but we also need all instructions executed
|
|
373
|
+
if all_instructions_executed and not _check_all_executions_completed(
|
|
374
|
+
results
|
|
375
|
+
):
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
# All conditions met, stop polling
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
@retry(
|
|
382
|
+
stop=stop_after_delay(timeout),
|
|
383
|
+
wait=wait_fixed(polling_interval),
|
|
384
|
+
retry=retry_if_result(_should_continue_polling),
|
|
385
|
+
)
|
|
386
|
+
def _poll_for_status():
|
|
387
|
+
results = self.get_workflow_results(run_id)
|
|
388
|
+
|
|
389
|
+
# Check if workflow has reached the target status
|
|
390
|
+
status_reached = results.status == target_status
|
|
391
|
+
all_executions_completed = (
|
|
392
|
+
_check_all_executions_completed(results)
|
|
393
|
+
if all_instructions_executed
|
|
394
|
+
else True
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
if status_reached and all_executions_completed:
|
|
398
|
+
return results
|
|
399
|
+
|
|
400
|
+
# Check if workflow has reached a terminal status that's not our target
|
|
401
|
+
if (
|
|
402
|
+
results.status in WorkflowRunStatus.TERMINAL_STATUSES
|
|
403
|
+
and results.status != target_status
|
|
404
|
+
):
|
|
405
|
+
current_status_name = WorkflowRunStatus.get_status_name(results.status)
|
|
406
|
+
target_status_name = WorkflowRunStatus.get_status_name(target_status)
|
|
407
|
+
raise WitriumClientException(
|
|
408
|
+
f"Workflow run reached terminal status '{current_status_name}' before reaching target status '{target_status_name}'"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# Return results for retry evaluation
|
|
412
|
+
return results
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
return _poll_for_status()
|
|
416
|
+
except Exception as e:
|
|
417
|
+
if "retry" in str(e).lower():
|
|
418
|
+
target_status_name = WorkflowRunStatus.get_status_name(target_status)
|
|
419
|
+
condition_msg = f"status '{target_status_name}'"
|
|
420
|
+
if all_instructions_executed:
|
|
421
|
+
condition_msg += " and all instructions executed"
|
|
422
|
+
raise WitriumClientException(
|
|
423
|
+
f"Workflow run did not reach {condition_msg} within {timeout} seconds"
|
|
424
|
+
)
|
|
425
|
+
raise
|
|
426
|
+
|
|
427
|
+
def cancel_run(self, run_id: str) -> WorkflowRunSchema:
|
|
428
|
+
"""
|
|
429
|
+
Cancel a workflow run and clean up associated resources.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
run_id: The ID of the workflow run to cancel.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Dict containing the workflow run results.
|
|
436
|
+
"""
|
|
437
|
+
url = f"{self.base_url}/v1/runs/{run_id}/cancel"
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
response = self._client.post(url)
|
|
441
|
+
response.raise_for_status()
|
|
442
|
+
return WorkflowRunSchema.model_validate(response.json())
|
|
443
|
+
except httpx.HTTPStatusError as e:
|
|
444
|
+
error_detail = self._extract_error_detail(e.response)
|
|
445
|
+
raise WitriumClientException(
|
|
446
|
+
f"Error cancelling workflow run: {error_detail} (Status code: {e.response.status_code})"
|
|
447
|
+
)
|
|
448
|
+
except Exception as e:
|
|
449
|
+
raise WitriumClientException(f"Error cancelling workflow run: {str(e)}")
|
|
450
|
+
|
|
451
|
+
def _extract_error_detail(self, response: httpx.Response) -> str:
|
|
452
|
+
"""Extract error detail from response."""
|
|
453
|
+
try:
|
|
454
|
+
error_json = response.json()
|
|
455
|
+
if "detail" in error_json:
|
|
456
|
+
return error_json["detail"]
|
|
457
|
+
return str(error_json)
|
|
458
|
+
except Exception:
|
|
459
|
+
return response.text or "Unknown error"
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class AsyncWitriumClient(WitriumClient):
|
|
463
|
+
"""Asynchronous Witrium API client."""
|
|
464
|
+
|
|
465
|
+
def __init__(self, api_token: str, timeout: int = 60, verify_ssl: bool = True):
|
|
466
|
+
"""Initialize the asynchronous client."""
|
|
467
|
+
super().__init__(
|
|
468
|
+
"https://api.witrium.com", api_token, timeout, verify_ssl
|
|
469
|
+
)
|
|
470
|
+
self._client = httpx.AsyncClient(
|
|
471
|
+
timeout=self.timeout, verify=self.verify_ssl, headers=self._headers
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
async def close(self):
|
|
475
|
+
"""Close the underlying HTTP client."""
|
|
476
|
+
if self._client:
|
|
477
|
+
await self._client.aclose()
|
|
478
|
+
|
|
479
|
+
async def __aenter__(self):
|
|
480
|
+
return self
|
|
481
|
+
|
|
482
|
+
async def __aexit__(self, exc_type, exc_value, traceback):
|
|
483
|
+
await self.close()
|
|
484
|
+
|
|
485
|
+
async def run_workflow(
|
|
486
|
+
self,
|
|
487
|
+
workflow_id: str,
|
|
488
|
+
args: Optional[Dict[str, Union[str, int, float]]] = None,
|
|
489
|
+
use_states: Optional[List[str]] = None,
|
|
490
|
+
preserve_state: Optional[str] = None,
|
|
491
|
+
no_intelligence: bool = False,
|
|
492
|
+
record_session: Optional[bool] = False,
|
|
493
|
+
keep_session_alive: bool = False,
|
|
494
|
+
use_existing_session: Optional[str] = None,
|
|
495
|
+
) -> WorkflowRunSubmittedSchema:
|
|
496
|
+
"""
|
|
497
|
+
Run a workflow by ID.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
workflow_id: The ID of the workflow to run.
|
|
501
|
+
args: Optional arguments to pass to the workflow.
|
|
502
|
+
use_states: Optional list of state names to use.
|
|
503
|
+
preserve_state: Optional state name to preserve.
|
|
504
|
+
no_intelligence: Whether to run without AI intelligence.
|
|
505
|
+
record_session: Whether to record the session.
|
|
506
|
+
keep_session_alive: Whether to keep the session alive.
|
|
507
|
+
use_existing_session: The ID of the existing session to use.
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
Dict containing workflow_id, run_id and status.
|
|
511
|
+
"""
|
|
512
|
+
url = f"{self.base_url}/v1/workflows/{workflow_id}/run"
|
|
513
|
+
payload = {
|
|
514
|
+
"args": args,
|
|
515
|
+
"use_states": use_states,
|
|
516
|
+
"preserve_state": preserve_state,
|
|
517
|
+
"no_intelligence": no_intelligence,
|
|
518
|
+
"record_session": record_session,
|
|
519
|
+
"keep_session_alive": keep_session_alive,
|
|
520
|
+
"use_existing_session": use_existing_session,
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
response = await self._client.post(url, json=payload)
|
|
525
|
+
response.raise_for_status()
|
|
526
|
+
return WorkflowRunSubmittedSchema.model_validate(response.json())
|
|
527
|
+
except httpx.HTTPStatusError as e:
|
|
528
|
+
error_detail = await self._extract_error_detail(e.response)
|
|
529
|
+
raise WitriumClientException(
|
|
530
|
+
f"Error running workflow: {error_detail} (Status code: {e.response.status_code})"
|
|
531
|
+
)
|
|
532
|
+
except Exception as e:
|
|
533
|
+
raise WitriumClientException(f"Error running workflow: {str(e)}")
|
|
534
|
+
|
|
535
|
+
async def get_workflow_results(self, run_id: str) -> WorkflowRunResultsSchema:
|
|
536
|
+
"""
|
|
537
|
+
Get workflow run results.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
run_id: The ID of the workflow run.
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
Dict containing the workflow run results.
|
|
544
|
+
"""
|
|
545
|
+
url = f"{self.base_url}/v1/runs/{run_id}/results"
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
response = await self._client.get(url)
|
|
549
|
+
response.raise_for_status()
|
|
550
|
+
return WorkflowRunResultsSchema.model_validate(response.json())
|
|
551
|
+
except httpx.HTTPStatusError as e:
|
|
552
|
+
error_detail = await self._extract_error_detail(e.response)
|
|
553
|
+
raise WitriumClientException(
|
|
554
|
+
f"Error getting workflow results: {error_detail} (Status code: {e.response.status_code})"
|
|
555
|
+
)
|
|
556
|
+
except Exception as e:
|
|
557
|
+
raise WitriumClientException(f"Error getting workflow results: {str(e)}")
|
|
558
|
+
|
|
559
|
+
async def run_workflow_and_wait(
|
|
560
|
+
self,
|
|
561
|
+
workflow_id: str,
|
|
562
|
+
args: Optional[Dict[str, Union[str, int, float]]] = None,
|
|
563
|
+
use_states: Optional[List[str]] = None,
|
|
564
|
+
preserve_state: Optional[str] = None,
|
|
565
|
+
no_intelligence: bool = False,
|
|
566
|
+
polling_interval: int = 5,
|
|
567
|
+
timeout: int = 300,
|
|
568
|
+
return_intermediate_results: bool = False,
|
|
569
|
+
on_progress: Optional[Callable[[WorkflowRunResultsSchema], Any]] = None,
|
|
570
|
+
) -> Union[WorkflowRunResultsSchema, List[WorkflowRunResultsSchema]]:
|
|
571
|
+
"""
|
|
572
|
+
Run a workflow and wait for results by polling until completion.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
workflow_id: The ID of the workflow to run.
|
|
576
|
+
args: Optional arguments to pass to the workflow.
|
|
577
|
+
use_states: Optional list of session IDs to use.
|
|
578
|
+
preserve_state: Optional session ID to preserve.
|
|
579
|
+
no_intelligence: Whether to run without AI intelligence.
|
|
580
|
+
polling_interval: Seconds to wait between polling attempts.
|
|
581
|
+
timeout: Maximum seconds to poll before timing out.
|
|
582
|
+
return_intermediate_results: If True, returns a list of all polled results.
|
|
583
|
+
on_progress: Optional callback function that receives each intermediate result.
|
|
584
|
+
This is called on each polling iteration with the current results.
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
Dict containing the final workflow run results, or if return_intermediate_results=True,
|
|
588
|
+
a list of all polled result dictionaries.
|
|
589
|
+
"""
|
|
590
|
+
# Run the workflow
|
|
591
|
+
run_response = await self.run_workflow(
|
|
592
|
+
workflow_id=workflow_id,
|
|
593
|
+
args=args,
|
|
594
|
+
use_states=use_states,
|
|
595
|
+
preserve_state=preserve_state,
|
|
596
|
+
no_intelligence=no_intelligence,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
run_id = run_response.run_id
|
|
600
|
+
start_time = time.time()
|
|
601
|
+
intermediate_results = []
|
|
602
|
+
|
|
603
|
+
# Poll for results
|
|
604
|
+
while time.time() - start_time < timeout:
|
|
605
|
+
results = await self.get_workflow_results(run_id)
|
|
606
|
+
|
|
607
|
+
# Store intermediate results if requested
|
|
608
|
+
if return_intermediate_results:
|
|
609
|
+
intermediate_results.append(results)
|
|
610
|
+
|
|
611
|
+
# Call progress callback if provided
|
|
612
|
+
if on_progress:
|
|
613
|
+
on_progress(results)
|
|
614
|
+
|
|
615
|
+
# Check if workflow run has completed
|
|
616
|
+
if results.status in WorkflowRunStatus.TERMINAL_STATUSES:
|
|
617
|
+
return intermediate_results if return_intermediate_results else results
|
|
618
|
+
|
|
619
|
+
# Wait before polling again
|
|
620
|
+
await asyncio.sleep(polling_interval)
|
|
621
|
+
|
|
622
|
+
raise WitriumClientException(
|
|
623
|
+
f"Workflow execution timed out after {timeout} seconds"
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
async def wait_until_state(
|
|
627
|
+
self,
|
|
628
|
+
run_id: str,
|
|
629
|
+
target_status: str,
|
|
630
|
+
all_instructions_executed: bool = False,
|
|
631
|
+
min_wait_time: int = 0,
|
|
632
|
+
polling_interval: int = 2,
|
|
633
|
+
timeout: int = 60,
|
|
634
|
+
) -> WorkflowRunResultsSchema:
|
|
635
|
+
"""
|
|
636
|
+
Wait for a workflow run to reach a specific status by polling.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
run_id: The ID of the workflow run to wait for.
|
|
640
|
+
target_status: The status to wait for (e.g., WorkflowRunStatus.RUNNING).
|
|
641
|
+
all_instructions_executed: If True, also wait for all executions to be completed.
|
|
642
|
+
min_wait_time: Minimum time in seconds to wait before starting polling. Useful when you know approximately how long the workflow will take.
|
|
643
|
+
polling_interval: Seconds to wait between polling attempts.
|
|
644
|
+
timeout: Maximum seconds to poll before timing out.
|
|
645
|
+
|
|
646
|
+
Returns:
|
|
647
|
+
WorkflowRunResultsSchema when the target status is reached.
|
|
648
|
+
|
|
649
|
+
Raises:
|
|
650
|
+
WitriumClientException: If timeout is reached or workflow reaches an unexpected terminal status.
|
|
651
|
+
"""
|
|
652
|
+
|
|
653
|
+
# Wait for minimum time before starting to poll
|
|
654
|
+
if min_wait_time > 0:
|
|
655
|
+
await asyncio.sleep(min_wait_time)
|
|
656
|
+
|
|
657
|
+
def _check_all_executions_completed(results: WorkflowRunResultsSchema) -> bool:
|
|
658
|
+
"""Check if all executions have completed status."""
|
|
659
|
+
if not results.executions:
|
|
660
|
+
return False
|
|
661
|
+
return results.executions[-1].status == AgentExecutionStatus.COMPLETED
|
|
662
|
+
|
|
663
|
+
def _should_continue_polling(results: WorkflowRunResultsSchema) -> bool:
|
|
664
|
+
"""Determine if we should continue polling based on target status and execution completion."""
|
|
665
|
+
status_not_reached = results.status != target_status
|
|
666
|
+
terminal_status_reached = (
|
|
667
|
+
results.status in WorkflowRunStatus.TERMINAL_STATUSES
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
# If we've reached a terminal status but it's not our target, stop retrying
|
|
671
|
+
if terminal_status_reached and status_not_reached:
|
|
672
|
+
return False
|
|
673
|
+
|
|
674
|
+
# If target status is not reached, continue polling
|
|
675
|
+
if status_not_reached:
|
|
676
|
+
return True
|
|
677
|
+
|
|
678
|
+
# If target status is reached but we also need all instructions executed
|
|
679
|
+
if all_instructions_executed and not _check_all_executions_completed(
|
|
680
|
+
results
|
|
681
|
+
):
|
|
682
|
+
return True
|
|
683
|
+
|
|
684
|
+
# All conditions met, stop polling
|
|
685
|
+
return False
|
|
686
|
+
|
|
687
|
+
@retry(
|
|
688
|
+
stop=stop_after_delay(timeout),
|
|
689
|
+
wait=wait_fixed(polling_interval),
|
|
690
|
+
retry=retry_if_result(_should_continue_polling),
|
|
691
|
+
)
|
|
692
|
+
async def _poll_for_status():
|
|
693
|
+
results = await self.get_workflow_results(run_id)
|
|
694
|
+
|
|
695
|
+
# Check if workflow has reached the target status
|
|
696
|
+
status_reached = results.status == target_status
|
|
697
|
+
all_executions_completed = (
|
|
698
|
+
_check_all_executions_completed(results)
|
|
699
|
+
if all_instructions_executed
|
|
700
|
+
else True
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
if status_reached and all_executions_completed:
|
|
704
|
+
return results
|
|
705
|
+
|
|
706
|
+
# Check if workflow has reached a terminal status that's not our target
|
|
707
|
+
if (
|
|
708
|
+
results.status in WorkflowRunStatus.TERMINAL_STATUSES
|
|
709
|
+
and results.status != target_status
|
|
710
|
+
):
|
|
711
|
+
current_status_name = WorkflowRunStatus.get_status_name(results.status)
|
|
712
|
+
target_status_name = WorkflowRunStatus.get_status_name(target_status)
|
|
713
|
+
raise WitriumClientException(
|
|
714
|
+
f"Workflow run reached terminal status '{current_status_name}' before reaching target status '{target_status_name}'"
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
# Return results for retry evaluation
|
|
718
|
+
return results
|
|
719
|
+
|
|
720
|
+
try:
|
|
721
|
+
return await _poll_for_status()
|
|
722
|
+
except Exception as e:
|
|
723
|
+
if "retry" in str(e).lower():
|
|
724
|
+
target_status_name = WorkflowRunStatus.get_status_name(target_status)
|
|
725
|
+
condition_msg = f"status '{target_status_name}'"
|
|
726
|
+
if all_instructions_executed:
|
|
727
|
+
condition_msg += " and all instructions executed"
|
|
728
|
+
raise WitriumClientException(
|
|
729
|
+
f"Workflow run did not reach {condition_msg} within {timeout} seconds"
|
|
730
|
+
)
|
|
731
|
+
raise
|
|
732
|
+
|
|
733
|
+
async def cancel_run(self, run_id: str) -> WorkflowRunSchema:
|
|
734
|
+
"""
|
|
735
|
+
Cancel a workflow run and clean up associated resources.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
run_id: The ID of the workflow run to cancel.
|
|
739
|
+
|
|
740
|
+
Returns:
|
|
741
|
+
Dict containing the workflow run results.
|
|
742
|
+
"""
|
|
743
|
+
url = f"{self.base_url}/v1/runs/{run_id}/cancel"
|
|
744
|
+
|
|
745
|
+
try:
|
|
746
|
+
response = await self._client.post(url)
|
|
747
|
+
response.raise_for_status()
|
|
748
|
+
return WorkflowRunSchema.model_validate(response.json())
|
|
749
|
+
except httpx.HTTPStatusError as e:
|
|
750
|
+
error_detail = await self._extract_error_detail(e.response)
|
|
751
|
+
raise WitriumClientException(
|
|
752
|
+
f"Error cancelling workflow run: {error_detail} (Status code: {e.response.status_code})"
|
|
753
|
+
)
|
|
754
|
+
except Exception as e:
|
|
755
|
+
raise WitriumClientException(f"Error cancelling workflow run: {str(e)}")
|
|
756
|
+
|
|
757
|
+
async def _extract_error_detail(self, response: httpx.Response) -> str:
|
|
758
|
+
"""Extract error detail from response."""
|
|
759
|
+
try:
|
|
760
|
+
error_json = response.json()
|
|
761
|
+
if "detail" in error_json:
|
|
762
|
+
return error_json["detail"]
|
|
763
|
+
return str(error_json)
|
|
764
|
+
except Exception:
|
|
765
|
+
return response.text or "Unknown error"
|
witrium/py.typed
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: witrium
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Witrium cloud browser automation API
|
|
5
|
+
Author-email: Witrium <support@witrium.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://witrium.com
|
|
8
|
+
Project-URL: Repository, https://github.com/witrium/witrium-py
|
|
9
|
+
Project-URL: Issues, https://github.com/witrium/witrium-py/issues
|
|
10
|
+
Keywords: automation,browser,web,api,client,witrium
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: httpx<1.0,>=0.28
|
|
22
|
+
Requires-Dist: pydantic<3,>=2.11
|
|
23
|
+
Requires-Dist: tenacity<10,>=9.1
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
## witrium
|
|
27
|
+
|
|
28
|
+
Python client for the Witrium cloud browser automation API.
|
|
29
|
+
|
|
30
|
+
### Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install witrium
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Quick start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from witrium import SyncWitriumClient, WorkflowRunStatus
|
|
40
|
+
|
|
41
|
+
with SyncWitriumClient(api_token="your-api-token") as client:
|
|
42
|
+
run = client.run_workflow(
|
|
43
|
+
workflow_id="workflow-uuid",
|
|
44
|
+
args={"key": "value"}
|
|
45
|
+
)
|
|
46
|
+
result = client.wait_until_state(
|
|
47
|
+
run_id=run.run_id,
|
|
48
|
+
target_status=WorkflowRunStatus.COMPLETED
|
|
49
|
+
)
|
|
50
|
+
print(result.result)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For full documentation and advanced patterns, see `witrium/README.md` in this repository.
|
|
54
|
+
|
|
55
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
witrium/__init__.py,sha256=XJkDkgVN8UUQZlBf3G-sYcyAYPIkKdiwavaFTez-ZOE,386
|
|
2
|
+
witrium/client.py,sha256=Aj_a909p22YRGYcOcbK2a-SNjU2FG5T65THd_im-0mo,28377
|
|
3
|
+
witrium/py.typed,sha256=daEdpEyAJIa8b2VkCqSKcw8PaExcB6Qro80XNes_sHA,2
|
|
4
|
+
witrium-0.1.0.dist-info/licenses/LICENSE,sha256=aYBbLVOK5jwybQuS8i_2XGurMLpQeRFo7z1p8q21cKQ,1064
|
|
5
|
+
witrium-0.1.0.dist-info/METADATA,sha256=W6zXkrz6Lp8bdc92fmwf6ysPiGdV-pwu-pkuifISItE,1564
|
|
6
|
+
witrium-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
+
witrium-0.1.0.dist-info/top_level.txt,sha256=03FKRXp2IwzH3hhHPmqRFi1Dh8QpLkVZIswZryS27Gw,8
|
|
8
|
+
witrium-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Witrium
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
witrium
|