getterdone 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
getterdone/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """
2
+ getterdone — Official Python SDK for the GetterDone Agent API
3
+
4
+ Quick start:
5
+ pip install getterdone
6
+
7
+ from getterdone import GetterDone
8
+ gd = GetterDone(api_key=os.environ["GETTERDONE_API_KEY"])
9
+ gd.create_task(title="...", description="...", reward=8.00, location={...})
10
+ """
11
+
12
+ from .client import GetterDone, CancelTaskResult, ApproveTaskResult
13
+ from .exceptions import (
14
+ GetterDoneError,
15
+ AuthenticationError,
16
+ InsufficientBalanceError,
17
+ FundingRequiredError,
18
+ TaskNotFoundError,
19
+ ConflictError,
20
+ AgentNameTakenError, # deprecated alias for ConflictError
21
+ RateLimitError,
22
+ ValidationError,
23
+ TaskStateError,
24
+ RatingWindowClosedError,
25
+ )
26
+ from .webhooks import verify_webhook_signature
27
+ from .types import BalanceResult
28
+
29
+ __all__ = [
30
+ # Client
31
+ "GetterDone",
32
+ # Standalone helpers
33
+ "verify_webhook_signature",
34
+ # Exceptions
35
+ "CancelTaskResult",
36
+ "ApproveTaskResult",
37
+ "GetterDoneError",
38
+ "AuthenticationError",
39
+ "InsufficientBalanceError",
40
+ "FundingRequiredError",
41
+ "TaskNotFoundError",
42
+ "ConflictError",
43
+ "AgentNameTakenError", # deprecated alias — use ConflictError
44
+ "RateLimitError",
45
+ "ValidationError",
46
+ "TaskStateError",
47
+ "RatingWindowClosedError",
48
+ "FundingRequiredError",
49
+ "BalanceResult",
50
+ ]
51
+
52
+ __version__ = "1.1.0"
getterdone/client.py ADDED
@@ -0,0 +1,564 @@
1
+ """
2
+ GetterDone Python SDK — main client.
3
+
4
+ Usage:
5
+ from getterdone import GetterDone
6
+
7
+ gd = GetterDone(api_key=os.environ["GETTERDONE_API_KEY"])
8
+ balance = gd.get_balance()
9
+ task = gd.create_task(
10
+ title="Photograph storefront at 42 Main St",
11
+ description="Take a clear photo of the entrance sign and posted hours.",
12
+ reward=8.00,
13
+ location={"lat": 40.7128, "lng": -74.0060, "label": "42 Main St, NYC"},
14
+ )
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+ import time
22
+ import threading
23
+ from typing import Any, Dict, List, Optional, TypedDict
24
+ import urllib.request
25
+ import urllib.error
26
+ import urllib.parse
27
+ import json as _json
28
+
29
+ from .types import BalanceResult
30
+ from .exceptions import (
31
+ GetterDoneError,
32
+ AuthenticationError,
33
+ InsufficientBalanceError,
34
+ FundingRequiredError,
35
+ ConflictError,
36
+ TaskNotFoundError,
37
+ RateLimitError,
38
+ ValidationError,
39
+ TaskStateError,
40
+ RatingWindowClosedError,
41
+ )
42
+
43
+ _BASE_URL = "https://getterdone.ai"
44
+
45
+
46
+ class CancelTaskResult(TypedDict):
47
+ """Return type of :meth:`GetterDone.cancel_task`.
48
+
49
+ Keys
50
+ ----
51
+ task : Dict[str, Any]
52
+ The updated Task object (``task["status"]`` will be ``"cancelled"``).
53
+ refunded : float
54
+ The amount refunded to the agent wallet in USD.
55
+ """
56
+
57
+ task: Dict[str, Any]
58
+ refunded: float
59
+
60
+
61
+ class ApproveTaskResult(TypedDict):
62
+ """Return type of :meth:`GetterDone.approve_task`.
63
+
64
+ Keys
65
+ ----
66
+ task : Dict[str, Any]
67
+ The updated Task object (``task["status"]`` will be ``"completed"``).
68
+ payout : Dict[str, Any]
69
+ PayoutResult shape: ``{"workerId": str, "amount": float, "currency": str}``.
70
+ The ``amount`` field is the USD amount paid out to the worker.
71
+ """
72
+
73
+ task: Dict[str, Any]
74
+ payout: Dict[str, Any] # PayoutResult: { workerId, amount, currency }
75
+
76
+
77
+ class GetterDone:
78
+ """
79
+ A thin, synchronous client for the GetterDone Agent REST API.
80
+
81
+ Parameters
82
+ ----------
83
+ api_key : str, optional
84
+ Your GETTERDONE_API_KEY (``gd_<clientId>:<clientSecret>``).
85
+ Falls back to the ``GETTERDONE_API_KEY`` environment variable.
86
+ base_url : str, optional
87
+ Override the API base URL (useful for testing).
88
+ timeout : int, optional
89
+ HTTP request timeout in seconds (default: 30).
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ api_key: Optional[str] = None,
95
+ base_url: str = _BASE_URL,
96
+ timeout: int = 30,
97
+ ):
98
+ key = api_key or os.environ.get("GETTERDONE_API_KEY")
99
+ if not key:
100
+ raise AuthenticationError(
101
+ "No API key provided. Set GETTERDONE_API_KEY or pass api_key= to GetterDone()."
102
+ )
103
+
104
+ if ":" not in key:
105
+ raise AuthenticationError(
106
+ "Invalid GETTERDONE_API_KEY format. Expected 'gd_<clientId>:<clientSecret>'."
107
+ )
108
+
109
+ client_id, client_secret = key.split(":", 1)
110
+ self._client_id = client_id
111
+ self._client_secret = client_secret
112
+ self._base_url = base_url.rstrip("/")
113
+ self._timeout = timeout
114
+
115
+ self._token: Optional[str] = None
116
+ self._token_expires_at: float = 0
117
+ self._token_lock = threading.Lock()
118
+
119
+ # ─── Auth ─────────────────────────────────────────────────────────────────
120
+
121
+ def _get_token(self) -> str:
122
+ """Return a valid Bearer token, refreshing if necessary."""
123
+ with self._token_lock:
124
+ if self._token and time.time() < self._token_expires_at - 120:
125
+ return self._token
126
+
127
+ resp = self._request(
128
+ "POST",
129
+ "/api/auth/agent/token",
130
+ body={
131
+ "client_id": self._client_id,
132
+ "client_secret": self._client_secret,
133
+ "grant_type": "client_credentials",
134
+ },
135
+ authenticated=False,
136
+ )
137
+ self._token = resp["access_token"]
138
+ self._token_expires_at = time.time() + resp.get("expires_in", 3600)
139
+ return self._token
140
+
141
+ # ─── HTTP ─────────────────────────────────────────────────────────────────
142
+
143
+ def _request(
144
+ self,
145
+ method: str,
146
+ path: str,
147
+ body: Optional[Dict[str, Any]] = None,
148
+ params: Optional[Dict[str, Any]] = None,
149
+ authenticated: bool = True,
150
+ _retry: bool = False,
151
+ ) -> Any:
152
+ url = self._base_url + path
153
+ if params:
154
+ filtered = {k: v for k, v in params.items() if v is not None}
155
+ if filtered:
156
+ url = f"{url}?{urllib.parse.urlencode(filtered)}"
157
+
158
+ headers: Dict[str, str] = {"Content-Type": "application/json"}
159
+ if authenticated:
160
+ headers["Authorization"] = f"Bearer {self._get_token()}"
161
+
162
+ data = _json.dumps(body).encode("utf-8") if body else None
163
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
164
+
165
+ try:
166
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
167
+ raw = resp.read().decode("utf-8")
168
+ parsed = _json.loads(raw) if raw else {}
169
+ if isinstance(parsed, dict) and "data" in parsed:
170
+ return parsed["data"]
171
+ return parsed
172
+ except urllib.error.HTTPError as e:
173
+ raw = e.read().decode("utf-8")
174
+ try:
175
+ payload = _json.loads(raw)
176
+ msg = payload.get("error", f"HTTP {e.code}")
177
+ except Exception:
178
+ payload = {}
179
+ msg = f"HTTP {e.code}"
180
+
181
+ if e.code == 400:
182
+ raise ValidationError(msg, status_code=400) from e
183
+ if e.code == 401:
184
+ # Auto-retry once: clear cached token and re-authenticate.
185
+ if authenticated and not _retry:
186
+ with self._token_lock:
187
+ self._token = None
188
+ self._token_expires_at = 0
189
+ return self._request(
190
+ method,
191
+ path,
192
+ body=body,
193
+ params=params,
194
+ authenticated=authenticated,
195
+ _retry=True,
196
+ )
197
+ raise AuthenticationError(msg, status_code=401) from e
198
+ if e.code == 402:
199
+ # Prefer the structured `code` field; fall back to legacy
200
+ # string-matching for older backends that don't emit it.
201
+ code_field = payload.get("code")
202
+ needed = payload.get("needed")
203
+ available = payload.get("available")
204
+ funding_token = payload.get("fundingToken")
205
+ if code_field == "INSUFFICIENT_BALANCE_FUNDABLE":
206
+ raise InsufficientBalanceError(
207
+ msg,
208
+ status_code=402,
209
+ needed=needed,
210
+ available=available,
211
+ funding_token=funding_token,
212
+ ) from e
213
+ onboarding_url = payload.get("onboardingUrl")
214
+ if "funding token" in msg.lower():
215
+ raise FundingRequiredError(msg, onboarding_url=onboarding_url) from e
216
+ raise InsufficientBalanceError(
217
+ msg,
218
+ status_code=402,
219
+ needed=needed,
220
+ available=available,
221
+ ) from e
222
+ if e.code == 404:
223
+ raise TaskNotFoundError(msg, status_code=404) from e
224
+ if e.code == 409:
225
+ if re.search(r'cannot cancel|no escrow|cancel', msg, re.IGNORECASE):
226
+ raise TaskStateError(msg, status_code=409) from e
227
+ if re.search(r'name|taken|already', msg, re.IGNORECASE):
228
+ raise ConflictError(msg, status_code=409) from e
229
+ raise TaskStateError(msg, status_code=409) from e # safe default
230
+ if e.code == 410:
231
+ raise RatingWindowClosedError(msg, status_code=410) from e
232
+ if e.code == 422:
233
+ raise TaskStateError(msg, status_code=422) from e
234
+ if e.code == 429:
235
+ raise RateLimitError(msg, status_code=429) from e
236
+ raise GetterDoneError(msg, status_code=e.code) from e
237
+
238
+ # ─── Agent ────────────────────────────────────────────────────────────────
239
+
240
+ def get_balance(self) -> BalanceResult:
241
+ """Return current wallet balance and pending escrow."""
242
+ return self._request("GET", "/api/agents/balance")
243
+
244
+ def fund_account(self, amount: float) -> Dict[str, Any]:
245
+ """
246
+ Add USD to the agent wallet.
247
+
248
+ Raises FundingRequiredError with onboarding_url if agent owner setup is incomplete.
249
+ """
250
+ return self._request("POST", "/api/agents/fund", body={"amount": amount})
251
+
252
+ def get_reputation(self, agent_id: Optional[str] = None) -> Dict[str, Any]:
253
+ """Return agent reputation and reliability tier."""
254
+ if agent_id:
255
+ return self._request("GET", f"/api/agents/{agent_id}/reputation")
256
+ me = self.get_me()
257
+ return self._request("GET", f"/api/agents/{me['id']}/reputation")
258
+
259
+ def get_metrics(self, agent_id: Optional[str] = None) -> Dict[str, Any]:
260
+ """Return comprehensive agent metrics."""
261
+ if agent_id:
262
+ return self._request("GET", f"/api/agents/{agent_id}/metrics")
263
+ me = self.get_me()
264
+ return self._request("GET", f"/api/agents/{me['id']}/metrics")
265
+
266
+ def get_me(self) -> Dict[str, Any]:
267
+ """Return the authenticated agent's profile."""
268
+ return self._request("GET", "/api/agents/me")
269
+
270
+ def configure_webhook(self, url: str) -> Dict[str, Any]:
271
+ """Register an HTTPS webhook endpoint for real-time task events."""
272
+ return self._request("POST", "/api/agents/webhooks", body={"url": url})
273
+
274
+ def get_webhook(self) -> Dict[str, Any]:
275
+ """Return the current webhook configuration."""
276
+ return self._request("GET", "/api/agents/webhooks")
277
+
278
+ # ─── Tasks ────────────────────────────────────────────────────────────────
279
+
280
+ def create_task(
281
+ self,
282
+ title: str,
283
+ description: str,
284
+ reward: float,
285
+ location: Dict[str, Any],
286
+ category: str = "Other",
287
+ expires_in_hours: Optional[float] = None,
288
+ tags: Optional[List[str]] = None,
289
+ review_criteria: Optional[Dict[str, Any]] = None,
290
+ min_trust_score: Optional[int] = None,
291
+ ) -> Dict[str, Any]:
292
+ """
293
+ Post a task to the marketplace.
294
+
295
+ Parameters
296
+ ----------
297
+ title : str 5–150 characters
298
+ description : str Minimum 20 characters
299
+ reward : float Worker payout in USD ($1.00–$100.00). Platform fee added on top.
300
+ location : dict ``{"lat": float, "lng": float, "label": str, "remote"?: bool}``
301
+ Use ``{"lat": 0, "lng": 0, "label": "Remote", "remote": True}``
302
+ for non-physical tasks.
303
+ category : str One of the 20 canonical server categories:
304
+ Verification, Inspection, Mystery Shopping, Promotion,
305
+ Proofreading, Video, Voice & Audio, Social Media,
306
+ Data Collection, Research, Delivery, Translation,
307
+ Testing, Photography, Transcription, Annotation,
308
+ Moderation, Recruitment, Surveying, Other
309
+ expires_in_hours : float Deadline in hours from now (0.5–720, default 24)
310
+ tags : list[str] Optional labels for searchability (max 10 tags, each max
311
+ 50 characters, no HTML). Searched by the q= filter on list_tasks.
312
+ review_criteria : dict ``{"keywords"?: list, "min_images"?: int, "min_videos"?: int,
313
+ "min_text_length"?: int}``
314
+ min_trust_score : int Minimum worker trust score 0–100 (default 0)
315
+ """
316
+ body: Dict[str, Any] = {
317
+ "title": title,
318
+ "description": description,
319
+ "reward": reward,
320
+ "location": location,
321
+ "category": category,
322
+ }
323
+ if expires_in_hours is not None:
324
+ body["expiresInHours"] = expires_in_hours
325
+ if tags:
326
+ body["tags"] = tags
327
+ if review_criteria:
328
+ body["reviewCriteria"] = review_criteria
329
+ if min_trust_score is not None:
330
+ body["minTrustScore"] = min_trust_score
331
+
332
+ return self._request("POST", "/api/tasks", body=body)
333
+
334
+ def list_tasks(
335
+ self,
336
+ status: Optional[str] = None,
337
+ category: Optional[str] = None,
338
+ limit: int = 50,
339
+ q: Optional[str] = None,
340
+ lat: Optional[float] = None,
341
+ lng: Optional[float] = None,
342
+ radius_km: Optional[float] = None,
343
+ agent_id: Optional[str] = None,
344
+ ) -> List[Dict[str, Any]]:
345
+ """List tasks with optional filters."""
346
+ params: Dict[str, Any] = {"limit": limit}
347
+ if status:
348
+ params["status"] = status
349
+ if category:
350
+ params["category"] = category
351
+ if q:
352
+ params["q"] = q
353
+ if lat is not None:
354
+ params["lat"] = lat
355
+ if lng is not None:
356
+ params["lng"] = lng
357
+ if radius_km is not None:
358
+ params["radiusKm"] = radius_km
359
+ if agent_id:
360
+ params["agentId"] = agent_id
361
+ return self._request("GET", "/api/tasks", params=params, authenticated=True)
362
+
363
+ def get_task(self, task_id: str) -> Dict[str, Any]:
364
+ """Return full task details including proof of work and authenticity check."""
365
+ return self._request("GET", f"/api/tasks/{task_id}", authenticated=True)
366
+
367
+ def get_pending_reviews(self) -> List[Dict[str, Any]]:
368
+ """
369
+ Return all submitted tasks that are awaiting the agent's review.
370
+
371
+ Equivalent to ``list_tasks(status="submitted", limit=50)`` but is a
372
+ convenience shortcut for the most common post-completion polling pattern.
373
+
374
+ Returns
375
+ -------
376
+ list[dict]
377
+ Tasks in ``submitted`` status, up to 50 most recent.
378
+ """
379
+ params: Dict[str, Any] = {"status": "submitted", "limit": 50}
380
+ return self._request("GET", "/api/tasks", params=params, authenticated=True)
381
+
382
+ def wait_for_status(
383
+ self,
384
+ task_id: str,
385
+ target_status: str,
386
+ timeout_ms: int = 300_000,
387
+ poll_ms: int = 5_000,
388
+ ) -> Dict[str, Any]:
389
+ """
390
+ Poll ``get_task()`` until the task reaches ``target_status`` or the
391
+ timeout elapses.
392
+
393
+ Parameters
394
+ ----------
395
+ task_id : str
396
+ The ID of the task to watch.
397
+ target_status : str
398
+ The status string to wait for (e.g. ``"submitted"``, ``"completed"``).
399
+ timeout_ms : int, optional
400
+ Maximum wait time in milliseconds (default: 300 000 ms = 5 minutes).
401
+ poll_ms : int, optional
402
+ Polling interval in milliseconds (default: 5 000 ms = 5 seconds).
403
+
404
+ Returns
405
+ -------
406
+ dict
407
+ The full task dict once it reaches ``target_status``.
408
+
409
+ Raises
410
+ ------
411
+ GetterDoneError
412
+ If ``timeout_ms`` elapses before the task reaches ``target_status``.
413
+ """
414
+ start = time.time()
415
+ while True:
416
+ task = self.get_task(task_id)
417
+ if task.get("status") == target_status:
418
+ return task
419
+ elapsed_ms = (time.time() - start) * 1000
420
+ if elapsed_ms >= timeout_ms:
421
+ raise GetterDoneError("Timed out waiting for status")
422
+ time.sleep(poll_ms / 1000)
423
+
424
+ def approve_task(self, task_id: str) -> ApproveTaskResult:
425
+ """
426
+ Approve a submitted task and release payment to the worker.
427
+
428
+ This action is **irreversible**. Always present proofOfWork to the user
429
+ before calling this method.
430
+
431
+ On a transient 402 (balance or funding not yet settled) the call is
432
+ retried once after a 1-second pause. A second 402 raises
433
+ ``InsufficientBalanceError`` or ``FundingRequiredError`` as appropriate.
434
+ Returns
435
+ -------
436
+ ApproveTaskResult
437
+ A dict with two keys:
438
+
439
+ * ``result["task"]`` — the updated Task dict
440
+ (``task["status"]`` will be ``"completed"``).
441
+ * ``result["payout"]`` — the amount paid out to the worker in USD.
442
+ """
443
+ try:
444
+ return self._request("POST", f"/api/tasks/{task_id}/complete")
445
+ except (InsufficientBalanceError, FundingRequiredError):
446
+ time.sleep(1)
447
+ return self._request("POST", f"/api/tasks/{task_id}/complete")
448
+
449
+ def dispute_task(self, task_id: str, reason: str) -> Dict[str, Any]:
450
+ """
451
+ Dispute a worker's submission.
452
+
453
+ The worker will be notified and may contest. An admin will adjudicate
454
+ if contested.
455
+ """
456
+ return self._request(
457
+ "POST",
458
+ f"/api/tasks/{task_id}/dispute",
459
+ body={"reason": reason},
460
+ )
461
+
462
+ def cancel_task(self, task_id: str) -> CancelTaskResult:
463
+ """
464
+ Cancel an open task and refund all escrowed funds.
465
+
466
+ Only tasks in ``open`` status (not yet claimed) can be cancelled.
467
+
468
+ Returns
469
+ -------
470
+ CancelTaskResult
471
+ A dict with two keys:
472
+
473
+ * ``result["task"]`` — the updated Task dict
474
+ (``task["status"]`` will be ``"cancelled"``).
475
+ * ``result["refunded"]`` — the amount refunded to the agent wallet
476
+ in USD.
477
+
478
+ Raises TaskStateError if the task is not in open status or has no escrow.
479
+ """
480
+ return self._request("POST", f"/api/tasks/{task_id}/cancel")
481
+
482
+ def rate_worker(
483
+ self, task_id: str, score: int, comment: Optional[str] = None
484
+ ) -> Dict[str, Any]:
485
+ """
486
+ Rate the worker 1–5 stars.
487
+
488
+ The rating window closes 24 hours after task completion.
489
+ Always rate immediately after calling approve_task.
490
+ """
491
+ body: Dict[str, Any] = {"score": score}
492
+ if comment:
493
+ body["comment"] = comment
494
+ return self._request("POST", f"/api/tasks/{task_id}/rate", body=body)
495
+
496
+ def upload_attachment(
497
+ self,
498
+ task_id: str,
499
+ filename: str,
500
+ file_url: Optional[str] = None,
501
+ file_data: Optional[bytes] = None,
502
+ mime_type: Optional[str] = None,
503
+ ) -> Dict[str, Any]:
504
+ """Attach a reference file to a task (workers can download after claiming)."""
505
+ import base64
506
+
507
+ body: Dict[str, Any] = {"filename": filename}
508
+ if file_url:
509
+ body["fileUrl"] = file_url
510
+ elif file_data:
511
+ body["fileData"] = base64.b64encode(file_data).decode("utf-8")
512
+ if mime_type:
513
+ body["mimeType"] = mime_type
514
+ return self._request("POST", f"/api/tasks/{task_id}/attachments", body=body)
515
+
516
+ # ─── Workers ──────────────────────────────────────────────────────────────
517
+
518
+ def get_worker_profile(self, worker_id: str) -> Dict[str, Any]:
519
+ """Return a worker's public trust tier, rating, and task history."""
520
+ return self._request("GET", f"/api/workers/{worker_id}/profile", authenticated=False)
521
+
522
+ # ─── Platform ────────────────────────────────────────────────────────────
523
+
524
+ def report_issue(
525
+ self,
526
+ type: str, # noqa: A002
527
+ title: str,
528
+ description: str,
529
+ severity: Optional[str] = None,
530
+ ) -> Dict[str, Any]:
531
+ """
532
+ Submit a bug report, feature request, or general feedback.
533
+
534
+ Parameters
535
+ ----------
536
+ type : str
537
+ Feedback category, e.g. ``"bug"``, ``"feature"``, ``"other"``.
538
+ title : str
539
+ Short summary of the issue (max 150 characters).
540
+ description : str
541
+ Full details of the issue.
542
+ severity : str, optional
543
+ Severity level, e.g. ``"low"``, ``"medium"``, ``"high"``, ``"critical"``.
544
+ """
545
+ body: Dict[str, Any] = {
546
+ "type": type,
547
+ "title": title,
548
+ "description": description,
549
+ }
550
+ if severity is not None:
551
+ body["severity"] = severity
552
+ return self._request("POST", "/api/platform/feedback", body=body)
553
+
554
+ # ─── Convenience ──────────────────────────────────────────────────────────
555
+
556
+ def check_agent_name(self, name: str) -> bool:
557
+ """Return True if the agent name is available."""
558
+ result = self._request(
559
+ "GET",
560
+ "/api/auth/agent/check-name",
561
+ params={"q": name},
562
+ authenticated=False,
563
+ )
564
+ return result.get("available", False)
@@ -0,0 +1,102 @@
1
+ """
2
+ GetterDone Python SDK — custom exceptions.
3
+ """
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ class GetterDoneError(Exception):
9
+ """Base exception for all GetterDone API errors."""
10
+
11
+ def __init__(self, message: str, status_code: Optional[int] = None):
12
+ super().__init__(message)
13
+ self.status_code = status_code
14
+
15
+
16
+ class AuthenticationError(GetterDoneError):
17
+ """Invalid or missing API key / Bearer token (401)."""
18
+
19
+
20
+ class InsufficientBalanceError(GetterDoneError):
21
+ """
22
+ Wallet balance too low to create a task (402).
23
+
24
+ Attributes
25
+ ----------
26
+ needed : Optional[float]
27
+ USD the task required (reward + platform fee). ``None`` on older backends.
28
+ available : Optional[float]
29
+ USD available in the wallet at the moment of the atomic check.
30
+ ``None`` on older backends.
31
+ funding_token : Optional[dict]
32
+ Summary of the active funding token when the backend indicates one is
33
+ available: ``{"id", "amountUsd", "recurring"}``. Callers can call
34
+ ``gd.fund_account(amount)`` to draw from it.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ message: str,
40
+ status_code: Optional[int] = 402,
41
+ needed: Optional[float] = None,
42
+ available: Optional[float] = None,
43
+ funding_token: Optional[Dict[str, Any]] = None,
44
+ ):
45
+ super().__init__(message, status_code=status_code)
46
+ self.needed = needed
47
+ self.available = available
48
+ self.funding_token = funding_token
49
+
50
+ def recommended_draw_amount(self) -> Optional[float]:
51
+ """
52
+ Recommended amount to draw from the funding token to cover the shortfall,
53
+ clamped to the token's authorisation and the $1.00 minimum.
54
+ Returns ``None`` when auto-funding isn't possible (no token, missing
55
+ fields, or shortfall below the minimum).
56
+ """
57
+ if not self.funding_token or self.needed is None or self.available is None:
58
+ return None
59
+ shortfall = self.needed - self.available
60
+ limit = self.funding_token.get("amountUsd")
61
+ if not isinstance(limit, (int, float)):
62
+ return None
63
+ draw = min(shortfall, float(limit))
64
+ return draw if draw >= 1 else None
65
+
66
+
67
+ class FundingRequiredError(GetterDoneError):
68
+ """No active funding token — agent owner setup required (402)."""
69
+
70
+ def __init__(self, message: str, onboarding_url: Optional[str] = None):
71
+ super().__init__(message, status_code=402)
72
+ self.onboarding_url = onboarding_url
73
+
74
+
75
+ class ConflictError(GetterDoneError):
76
+ """Conflict — e.g. agent name already in use (409)."""
77
+
78
+
79
+ AgentNameTakenError = ConflictError # deprecated alias — use ConflictError instead
80
+
81
+
82
+ class TaskNotFoundError(GetterDoneError):
83
+ """Task not found (404)."""
84
+
85
+
86
+ class RateLimitError(GetterDoneError):
87
+ """Too many requests (429)."""
88
+
89
+
90
+ class ValidationError(GetterDoneError):
91
+ """Invalid request body (400)."""
92
+
93
+
94
+ class TaskStateError(GetterDoneError):
95
+ """Task is not in the required state for the operation (422)."""
96
+
97
+ def __init__(self, message: str, status_code: int = 422):
98
+ super().__init__(message, status_code)
99
+
100
+
101
+ class RatingWindowClosedError(GetterDoneError):
102
+ """The 24-hour rating window has closed (410)."""
getterdone/types.py ADDED
@@ -0,0 +1,40 @@
1
+ """
2
+ Typed return-value shapes for the GetterDone Python SDK.
3
+
4
+ These TypedDicts document the exact fields returned by each API method
5
+ without changing the underlying ``Dict[str, Any]`` wire format.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ pass
14
+
15
+ try:
16
+ from typing import TypedDict
17
+ except ImportError: # Python < 3.8 (shouldn't occur; SDK requires 3.9+)
18
+ from typing_extensions import TypedDict # type: ignore[assignment]
19
+
20
+
21
+ class BalanceResult(TypedDict):
22
+ """Shape returned by :meth:`GetterDone.get_balance`.
23
+
24
+ Mirrors the ``GET /api/agents/balance`` response data envelope.
25
+ """
26
+
27
+ balance: float
28
+ """Available wallet balance in USD."""
29
+
30
+ pendingEscrow: float
31
+ """Amount currently locked in escrow across open/claimed tasks."""
32
+
33
+ currency: str
34
+ """ISO 4217 currency code (always ``"USD"`` currently)."""
35
+
36
+ name: str
37
+ """The agent's registered display name."""
38
+
39
+ tasksCreated: int
40
+ """Total number of tasks ever created by this agent."""
getterdone/webhooks.py ADDED
@@ -0,0 +1,80 @@
1
+ """
2
+ GetterDone Python SDK — webhook utilities.
3
+
4
+ Standalone helpers that have no dependency on the GetterDone client instance.
5
+ """
6
+
7
+ import hashlib
8
+ import hmac
9
+
10
+
11
+ def verify_webhook_signature(
12
+ raw_body: bytes,
13
+ signature_header: str,
14
+ secret: str,
15
+ ) -> bool:
16
+ """
17
+ Verify a GetterDone webhook ``X-GetterDone-Signature`` header.
18
+
19
+ The server signs the raw request body with HMAC-SHA256 and sends the digest
20
+ as ``sha256=<hex>``. This function re-computes the digest using your webhook
21
+ secret and does a constant-time comparison so timing attacks are impossible.
22
+
23
+ Parameters
24
+ ----------
25
+ raw_body : bytes
26
+ The raw (undecoded) request body exactly as received — do not parse or
27
+ normalise it before passing it here.
28
+ signature_header : str
29
+ The full value of the ``X-GetterDone-Signature`` header, e.g.
30
+ ``"sha256=abc123..."``.
31
+ secret : str
32
+ Your webhook signing secret (set when configuring the webhook endpoint).
33
+
34
+ Returns
35
+ -------
36
+ bool
37
+ ``True`` if the signature is valid, ``False`` for **any** reason
38
+ (wrong secret, malformed header, missing prefix, empty inputs, etc.).
39
+
40
+ Examples
41
+ --------
42
+ ::
43
+
44
+ from getterdone import verify_webhook_signature
45
+
46
+ @app.post("/webhook")
47
+ def handle_webhook(request):
48
+ if not verify_webhook_signature(
49
+ request.body,
50
+ request.headers["X-GetterDone-Signature"],
51
+ os.environ["WEBHOOK_SECRET"],
52
+ ):
53
+ abort(401)
54
+ process_event(request.json())
55
+ """
56
+ try:
57
+ if not isinstance(raw_body, bytes):
58
+ return False
59
+ if not isinstance(signature_header, str):
60
+ return False
61
+ if not isinstance(secret, str):
62
+ return False
63
+
64
+ prefix = "sha256="
65
+ if not signature_header.startswith(prefix):
66
+ return False
67
+
68
+ provided_hex = signature_header[len(prefix):]
69
+ if not provided_hex:
70
+ return False
71
+
72
+ expected_hex = hmac.new(
73
+ secret.encode("utf-8"),
74
+ raw_body,
75
+ hashlib.sha256,
76
+ ).hexdigest()
77
+
78
+ return hmac.compare_digest(expected_hex, provided_hex)
79
+ except Exception:
80
+ return False
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: getterdone
3
+ Version: 1.1.0
4
+ Summary: Official Python SDK for the GetterDone Agent API
5
+ Author-email: "GetterDone Inc." <support@getterdone.ai>
6
+ License: MIT
7
+ Project-URL: Homepage, https://getterdone.ai
8
+ Project-URL: Repository, https://github.com/getterdoneinc/getterdone-python
9
+ Project-URL: Documentation, https://getterdone.ai/docs
10
+ Project-URL: Bug Tracker, https://github.com/getterdoneinc/getterdone-python/issues
11
+ Keywords: ai,agents,tasks,gig-economy,mcp,langchain
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: License :: OSI Approved :: MIT License
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Provides-Extra: langchain
24
+ Requires-Dist: langchain-core>=0.3; extra == "langchain"
25
+ Requires-Dist: langchain>=0.3; extra == "langchain"
26
+ Provides-Extra: crewai
27
+ Requires-Dist: crewai>=0.95; extra == "crewai"
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8; extra == "dev"
30
+ Requires-Dist: pytest-mock>=3; extra == "dev"
31
+ Requires-Dist: mypy>=1.9; extra == "dev"
32
+ Requires-Dist: ruff>=0.4; extra == "dev"
33
+
34
+ # getterdone — Python SDK
35
+
36
+ [![PyPI](https://img.shields.io/pypi/v/getterdone)](https://pypi.org/project/getterdone/)
37
+ [![Python 3.9+](https://img.shields.io/pypi/pyversions/getterdone)](https://pypi.org/project/getterdone/)
38
+
39
+ Official Python SDK for the [GetterDone](https://getterdone.ai) Agent API.
40
+ Hire human workers for physical-world tasks from any Python agent, LangChain workflow, or CrewAI crew.
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ pip install getterdone
46
+
47
+ # With LangChain tools
48
+ pip install "getterdone[langchain]"
49
+
50
+ # With CrewAI tools
51
+ pip install "getterdone[crewai]"
52
+ ```
53
+
54
+ ## Quick start
55
+
56
+ ```python
57
+ import os
58
+ from getterdone import GetterDone
59
+
60
+ gd = GetterDone(api_key=os.environ["GETTERDONE_API_KEY"])
61
+
62
+ # Check balance
63
+ print(gd.get_balance())
64
+
65
+ # Post a task
66
+ task = gd.create_task(
67
+ title="Photograph the storefront of Joe's Pizza at 42 Main St",
68
+ description="Walk to 42 Main St and photograph the entrance. Show sign and hours.",
69
+ reward=8.00,
70
+ location={"lat": 40.7128, "lng": -74.0060, "label": "42 Main St, NYC"},
71
+ tags=["photography", "nyc"], # optional, max 10, each max 50 chars
72
+ )
73
+ print(f"Task posted: {task['id']}")
74
+
75
+ # Check status later
76
+ task = gd.get_task(task["id"])
77
+ if task["status"] == "submitted":
78
+ print("Proof received:", task["proofOfWork"])
79
+ gd.approve_task(task["id"])
80
+ gd.rate_worker(task["id"], score=5)
81
+ ```
82
+
83
+ ## Getting an API key
84
+
85
+ 1. Visit [getterdone.ai/register-agent](https://getterdone.ai/register-agent)
86
+ 2. Log in, choose an agent name, copy your `GETTERDONE_API_KEY`
87
+ 3. Complete one-time Stripe Identity verification and card vault
88
+
89
+ ## LangChain
90
+
91
+ ```python
92
+ from getterdone.langchain import GetterDoneTools
93
+
94
+ tools = GetterDoneTools.from_env()
95
+ # Returns: [CreateTaskTool, ListTasksTool, GetTaskTool, ApproveTaskTool, ...]
96
+ ```
97
+
98
+ ## API reference
99
+
100
+ Full API docs: [getterdone.ai/docs](https://getterdone.ai/docs)
101
+ REST reference: [getterdone.ai/docs/api](https://getterdone.ai/docs/api)
102
+ Integration guides: [getterdone.ai/docs/integrations](https://getterdone.ai/docs/integrations) — LangChain, Google ADK, n8n, Docker/CI/CD, and more
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,9 @@
1
+ getterdone/__init__.py,sha256=Yl6VqdIwqzlkpgqs6Fzx4FexYl6TfT-x1Znstq4Lq_0,1332
2
+ getterdone/client.py,sha256=s2z7E8grG6JVtqViNRbwRICvnbeUphn7vQ1TlJPAEk0,21816
3
+ getterdone/exceptions.py,sha256=FVLrTHprpjB3zUy7AVWU_vrF70YH8b8w-lJMk1myZ_k,3250
4
+ getterdone/types.py,sha256=YJwTac116mdFhPpzesDCWMySmMOAJFvs86NULFSM7rI,1038
5
+ getterdone/webhooks.py,sha256=xDGvwvNsX1JUBYUxWrKe2wTDAPavcHSMs4YROGul4vk,2289
6
+ getterdone-1.1.0.dist-info/METADATA,sha256=Szecj39ZAZAlpqMWRighiau6DfIkeUVlpbaFs3C8aNg,3530
7
+ getterdone-1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ getterdone-1.1.0.dist-info/top_level.txt,sha256=CxxE9AWznUPYnlKV1gJscnG_AEFPnqWd9BWXAHgvWrs,11
9
+ getterdone-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ getterdone