hax-sdk 0.2.4__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.
- hax/__init__.py +79 -0
- hax/client.py +620 -0
- hax/crypto.py +378 -0
- hax/exceptions.py +82 -0
- hax/form_builder.py +223 -0
- hax/http.py +114 -0
- hax/models/__init__.py +13 -0
- hax/models/delivery.py +40 -0
- hax/models/form_request.py +40 -0
- hax/models/request.py +150 -0
- hax/py.typed +0 -0
- hax/webhooks.py +169 -0
- hax_sdk-0.2.4.dist-info/METADATA +311 -0
- hax_sdk-0.2.4.dist-info/RECORD +15 -0
- hax_sdk-0.2.4.dist-info/WHEEL +4 -0
hax/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""HAX Python SDK - Human Approval eXchange.
|
|
2
|
+
|
|
3
|
+
A minimal, stable interface for interacting with the HAX API.
|
|
4
|
+
Enables agents and automated systems to programmatically collect human input.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from hax import HaxClient
|
|
8
|
+
>>>
|
|
9
|
+
>>> client = HaxClient(api_key="hax_live_...")
|
|
10
|
+
>>> request = client.create_request(
|
|
11
|
+
... type="text-approval-v1",
|
|
12
|
+
... payload={"text": "Approve deployment?"},
|
|
13
|
+
... webhook_url="https://myapp.com/webhook",
|
|
14
|
+
... )
|
|
15
|
+
>>> print(f"Share this URL: {request.url}")
|
|
16
|
+
|
|
17
|
+
With encryption:
|
|
18
|
+
>>> from hax import HaxClient
|
|
19
|
+
>>>
|
|
20
|
+
>>> client = HaxClient(
|
|
21
|
+
... api_key="hax_live_...",
|
|
22
|
+
... encryption_key="my-secret-passphrase",
|
|
23
|
+
... )
|
|
24
|
+
>>> # Public key is automatically sent with requests
|
|
25
|
+
>>> # Responses are automatically decrypted when retrieved
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from hax.client import FormRequestHandle, HaxClient
|
|
29
|
+
from hax.crypto import decrypt_response, generate_key_pair, is_encrypted_response
|
|
30
|
+
from hax.exceptions import (
|
|
31
|
+
AuthenticationError,
|
|
32
|
+
DecryptionError,
|
|
33
|
+
HaxError,
|
|
34
|
+
NotFoundError,
|
|
35
|
+
RateLimitError,
|
|
36
|
+
ServerError,
|
|
37
|
+
ValidationError,
|
|
38
|
+
)
|
|
39
|
+
from hax.form_builder import FormBuilder
|
|
40
|
+
from hax.models import CreatedRequest, Request, RequestStatus, RequestSummary
|
|
41
|
+
from hax.models.delivery import DeliveryConfig, DeliveryResult
|
|
42
|
+
from hax.models.form_request import FormRequestResponse, FormRequestStatus
|
|
43
|
+
from hax.webhooks import WebhookEvent, parse_event, verify_signature
|
|
44
|
+
|
|
45
|
+
__version__ = "0.2.4"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
# Client
|
|
49
|
+
"HaxClient",
|
|
50
|
+
# Form Builder
|
|
51
|
+
"FormBuilder",
|
|
52
|
+
"FormRequestHandle",
|
|
53
|
+
"FormRequestResponse",
|
|
54
|
+
"FormRequestStatus",
|
|
55
|
+
# Models
|
|
56
|
+
"Request",
|
|
57
|
+
"CreatedRequest",
|
|
58
|
+
"RequestStatus",
|
|
59
|
+
"RequestSummary",
|
|
60
|
+
# Delivery
|
|
61
|
+
"DeliveryConfig",
|
|
62
|
+
"DeliveryResult",
|
|
63
|
+
# Webhooks
|
|
64
|
+
"WebhookEvent",
|
|
65
|
+
"verify_signature",
|
|
66
|
+
"parse_event",
|
|
67
|
+
# Encryption (RSA-OAEP)
|
|
68
|
+
"generate_key_pair",
|
|
69
|
+
"decrypt_response",
|
|
70
|
+
"is_encrypted_response",
|
|
71
|
+
# Exceptions
|
|
72
|
+
"HaxError",
|
|
73
|
+
"AuthenticationError",
|
|
74
|
+
"NotFoundError",
|
|
75
|
+
"ValidationError",
|
|
76
|
+
"RateLimitError",
|
|
77
|
+
"ServerError",
|
|
78
|
+
"DecryptionError",
|
|
79
|
+
]
|
hax/client.py
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
"""Main HaxClient class for interacting with the HAX API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import time
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from hax.http import HttpClient
|
|
10
|
+
from hax.models.delivery import DeliveryConfig
|
|
11
|
+
from hax.models.form_request import FormRequestResponse, FormRequestStatus
|
|
12
|
+
from hax.models.request import CreatedRequest, Request, RequestSummary
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://hax-sdk-production.up.railway.app/api/v1"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HaxClient:
|
|
18
|
+
"""Client for interacting with the HAX API.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
>>> from hax import HaxClient
|
|
22
|
+
>>>
|
|
23
|
+
>>> client = HaxClient(api_key="hax_live_...")
|
|
24
|
+
>>> request = client.create_request(
|
|
25
|
+
... type="text-approval-v1",
|
|
26
|
+
... payload={"text": "Approve deployment?"},
|
|
27
|
+
... webhook_url="https://myapp.com/webhook",
|
|
28
|
+
... )
|
|
29
|
+
>>> print(f"Share this URL: {request.url}")
|
|
30
|
+
|
|
31
|
+
With encryption:
|
|
32
|
+
>>> client = HaxClient(
|
|
33
|
+
... api_key="hax_live_...",
|
|
34
|
+
... encryption_key="my-secret-passphrase",
|
|
35
|
+
... )
|
|
36
|
+
>>> # Public key is automatically sent with requests
|
|
37
|
+
>>> # Responses are automatically decrypted when retrieved
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
api_key: str,
|
|
43
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
44
|
+
webhook_url: str | None = None,
|
|
45
|
+
timeout: float = 30.0,
|
|
46
|
+
encryption_key: str | None = None,
|
|
47
|
+
public_key: dict[str, Any] | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Initialize the HAX client.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
api_key: Your HAX API key (e.g., "hax_live_...")
|
|
53
|
+
base_url: Base URL for the HAX API. Defaults to production.
|
|
54
|
+
webhook_url: Default webhook URL for all requests. Can be overridden per-request.
|
|
55
|
+
timeout: Request timeout in seconds.
|
|
56
|
+
encryption_key: Optional encryption passphrase for end-to-end response encryption.
|
|
57
|
+
If provided, a deterministic RSA keypair is generated from this passphrase.
|
|
58
|
+
The public key is sent to the server with each request, and the server
|
|
59
|
+
will encrypt user responses with it. Responses are automatically decrypted
|
|
60
|
+
when retrieved.
|
|
61
|
+
public_key: Optional public key for end-to-end response encryption.
|
|
62
|
+
Use this when you want to provide your own RSA keypair instead of
|
|
63
|
+
deriving one from a passphrase. When using this option, responses
|
|
64
|
+
will NOT be automatically decrypted - use decrypt_response() manually
|
|
65
|
+
with your private key.
|
|
66
|
+
"""
|
|
67
|
+
self._api_key = api_key
|
|
68
|
+
self._base_url = base_url.rstrip("/")
|
|
69
|
+
self._default_webhook_url = webhook_url
|
|
70
|
+
self._http = HttpClient(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
71
|
+
|
|
72
|
+
# Set up encryption if key provided
|
|
73
|
+
self._key_pair: tuple[dict[str, Any], dict[str, Any]] | None = None
|
|
74
|
+
self._public_key_only: dict[str, Any] | None = None
|
|
75
|
+
|
|
76
|
+
if encryption_key is not None:
|
|
77
|
+
from hax.crypto import generate_key_pair
|
|
78
|
+
|
|
79
|
+
self._key_pair = generate_key_pair(encryption_key)
|
|
80
|
+
elif public_key is not None:
|
|
81
|
+
self._public_key_only = public_key
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def public_key(self) -> dict[str, Any] | None:
|
|
85
|
+
"""Get the public key for this client (if encryption is enabled).
|
|
86
|
+
|
|
87
|
+
Useful for verifying the public key that will be sent to the server.
|
|
88
|
+
"""
|
|
89
|
+
if self._key_pair:
|
|
90
|
+
return self._key_pair[0]
|
|
91
|
+
return self._public_key_only
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def private_key(self) -> dict[str, Any] | None:
|
|
95
|
+
"""Get the private key for this client (if derived from passphrase).
|
|
96
|
+
|
|
97
|
+
Useful for manual decryption or storing for later use.
|
|
98
|
+
Returns None if using user-provided public key only.
|
|
99
|
+
"""
|
|
100
|
+
if self._key_pair:
|
|
101
|
+
return self._key_pair[1]
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def create_request(
|
|
105
|
+
self,
|
|
106
|
+
type: str,
|
|
107
|
+
payload: dict[str, Any],
|
|
108
|
+
*,
|
|
109
|
+
title: str | None = None,
|
|
110
|
+
description: str | None = None,
|
|
111
|
+
webhook_url: str | None = None,
|
|
112
|
+
expires_in_seconds: int | None = None,
|
|
113
|
+
metadata: dict[str, Any] | None = None,
|
|
114
|
+
delivery: DeliveryConfig | None = None,
|
|
115
|
+
user_id: str | None = None,
|
|
116
|
+
) -> CreatedRequest:
|
|
117
|
+
"""Create a new human input request.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
type: The template type (e.g., "text-approval-v1", "collect-email-v1").
|
|
121
|
+
payload: Template-specific payload. See HAX docs for schema.
|
|
122
|
+
title: Optional title displayed to the user.
|
|
123
|
+
description: Optional description displayed to the user.
|
|
124
|
+
webhook_url: URL to receive webhook notifications. Overrides client default.
|
|
125
|
+
expires_in_seconds: Optional expiration time in seconds.
|
|
126
|
+
metadata: Optional metadata dict to be returned in webhooks.
|
|
127
|
+
delivery: Optional delivery config to send the request URL via email/SMS.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The created request with id, url, status, and optional delivery result.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
>>> request = client.create_request(
|
|
134
|
+
... type="text-approval-v1",
|
|
135
|
+
... payload={"text": "Deploy to production?", "approveLabel": "Ship It!"},
|
|
136
|
+
... webhook_url="https://myapp.com/webhook",
|
|
137
|
+
... expires_in_seconds=3600,
|
|
138
|
+
... metadata={"pr_number": 123},
|
|
139
|
+
... )
|
|
140
|
+
>>> print(f"Send this URL: {request.url}")
|
|
141
|
+
"""
|
|
142
|
+
body: dict[str, Any] = {
|
|
143
|
+
"type": type,
|
|
144
|
+
"payload": payload,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if title is not None:
|
|
148
|
+
body["title"] = title
|
|
149
|
+
if description is not None:
|
|
150
|
+
body["description"] = description
|
|
151
|
+
|
|
152
|
+
# Use request-level webhook_url, fall back to client default
|
|
153
|
+
effective_webhook_url = webhook_url or self._default_webhook_url
|
|
154
|
+
if effective_webhook_url is not None:
|
|
155
|
+
body["webhookUrl"] = effective_webhook_url
|
|
156
|
+
|
|
157
|
+
if expires_in_seconds is not None:
|
|
158
|
+
body["expiresInSeconds"] = expires_in_seconds
|
|
159
|
+
if metadata is not None:
|
|
160
|
+
body["metadata"] = metadata
|
|
161
|
+
|
|
162
|
+
if delivery is not None:
|
|
163
|
+
body["delivery"] = delivery.model_dump(exclude_none=True)
|
|
164
|
+
if user_id is not None:
|
|
165
|
+
body["userId"] = user_id
|
|
166
|
+
|
|
167
|
+
# Include public key for response encryption
|
|
168
|
+
public_key = self.public_key
|
|
169
|
+
if public_key is not None:
|
|
170
|
+
body["publicKey"] = public_key
|
|
171
|
+
|
|
172
|
+
data = self._http.post("/requests", json=body)
|
|
173
|
+
return CreatedRequest.model_validate(data)
|
|
174
|
+
|
|
175
|
+
def request_via_email(
|
|
176
|
+
self,
|
|
177
|
+
type: str,
|
|
178
|
+
payload: dict[str, Any],
|
|
179
|
+
*,
|
|
180
|
+
to_email: str,
|
|
181
|
+
subject: str | None = None,
|
|
182
|
+
message: str | None = None,
|
|
183
|
+
**kwargs: Any,
|
|
184
|
+
) -> CreatedRequest:
|
|
185
|
+
"""Create a request and send it via email.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
type: The template type.
|
|
189
|
+
payload: Template-specific payload.
|
|
190
|
+
to_email: The recipient email address.
|
|
191
|
+
subject: Optional custom email subject.
|
|
192
|
+
message: Optional custom message in the email.
|
|
193
|
+
**kwargs: Additional arguments passed to create_request.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
The created request with delivery status.
|
|
197
|
+
|
|
198
|
+
Example:
|
|
199
|
+
>>> request = client.request_via_email(
|
|
200
|
+
... type="confirm-action-v1",
|
|
201
|
+
... payload={"title": "Approve?", "confirmText": "Yes"},
|
|
202
|
+
... to_email="manager@company.com",
|
|
203
|
+
... subject="Approval Needed",
|
|
204
|
+
... )
|
|
205
|
+
"""
|
|
206
|
+
return self.create_request(
|
|
207
|
+
type=type,
|
|
208
|
+
payload=payload,
|
|
209
|
+
delivery=DeliveryConfig(
|
|
210
|
+
channel="email",
|
|
211
|
+
recipient=to_email,
|
|
212
|
+
subject=subject,
|
|
213
|
+
message=message,
|
|
214
|
+
),
|
|
215
|
+
**kwargs,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def request_via_sms(
|
|
219
|
+
self,
|
|
220
|
+
type: str,
|
|
221
|
+
payload: dict[str, Any],
|
|
222
|
+
*,
|
|
223
|
+
to_phone: str,
|
|
224
|
+
message: str | None = None,
|
|
225
|
+
**kwargs: Any,
|
|
226
|
+
) -> CreatedRequest:
|
|
227
|
+
"""Create a request and send it via SMS.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
type: The template type.
|
|
231
|
+
payload: Template-specific payload.
|
|
232
|
+
to_phone: The recipient phone number in E.164 format (e.g., +15551234567).
|
|
233
|
+
message: Optional custom message in the SMS.
|
|
234
|
+
**kwargs: Additional arguments passed to create_request.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
The created request with delivery status.
|
|
238
|
+
|
|
239
|
+
Example:
|
|
240
|
+
>>> request = client.request_via_sms(
|
|
241
|
+
... type="confirm-action-v1",
|
|
242
|
+
... payload={"title": "Confirm?", "confirmText": "Yes"},
|
|
243
|
+
... to_phone="+15551234567",
|
|
244
|
+
... )
|
|
245
|
+
"""
|
|
246
|
+
return self.create_request(
|
|
247
|
+
type=type,
|
|
248
|
+
payload=payload,
|
|
249
|
+
delivery=DeliveryConfig(
|
|
250
|
+
channel="sms",
|
|
251
|
+
recipient=to_phone,
|
|
252
|
+
message=message,
|
|
253
|
+
),
|
|
254
|
+
**kwargs,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def configure_messaging(
|
|
258
|
+
self,
|
|
259
|
+
*,
|
|
260
|
+
email: dict[str, Any] | None = None,
|
|
261
|
+
sms: dict[str, Any] | None = None,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Configure messaging providers for the project.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
email: Email provider configuration.
|
|
267
|
+
Example: {"provider": "sendgrid", "apiKey": "SG.xxx",
|
|
268
|
+
"fromEmail": "noreply@example.com", "fromName": "My App"}
|
|
269
|
+
sms: SMS provider configuration.
|
|
270
|
+
Example: {"provider": "twilio", "accountSid": "ACxxx",
|
|
271
|
+
"authToken": "xxx", "fromNumber": "+15551234567"}
|
|
272
|
+
|
|
273
|
+
Example:
|
|
274
|
+
>>> client.configure_messaging(
|
|
275
|
+
... email={
|
|
276
|
+
... "provider": "sendgrid",
|
|
277
|
+
... "apiKey": "SG.xxx",
|
|
278
|
+
... "fromEmail": "noreply@example.com",
|
|
279
|
+
... },
|
|
280
|
+
... )
|
|
281
|
+
"""
|
|
282
|
+
body: dict[str, Any] = {"messaging": {}}
|
|
283
|
+
if email is not None:
|
|
284
|
+
body["messaging"]["email"] = email
|
|
285
|
+
if sms is not None:
|
|
286
|
+
body["messaging"]["sms"] = sms
|
|
287
|
+
|
|
288
|
+
self._http.patch("/projects/settings", json=body)
|
|
289
|
+
|
|
290
|
+
def get_request(self, request_id: str) -> Request:
|
|
291
|
+
"""Get a request by ID.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
request_id: The request ID.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
The Request object with full details.
|
|
298
|
+
If encryption_key was provided to the client, encrypted response fields
|
|
299
|
+
are automatically decrypted.
|
|
300
|
+
"""
|
|
301
|
+
data = self._http.get(f"/requests/{request_id}")
|
|
302
|
+
request_data = data.get("request", data)
|
|
303
|
+
|
|
304
|
+
# Auto-decrypt response if we have a private key
|
|
305
|
+
if self.private_key is not None:
|
|
306
|
+
request_data = self._decrypt_response_field(request_data)
|
|
307
|
+
|
|
308
|
+
request = Request.model_validate(request_data)
|
|
309
|
+
request.set_base_url(self._base_url)
|
|
310
|
+
return request
|
|
311
|
+
|
|
312
|
+
def list_requests(self) -> list[RequestSummary]:
|
|
313
|
+
"""List recent requests for the project.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
List of RequestSummary objects (latest 25).
|
|
317
|
+
If encryption_key was provided, encrypted response fields are decrypted.
|
|
318
|
+
"""
|
|
319
|
+
data = self._http.get("/requests")
|
|
320
|
+
requests_data = data.get("requests", [])
|
|
321
|
+
|
|
322
|
+
# Auto-decrypt each request's response if we have a private key
|
|
323
|
+
if self.private_key is not None:
|
|
324
|
+
requests_data = [self._decrypt_response_field(r) for r in requests_data]
|
|
325
|
+
|
|
326
|
+
return [RequestSummary.model_validate(r) for r in requests_data]
|
|
327
|
+
|
|
328
|
+
def cancel_request(self, request_id: str) -> Request:
|
|
329
|
+
"""Cancel a pending request.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
request_id: The request ID to cancel.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
The updated Request object.
|
|
336
|
+
"""
|
|
337
|
+
data = self._http.patch(f"/requests/{request_id}", json={"status": "cancelled"})
|
|
338
|
+
request_data = data.get("request", data)
|
|
339
|
+
request = Request.model_validate(request_data)
|
|
340
|
+
request.set_base_url(self._base_url)
|
|
341
|
+
return request
|
|
342
|
+
|
|
343
|
+
def submit_response(
|
|
344
|
+
self,
|
|
345
|
+
request_id: str,
|
|
346
|
+
response: dict[str, Any],
|
|
347
|
+
) -> Request:
|
|
348
|
+
"""Submit a response to a request (for testing/internal use).
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
request_id: The request ID.
|
|
352
|
+
response: The response data matching the template type.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
The updated Request object.
|
|
356
|
+
"""
|
|
357
|
+
data = self._http.post(
|
|
358
|
+
f"/requests/{request_id}/response", json={"response": response}
|
|
359
|
+
)
|
|
360
|
+
request_data = data.get("request", data)
|
|
361
|
+
request = Request.model_validate(request_data)
|
|
362
|
+
request.set_base_url(self._base_url)
|
|
363
|
+
return request
|
|
364
|
+
|
|
365
|
+
def list_types(self) -> list[dict[str, str]]:
|
|
366
|
+
"""List available template types.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
List of dicts with "name" and "description" keys.
|
|
370
|
+
"""
|
|
371
|
+
data = self._http.get("/types")
|
|
372
|
+
return data.get("types", []) # type: ignore[no-any-return]
|
|
373
|
+
|
|
374
|
+
def wait_for_response(
|
|
375
|
+
self,
|
|
376
|
+
request_id: str,
|
|
377
|
+
poll_interval: float = 2.0,
|
|
378
|
+
timeout: float | None = None,
|
|
379
|
+
) -> Request:
|
|
380
|
+
"""Poll until a request is completed, expired, or cancelled.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
request_id: The request ID to poll.
|
|
384
|
+
poll_interval: Seconds between polls. Default 2.0.
|
|
385
|
+
timeout: Optional timeout in seconds. None means no timeout.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
The completed Request object.
|
|
389
|
+
|
|
390
|
+
Raises:
|
|
391
|
+
TimeoutError: If timeout is reached before completion.
|
|
392
|
+
"""
|
|
393
|
+
start_time = time.time()
|
|
394
|
+
|
|
395
|
+
while True:
|
|
396
|
+
request = self.get_request(request_id)
|
|
397
|
+
|
|
398
|
+
if request.is_completed or request.is_expired or request.is_cancelled:
|
|
399
|
+
return request
|
|
400
|
+
|
|
401
|
+
if timeout is not None:
|
|
402
|
+
elapsed = time.time() - start_time
|
|
403
|
+
if elapsed >= timeout:
|
|
404
|
+
raise TimeoutError(
|
|
405
|
+
f"Request {request_id} did not complete within {timeout} seconds"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
time.sleep(poll_interval)
|
|
409
|
+
|
|
410
|
+
def create_form_request(
|
|
411
|
+
self,
|
|
412
|
+
form: FormBuilder,
|
|
413
|
+
*,
|
|
414
|
+
title: str | None = None,
|
|
415
|
+
description: str | None = None,
|
|
416
|
+
webhook_url: str | None = None,
|
|
417
|
+
expires_in_seconds: int | None = None,
|
|
418
|
+
metadata: dict[str, Any] | None = None,
|
|
419
|
+
) -> FormRequestHandle:
|
|
420
|
+
"""Create a form request with typed responses.
|
|
421
|
+
|
|
422
|
+
Uses the FormBuilder to create a form-builder request and returns a
|
|
423
|
+
typed handle for waiting and retrieving responses.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
form: A FormBuilder instance defining the form fields.
|
|
427
|
+
title: Optional title displayed to the user.
|
|
428
|
+
description: Optional description displayed to the user.
|
|
429
|
+
webhook_url: URL to receive webhook notifications.
|
|
430
|
+
expires_in_seconds: Optional expiration time in seconds.
|
|
431
|
+
metadata: Optional metadata dict to be returned in webhooks.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
A FormRequestHandle for waiting and retrieving typed responses.
|
|
435
|
+
|
|
436
|
+
Example:
|
|
437
|
+
>>> from hax.form_builder import FormBuilder
|
|
438
|
+
>>>
|
|
439
|
+
>>> form = (FormBuilder()
|
|
440
|
+
... .title("Registration")
|
|
441
|
+
... .input("email", variant="email", required=True)
|
|
442
|
+
... .number("age", min=0, max=120)
|
|
443
|
+
... .checkbox("newsletter", checkbox_label="Subscribe"))
|
|
444
|
+
>>>
|
|
445
|
+
>>> handle = client.create_form_request(form,
|
|
446
|
+
... webhook_url="https://myapp.com/webhook")
|
|
447
|
+
>>>
|
|
448
|
+
>>> print(f"Form URL: {handle.url}")
|
|
449
|
+
>>>
|
|
450
|
+
>>> response = handle.wait_for_response()
|
|
451
|
+
>>> print(response.values.email) # str
|
|
452
|
+
>>> print(response.values.age) # float
|
|
453
|
+
"""
|
|
454
|
+
created = self.create_request(
|
|
455
|
+
type="form-builder",
|
|
456
|
+
payload=form.to_payload(),
|
|
457
|
+
title=title,
|
|
458
|
+
description=description,
|
|
459
|
+
webhook_url=webhook_url,
|
|
460
|
+
expires_in_seconds=expires_in_seconds,
|
|
461
|
+
metadata=metadata,
|
|
462
|
+
)
|
|
463
|
+
return FormRequestHandle(self, created.id, created.url, form)
|
|
464
|
+
|
|
465
|
+
def _decrypt_response_field(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
466
|
+
"""Decrypt response field if encrypted.
|
|
467
|
+
|
|
468
|
+
Handles two encryption formats:
|
|
469
|
+
1. Legacy: response = { _encrypted: "..." } (entire response encrypted)
|
|
470
|
+
2. New: response = { values: { _encrypted: "..." }, ... } (only values encrypted)
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
data: Request data dict from API.
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
Data dict with decrypted response field.
|
|
477
|
+
"""
|
|
478
|
+
from hax.crypto import decrypt_response, is_encrypted_response
|
|
479
|
+
|
|
480
|
+
if self.private_key is None:
|
|
481
|
+
return data
|
|
482
|
+
|
|
483
|
+
# Make a copy to avoid mutating the original
|
|
484
|
+
data = dict(data)
|
|
485
|
+
response = data.get("response")
|
|
486
|
+
|
|
487
|
+
if response is None:
|
|
488
|
+
return data
|
|
489
|
+
|
|
490
|
+
# Format 1: Entire response is encrypted { _encrypted: "..." }
|
|
491
|
+
if is_encrypted_response(response):
|
|
492
|
+
data["response"] = decrypt_response(
|
|
493
|
+
response["_encrypted"], self.private_key
|
|
494
|
+
)
|
|
495
|
+
# Format 2: Only values are encrypted { values: { _encrypted: "..." }, ... }
|
|
496
|
+
elif isinstance(response, dict) and is_encrypted_response(response.get("values")):
|
|
497
|
+
response = dict(response) # Copy response
|
|
498
|
+
response["values"] = decrypt_response(
|
|
499
|
+
response["values"]["_encrypted"], self.private_key
|
|
500
|
+
)
|
|
501
|
+
data["response"] = response
|
|
502
|
+
|
|
503
|
+
return data
|
|
504
|
+
|
|
505
|
+
def close(self) -> None:
|
|
506
|
+
"""Close the client and release resources."""
|
|
507
|
+
self._http.close()
|
|
508
|
+
|
|
509
|
+
def __enter__(self) -> HaxClient:
|
|
510
|
+
return self
|
|
511
|
+
|
|
512
|
+
def __exit__(self, *args: Any) -> None:
|
|
513
|
+
self.close()
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class FormRequestHandle:
|
|
517
|
+
"""Handle for a form request with typed responses.
|
|
518
|
+
|
|
519
|
+
Provides methods to wait for and retrieve typed form responses.
|
|
520
|
+
"""
|
|
521
|
+
|
|
522
|
+
def __init__(
|
|
523
|
+
self,
|
|
524
|
+
client: HaxClient,
|
|
525
|
+
request_id: str,
|
|
526
|
+
url: str,
|
|
527
|
+
form: FormBuilder,
|
|
528
|
+
) -> None:
|
|
529
|
+
"""Initialize the handle.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
client: The HaxClient instance.
|
|
533
|
+
request_id: The request ID.
|
|
534
|
+
url: The URL for the human to respond.
|
|
535
|
+
form: The FormBuilder instance for parsing responses.
|
|
536
|
+
"""
|
|
537
|
+
self._client = client
|
|
538
|
+
self.id = request_id
|
|
539
|
+
self.url = url
|
|
540
|
+
self._form = form
|
|
541
|
+
|
|
542
|
+
def wait_for_response(
|
|
543
|
+
self,
|
|
544
|
+
poll_interval: float = 2.0,
|
|
545
|
+
timeout: float | None = None,
|
|
546
|
+
) -> FormRequestResponse[Any]:
|
|
547
|
+
"""Wait for the form to be completed.
|
|
548
|
+
|
|
549
|
+
Polls until completed, expired, or cancelled.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
poll_interval: Seconds between polls. Default 2.0.
|
|
553
|
+
timeout: Optional timeout in seconds.
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
FormRequestResponse with typed values.
|
|
557
|
+
|
|
558
|
+
Raises:
|
|
559
|
+
TimeoutError: If timeout is reached before completion.
|
|
560
|
+
"""
|
|
561
|
+
from datetime import datetime
|
|
562
|
+
|
|
563
|
+
result = self._client.wait_for_response(
|
|
564
|
+
self.id,
|
|
565
|
+
poll_interval=poll_interval,
|
|
566
|
+
timeout=timeout,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
response_data = result.response or {}
|
|
570
|
+
meta = response_data.get("meta", {})
|
|
571
|
+
submitted_at_str = meta.get("submittedAt")
|
|
572
|
+
submitted_at = None
|
|
573
|
+
if submitted_at_str:
|
|
574
|
+
with contextlib.suppress(ValueError, AttributeError):
|
|
575
|
+
submitted_at = datetime.fromisoformat(submitted_at_str.replace("Z", "+00:00"))
|
|
576
|
+
|
|
577
|
+
return FormRequestResponse(
|
|
578
|
+
values=self._form.parse_response(result.response),
|
|
579
|
+
submitted_at=submitted_at,
|
|
580
|
+
responded_by=result.responded_by,
|
|
581
|
+
responded_at=result.responded_at,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
def get_status(self) -> FormRequestStatus[Any]:
|
|
585
|
+
"""Get the current status of the form request.
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
FormRequestStatus with status and optional typed response.
|
|
589
|
+
"""
|
|
590
|
+
from datetime import datetime
|
|
591
|
+
|
|
592
|
+
result = self._client.get_request(self.id)
|
|
593
|
+
completed = result.is_completed
|
|
594
|
+
|
|
595
|
+
response = None
|
|
596
|
+
if completed and result.response:
|
|
597
|
+
response_data = result.response or {}
|
|
598
|
+
meta = response_data.get("meta", {})
|
|
599
|
+
submitted_at_str = meta.get("submittedAt")
|
|
600
|
+
submitted_at = None
|
|
601
|
+
if submitted_at_str:
|
|
602
|
+
with contextlib.suppress(ValueError, AttributeError):
|
|
603
|
+
submitted_at = datetime.fromisoformat(submitted_at_str.replace("Z", "+00:00"))
|
|
604
|
+
|
|
605
|
+
response = FormRequestResponse(
|
|
606
|
+
values=self._form.parse_response(result.response),
|
|
607
|
+
submitted_at=submitted_at,
|
|
608
|
+
responded_by=result.responded_by,
|
|
609
|
+
responded_at=result.responded_at,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
return FormRequestStatus(
|
|
613
|
+
status=result.status.value,
|
|
614
|
+
completed=completed,
|
|
615
|
+
response=response,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
if TYPE_CHECKING:
|
|
620
|
+
from hax.form_builder import FormBuilder
|