actionbox 0.1.2__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.
actionbox/__init__.py ADDED
@@ -0,0 +1,833 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import ipaddress
5
+ import random
6
+ import secrets
7
+ import threading
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from email.utils import parsedate_to_datetime
12
+ from typing import Any, Literal, NotRequired, TypedDict, cast
13
+ from urllib.parse import quote, urlsplit
14
+
15
+ import httpx
16
+
17
+
18
+ __version__ = "0.1.2"
19
+ _MAX_RESPONSE_BYTES = 1_048_576
20
+ _MAX_RETRY_DELAY = 30.0
21
+
22
+ OptionStyle = Literal["default", "primary", "destructive"]
23
+
24
+
25
+ class ActionOption(TypedDict):
26
+ """An option used by a single-choice or multi-choice interaction."""
27
+
28
+ id: str
29
+ label: str
30
+ style: NotRequired[OptionStyle]
31
+ sort_order: NotRequired[int]
32
+
33
+
34
+ ActionOptionInput = ActionOption
35
+
36
+
37
+ class BooleanInteraction(TypedDict):
38
+ type: Literal["boolean"]
39
+ label: str
40
+ true_label: NotRequired[str]
41
+ false_label: NotRequired[str]
42
+
43
+
44
+ class SingleChoiceInteraction(TypedDict):
45
+ type: Literal["single_choice"]
46
+ label: str
47
+ options: list[ActionOption]
48
+
49
+
50
+ class MultiChoiceInteraction(TypedDict):
51
+ type: Literal["multi_choice"]
52
+ label: str
53
+ options: list[ActionOption]
54
+ min_selections: NotRequired[int]
55
+ max_selections: NotRequired[int | None]
56
+
57
+
58
+ class TextInteraction(TypedDict):
59
+ type: Literal["text"]
60
+ label: str
61
+ placeholder: NotRequired[str | None]
62
+ multiline: NotRequired[bool]
63
+ min_length: NotRequired[int]
64
+ max_length: NotRequired[int]
65
+
66
+
67
+ class IntegerInteraction(TypedDict):
68
+ type: Literal["integer"]
69
+ label: str
70
+ min: NotRequired[int | None]
71
+ max: NotRequired[int | None]
72
+ step: NotRequired[int]
73
+ unit: NotRequired[str | None]
74
+
75
+
76
+ class NumberInteraction(TypedDict):
77
+ type: Literal["number"]
78
+ label: str
79
+ min: NotRequired[int | float | None]
80
+ max: NotRequired[int | float | None]
81
+ step: NotRequired[int | float]
82
+ unit: NotRequired[str | None]
83
+
84
+
85
+ class RatingInteraction(TypedDict):
86
+ type: Literal["rating"]
87
+ label: str
88
+ min: NotRequired[int]
89
+ max: NotRequired[int]
90
+ low_label: NotRequired[str | None]
91
+ high_label: NotRequired[str | None]
92
+
93
+
94
+ class FormBooleanField(BooleanInteraction):
95
+ id: str
96
+ required: NotRequired[bool]
97
+
98
+
99
+ class FormSingleChoiceField(SingleChoiceInteraction):
100
+ id: str
101
+ required: NotRequired[bool]
102
+
103
+
104
+ class FormMultiChoiceField(MultiChoiceInteraction):
105
+ id: str
106
+ required: NotRequired[bool]
107
+
108
+
109
+ class FormTextField(TextInteraction):
110
+ id: str
111
+ required: NotRequired[bool]
112
+
113
+
114
+ class FormIntegerField(IntegerInteraction):
115
+ id: str
116
+ required: NotRequired[bool]
117
+
118
+
119
+ class FormNumberField(NumberInteraction):
120
+ id: str
121
+ required: NotRequired[bool]
122
+
123
+
124
+ class FormRatingField(RatingInteraction):
125
+ id: str
126
+ required: NotRequired[bool]
127
+
128
+
129
+ FormField = (
130
+ FormBooleanField
131
+ | FormSingleChoiceField
132
+ | FormMultiChoiceField
133
+ | FormTextField
134
+ | FormIntegerField
135
+ | FormNumberField
136
+ | FormRatingField
137
+ )
138
+
139
+
140
+ class FormInteraction(TypedDict):
141
+ type: Literal["form"]
142
+ label: str
143
+ fields: list[FormField]
144
+
145
+
146
+ TypedInteraction = (
147
+ BooleanInteraction
148
+ | SingleChoiceInteraction
149
+ | MultiChoiceInteraction
150
+ | TextInteraction
151
+ | IntegerInteraction
152
+ | NumberInteraction
153
+ | RatingInteraction
154
+ | FormInteraction
155
+ )
156
+ Interaction = TypedInteraction
157
+
158
+
159
+ class BooleanResponse(TypedDict):
160
+ type: Literal["boolean"]
161
+ value: bool
162
+
163
+
164
+ class TextResponse(TypedDict):
165
+ type: Literal["text"]
166
+ value: str
167
+
168
+
169
+ class IntegerResponse(TypedDict):
170
+ type: Literal["integer"]
171
+ value: int
172
+
173
+
174
+ class NumberResponse(TypedDict):
175
+ type: Literal["number"]
176
+ value: int | float
177
+
178
+
179
+ class RatingResponse(TypedDict):
180
+ type: Literal["rating"]
181
+ value: int
182
+
183
+
184
+ class SingleChoiceResponse(TypedDict):
185
+ type: Literal["single_choice"]
186
+ value: str
187
+
188
+
189
+ class MultiChoiceResponse(TypedDict):
190
+ type: Literal["multi_choice"]
191
+ value: list[str]
192
+
193
+
194
+ class FormResponse(TypedDict):
195
+ type: Literal["form"]
196
+ values: dict[str, Any]
197
+
198
+
199
+ TypedResponse = (
200
+ BooleanResponse
201
+ | TextResponse
202
+ | IntegerResponse
203
+ | NumberResponse
204
+ | RatingResponse
205
+ | SingleChoiceResponse
206
+ | MultiChoiceResponse
207
+ | FormResponse
208
+ )
209
+ InteractionResponse = TypedResponse
210
+ Response = TypedResponse
211
+
212
+
213
+ class ActionCreateInput(TypedDict, total=False):
214
+ """The JSON fields accepted by ``Actionbox.create``."""
215
+
216
+ title: str
217
+ description: str
218
+ priority: Literal["low", "normal", "high", "urgent"]
219
+ open_url: str
220
+ dedupe_key: str
221
+ callback_url: str
222
+ expires_at: str
223
+ on_expire: dict[str, Any]
224
+ context: list[dict[str, Any]]
225
+ options: list[ActionOption]
226
+ interaction: TypedInteraction
227
+ metadata: dict[str, Any]
228
+
229
+
230
+ class ResolveInput(TypedDict, total=False):
231
+ """The JSON fields accepted by the generic resolve endpoint."""
232
+
233
+ action_version: int
234
+ fingerprint: str
235
+ option_id: str
236
+ response: TypedResponse
237
+ reason: str
238
+
239
+
240
+ class ActionboxError(RuntimeError):
241
+ def __init__(self, message: str, code: str = "UNKNOWN", status: int = 500) -> None:
242
+ super().__init__(message)
243
+ self.code = code
244
+ self.status = status
245
+
246
+
247
+ WatchStatus = Literal["new", "healthy", "down", "paused"]
248
+ WatchScheduleType = Literal["interval", "cron"]
249
+
250
+
251
+ class WatchCreateInput(TypedDict, total=False):
252
+ source_id: str
253
+ name: str
254
+ schedule_type: WatchScheduleType
255
+ interval_seconds: int
256
+ cron_expression: str
257
+ timezone: str
258
+ grace_seconds: int
259
+ max_runtime_seconds: int
260
+ priority: Literal["low", "normal", "high", "urgent"]
261
+ runbook_url: str
262
+
263
+
264
+ @dataclass
265
+ class Watch:
266
+ """Safe Watch state. The raw heartbeat URL is only present on creation."""
267
+
268
+ client: "Actionbox"
269
+ data: dict[str, Any]
270
+
271
+ @property
272
+ def id(self) -> str:
273
+ return self.data["id"]
274
+
275
+ @property
276
+ def name(self) -> str:
277
+ return self.data["name"]
278
+
279
+ @property
280
+ def status(self) -> str:
281
+ return self.data["status"]
282
+
283
+ @property
284
+ def heartbeat_url(self) -> str | None:
285
+ value = self.data.get("heartbeat_url")
286
+ return value if isinstance(value, str) else None
287
+
288
+
289
+ class WatchesResource:
290
+ def __init__(self, client: "Actionbox") -> None:
291
+ self.client = client
292
+
293
+ def list(self, *, cancel_event: threading.Event | None = None) -> list[Watch]:
294
+ data = self.client._request("GET", "/v1/source/watches", cancel_event=cancel_event)
295
+ if not isinstance(data, list):
296
+ raise ActionboxError("Actionbox returned an invalid Watch list.", "INVALID_RESPONSE", 200)
297
+ return [Watch(self.client, item) for item in data if isinstance(item, dict)]
298
+
299
+ def create(self, *, cancel_event: threading.Event | None = None, **payload: Any) -> Watch:
300
+ data = self.client._request(
301
+ "POST",
302
+ "/v1/source/watches",
303
+ json=payload,
304
+ cancel_event=cancel_event,
305
+ )
306
+ return Watch(self.client, data)
307
+
308
+ def pause(self, watch_id: str, *, cancel_event: threading.Event | None = None) -> Watch:
309
+ data = self.client._request(
310
+ "POST",
311
+ f"/v1/source/watches/{quote(watch_id, safe='')}/pause",
312
+ cancel_event=cancel_event,
313
+ )
314
+ return Watch(self.client, data)
315
+
316
+ def resume(self, watch_id: str, *, cancel_event: threading.Event | None = None) -> Watch:
317
+ data = self.client._request(
318
+ "POST",
319
+ f"/v1/source/watches/{quote(watch_id, safe='')}/resume",
320
+ cancel_event=cancel_event,
321
+ )
322
+ return Watch(self.client, data)
323
+
324
+ def rotate_token(self, watch_id: str, *, cancel_event: threading.Event | None = None) -> Watch:
325
+ data = self.client._request(
326
+ "POST",
327
+ f"/v1/source/watches/{quote(watch_id, safe='')}/token/rotate",
328
+ cancel_event=cancel_event,
329
+ )
330
+ return Watch(self.client, data)
331
+
332
+ def archive(self, watch_id: str, *, cancel_event: threading.Event | None = None) -> None:
333
+ self.client._request(
334
+ "DELETE",
335
+ f"/v1/source/watches/{quote(watch_id, safe='')}",
336
+ cancel_event=cancel_event,
337
+ )
338
+
339
+
340
+ @dataclass
341
+ class Action:
342
+ client: "Actionbox"
343
+ data: dict[str, Any]
344
+
345
+ @property
346
+ def id(self) -> str:
347
+ return self.data["id"]
348
+
349
+ @property
350
+ def status(self) -> str:
351
+ return self.data["status"]
352
+
353
+ @property
354
+ def environment(self) -> str:
355
+ value = self.data.get("environment", "live")
356
+ return value if value in {"live", "test"} else "live"
357
+
358
+ @property
359
+ def action_version(self) -> int:
360
+ return int(self.data.get("action_version", 1))
361
+
362
+ @property
363
+ def fingerprint(self) -> str | None:
364
+ value = self.data.get("fingerprint")
365
+ return value if isinstance(value, str) else None
366
+
367
+ @property
368
+ def decision(self) -> str | None:
369
+ return self.data.get("resolution_option_id")
370
+
371
+ @property
372
+ def interaction(self) -> TypedInteraction | None:
373
+ return cast(TypedInteraction | None, self.data.get("interaction"))
374
+
375
+ @property
376
+ def response(self) -> TypedResponse | None:
377
+ return cast(TypedResponse | None, self.data.get("response"))
378
+
379
+ @property
380
+ def receipt(self) -> str | None:
381
+ value = self.data.get("receipt")
382
+ return value if isinstance(value, str) else None
383
+
384
+ def refresh(self, *, cancel_event: threading.Event | None = None) -> "Action":
385
+ self.data = self.client.get(self.id, cancel_event=cancel_event).data
386
+ return self
387
+
388
+ def wait(
389
+ self,
390
+ timeout: float = 3600,
391
+ poll_interval: float = 2,
392
+ *,
393
+ cancel_event: threading.Event | None = None,
394
+ ) -> str | TypedResponse | None:
395
+ if timeout <= 0:
396
+ raise ValueError("timeout must be greater than zero")
397
+ if poll_interval <= 0:
398
+ raise ValueError("poll_interval must be greater than zero")
399
+ deadline = time.monotonic() + timeout
400
+ while self.status == "open" and time.monotonic() < deadline:
401
+ remaining = deadline - time.monotonic()
402
+ if remaining <= 0:
403
+ break
404
+ wait_sec = min(int(max(1, min(remaining, poll_interval * 5))), 30)
405
+ if cancel_event is not None and cancel_event.is_set():
406
+ raise ActionboxError("Actionbox wait was cancelled.", "ABORTED", 0)
407
+ self.data = self.client.get(self.id, wait_seconds=wait_sec, cancel_event=cancel_event).data
408
+ if self.status != "open" or time.monotonic() >= deadline:
409
+ break
410
+ if self.status == "open":
411
+ return None
412
+ # Return a concise string for single-choice Actions and the typed
413
+ # response for boolean, numeric, text, multi-choice, and form inputs.
414
+ return self.decision if self.decision is not None else self.response
415
+
416
+
417
+ class ActionsResource:
418
+ def __init__(self, client: "Actionbox") -> None:
419
+ self.client = client
420
+
421
+ def create(self, **payload: Any) -> Action:
422
+ return self.client.create(**payload)
423
+
424
+ def resolve(
425
+ self,
426
+ action_id: str,
427
+ option_id: str | ResolveInput | TypedResponse | None = None,
428
+ reason: str | None = None,
429
+ *,
430
+ response: TypedResponse | None = None,
431
+ action_version: int | None = None,
432
+ fingerprint: str | None = None,
433
+ ) -> Action:
434
+ return self.client.resolve(
435
+ action_id,
436
+ option_id,
437
+ reason,
438
+ response=response,
439
+ action_version=action_version,
440
+ fingerprint=fingerprint,
441
+ )
442
+
443
+
444
+ class Actionbox:
445
+ """Small synchronous client for source-authenticated Actionbox APIs."""
446
+
447
+ def __init__(
448
+ self,
449
+ api_key: str,
450
+ base_url: str = "https://api.actionbox.cloud",
451
+ timeout: float = 10,
452
+ max_retries: int = 2,
453
+ *,
454
+ http_client: httpx.Client | None = None,
455
+ ) -> None:
456
+ if not api_key or any(character.isspace() for character in api_key):
457
+ raise ValueError("api_key must be a non-empty token without whitespace")
458
+ if timeout <= 0:
459
+ raise ValueError("timeout must be greater than zero")
460
+ if not isinstance(max_retries, int) or not 0 <= max_retries <= 5:
461
+ raise ValueError("max_retries must be an integer from 0 through 5")
462
+ self.base_url = _validate_base_url(base_url)
463
+ self.http = http_client or httpx.Client(timeout=timeout)
464
+ self._owns_http_client = http_client is None
465
+ self.timeout = timeout
466
+ self.max_retries = max_retries
467
+ self.headers = {
468
+ "Authorization": f"Bearer {api_key}",
469
+ "X-Actionbox-Client": f"python-sdk/{__version__}",
470
+ }
471
+ self.actions = ActionsResource(self)
472
+ self.watches = WatchesResource(self)
473
+
474
+ def close(self) -> None:
475
+ if self._owns_http_client:
476
+ self.http.close()
477
+
478
+ def __enter__(self) -> "Actionbox":
479
+ return self
480
+
481
+ def __exit__(self, *_: object) -> None:
482
+ self.close()
483
+
484
+ def _request(
485
+ self,
486
+ method: str,
487
+ path: str,
488
+ *,
489
+ json: dict[str, Any] | None = None,
490
+ headers: dict[str, str] | None = None,
491
+ cancel_event: threading.Event | None = None,
492
+ timeout: float | None = None,
493
+ ) -> dict[str, Any] | list[Any]:
494
+ request_headers = {**self.headers, **(headers or {})}
495
+ retryable = method.upper() in {"GET", "HEAD", "OPTIONS"} or "Idempotency-Key" in request_headers
496
+ attempts = self.max_retries + 1 if retryable else 1
497
+
498
+ for attempt in range(attempts):
499
+ _raise_if_cancelled(cancel_event)
500
+ request = self.http.build_request(
501
+ method,
502
+ f"{self.base_url}{path}",
503
+ json=json,
504
+ headers=request_headers,
505
+ timeout=timeout if timeout is not None else self.timeout,
506
+ )
507
+ try:
508
+ response = self.http.send(request, stream=True)
509
+ except httpx.TimeoutException as exc:
510
+ if retryable and attempt + 1 < attempts:
511
+ _wait_before_retry(attempt, None, cancel_event)
512
+ continue
513
+ raise ActionboxError("Actionbox request timed out.", "TIMEOUT", 0) from exc
514
+ except httpx.TransportError as exc:
515
+ if retryable and attempt + 1 < attempts:
516
+ _wait_before_retry(attempt, None, cancel_event)
517
+ continue
518
+ raise ActionboxError("Actionbox network request failed.", "NETWORK_ERROR", 0) from exc
519
+
520
+ try:
521
+ raw_body = _read_bounded_body(response)
522
+ finally:
523
+ response.close()
524
+
525
+ if retryable and attempt + 1 < attempts and (response.status_code == 429 or response.status_code >= 500):
526
+ _wait_before_retry(attempt, response.headers.get("Retry-After"), cancel_event)
527
+ continue
528
+
529
+ body = _parse_response_body(raw_body, response.status_code)
530
+ if not response.is_success:
531
+ error = body.get("error", {}) if isinstance(body.get("error"), dict) else {}
532
+ detail = body.get("detail") if isinstance(body.get("detail"), str) else None
533
+ raise ActionboxError(
534
+ error.get("message", detail or "Actionbox request failed."),
535
+ error.get("code", "UNKNOWN"),
536
+ response.status_code,
537
+ )
538
+ data = body.get("data", body)
539
+ if not isinstance(data, (dict, list)):
540
+ raise ActionboxError("Actionbox returned an invalid response envelope.", "INVALID_RESPONSE", response.status_code)
541
+ return data
542
+
543
+ raise ActionboxError("Actionbox request failed after retries.", "RETRY_EXHAUSTED", 0)
544
+
545
+ def create(
546
+ self,
547
+ *,
548
+ title: str,
549
+ description: str = "",
550
+ options: list[ActionOption] | None = None,
551
+ interaction: TypedInteraction | None = None,
552
+ idempotency_key: str | None = None,
553
+ **payload: Any,
554
+ ) -> Action:
555
+ if options is not None and interaction is not None:
556
+ raise ValueError("provide options or interaction, not both")
557
+ request_headers = {"Idempotency-Key": idempotency_key or f"sdk-{secrets.token_urlsafe(18)}"}
558
+ request_payload: dict[str, Any] = {"title": title, "description": description, **payload}
559
+ if interaction is not None:
560
+ request_payload["interaction"] = interaction
561
+ else:
562
+ request_payload["options"] = options or []
563
+ data = self._request(
564
+ "POST",
565
+ "/v1/actions",
566
+ json=request_payload,
567
+ headers=request_headers,
568
+ )
569
+ return Action(self, data)
570
+
571
+ def get(
572
+ self,
573
+ action_id: str,
574
+ *,
575
+ wait_seconds: int = 0,
576
+ cancel_event: threading.Event | None = None,
577
+ ) -> Action:
578
+ encoded_id = quote(action_id, safe="")
579
+ path = f"/v1/source/actions/{encoded_id}"
580
+ if wait_seconds > 0:
581
+ path = f"{path}?wait_seconds={min(int(wait_seconds), 30)}"
582
+ request_timeout = self.timeout
583
+ if wait_seconds > 0:
584
+ request_timeout = max(request_timeout, min(wait_seconds, 30) + 5)
585
+ return Action(
586
+ self,
587
+ self._request(
588
+ "GET",
589
+ path,
590
+ cancel_event=cancel_event,
591
+ timeout=request_timeout,
592
+ ),
593
+ )
594
+
595
+ def resolve(
596
+ self,
597
+ action_id: str,
598
+ option_id: str | ResolveInput | TypedResponse | None = None,
599
+ reason: str | None = None,
600
+ *,
601
+ response: TypedResponse | None = None,
602
+ action_version: int | None = None,
603
+ fingerprint: str | None = None,
604
+ ) -> Action:
605
+ if isinstance(option_id, Mapping):
606
+ generic_input = dict(option_id)
607
+ if "type" in generic_input and "response" not in generic_input:
608
+ response = cast(TypedResponse, generic_input)
609
+ option_id = None
610
+ else:
611
+ response = cast(TypedResponse | None, generic_input.get("response", response))
612
+ reason = cast(str | None, generic_input.get("reason", reason))
613
+ option_id = cast(str | None, generic_input.get("option_id"))
614
+ action_version = cast(int | None, generic_input.get("action_version", action_version))
615
+ fingerprint = cast(str | None, generic_input.get("fingerprint", fingerprint))
616
+
617
+ request_payload: dict[str, Any] = {"option_id": option_id, "reason": reason}
618
+ if action_version is not None:
619
+ request_payload["action_version"] = action_version
620
+ if fingerprint is not None:
621
+ request_payload["fingerprint"] = fingerprint
622
+ if response is not None:
623
+ request_payload["response"] = response
624
+ return Action(
625
+ self,
626
+ self._request(
627
+ "POST",
628
+ f"/v1/actions/{quote(action_id, safe='')}/resolve",
629
+ json=request_payload,
630
+ ),
631
+ )
632
+
633
+ def cancel(self, action_id: str, reason: str | None = None) -> Action:
634
+ return Action(self, self._request("POST", f"/v1/actions/{quote(action_id, safe='')}/cancel", json={"reason": reason}))
635
+
636
+ def ask(
637
+ self,
638
+ *,
639
+ title: str,
640
+ options: list[str] | None = None,
641
+ interaction: TypedInteraction | None = None,
642
+ wait: bool = True,
643
+ timeout: float = 3600,
644
+ poll_interval: float = 2,
645
+ cancel_event: threading.Event | None = None,
646
+ **payload: Any,
647
+ ) -> str | TypedResponse | Action | None:
648
+ if options is None and interaction is None:
649
+ raise ValueError("provide options or interaction")
650
+ if options is not None and interaction is not None:
651
+ raise ValueError("provide options or interaction, not both")
652
+ normalized = (
653
+ [{"id": option.lower().replace(" ", "-"), "label": option} for option in options]
654
+ if options is not None
655
+ else None
656
+ )
657
+ action = self.create(title=title, options=normalized, interaction=interaction, **payload)
658
+ return action.wait(timeout=timeout, poll_interval=poll_interval, cancel_event=cancel_event) if wait else action
659
+
660
+
661
+ def send_heartbeat(
662
+ heartbeat_url: str,
663
+ signal: Literal["ping", "start", "success", "fail"] = "ping",
664
+ *,
665
+ timeout: float = 10,
666
+ cancel_event: threading.Event | None = None,
667
+ ) -> None:
668
+ """Deliver one Watch heartbeat without placing its capability in errors."""
669
+ _raise_if_cancelled(cancel_event)
670
+ if timeout <= 0:
671
+ raise ValueError("timeout must be greater than zero")
672
+ if signal not in {"ping", "start", "success", "fail"}:
673
+ raise ValueError("signal must be ping, start, success, or fail")
674
+ parsed = urlsplit(heartbeat_url.strip())
675
+ segments = [part for part in parsed.path.split("/") if part]
676
+ if (
677
+ len(segments) != 2
678
+ or segments[0] != "hb"
679
+ or not segments[1].startswith("hb_")
680
+ or parsed.username
681
+ or parsed.password
682
+ or parsed.query
683
+ or parsed.fragment
684
+ or not parsed.hostname
685
+ ):
686
+ raise ValueError("heartbeat_url must be an Actionbox heartbeat URL")
687
+ try:
688
+ address = ipaddress.ip_address(parsed.hostname)
689
+ loopback = address.is_loopback
690
+ except ValueError:
691
+ loopback = parsed.hostname.casefold() == "localhost"
692
+ if parsed.scheme != "https" and not (parsed.scheme == "http" and loopback):
693
+ raise ValueError("heartbeat_url must use HTTPS unless it targets localhost")
694
+ path = parsed.path if signal == "ping" else f"{parsed.path.rstrip('/')}/{signal}"
695
+ url = f"{parsed.scheme}://{parsed.netloc}{path}"
696
+ try:
697
+ response = httpx.get(url, timeout=timeout)
698
+ except httpx.HTTPError as exc:
699
+ raise ActionboxError("Heartbeat delivery failed.", "HEARTBEAT_FAILED", 0) from exc
700
+ if response.status_code != 204:
701
+ raise ActionboxError(
702
+ f"Heartbeat delivery failed with HTTP {response.status_code}.",
703
+ "HEARTBEAT_FAILED",
704
+ response.status_code,
705
+ )
706
+
707
+
708
+ def _validate_base_url(value: str) -> str:
709
+ parsed = urlsplit(value.strip())
710
+ if not parsed.scheme or not parsed.hostname:
711
+ raise ValueError("base_url must be a full HTTPS URL")
712
+ try:
713
+ address = ipaddress.ip_address(parsed.hostname)
714
+ loopback = address.is_loopback
715
+ except ValueError:
716
+ loopback = parsed.hostname.lower() == "localhost"
717
+ if parsed.scheme != "https" and not (parsed.scheme == "http" and loopback):
718
+ raise ValueError("base_url must use HTTPS unless it targets localhost")
719
+ if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {"", "/"}:
720
+ raise ValueError("base_url must not contain credentials, a path, query parameters, or a fragment")
721
+ port = f":{parsed.port}" if parsed.port is not None else ""
722
+ host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
723
+ return f"{parsed.scheme}://{host}{port}"
724
+
725
+
726
+ def _read_bounded_body(response: httpx.Response) -> bytes:
727
+ chunks: list[bytes] = []
728
+ size = 0
729
+ for chunk in response.iter_bytes():
730
+ size += len(chunk)
731
+ if size > _MAX_RESPONSE_BYTES:
732
+ raise ActionboxError(
733
+ f"Actionbox response exceeds {_MAX_RESPONSE_BYTES} bytes.",
734
+ "RESPONSE_TOO_LARGE",
735
+ response.status_code,
736
+ )
737
+ chunks.append(chunk)
738
+ return b"".join(chunks)
739
+
740
+
741
+ def _parse_response_body(raw: bytes, status: int) -> dict[str, Any]:
742
+ if not raw:
743
+ return {}
744
+ try:
745
+ body = httpx.Response(status, content=raw).json()
746
+ except ValueError as exc:
747
+ raise ActionboxError("Actionbox returned invalid JSON.", "INVALID_RESPONSE", status) from exc
748
+ if not isinstance(body, dict):
749
+ raise ActionboxError("Actionbox returned an invalid response envelope.", "INVALID_RESPONSE", status)
750
+ return cast(dict[str, Any], body)
751
+
752
+
753
+ def _retry_after_seconds(value: str | None) -> float | None:
754
+ if not value:
755
+ return None
756
+ try:
757
+ seconds = float(value)
758
+ return min(max(seconds, 0.0), _MAX_RETRY_DELAY)
759
+ except ValueError:
760
+ pass
761
+ try:
762
+ timestamp = parsedate_to_datetime(value)
763
+ except (TypeError, ValueError):
764
+ return None
765
+ if timestamp.tzinfo is None:
766
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
767
+ return min(max((timestamp - datetime.now(timezone.utc)).total_seconds(), 0.0), _MAX_RETRY_DELAY)
768
+
769
+
770
+ def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
771
+ if cancel_event is not None and cancel_event.is_set():
772
+ raise ActionboxError("Actionbox request was cancelled.", "ABORTED", 0)
773
+
774
+
775
+ def _wait_before_retry(
776
+ attempt: int,
777
+ retry_after: str | None,
778
+ cancel_event: threading.Event | None,
779
+ ) -> None:
780
+ server_delay = _retry_after_seconds(retry_after)
781
+ base = 0.25 * (2**attempt)
782
+ delay = server_delay if server_delay is not None else base + random.uniform(0, base / 2)
783
+ if cancel_event is not None:
784
+ if cancel_event.wait(delay):
785
+ raise ActionboxError("Actionbox request was cancelled.", "ABORTED", 0)
786
+ else:
787
+ time.sleep(delay)
788
+
789
+
790
+ __all__ = [
791
+ "__version__",
792
+ "Action",
793
+ "ActionOption",
794
+ "ActionOptionInput",
795
+ "ActionCreateInput",
796
+ "Actionbox",
797
+ "ActionboxError",
798
+ "ActionsResource",
799
+ "Watch",
800
+ "WatchCreateInput",
801
+ "WatchScheduleType",
802
+ "WatchStatus",
803
+ "WatchesResource",
804
+ "BooleanInteraction",
805
+ "BooleanResponse",
806
+ "FormBooleanField",
807
+ "FormField",
808
+ "FormInteraction",
809
+ "FormIntegerField",
810
+ "FormMultiChoiceField",
811
+ "FormNumberField",
812
+ "FormResponse",
813
+ "FormSingleChoiceField",
814
+ "FormTextField",
815
+ "IntegerInteraction",
816
+ "IntegerResponse",
817
+ "Interaction",
818
+ "InteractionResponse",
819
+ "MultiChoiceInteraction",
820
+ "MultiChoiceResponse",
821
+ "NumberInteraction",
822
+ "NumberResponse",
823
+ "OptionStyle",
824
+ "ResolveInput",
825
+ "Response",
826
+ "SingleChoiceInteraction",
827
+ "SingleChoiceResponse",
828
+ "TextInteraction",
829
+ "TextResponse",
830
+ "TypedInteraction",
831
+ "TypedResponse",
832
+ "send_heartbeat",
833
+ ]
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: actionbox
3
+ Version: 0.1.2
4
+ Summary: Python client for Actionbox durable human decisions
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://actionbox.cloud/docs
7
+ Project-URL: API, https://api.actionbox.cloud/docs
8
+ Project-URL: Source, https://github.com/susonsapkota/actionbox
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: httpx>=0.27
13
+ Dynamic: license-file
14
+
15
+ <img src="https://actionbox.cloud/appbox.svg" width="64" alt="Actionbox logo">
16
+
17
+ # Actionbox Python SDK
18
+
19
+ Actionbox gives backend services a durable, server-authoritative way to ask a
20
+ human for a decision and continue when that decision is available. This package
21
+ is the typed Python client for creating, resolving, and waiting on Actions,
22
+ plus managing source-scoped heartbeat Watches.
23
+
24
+ ## Documentation
25
+
26
+ - [Actionbox documentation](https://actionbox.cloud/docs)
27
+
28
+ ## Requirements
29
+
30
+ - Python 3.11 or newer
31
+ - An Actionbox Source API key, supplied through `ACTIONBOX_API_KEY`
32
+
33
+ Keep API keys and Watch capability URLs on trusted servers, workers, or CI
34
+ jobs. Do not put this SDK or its credentials in browser code.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install actionbox
40
+ ```
41
+
42
+ ```python
43
+ import os
44
+ from actionbox import Actionbox
45
+
46
+ with Actionbox(os.environ["ACTIONBOX_API_KEY"]) as client:
47
+ decision = client.ask(
48
+ title="Deploy to production?",
49
+ options=["Approve", "Reject"],
50
+ callback_url="https://ci.example.com/actionbox",
51
+ )
52
+ print(decision)
53
+ ```
54
+
55
+ The SDK uses the hosted production API at `https://api.actionbox.cloud` by
56
+ default. Customer integrations should use that default and only pass
57
+ `base_url` in maintainer-controlled test environments.
58
+
59
+ `ask(..., wait=False)` returns an `Action`; `Action.wait()` polls the server and leaves the Action open when the local timeout expires. The concise single-choice API returns the selected option ID as a string.
60
+
61
+ ## Typed interactions and responses
62
+
63
+ The SDK exports typed interaction and response contracts that match the REST API. Use an explicit typed interaction with `create` or `ask` when the human response is more than a single choice:
64
+
65
+ ```python
66
+ from actionbox import Actionbox, BooleanInteraction
67
+
68
+ with Actionbox(os.environ["ACTIONBOX_API_KEY"]) as client:
69
+ action = client.create(
70
+ title="Deploy configuration",
71
+ interaction=BooleanInteraction(
72
+ type="boolean",
73
+ label="Deploy now?",
74
+ true_label="Deploy",
75
+ false_label="Hold",
76
+ ),
77
+ )
78
+ resolved = client.resolve(
79
+ action.id,
80
+ response={"type": "boolean", "value": True},
81
+ reason="Approved by release manager",
82
+ )
83
+ print(resolved.response) # {"type": "boolean", "value": True}
84
+ ```
85
+
86
+ The available interaction types are `boolean`, `single_choice`, `multi_choice`, `text`, `integer`, `number`, `rating`, and `form`. Form fields use the same typed field shapes and are returned as `{"type": "form", "values": {...}}`. Create inputs also accept bounded developer `context` blocks and an explicit typed `on_expire` fallback; omitting it returns `expired` without inventing a response.
87
+
88
+ `resolve` supports concise single-choice syntax and generic typed input:
89
+
90
+ ```python
91
+ client.resolve(action.id, "approve") # single-choice shorthand
92
+ client.resolve(action.id, {"response": {"type": "text", "value": "ship"}})
93
+ client.actions.resolve(action.id, response={"type": "number", "value": 4.5})
94
+ ```
95
+
96
+ `Action.interaction` and `Action.response` expose the canonical typed wire values. `options`, `option_id`, `ask(..., options=[...])`, and string decision results are first-class single-choice conveniences.
97
+
98
+ ## Heartbeat Watches
99
+
100
+ Source credentials can create and list Watches scoped to that Source. The raw heartbeat URL is returned only by creation:
101
+
102
+ ```python
103
+ from actionbox import Actionbox, send_heartbeat
104
+
105
+ with Actionbox(os.environ["ACTIONBOX_API_KEY"]) as client:
106
+ watch = client.watches.create(
107
+ source_id="src_…",
108
+ name="Nightly backup",
109
+ schedule_type="interval",
110
+ interval_seconds=3600,
111
+ grace_seconds=60,
112
+ )
113
+ send_heartbeat(watch.heartbeat_url, "start")
114
+ ```
115
+
116
+ Use `send_heartbeat` for `ping`, `start`, `success`, or `fail`. The
117
+ source-scoped resource also exposes `client.watches.pause(id)`,
118
+ `resume(id)`, `rotate_token(id)`, and `archive(id)`; only create/rotate return
119
+ a raw URL. Store capability URLs in a secret manager; Watch details and
120
+ exports never return them.
121
+
122
+ ## Release and versioning
123
+
124
+ The Node and Python SDKs are released together from signed `sdk-vMAJOR.MINOR.PATCH`
125
+ tags. The release workflow derives the package versions from the tag, runs both
126
+ test suites, and publishes the packages only after the tag and publishing
127
+ environments pass their checks.
128
+
129
+ ## License
130
+
131
+ MIT. See [LICENSE](./LICENSE).
@@ -0,0 +1,6 @@
1
+ actionbox/__init__.py,sha256=yQGmo0Dr5S1f-bCtuuCiP9LoPUTqpF9-ZUfafyJruyo,26289
2
+ actionbox-0.1.2.dist-info/licenses/LICENSE,sha256=9-tv8G2RZXRAQFiBV4gKdBDMaqUOgNGSM3aoS5SSoEM,1070
3
+ actionbox-0.1.2.dist-info/METADATA,sha256=XG1mn4cDtdlSyn0mVd452O5RH1QYUbP4TAxn7Bfd43c,4818
4
+ actionbox-0.1.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ actionbox-0.1.2.dist-info/top_level.txt,sha256=qmZZebCeHoXL38ilOVzKzmA0IcHa_zYbOsgAPbAhZKo,10
6
+ actionbox-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suson Sapkota
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
+ actionbox