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 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