aztea 1.0.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.
- aztea/__init__.py +85 -0
- aztea/agent.py +522 -0
- aztea/client.py +964 -0
- aztea/exceptions.py +155 -0
- aztea/models.py +121 -0
- aztea-1.0.0.dist-info/LICENSE +21 -0
- aztea-1.0.0.dist-info/METADATA +140 -0
- aztea-1.0.0.dist-info/RECORD +11 -0
- aztea-1.0.0.dist-info/WHEEL +5 -0
- aztea-1.0.0.dist-info/entry_points.txt +2 -0
- aztea-1.0.0.dist-info/top_level.txt +1 -0
aztea/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aztea — Python SDK for the Aztea platform.
|
|
3
|
+
|
|
4
|
+
Quick start
|
|
5
|
+
-----------
|
|
6
|
+
Hire an agent::
|
|
7
|
+
|
|
8
|
+
from aztea import AzteaClient
|
|
9
|
+
|
|
10
|
+
client = AzteaClient(api_key="az_...", base_url="https://yourplatform.com")
|
|
11
|
+
result = client.hire("agent-id", {"url": "https://example.com"})
|
|
12
|
+
print(result.output)
|
|
13
|
+
|
|
14
|
+
Register and run your own agent::
|
|
15
|
+
|
|
16
|
+
from aztea import AgentServer, InputError, ClarificationNeeded
|
|
17
|
+
|
|
18
|
+
server = AgentServer(api_key="az_...", base_url="https://yourplatform.com",
|
|
19
|
+
name="My Agent", price_per_call_usd=0.01, ...)
|
|
20
|
+
|
|
21
|
+
@server.handler
|
|
22
|
+
def handle(input: dict) -> dict:
|
|
23
|
+
if "required_field" not in input:
|
|
24
|
+
raise InputError("'required_field' is missing.") # 80% refund to caller
|
|
25
|
+
if input.get("ambiguous"):
|
|
26
|
+
raise ClarificationNeeded("Which format do you want: JSON or CSV?")
|
|
27
|
+
return {"answer": 42}
|
|
28
|
+
|
|
29
|
+
server.run()
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
__version__ = "0.2.0"
|
|
33
|
+
|
|
34
|
+
from .agent import AgentServer, CallbackReceiver, verify_callback_signature
|
|
35
|
+
from .client import AzteaClient, AsyncAzteaClient
|
|
36
|
+
from .exceptions import (
|
|
37
|
+
AzteaError,
|
|
38
|
+
AgentNotFoundError,
|
|
39
|
+
AuthenticationError,
|
|
40
|
+
ClarificationNeeded,
|
|
41
|
+
ClarificationNeededError,
|
|
42
|
+
ContractVerificationError,
|
|
43
|
+
InputError,
|
|
44
|
+
InsufficientFundsError,
|
|
45
|
+
JobFailedError,
|
|
46
|
+
PermissionError,
|
|
47
|
+
RateLimitError,
|
|
48
|
+
)
|
|
49
|
+
from .models import (
|
|
50
|
+
Agent,
|
|
51
|
+
Job,
|
|
52
|
+
JobResult,
|
|
53
|
+
Transaction,
|
|
54
|
+
VerificationContract,
|
|
55
|
+
Wallet,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
# Main classes
|
|
60
|
+
"AzteaClient",
|
|
61
|
+
"AsyncAzteaClient",
|
|
62
|
+
"AgentServer",
|
|
63
|
+
"CallbackReceiver",
|
|
64
|
+
"verify_callback_signature",
|
|
65
|
+
# Models
|
|
66
|
+
"Agent",
|
|
67
|
+
"Job",
|
|
68
|
+
"JobResult",
|
|
69
|
+
"Transaction",
|
|
70
|
+
"VerificationContract",
|
|
71
|
+
"Wallet",
|
|
72
|
+
# Exceptions — caller side
|
|
73
|
+
"AzteaError",
|
|
74
|
+
"AgentNotFoundError",
|
|
75
|
+
"AuthenticationError",
|
|
76
|
+
"ClarificationNeededError",
|
|
77
|
+
"ContractVerificationError",
|
|
78
|
+
"InsufficientFundsError",
|
|
79
|
+
"JobFailedError",
|
|
80
|
+
"PermissionError",
|
|
81
|
+
"RateLimitError",
|
|
82
|
+
# Exceptions — agent/server side (raise from @server.handler)
|
|
83
|
+
"ClarificationNeeded",
|
|
84
|
+
"InputError",
|
|
85
|
+
]
|
aztea/agent.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent.py — AgentServer: decorator-based worker that registers an agent and
|
|
3
|
+
polls the marketplace for jobs.
|
|
4
|
+
|
|
5
|
+
Usage
|
|
6
|
+
-----
|
|
7
|
+
from aztea import AgentServer
|
|
8
|
+
|
|
9
|
+
server = AgentServer(
|
|
10
|
+
api_key="az_...",
|
|
11
|
+
name="Data Extractor",
|
|
12
|
+
description="Extracts structured company data from URLs",
|
|
13
|
+
price_per_call_usd=0.10,
|
|
14
|
+
input_schema={"url": {"type": "string"}},
|
|
15
|
+
output_schema={"company_name": {"type": "string"}, "founded_year": {"type": "number"}},
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
@server.handler
|
|
19
|
+
def handle(input: dict) -> dict:
|
|
20
|
+
return {"company_name": "Anthropic", "founded_year": 2021}
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
server.run()
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import hmac
|
|
30
|
+
import json
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from typing import Any, Callable, Dict, List
|
|
34
|
+
|
|
35
|
+
from .client import AzteaClient, _parse_payload
|
|
36
|
+
from .exceptions import AzteaError, ClarificationNeeded, InputError
|
|
37
|
+
|
|
38
|
+
_HEARTBEAT_INTERVAL = 20 # seconds
|
|
39
|
+
_POLL_INTERVAL = 2 # seconds between job-list polls
|
|
40
|
+
_LEASE_SECONDS = 300 # initial lease + heartbeat renewal
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _print_status(msg: str) -> None:
|
|
44
|
+
print(msg, flush=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AgentServer:
|
|
48
|
+
"""
|
|
49
|
+
Decorator-based agent worker.
|
|
50
|
+
|
|
51
|
+
Wrap your handler function with ``@server.handler``, then call
|
|
52
|
+
``server.run()`` to register with the marketplace and start processing
|
|
53
|
+
jobs.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
api_key
|
|
58
|
+
A key with the ``worker`` scope.
|
|
59
|
+
name
|
|
60
|
+
Unique agent name shown in the registry.
|
|
61
|
+
description
|
|
62
|
+
Human-readable description of what the agent does.
|
|
63
|
+
price_per_call_usd
|
|
64
|
+
Price charged per call in USD (e.g. ``0.10`` for 10 cents).
|
|
65
|
+
input_schema
|
|
66
|
+
JSON Schema describing the expected input dict fields.
|
|
67
|
+
output_schema
|
|
68
|
+
JSON Schema describing the output dict fields.
|
|
69
|
+
tags
|
|
70
|
+
Optional list of tag strings for discoverability.
|
|
71
|
+
endpoint_url
|
|
72
|
+
The public URL where the marketplace can make sync calls.
|
|
73
|
+
Defaults to ``http://localhost:{port}``. Must be reachable by the
|
|
74
|
+
server for sync calls; async polling works regardless.
|
|
75
|
+
port
|
|
76
|
+
Port for the optional HTTP server (sync call path).
|
|
77
|
+
base_url
|
|
78
|
+
Aztea server base URL.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
api_key: str,
|
|
84
|
+
name: str,
|
|
85
|
+
description: str,
|
|
86
|
+
price_per_call_usd: float,
|
|
87
|
+
input_schema: Dict[str, Any] | None = None,
|
|
88
|
+
output_schema: Dict[str, Any] | None = None,
|
|
89
|
+
tags: List[str] | None = None,
|
|
90
|
+
endpoint_url: str | None = None,
|
|
91
|
+
port: int = 8080,
|
|
92
|
+
base_url: str = "https://api.aztea.dev",
|
|
93
|
+
) -> None:
|
|
94
|
+
self._key = api_key
|
|
95
|
+
self.name = name
|
|
96
|
+
self.description = description
|
|
97
|
+
self.price_per_call_usd = price_per_call_usd
|
|
98
|
+
self.input_schema = input_schema or {}
|
|
99
|
+
self.output_schema = output_schema or {}
|
|
100
|
+
self.tags = tags or []
|
|
101
|
+
self._port = port
|
|
102
|
+
self._endpoint_url = endpoint_url or f"http://localhost:{port}"
|
|
103
|
+
self._base_url = base_url
|
|
104
|
+
|
|
105
|
+
self._client = AzteaClient(api_key=api_key, base_url=base_url)
|
|
106
|
+
self._handler_func: Callable[[Dict[str, Any]], Dict[str, Any]] | None = None
|
|
107
|
+
self._agent_id: str | None = None
|
|
108
|
+
|
|
109
|
+
# ── Decorator ─────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def handler(
|
|
112
|
+
self, func: Callable[[Dict[str, Any]], Dict[str, Any]]
|
|
113
|
+
) -> Callable[[Dict[str, Any]], Dict[str, Any]]:
|
|
114
|
+
"""Register *func* as the job handler. Use as a decorator."""
|
|
115
|
+
self._handler_func = func
|
|
116
|
+
return func
|
|
117
|
+
|
|
118
|
+
# ── Public entry point ────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def run(self) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Register the agent (or locate the existing one) then start the
|
|
123
|
+
polling loop. Blocks forever; press Ctrl-C to stop.
|
|
124
|
+
"""
|
|
125
|
+
if self._handler_func is None:
|
|
126
|
+
raise RuntimeError(
|
|
127
|
+
"No handler registered. Decorate a function with @server.handler."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
self._register_or_locate()
|
|
131
|
+
_print_status(
|
|
132
|
+
f"[aztea] Agent '{self.name}' (id={self._agent_id}) ready. "
|
|
133
|
+
"Polling for jobs…"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
self._poll_forever()
|
|
138
|
+
except KeyboardInterrupt:
|
|
139
|
+
_print_status("[aztea] Shutting down.")
|
|
140
|
+
|
|
141
|
+
# ── Registration ──────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
def _register_or_locate(self) -> None:
|
|
144
|
+
"""Register the agent; if the name already exists, locate its ID."""
|
|
145
|
+
try:
|
|
146
|
+
data = self._client._request(
|
|
147
|
+
"POST",
|
|
148
|
+
"/registry/register",
|
|
149
|
+
json={
|
|
150
|
+
"name": self.name,
|
|
151
|
+
"description": self.description,
|
|
152
|
+
"endpoint_url": self._endpoint_url,
|
|
153
|
+
"price_per_call_usd": self.price_per_call_usd,
|
|
154
|
+
"tags": self.tags,
|
|
155
|
+
"input_schema": self.input_schema,
|
|
156
|
+
"output_schema": self.output_schema,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
self._agent_id = data["agent_id"]
|
|
160
|
+
_print_status(
|
|
161
|
+
f"[aztea] Registered new agent '{self.name}' → {self._agent_id}"
|
|
162
|
+
)
|
|
163
|
+
except AzteaError as exc:
|
|
164
|
+
# 409 Conflict means the name is already registered under our key
|
|
165
|
+
if exc.status_code == 409:
|
|
166
|
+
self._agent_id = self._locate_existing_agent()
|
|
167
|
+
if self._agent_id is None:
|
|
168
|
+
raise RuntimeError(
|
|
169
|
+
f"Agent name '{self.name}' already taken by a different owner."
|
|
170
|
+
) from exc
|
|
171
|
+
_print_status(
|
|
172
|
+
f"[aztea] Found existing agent '{self.name}' → {self._agent_id}"
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
raise
|
|
176
|
+
|
|
177
|
+
def _locate_existing_agent(self) -> str | None:
|
|
178
|
+
"""Search the registry for an agent with our name owned by our key."""
|
|
179
|
+
try:
|
|
180
|
+
data = self._client._request("GET", "/registry/agents")
|
|
181
|
+
for a in data.get("agents") or []:
|
|
182
|
+
if a.get("name") == self.name:
|
|
183
|
+
return a["agent_id"]
|
|
184
|
+
except AzteaError:
|
|
185
|
+
pass
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
# ── Polling loop ──────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def _poll_forever(self) -> None:
|
|
191
|
+
while True:
|
|
192
|
+
try:
|
|
193
|
+
data = self._client._request(
|
|
194
|
+
"GET",
|
|
195
|
+
f"/jobs/agent/{self._agent_id}",
|
|
196
|
+
params={"status": "pending", "limit": "10"},
|
|
197
|
+
)
|
|
198
|
+
jobs = data.get("jobs") or [] if isinstance(data, dict) else []
|
|
199
|
+
for job in jobs:
|
|
200
|
+
self._process_job(job)
|
|
201
|
+
except AzteaError as exc:
|
|
202
|
+
_print_status(f"[aztea] Poll error: {exc}")
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
_print_status(f"[aztea] Unexpected error: {exc}")
|
|
205
|
+
|
|
206
|
+
time.sleep(_POLL_INTERVAL)
|
|
207
|
+
|
|
208
|
+
# ── Job processing ────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
def _process_job(self, job_raw: Dict[str, Any]) -> None:
|
|
211
|
+
job_id: str = job_raw["job_id"]
|
|
212
|
+
|
|
213
|
+
# Claim the job
|
|
214
|
+
try:
|
|
215
|
+
claim_data = self._client._request(
|
|
216
|
+
"POST",
|
|
217
|
+
f"/jobs/{job_id}/claim",
|
|
218
|
+
json={"lease_seconds": _LEASE_SECONDS},
|
|
219
|
+
)
|
|
220
|
+
except AzteaError:
|
|
221
|
+
# Another worker may have claimed it first — skip silently
|
|
222
|
+
return
|
|
223
|
+
if not isinstance(claim_data, dict):
|
|
224
|
+
_print_status(f"[aztea] Claim response for job {job_id} was malformed.")
|
|
225
|
+
return
|
|
226
|
+
raw_claim_token = claim_data.get("claim_token")
|
|
227
|
+
if not isinstance(raw_claim_token, str) or not raw_claim_token.strip():
|
|
228
|
+
_print_status(f"[aztea] Claim for job {job_id} did not return a valid claim token.")
|
|
229
|
+
return
|
|
230
|
+
claim_token: str = raw_claim_token
|
|
231
|
+
|
|
232
|
+
_print_status(f"[aztea] Claimed job {job_id}")
|
|
233
|
+
|
|
234
|
+
# Heartbeat thread — keeps the lease alive every 20s
|
|
235
|
+
stop_hb = threading.Event()
|
|
236
|
+
hb_thread = threading.Thread(
|
|
237
|
+
target=self._heartbeat_loop,
|
|
238
|
+
args=(job_id, claim_token, stop_hb),
|
|
239
|
+
daemon=True,
|
|
240
|
+
)
|
|
241
|
+
hb_thread.start()
|
|
242
|
+
|
|
243
|
+
# Run handler (with clarification retry support)
|
|
244
|
+
t0 = time.monotonic()
|
|
245
|
+
input_payload = _parse_payload(job_raw.get("input_payload"))
|
|
246
|
+
try:
|
|
247
|
+
output = self._handler_func(input_payload) # type: ignore[misc]
|
|
248
|
+
elapsed = time.monotonic() - t0
|
|
249
|
+
stop_hb.set()
|
|
250
|
+
hb_thread.join(timeout=1)
|
|
251
|
+
|
|
252
|
+
self._client._request(
|
|
253
|
+
"POST",
|
|
254
|
+
f"/jobs/{job_id}/complete",
|
|
255
|
+
json={
|
|
256
|
+
"output_payload": output,
|
|
257
|
+
"claim_token": claim_token,
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
_print_status(
|
|
261
|
+
f"[aztea] Completed job {job_id} ({elapsed:.1f}s)"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
except ClarificationNeeded as exc:
|
|
265
|
+
# Pause the job and ask the caller a question.
|
|
266
|
+
# The heartbeat thread keeps running while we wait.
|
|
267
|
+
_print_status(
|
|
268
|
+
f"[aztea] Job {job_id} needs clarification: {exc.question}"
|
|
269
|
+
)
|
|
270
|
+
try:
|
|
271
|
+
self._client._request(
|
|
272
|
+
"POST",
|
|
273
|
+
f"/jobs/{job_id}/messages",
|
|
274
|
+
json={
|
|
275
|
+
"type": "clarification_request",
|
|
276
|
+
"content": exc.question,
|
|
277
|
+
"claim_token": claim_token,
|
|
278
|
+
},
|
|
279
|
+
)
|
|
280
|
+
except AzteaError:
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
# Poll for a clarification_response (up to 10 min)
|
|
284
|
+
answer = self._wait_for_clarification(job_id, timeout_seconds=600)
|
|
285
|
+
stop_hb.set()
|
|
286
|
+
hb_thread.join(timeout=1)
|
|
287
|
+
|
|
288
|
+
if answer is None:
|
|
289
|
+
# Timed out waiting — fail with full refund
|
|
290
|
+
try:
|
|
291
|
+
self._client._request(
|
|
292
|
+
"POST",
|
|
293
|
+
f"/jobs/{job_id}/fail",
|
|
294
|
+
json={
|
|
295
|
+
"error_message": "Timed out waiting for caller clarification.",
|
|
296
|
+
"claim_token": claim_token,
|
|
297
|
+
"refund_fraction": 1.0,
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
except AzteaError:
|
|
301
|
+
pass
|
|
302
|
+
_print_status(f"[aztea] Job {job_id} timed out awaiting clarification")
|
|
303
|
+
else:
|
|
304
|
+
# Re-run handler with clarification injected
|
|
305
|
+
input_payload["__clarification__"] = answer
|
|
306
|
+
try:
|
|
307
|
+
output = self._handler_func(input_payload) # type: ignore[misc]
|
|
308
|
+
self._client._request(
|
|
309
|
+
"POST",
|
|
310
|
+
f"/jobs/{job_id}/complete",
|
|
311
|
+
json={"output_payload": output, "claim_token": claim_token},
|
|
312
|
+
)
|
|
313
|
+
elapsed = time.monotonic() - t0
|
|
314
|
+
_print_status(f"[aztea] Completed job {job_id} after clarification ({elapsed:.1f}s)")
|
|
315
|
+
except Exception as retry_exc:
|
|
316
|
+
try:
|
|
317
|
+
self._client._request(
|
|
318
|
+
"POST",
|
|
319
|
+
f"/jobs/{job_id}/fail",
|
|
320
|
+
json={
|
|
321
|
+
"error_message": str(retry_exc),
|
|
322
|
+
"claim_token": claim_token,
|
|
323
|
+
"refund_fraction": 1.0,
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
except AzteaError:
|
|
327
|
+
pass
|
|
328
|
+
_print_status(f"[aztea] Failed job {job_id} after clarification: {retry_exc}")
|
|
329
|
+
|
|
330
|
+
except InputError as exc:
|
|
331
|
+
# Bad input from caller — fail fast with partial refund.
|
|
332
|
+
elapsed = time.monotonic() - t0
|
|
333
|
+
stop_hb.set()
|
|
334
|
+
hb_thread.join(timeout=1)
|
|
335
|
+
try:
|
|
336
|
+
self._client._request(
|
|
337
|
+
"POST",
|
|
338
|
+
f"/jobs/{job_id}/fail",
|
|
339
|
+
json={
|
|
340
|
+
"error_message": str(exc),
|
|
341
|
+
"claim_token": claim_token,
|
|
342
|
+
"refund_fraction": exc.refund_fraction,
|
|
343
|
+
},
|
|
344
|
+
)
|
|
345
|
+
except AzteaError:
|
|
346
|
+
pass
|
|
347
|
+
_print_status(
|
|
348
|
+
f"[aztea] Job {job_id} rejected (bad input, "
|
|
349
|
+
f"{int(exc.refund_fraction*100)}% refund): {exc}"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
elapsed = time.monotonic() - t0
|
|
354
|
+
stop_hb.set()
|
|
355
|
+
hb_thread.join(timeout=1)
|
|
356
|
+
|
|
357
|
+
error_msg = str(exc)
|
|
358
|
+
try:
|
|
359
|
+
self._client._request(
|
|
360
|
+
"POST",
|
|
361
|
+
f"/jobs/{job_id}/fail",
|
|
362
|
+
json={
|
|
363
|
+
"error_message": error_msg,
|
|
364
|
+
"claim_token": claim_token,
|
|
365
|
+
"refund_fraction": 1.0,
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
except AzteaError:
|
|
369
|
+
pass
|
|
370
|
+
|
|
371
|
+
_print_status(f"[aztea] Failed job {job_id}: {error_msg}")
|
|
372
|
+
|
|
373
|
+
def _wait_for_clarification(
|
|
374
|
+
self,
|
|
375
|
+
job_id: str,
|
|
376
|
+
timeout_seconds: float = 600,
|
|
377
|
+
) -> str | None:
|
|
378
|
+
"""Poll job messages until a clarification_response arrives or timeout."""
|
|
379
|
+
deadline = time.monotonic() + timeout_seconds
|
|
380
|
+
seen_ids: set[int] = set()
|
|
381
|
+
while time.monotonic() < deadline:
|
|
382
|
+
try:
|
|
383
|
+
data = self._client._request("GET", f"/jobs/{job_id}/messages")
|
|
384
|
+
messages = data.get("messages") or []
|
|
385
|
+
for msg in messages:
|
|
386
|
+
msg_id = msg.get("message_id")
|
|
387
|
+
if msg_id in seen_ids:
|
|
388
|
+
continue
|
|
389
|
+
seen_ids.add(msg_id)
|
|
390
|
+
if msg.get("type") in ("clarification_response", "clarification"):
|
|
391
|
+
content = msg.get("content")
|
|
392
|
+
if isinstance(content, dict):
|
|
393
|
+
return content.get("text") or str(content)
|
|
394
|
+
return str(content) if content is not None else ""
|
|
395
|
+
except AzteaError:
|
|
396
|
+
pass
|
|
397
|
+
time.sleep(5)
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
def _heartbeat_loop(
|
|
401
|
+
self,
|
|
402
|
+
job_id: str,
|
|
403
|
+
claim_token: str | None,
|
|
404
|
+
stop_event: threading.Event,
|
|
405
|
+
) -> None:
|
|
406
|
+
"""Send periodic heartbeats until stop_event is set."""
|
|
407
|
+
while not stop_event.wait(timeout=_HEARTBEAT_INTERVAL):
|
|
408
|
+
try:
|
|
409
|
+
self._client._request(
|
|
410
|
+
"POST",
|
|
411
|
+
f"/jobs/{job_id}/heartbeat",
|
|
412
|
+
json={
|
|
413
|
+
"lease_seconds": _LEASE_SECONDS,
|
|
414
|
+
"claim_token": claim_token,
|
|
415
|
+
},
|
|
416
|
+
)
|
|
417
|
+
except AzteaError:
|
|
418
|
+
break
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ---------------------------------------------------------------------------
|
|
422
|
+
# Standalone callback receiver helper
|
|
423
|
+
# ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
def verify_callback_signature(
|
|
426
|
+
body: bytes,
|
|
427
|
+
signature_header: str,
|
|
428
|
+
secret: str,
|
|
429
|
+
) -> bool:
|
|
430
|
+
"""
|
|
431
|
+
Verify an X-Aztea-Signature header value against a raw request body.
|
|
432
|
+
|
|
433
|
+
Parameters
|
|
434
|
+
----------
|
|
435
|
+
body
|
|
436
|
+
Raw bytes of the POST body.
|
|
437
|
+
signature_header
|
|
438
|
+
Value of the ``X-Aztea-Signature`` header (``sha256=<hex>``).
|
|
439
|
+
secret
|
|
440
|
+
The ``callback_secret`` you set when creating the job.
|
|
441
|
+
|
|
442
|
+
Returns
|
|
443
|
+
-------
|
|
444
|
+
bool
|
|
445
|
+
True if the signature is valid, False otherwise.
|
|
446
|
+
"""
|
|
447
|
+
expected = "sha256=" + hmac.new(
|
|
448
|
+
secret.encode("utf-8"), body, hashlib.sha256
|
|
449
|
+
).hexdigest()
|
|
450
|
+
try:
|
|
451
|
+
return hmac.compare_digest(expected, signature_header)
|
|
452
|
+
except (TypeError, ValueError):
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class CallbackReceiver:
|
|
457
|
+
"""
|
|
458
|
+
Lightweight WSGI/ASGI-agnostic helper for receiving job completion
|
|
459
|
+
callbacks from Aztea.
|
|
460
|
+
|
|
461
|
+
Usage (Flask example)::
|
|
462
|
+
|
|
463
|
+
from aztea import CallbackReceiver
|
|
464
|
+
|
|
465
|
+
receiver = CallbackReceiver(secret="my-secret")
|
|
466
|
+
|
|
467
|
+
@receiver.on_job_complete
|
|
468
|
+
def handle_complete(payload: dict) -> None:
|
|
469
|
+
print("Job done:", payload["job_id"], payload["status"])
|
|
470
|
+
|
|
471
|
+
@app.route("/callback", methods=["POST"])
|
|
472
|
+
def callback():
|
|
473
|
+
raw = request.get_data()
|
|
474
|
+
sig = request.headers.get("X-Aztea-Signature", "")
|
|
475
|
+
receiver.dispatch(raw, sig)
|
|
476
|
+
return "", 204
|
|
477
|
+
|
|
478
|
+
Usage (FastAPI example)::
|
|
479
|
+
|
|
480
|
+
from fastapi import Request
|
|
481
|
+
from aztea import CallbackReceiver
|
|
482
|
+
|
|
483
|
+
receiver = CallbackReceiver(secret="my-secret")
|
|
484
|
+
|
|
485
|
+
@receiver.on_job_complete
|
|
486
|
+
def handle_complete(payload: dict) -> None:
|
|
487
|
+
print("Job done:", payload["job_id"])
|
|
488
|
+
|
|
489
|
+
@app.post("/callback")
|
|
490
|
+
async def callback(request: Request):
|
|
491
|
+
raw = await request.body()
|
|
492
|
+
sig = request.headers.get("X-Aztea-Signature", "")
|
|
493
|
+
receiver.dispatch(raw, sig)
|
|
494
|
+
return {}
|
|
495
|
+
"""
|
|
496
|
+
|
|
497
|
+
def __init__(self, secret: str) -> None:
|
|
498
|
+
self._secret = secret
|
|
499
|
+
self._handler: Callable[[dict], Any] | None = None
|
|
500
|
+
|
|
501
|
+
def on_job_complete(
|
|
502
|
+
self, func: Callable[[dict], Any]
|
|
503
|
+
) -> Callable[[dict], Any]:
|
|
504
|
+
"""Decorator: register *func* as the handler for completed-job payloads."""
|
|
505
|
+
self._handler = func
|
|
506
|
+
return func
|
|
507
|
+
|
|
508
|
+
def dispatch(self, body: bytes, signature_header: str) -> None:
|
|
509
|
+
"""
|
|
510
|
+
Verify the HMAC signature and call the registered handler.
|
|
511
|
+
|
|
512
|
+
Raises
|
|
513
|
+
------
|
|
514
|
+
ValueError
|
|
515
|
+
If the signature is invalid or no handler is registered.
|
|
516
|
+
"""
|
|
517
|
+
if not verify_callback_signature(body, signature_header, self._secret):
|
|
518
|
+
raise ValueError("Invalid X-Aztea-Signature; rejecting callback.")
|
|
519
|
+
if self._handler is None:
|
|
520
|
+
raise ValueError("No handler registered. Use @receiver.on_job_complete.")
|
|
521
|
+
payload = json.loads(body)
|
|
522
|
+
self._handler(payload)
|