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 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,2 @@
1
+
2
+
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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