getabrain 0.1.0__tar.gz
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.
- getabrain-0.1.0/.gitignore +6 -0
- getabrain-0.1.0/PKG-INFO +77 -0
- getabrain-0.1.0/README.md +54 -0
- getabrain-0.1.0/pyproject.toml +37 -0
- getabrain-0.1.0/src/getabrain/__init__.py +57 -0
- getabrain-0.1.0/src/getabrain/_http.py +85 -0
- getabrain-0.1.0/src/getabrain/client.py +58 -0
- getabrain-0.1.0/src/getabrain/errors.py +103 -0
- getabrain-0.1.0/src/getabrain/py.typed +0 -0
- getabrain-0.1.0/src/getabrain/resources/__init__.py +0 -0
- getabrain-0.1.0/src/getabrain/resources/account.py +16 -0
- getabrain-0.1.0/src/getabrain/resources/queries.py +68 -0
- getabrain-0.1.0/src/getabrain/resources/responses.py +36 -0
- getabrain-0.1.0/src/getabrain/types.py +225 -0
- getabrain-0.1.0/tests/test_client.py +107 -0
- getabrain-0.1.0/tests/test_errors.py +55 -0
- getabrain-0.1.0/tests/test_http.py +138 -0
- getabrain-0.1.0/tests/test_queries.py +105 -0
- getabrain-0.1.0/tests/test_types.py +28 -0
getabrain-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: getabrain
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for GetABrain.ai — real human judgment as an API
|
|
5
|
+
Project-URL: Homepage, https://getabrain.ai
|
|
6
|
+
Project-URL: Documentation, https://getabrain.ai/docs/api
|
|
7
|
+
Project-URL: Source, https://github.com/Guitarmaniac24/Getabrain.ai
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/Guitarmaniac24/Getabrain.ai/issues
|
|
9
|
+
Author-email: GetABrain <hello@getabrain.ai>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: ai,api,data-labeling,getabrain,human-in-the-loop,rlhf,sdk
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
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: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# getabrain
|
|
25
|
+
|
|
26
|
+
Official Python SDK for [GetABrain.ai](https://getabrain.ai) — real human judgment as an API.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install getabrain
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from getabrain import GetABrain, InsufficientBalanceError
|
|
38
|
+
|
|
39
|
+
gab = GetABrain(api_key="gab_k_...", api_secret="gab_s_...")
|
|
40
|
+
|
|
41
|
+
# Submit a query (returns immediately)
|
|
42
|
+
query = gab.queries.create(
|
|
43
|
+
type="ab_test",
|
|
44
|
+
title="Which headline converts better?",
|
|
45
|
+
content_data={
|
|
46
|
+
"question": "Which is more compelling?",
|
|
47
|
+
"variant_a": {"description": "Save 20% today"},
|
|
48
|
+
"variant_b": {"description": "Your future self will thank you"},
|
|
49
|
+
},
|
|
50
|
+
required_responses=5,
|
|
51
|
+
bid_amount_cents=25,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Wait for humans to answer (polls for you)
|
|
55
|
+
try:
|
|
56
|
+
responses = gab.queries.wait_for_responses(query["id"], min_responses=5, timeout_s=600)
|
|
57
|
+
for r in responses:
|
|
58
|
+
print(r["response_data"])
|
|
59
|
+
except InsufficientBalanceError:
|
|
60
|
+
print("Top up your balance at https://getabrain.ai")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## API
|
|
64
|
+
|
|
65
|
+
- `gab.queries.create(type=..., title=..., content_data={...}, required_responses=..., bid_amount_cents=...)`
|
|
66
|
+
- `gab.queries.get(query_id)` · `gab.queries.list(status=..., limit=...)` · `gab.queries.cancel(query_id)`
|
|
67
|
+
- `gab.queries.wait_for_responses(query_id, min_responses=..., timeout_s=600, poll_interval_s=5)`
|
|
68
|
+
- `gab.responses.rate(query_id, response_id, score=5, feedback_text="...")` · `gab.responses.get_rating(query_id, response_id)`
|
|
69
|
+
- `gab.account.stats()` · `gab.account.balance()`
|
|
70
|
+
|
|
71
|
+
Query types: `text`, `multiple_choice`, `rating_scale`, `image_comparison`, `ranking`, `yes_no`, `sentiment`, `image_selection`, `free_form_text`, `video_review`, `audio_review`, `image_analysis`, `ab_test`.
|
|
72
|
+
|
|
73
|
+
## Errors
|
|
74
|
+
|
|
75
|
+
All errors subclass `GetABrainError`: `ValidationError`, `AuthError`, `InsufficientBalanceError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `RateLimitError` (`.retry_after_ms`), `ServerError`, `TimeoutError`, `NetworkError`.
|
|
76
|
+
|
|
77
|
+
Full API docs: https://getabrain.ai/docs/api
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# getabrain
|
|
2
|
+
|
|
3
|
+
Official Python SDK for [GetABrain.ai](https://getabrain.ai) — real human judgment as an API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install getabrain
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from getabrain import GetABrain, InsufficientBalanceError
|
|
15
|
+
|
|
16
|
+
gab = GetABrain(api_key="gab_k_...", api_secret="gab_s_...")
|
|
17
|
+
|
|
18
|
+
# Submit a query (returns immediately)
|
|
19
|
+
query = gab.queries.create(
|
|
20
|
+
type="ab_test",
|
|
21
|
+
title="Which headline converts better?",
|
|
22
|
+
content_data={
|
|
23
|
+
"question": "Which is more compelling?",
|
|
24
|
+
"variant_a": {"description": "Save 20% today"},
|
|
25
|
+
"variant_b": {"description": "Your future self will thank you"},
|
|
26
|
+
},
|
|
27
|
+
required_responses=5,
|
|
28
|
+
bid_amount_cents=25,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Wait for humans to answer (polls for you)
|
|
32
|
+
try:
|
|
33
|
+
responses = gab.queries.wait_for_responses(query["id"], min_responses=5, timeout_s=600)
|
|
34
|
+
for r in responses:
|
|
35
|
+
print(r["response_data"])
|
|
36
|
+
except InsufficientBalanceError:
|
|
37
|
+
print("Top up your balance at https://getabrain.ai")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## API
|
|
41
|
+
|
|
42
|
+
- `gab.queries.create(type=..., title=..., content_data={...}, required_responses=..., bid_amount_cents=...)`
|
|
43
|
+
- `gab.queries.get(query_id)` · `gab.queries.list(status=..., limit=...)` · `gab.queries.cancel(query_id)`
|
|
44
|
+
- `gab.queries.wait_for_responses(query_id, min_responses=..., timeout_s=600, poll_interval_s=5)`
|
|
45
|
+
- `gab.responses.rate(query_id, response_id, score=5, feedback_text="...")` · `gab.responses.get_rating(query_id, response_id)`
|
|
46
|
+
- `gab.account.stats()` · `gab.account.balance()`
|
|
47
|
+
|
|
48
|
+
Query types: `text`, `multiple_choice`, `rating_scale`, `image_comparison`, `ranking`, `yes_no`, `sentiment`, `image_selection`, `free_form_text`, `video_review`, `audio_review`, `image_analysis`, `ab_test`.
|
|
49
|
+
|
|
50
|
+
## Errors
|
|
51
|
+
|
|
52
|
+
All errors subclass `GetABrainError`: `ValidationError`, `AuthError`, `InsufficientBalanceError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `RateLimitError` (`.retry_after_ms`), `ServerError`, `TimeoutError`, `NetworkError`.
|
|
53
|
+
|
|
54
|
+
Full API docs: https://getabrain.ai/docs/api
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "getabrain"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for GetABrain.ai — real human judgment as an API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "GetABrain", email = "hello@getabrain.ai" }]
|
|
13
|
+
keywords = ["getabrain", "human-in-the-loop", "ai", "api", "sdk", "rlhf", "data-labeling"]
|
|
14
|
+
dependencies = ["httpx>=0.27"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://getabrain.ai"
|
|
28
|
+
Documentation = "https://getabrain.ai/docs/api"
|
|
29
|
+
Source = "https://github.com/Guitarmaniac24/Getabrain.ai"
|
|
30
|
+
"Bug Tracker" = "https://github.com/Guitarmaniac24/Getabrain.ai/issues"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/getabrain"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
pythonpath = ["src"]
|
|
37
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""getabrain — official Python SDK for GetABrain.ai."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .client import GetABrain
|
|
5
|
+
from .errors import (
|
|
6
|
+
GetABrainError,
|
|
7
|
+
ValidationError,
|
|
8
|
+
AuthError,
|
|
9
|
+
InsufficientBalanceError,
|
|
10
|
+
ForbiddenError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
ConflictError,
|
|
13
|
+
ServerError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
TimeoutError,
|
|
16
|
+
NetworkError,
|
|
17
|
+
error_from_response,
|
|
18
|
+
)
|
|
19
|
+
from .types import (
|
|
20
|
+
QUERY_TYPES,
|
|
21
|
+
QueryType,
|
|
22
|
+
Query,
|
|
23
|
+
QueryResponse,
|
|
24
|
+
QueryList,
|
|
25
|
+
RatingResult,
|
|
26
|
+
Rating,
|
|
27
|
+
AccountStats,
|
|
28
|
+
BillingInfo,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"GetABrain",
|
|
35
|
+
"GetABrainError",
|
|
36
|
+
"ValidationError",
|
|
37
|
+
"AuthError",
|
|
38
|
+
"InsufficientBalanceError",
|
|
39
|
+
"ForbiddenError",
|
|
40
|
+
"NotFoundError",
|
|
41
|
+
"ConflictError",
|
|
42
|
+
"ServerError",
|
|
43
|
+
"RateLimitError",
|
|
44
|
+
"TimeoutError",
|
|
45
|
+
"NetworkError",
|
|
46
|
+
"error_from_response",
|
|
47
|
+
"QUERY_TYPES",
|
|
48
|
+
"QueryType",
|
|
49
|
+
"Query",
|
|
50
|
+
"QueryResponse",
|
|
51
|
+
"QueryList",
|
|
52
|
+
"RatingResult",
|
|
53
|
+
"Rating",
|
|
54
|
+
"AccountStats",
|
|
55
|
+
"BillingInfo",
|
|
56
|
+
"__version__",
|
|
57
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""HTTP transport: builds an httpx.Client and a `_request` callable with auth,
|
|
2
|
+
JSON-envelope error mapping, and GET-only retries."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .errors import NetworkError, TimeoutError, error_from_response
|
|
11
|
+
|
|
12
|
+
DEFAULT_BASE_URL = "https://www.getabrain.ai/api/v1"
|
|
13
|
+
|
|
14
|
+
RequestFn = Callable[..., Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _backoff(attempt: int) -> float:
|
|
18
|
+
return min(2.0 ** (attempt - 1), 8.0)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_retry_after(value: Optional[str]) -> Optional[int]:
|
|
22
|
+
if not value:
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
return int(float(value) * 1000)
|
|
26
|
+
except ValueError:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_transport(
|
|
31
|
+
*,
|
|
32
|
+
api_key: str,
|
|
33
|
+
api_secret: str,
|
|
34
|
+
base_url: Optional[str] = None,
|
|
35
|
+
timeout_s: float = 30.0,
|
|
36
|
+
max_retries: int = 2,
|
|
37
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
38
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
39
|
+
) -> Tuple[httpx.Client, RequestFn]:
|
|
40
|
+
base = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
41
|
+
client = httpx.Client(
|
|
42
|
+
timeout=timeout_s,
|
|
43
|
+
transport=transport,
|
|
44
|
+
headers={
|
|
45
|
+
"X-API-Key": api_key,
|
|
46
|
+
"X-API-Secret": api_secret,
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def request(method: str, path: str, *, body: Any = None, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
52
|
+
url = base + path
|
|
53
|
+
clean = {k: v for k, v in (params or {}).items() if v is not None}
|
|
54
|
+
is_read = method.upper() == "GET"
|
|
55
|
+
attempt = 0
|
|
56
|
+
while True:
|
|
57
|
+
try:
|
|
58
|
+
resp = client.request(method, url, json=body, params=clean or None)
|
|
59
|
+
except httpx.TimeoutException:
|
|
60
|
+
raise TimeoutError()
|
|
61
|
+
except httpx.TransportError as exc:
|
|
62
|
+
if is_read and attempt < max_retries:
|
|
63
|
+
attempt += 1
|
|
64
|
+
sleep(_backoff(attempt))
|
|
65
|
+
continue
|
|
66
|
+
raise NetworkError(str(exc))
|
|
67
|
+
|
|
68
|
+
if resp.is_success:
|
|
69
|
+
if resp.status_code == 204:
|
|
70
|
+
return None
|
|
71
|
+
return resp.json()
|
|
72
|
+
|
|
73
|
+
retry_after_ms = _parse_retry_after(resp.headers.get("retry-after"))
|
|
74
|
+
if is_read and (resp.status_code == 429 or resp.status_code >= 500) and attempt < max_retries:
|
|
75
|
+
attempt += 1
|
|
76
|
+
sleep(retry_after_ms / 1000 if retry_after_ms is not None else _backoff(attempt))
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
err_body = resp.json()
|
|
81
|
+
except Exception:
|
|
82
|
+
err_body = None
|
|
83
|
+
raise error_from_response(resp.status_code, err_body, retry_after_ms)
|
|
84
|
+
|
|
85
|
+
return client, request
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The GetABrain client — wires config -> transport -> resources."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._http import build_transport
|
|
10
|
+
from .resources.account import AccountResource
|
|
11
|
+
from .resources.queries import QueriesResource
|
|
12
|
+
from .resources.responses import ResponsesResource
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GetABrain:
|
|
16
|
+
"""Client for the GetABrain requestor API.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
gab = GetABrain(api_key="gab_k_...", api_secret="gab_s_...")
|
|
20
|
+
q = gab.queries.create(type="yes_no", title="Is it live?",
|
|
21
|
+
content_data={"question": "Is it live?"},
|
|
22
|
+
required_responses=2, bid_amount_cents=50)
|
|
23
|
+
responses = gab.queries.wait_for_responses(q["id"], min_responses=2)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_key: str,
|
|
29
|
+
api_secret: str,
|
|
30
|
+
*,
|
|
31
|
+
base_url: Optional[str] = None,
|
|
32
|
+
timeout_s: float = 30.0,
|
|
33
|
+
max_retries: int = 2,
|
|
34
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
35
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._client, request = build_transport(
|
|
38
|
+
api_key=api_key,
|
|
39
|
+
api_secret=api_secret,
|
|
40
|
+
base_url=base_url,
|
|
41
|
+
timeout_s=timeout_s,
|
|
42
|
+
max_retries=max_retries,
|
|
43
|
+
transport=transport,
|
|
44
|
+
sleep=sleep,
|
|
45
|
+
)
|
|
46
|
+
self.queries = QueriesResource(request)
|
|
47
|
+
self.responses = ResponsesResource(request)
|
|
48
|
+
self.account = AccountResource(request)
|
|
49
|
+
|
|
50
|
+
def close(self) -> None:
|
|
51
|
+
"""Close the underlying HTTP client."""
|
|
52
|
+
self._client.close()
|
|
53
|
+
|
|
54
|
+
def __enter__(self) -> "GetABrain":
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, *exc: object) -> None:
|
|
58
|
+
self.close()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Typed error hierarchy mirroring the GetABrain HTTP envelope."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GetABrainError(Exception):
|
|
8
|
+
"""Base error. `status` is the HTTP status (0 for client-side errors)."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
message: str,
|
|
13
|
+
status: int = 0,
|
|
14
|
+
code: str = "error",
|
|
15
|
+
request_id: Optional[str] = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.message = message
|
|
19
|
+
self.status = status
|
|
20
|
+
self.code = code
|
|
21
|
+
self.request_id = request_id
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ValidationError(GetABrainError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthError(GetABrainError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InsufficientBalanceError(GetABrainError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ForbiddenError(GetABrainError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NotFoundError(GetABrainError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConflictError(GetABrainError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ServerError(GetABrainError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RateLimitError(GetABrainError):
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
message: str,
|
|
56
|
+
status: int = 429,
|
|
57
|
+
code: str = "error",
|
|
58
|
+
retry_after_ms: Optional[int] = None,
|
|
59
|
+
request_id: Optional[str] = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__(message, status, code, request_id)
|
|
62
|
+
self.retry_after_ms = retry_after_ms
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TimeoutError(GetABrainError):
|
|
66
|
+
"""Request timed out, or wait_for_responses exceeded its deadline.
|
|
67
|
+
|
|
68
|
+
Subclasses GetABrainError (NOT the builtin TimeoutError) so callers can
|
|
69
|
+
`except GetABrainError`.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, message: str = "Request timed out") -> None:
|
|
73
|
+
super().__init__(message, 0, "timeout")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class NetworkError(GetABrainError):
|
|
77
|
+
def __init__(self, message: str = "Network request failed") -> None:
|
|
78
|
+
super().__init__(message, 0, "network")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def error_from_response(
|
|
82
|
+
status: int, body: Any, retry_after_ms: Optional[int] = None
|
|
83
|
+
) -> GetABrainError:
|
|
84
|
+
b = body if isinstance(body, dict) else {}
|
|
85
|
+
message = b.get("message") or b.get("error") or f"HTTP {status}"
|
|
86
|
+
code = b.get("error") or "error"
|
|
87
|
+
if status == 400:
|
|
88
|
+
return ValidationError(message, status, code)
|
|
89
|
+
if status == 401:
|
|
90
|
+
return AuthError(message, status, code)
|
|
91
|
+
if status == 402:
|
|
92
|
+
return InsufficientBalanceError(message, status, code)
|
|
93
|
+
if status == 403:
|
|
94
|
+
return ForbiddenError(message, status, code)
|
|
95
|
+
if status == 404:
|
|
96
|
+
return NotFoundError(message, status, code)
|
|
97
|
+
if status == 409:
|
|
98
|
+
return ConflictError(message, status, code)
|
|
99
|
+
if status == 429:
|
|
100
|
+
return RateLimitError(message, status, code, retry_after_ms)
|
|
101
|
+
if status >= 500:
|
|
102
|
+
return ServerError(message, status, code)
|
|
103
|
+
return GetABrainError(message, status, code)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Account resource (stats + billing)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .._http import RequestFn
|
|
5
|
+
from ..types import AccountStats, BillingInfo
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AccountResource:
|
|
9
|
+
def __init__(self, request: RequestFn) -> None:
|
|
10
|
+
self._request = request
|
|
11
|
+
|
|
12
|
+
def stats(self) -> AccountStats:
|
|
13
|
+
return self._request("GET", "/requestor/stats")
|
|
14
|
+
|
|
15
|
+
def balance(self) -> BillingInfo:
|
|
16
|
+
return self._request("GET", "/requestor/billing")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Queries resource."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .._http import RequestFn
|
|
9
|
+
from ..errors import TimeoutError
|
|
10
|
+
from ..types import Query, QueryList, QueryResponse
|
|
11
|
+
|
|
12
|
+
_TERMINAL = {"completed", "cancelled", "failed", "expired"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class QueriesResource:
|
|
16
|
+
def __init__(self, request: RequestFn) -> None:
|
|
17
|
+
self._request = request
|
|
18
|
+
|
|
19
|
+
def create(self, **input: Any) -> Query:
|
|
20
|
+
return self._request("POST", "/requestor/queries", body=input)
|
|
21
|
+
|
|
22
|
+
def get(self, query_id: str) -> Query:
|
|
23
|
+
return self._request("GET", f"/requestor/queries/{quote(query_id, safe='')}")
|
|
24
|
+
|
|
25
|
+
def list(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
status: Optional[str] = None,
|
|
29
|
+
limit: Optional[int] = None,
|
|
30
|
+
offset: Optional[int] = None,
|
|
31
|
+
) -> QueryList:
|
|
32
|
+
return self._request(
|
|
33
|
+
"GET", "/requestor/queries",
|
|
34
|
+
params={"status": status, "limit": limit, "offset": offset},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def cancel(self, query_id: str) -> Dict[str, Any]:
|
|
38
|
+
return self._request(
|
|
39
|
+
"PUT", f"/requestor/queries/{quote(query_id, safe='')}",
|
|
40
|
+
body={"action": "cancel"},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def wait_for_responses(
|
|
44
|
+
self,
|
|
45
|
+
query_id: str,
|
|
46
|
+
*,
|
|
47
|
+
min_responses: int,
|
|
48
|
+
timeout_s: float = 600.0,
|
|
49
|
+
poll_interval_s: float = 5.0,
|
|
50
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
51
|
+
now: Callable[[], float] = time.monotonic,
|
|
52
|
+
) -> List[QueryResponse]:
|
|
53
|
+
deadline = now() + timeout_s
|
|
54
|
+
while True:
|
|
55
|
+
query = self.get(query_id)
|
|
56
|
+
responses = query.get("responses") or []
|
|
57
|
+
completed = query.get("completed_responses")
|
|
58
|
+
if completed is None:
|
|
59
|
+
completed = len(responses)
|
|
60
|
+
if completed >= min_responses:
|
|
61
|
+
return responses
|
|
62
|
+
if query.get("status") in _TERMINAL:
|
|
63
|
+
return responses
|
|
64
|
+
if now() >= deadline:
|
|
65
|
+
raise TimeoutError(
|
|
66
|
+
f"wait_for_responses timed out after {timeout_s}s for query {query_id}"
|
|
67
|
+
)
|
|
68
|
+
sleep(poll_interval_s)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Responses resource (ratings)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .._http import RequestFn
|
|
8
|
+
from ..types import Rating, RatingResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ResponsesResource:
|
|
12
|
+
def __init__(self, request: RequestFn) -> None:
|
|
13
|
+
self._request = request
|
|
14
|
+
|
|
15
|
+
def rate(
|
|
16
|
+
self,
|
|
17
|
+
query_id: str,
|
|
18
|
+
response_id: str,
|
|
19
|
+
*,
|
|
20
|
+
score: int,
|
|
21
|
+
feedback_text: Optional[str] = None,
|
|
22
|
+
) -> RatingResult:
|
|
23
|
+
q = quote(query_id, safe="")
|
|
24
|
+
r = quote(response_id, safe="")
|
|
25
|
+
body: dict = {"score": score}
|
|
26
|
+
if feedback_text is not None:
|
|
27
|
+
body["feedback_text"] = feedback_text
|
|
28
|
+
return self._request(
|
|
29
|
+
"POST", f"/requestor/queries/{q}/responses/{r}/rate",
|
|
30
|
+
body=body,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def get_rating(self, query_id: str, response_id: str) -> Rating:
|
|
34
|
+
q = quote(query_id, safe="")
|
|
35
|
+
r = quote(response_id, safe="")
|
|
36
|
+
return self._request("GET", f"/requestor/queries/{q}/responses/{r}/rate")
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Query-type Literal + content/response TypedDicts (mirror of the OpenAPI contract).
|
|
2
|
+
|
|
3
|
+
All TypedDicts are `total=False`: the SDK is a thin typed client and the server is
|
|
4
|
+
the validation authority, so every field is optional at the type level. They give
|
|
5
|
+
IDE help; they do not enforce required keys at runtime.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
|
10
|
+
|
|
11
|
+
QUERY_TYPES = (
|
|
12
|
+
"text",
|
|
13
|
+
"multiple_choice",
|
|
14
|
+
"rating_scale",
|
|
15
|
+
"image_comparison",
|
|
16
|
+
"ranking",
|
|
17
|
+
"yes_no",
|
|
18
|
+
"sentiment",
|
|
19
|
+
"image_selection",
|
|
20
|
+
"free_form_text",
|
|
21
|
+
"video_review",
|
|
22
|
+
"audio_review",
|
|
23
|
+
"image_analysis",
|
|
24
|
+
"ab_test",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
QueryType = Literal[
|
|
28
|
+
"text",
|
|
29
|
+
"multiple_choice",
|
|
30
|
+
"rating_scale",
|
|
31
|
+
"image_comparison",
|
|
32
|
+
"ranking",
|
|
33
|
+
"yes_no",
|
|
34
|
+
"sentiment",
|
|
35
|
+
"image_selection",
|
|
36
|
+
"free_form_text",
|
|
37
|
+
"video_review",
|
|
38
|
+
"audio_review",
|
|
39
|
+
"image_analysis",
|
|
40
|
+
"ab_test",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TextContent(TypedDict, total=False):
|
|
45
|
+
question: str
|
|
46
|
+
instructions: str
|
|
47
|
+
context: str
|
|
48
|
+
min_words: int
|
|
49
|
+
max_words: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MultipleChoiceContent(TypedDict, total=False):
|
|
53
|
+
question: str
|
|
54
|
+
instructions: str
|
|
55
|
+
context: str
|
|
56
|
+
options: List[Dict[str, Any]]
|
|
57
|
+
allow_multiple: bool
|
|
58
|
+
min_selections: int
|
|
59
|
+
max_selections: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RatingScaleContent(TypedDict, total=False):
|
|
63
|
+
question: str
|
|
64
|
+
instructions: str
|
|
65
|
+
context: str
|
|
66
|
+
scale_type: Literal["1-5", "1-10", "0-100", "stars"]
|
|
67
|
+
scale_min: int
|
|
68
|
+
scale_max: int
|
|
69
|
+
scale_labels: Dict[str, str]
|
|
70
|
+
criteria: List[str]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ImageComparisonContent(TypedDict, total=False):
|
|
74
|
+
question: str
|
|
75
|
+
instructions: str
|
|
76
|
+
context: str
|
|
77
|
+
images: List[Dict[str, Any]]
|
|
78
|
+
comparison_type: Literal["preference", "quality", "relevance", "custom"]
|
|
79
|
+
require_reasoning: bool
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RankingContent(TypedDict, total=False):
|
|
83
|
+
question: str
|
|
84
|
+
instructions: str
|
|
85
|
+
context: str
|
|
86
|
+
items: List[Dict[str, Any]]
|
|
87
|
+
max_items: int
|
|
88
|
+
require_all: bool
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class YesNoContent(TypedDict, total=False):
|
|
92
|
+
question: str
|
|
93
|
+
instructions: str
|
|
94
|
+
context: str
|
|
95
|
+
require_explanation: bool
|
|
96
|
+
min_explanation_words: int
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SentimentContent(TypedDict, total=False):
|
|
100
|
+
question: str
|
|
101
|
+
instructions: str
|
|
102
|
+
context: str
|
|
103
|
+
subject: str
|
|
104
|
+
sentiment_options: List[str]
|
|
105
|
+
require_reasoning: bool
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ImageSelectionContent(TypedDict, total=False):
|
|
109
|
+
question: str
|
|
110
|
+
instructions: str
|
|
111
|
+
context: str
|
|
112
|
+
images: List[Dict[str, Any]]
|
|
113
|
+
min_selections: int
|
|
114
|
+
max_selections: int
|
|
115
|
+
selection_criteria: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class FreeFormTextContent(TypedDict, total=False):
|
|
119
|
+
question: str
|
|
120
|
+
instructions: str
|
|
121
|
+
context: str
|
|
122
|
+
prompt: str
|
|
123
|
+
min_characters: int
|
|
124
|
+
max_characters: int
|
|
125
|
+
format_guidelines: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class VideoReviewContent(TypedDict, total=False):
|
|
129
|
+
question: str
|
|
130
|
+
instructions: str
|
|
131
|
+
context: str
|
|
132
|
+
video_url: str
|
|
133
|
+
video_duration_seconds: int
|
|
134
|
+
review_aspects: List[str]
|
|
135
|
+
min_watch_time_seconds: int
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class AudioReviewContent(TypedDict, total=False):
|
|
139
|
+
question: str
|
|
140
|
+
instructions: str
|
|
141
|
+
context: str
|
|
142
|
+
audio_url: str
|
|
143
|
+
audio_duration_seconds: int
|
|
144
|
+
review_aspects: List[str]
|
|
145
|
+
transcription_required: bool
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ImageAnalysisContent(TypedDict, total=False):
|
|
149
|
+
question: str
|
|
150
|
+
image_url: str
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class AbTestContent(TypedDict, total=False):
|
|
154
|
+
question: str
|
|
155
|
+
variant_a: Dict[str, Any]
|
|
156
|
+
variant_b: Dict[str, Any]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class QueryResponse(TypedDict, total=False):
|
|
160
|
+
id: str
|
|
161
|
+
response_data: Dict[str, Any]
|
|
162
|
+
status: str
|
|
163
|
+
submitted_at: str
|
|
164
|
+
worker: Dict[str, Any]
|
|
165
|
+
rating: Optional[Dict[str, Any]]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class Query(TypedDict, total=False):
|
|
169
|
+
id: str
|
|
170
|
+
type: str
|
|
171
|
+
title: str
|
|
172
|
+
description: str
|
|
173
|
+
status: str
|
|
174
|
+
required_responses: int
|
|
175
|
+
completed_responses: int
|
|
176
|
+
bid_amount_cents: int
|
|
177
|
+
bonus_amount_cents: int
|
|
178
|
+
total_cost_cents: int
|
|
179
|
+
created_at: str
|
|
180
|
+
expires_at: Optional[str]
|
|
181
|
+
content_data: Dict[str, Any]
|
|
182
|
+
responses: List[QueryResponse]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class QueryList(TypedDict, total=False):
|
|
186
|
+
queries: List[Query]
|
|
187
|
+
total: int
|
|
188
|
+
limit: int
|
|
189
|
+
offset: int
|
|
190
|
+
has_more: bool
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class RatingResult(TypedDict, total=False):
|
|
194
|
+
response_id: str
|
|
195
|
+
score: int
|
|
196
|
+
worker_new_quality_score: float
|
|
197
|
+
worker_new_rating_count: int
|
|
198
|
+
worker_suspended: bool
|
|
199
|
+
feedback_text: Optional[str]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Rating(TypedDict, total=False):
|
|
203
|
+
id: str
|
|
204
|
+
response_id: str
|
|
205
|
+
score: int
|
|
206
|
+
feedback_text: Optional[str]
|
|
207
|
+
created_at: str
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class AccountStats(TypedDict, total=False):
|
|
211
|
+
balance_cents: int
|
|
212
|
+
company_name: str
|
|
213
|
+
total_queries: int
|
|
214
|
+
active_queries: int
|
|
215
|
+
completed_queries: int
|
|
216
|
+
total_spent_cents: int
|
|
217
|
+
recent_queries: List[Query]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class BillingInfo(TypedDict, total=False):
|
|
221
|
+
balance_cents: int
|
|
222
|
+
company_name: str
|
|
223
|
+
transactions: List[Dict[str, Any]]
|
|
224
|
+
total: int
|
|
225
|
+
has_more: bool
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from getabrain import GetABrain, QUERY_TYPES, InsufficientBalanceError
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def client_with(handler):
|
|
8
|
+
return GetABrain(api_key="gab_k_x", api_secret="gab_s_y",
|
|
9
|
+
transport=httpx.MockTransport(handler))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_exports_present():
|
|
13
|
+
assert "ab_test" in QUERY_TYPES
|
|
14
|
+
gab = GetABrain(api_key="gab_k_x", api_secret="gab_s_y")
|
|
15
|
+
assert gab.queries is not None
|
|
16
|
+
assert gab.responses is not None
|
|
17
|
+
assert gab.account is not None
|
|
18
|
+
gab.close()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_context_manager_exposes_resources():
|
|
22
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
23
|
+
return httpx.Response(200, json={"balance_cents": 0})
|
|
24
|
+
|
|
25
|
+
with client_with(handler) as gab:
|
|
26
|
+
assert gab.account is not None
|
|
27
|
+
assert gab.account.balance()["balance_cents"] == 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_rate_omits_feedback_text_when_not_provided():
|
|
31
|
+
seen = {}
|
|
32
|
+
|
|
33
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
34
|
+
seen["body"] = req.content.decode()
|
|
35
|
+
return httpx.Response(200, json={"response_id": "r1", "score": 5,
|
|
36
|
+
"worker_new_quality_score": 4.9, "worker_suspended": False})
|
|
37
|
+
|
|
38
|
+
gab = client_with(handler)
|
|
39
|
+
gab.responses.rate("q1", "r1", score=5)
|
|
40
|
+
assert '"score"' in seen["body"]
|
|
41
|
+
assert "feedback_text" not in seen["body"] # omitted, not sent as null
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_account_balance_and_stats():
|
|
45
|
+
paths = []
|
|
46
|
+
|
|
47
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
48
|
+
paths.append(req.url.path)
|
|
49
|
+
if req.url.path.endswith("/requestor/billing"):
|
|
50
|
+
return httpx.Response(200, json={"balance_cents": 900, "company_name": "X"})
|
|
51
|
+
return httpx.Response(200, json={"balance_cents": 900, "total_queries": 3})
|
|
52
|
+
|
|
53
|
+
gab = client_with(handler)
|
|
54
|
+
assert gab.account.balance()["balance_cents"] == 900
|
|
55
|
+
assert gab.account.stats()["total_queries"] == 3
|
|
56
|
+
assert "/api/v1/requestor/billing" in paths
|
|
57
|
+
assert "/api/v1/requestor/stats" in paths
|
|
58
|
+
gab.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_responses_rate_posts_to_the_rate_path():
|
|
62
|
+
seen = {}
|
|
63
|
+
|
|
64
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
65
|
+
seen["method"] = req.method
|
|
66
|
+
seen["path"] = req.url.path
|
|
67
|
+
seen["body"] = req.content.decode()
|
|
68
|
+
return httpx.Response(200, json={"response_id": "r1", "score": 5,
|
|
69
|
+
"worker_new_quality_score": 4.9, "worker_suspended": False})
|
|
70
|
+
|
|
71
|
+
gab = client_with(handler)
|
|
72
|
+
out = gab.responses.rate("q1", "r1", score=5, feedback_text="great")
|
|
73
|
+
assert out["score"] == 5
|
|
74
|
+
assert seen["method"] == "POST"
|
|
75
|
+
assert seen["path"] == "/api/v1/requestor/queries/q1/responses/r1/rate"
|
|
76
|
+
assert '"score"' in seen["body"] and '"great"' in seen["body"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_get_rating_gets_the_rate_path():
|
|
80
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
81
|
+
assert req.method == "GET"
|
|
82
|
+
assert req.url.path == "/api/v1/requestor/queries/q1/responses/r1/rate"
|
|
83
|
+
return httpx.Response(200, json={"id": "rt1", "response_id": "r1", "score": 4})
|
|
84
|
+
|
|
85
|
+
gab = client_with(handler)
|
|
86
|
+
assert gab.responses.get_rating("q1", "r1")["score"] == 4
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_create_query_flows_through_transport():
|
|
90
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
91
|
+
return httpx.Response(201, json={"id": "q1", "status": "active", "total_cost_cents": 100})
|
|
92
|
+
|
|
93
|
+
gab = client_with(handler)
|
|
94
|
+
q = gab.queries.create(type="yes_no", title="Is it live?",
|
|
95
|
+
content_data={"question": "Is it live?"},
|
|
96
|
+
required_responses=2, bid_amount_cents=50)
|
|
97
|
+
assert q["id"] == "q1"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_insufficient_balance_raises():
|
|
101
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
102
|
+
return httpx.Response(402, json={"error": "Payment Failed", "message": "Insufficient balance"})
|
|
103
|
+
|
|
104
|
+
gab = client_with(handler)
|
|
105
|
+
with pytest.raises(InsufficientBalanceError):
|
|
106
|
+
gab.queries.create(type="yes_no", title="x", content_data={"question": "x"},
|
|
107
|
+
required_responses=1, bid_amount_cents=5)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from getabrain.errors import (
|
|
2
|
+
GetABrainError, ValidationError, AuthError, InsufficientBalanceError,
|
|
3
|
+
ForbiddenError, NotFoundError, ConflictError, ServerError, RateLimitError,
|
|
4
|
+
TimeoutError, NetworkError, error_from_response,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_base_error_fields():
|
|
9
|
+
e = GetABrainError("boom", 418, "teapot", "req_1")
|
|
10
|
+
assert e.message == "boom"
|
|
11
|
+
assert e.status == 418
|
|
12
|
+
assert e.code == "teapot"
|
|
13
|
+
assert e.request_id == "req_1"
|
|
14
|
+
assert str(e) == "boom"
|
|
15
|
+
assert isinstance(e, Exception)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_error_from_response_maps_each_status():
|
|
19
|
+
body = {"error": "bad", "message": "nope"}
|
|
20
|
+
assert isinstance(error_from_response(400, body), ValidationError)
|
|
21
|
+
assert isinstance(error_from_response(401, body), AuthError)
|
|
22
|
+
assert isinstance(error_from_response(402, body), InsufficientBalanceError)
|
|
23
|
+
assert isinstance(error_from_response(403, body), ForbiddenError)
|
|
24
|
+
assert isinstance(error_from_response(404, body), NotFoundError)
|
|
25
|
+
assert isinstance(error_from_response(409, body), ConflictError)
|
|
26
|
+
assert isinstance(error_from_response(500, body), ServerError)
|
|
27
|
+
assert isinstance(error_from_response(503, body), ServerError)
|
|
28
|
+
# unmapped 4xx falls back to the base class
|
|
29
|
+
e = error_from_response(418, body)
|
|
30
|
+
assert type(e) is GetABrainError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_error_message_and_code_extracted():
|
|
34
|
+
e = error_from_response(400, {"error": "validation_error", "message": "title too short"})
|
|
35
|
+
assert e.message == "title too short"
|
|
36
|
+
assert e.code == "validation_error"
|
|
37
|
+
# missing body -> synthesized message
|
|
38
|
+
e2 = error_from_response(500, None)
|
|
39
|
+
assert e2.message == "HTTP 500"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_rate_limit_error_carries_retry_after_ms():
|
|
43
|
+
e = error_from_response(429, {"message": "slow down"}, retry_after_ms=5000)
|
|
44
|
+
assert isinstance(e, RateLimitError)
|
|
45
|
+
assert e.retry_after_ms == 5000
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_timeout_and_network_errors_subclass_base():
|
|
49
|
+
assert isinstance(TimeoutError(), GetABrainError)
|
|
50
|
+
assert isinstance(NetworkError(), GetABrainError)
|
|
51
|
+
assert TimeoutError().code == "timeout"
|
|
52
|
+
assert NetworkError().code == "network"
|
|
53
|
+
# subclasses are catchable as the base
|
|
54
|
+
for cls in (ValidationError, AuthError, RateLimitError, ServerError):
|
|
55
|
+
assert issubclass(cls, GetABrainError)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from getabrain._http import build_transport
|
|
5
|
+
from getabrain.errors import AuthError, RateLimitError, ServerError, NetworkError, TimeoutError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make(handler, **kw):
|
|
9
|
+
sleeps = []
|
|
10
|
+
_, request = build_transport(
|
|
11
|
+
api_key="gab_k_x", api_secret="gab_s_y",
|
|
12
|
+
transport=httpx.MockTransport(handler),
|
|
13
|
+
sleep=lambda s: sleeps.append(s),
|
|
14
|
+
**kw,
|
|
15
|
+
)
|
|
16
|
+
return request, sleeps
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_sets_auth_headers_and_parses_json():
|
|
20
|
+
seen = {}
|
|
21
|
+
|
|
22
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
23
|
+
seen["key"] = req.headers.get("x-api-key")
|
|
24
|
+
seen["secret"] = req.headers.get("x-api-secret")
|
|
25
|
+
seen["url"] = str(req.url)
|
|
26
|
+
return httpx.Response(200, json={"ok": True})
|
|
27
|
+
|
|
28
|
+
request, _ = make(handler)
|
|
29
|
+
out = request("GET", "/requestor/stats")
|
|
30
|
+
assert out == {"ok": True}
|
|
31
|
+
assert seen["key"] == "gab_k_x"
|
|
32
|
+
assert seen["secret"] == "gab_s_y"
|
|
33
|
+
assert seen["url"] == "https://www.getabrain.ai/api/v1/requestor/stats"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_post_body_and_query_params():
|
|
37
|
+
seen = {}
|
|
38
|
+
|
|
39
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
40
|
+
seen["method"] = req.method
|
|
41
|
+
seen["content"] = req.content.decode()
|
|
42
|
+
seen["query"] = req.url.params.get("status")
|
|
43
|
+
return httpx.Response(201, json={"id": "q1"})
|
|
44
|
+
|
|
45
|
+
request, _ = make(handler)
|
|
46
|
+
request("POST", "/requestor/queries", body={"title": "hi"})
|
|
47
|
+
assert seen["method"] == "POST"
|
|
48
|
+
assert '"title"' in seen["content"]
|
|
49
|
+
|
|
50
|
+
request2, _ = make(handler)
|
|
51
|
+
request2("GET", "/requestor/queries", params={"status": "active", "limit": None})
|
|
52
|
+
assert seen["query"] == "active" # None params are dropped
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_204_returns_none():
|
|
56
|
+
request, _ = make(lambda req: httpx.Response(204))
|
|
57
|
+
assert request("GET", "/x") is None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_maps_error_status_to_typed_error():
|
|
61
|
+
request, _ = make(lambda req: httpx.Response(401, json={"error": "auth", "message": "bad key"}))
|
|
62
|
+
with pytest.raises(AuthError):
|
|
63
|
+
request("GET", "/requestor/stats")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_get_retries_on_500_then_succeeds():
|
|
67
|
+
calls = {"n": 0}
|
|
68
|
+
|
|
69
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
70
|
+
calls["n"] += 1
|
|
71
|
+
if calls["n"] < 3:
|
|
72
|
+
return httpx.Response(500, json={"error": "server"})
|
|
73
|
+
return httpx.Response(200, json={"ok": True})
|
|
74
|
+
|
|
75
|
+
request, sleeps = make(handler)
|
|
76
|
+
out = request("GET", "/requestor/stats")
|
|
77
|
+
assert out == {"ok": True}
|
|
78
|
+
assert calls["n"] == 3
|
|
79
|
+
assert len(sleeps) == 2 # two retries slept
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_get_honors_retry_after_and_raises_after_max():
|
|
83
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
84
|
+
return httpx.Response(429, headers={"Retry-After": "2"}, json={"error": "rate"})
|
|
85
|
+
|
|
86
|
+
request, sleeps = make(handler, max_retries=1)
|
|
87
|
+
with pytest.raises(RateLimitError) as ei:
|
|
88
|
+
request("GET", "/requestor/stats")
|
|
89
|
+
assert ei.value.retry_after_ms == 2000
|
|
90
|
+
assert sleeps == [2.0] # slept the Retry-After (2s) once before giving up
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_post_does_not_retry_on_500():
|
|
94
|
+
calls = {"n": 0}
|
|
95
|
+
|
|
96
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
97
|
+
calls["n"] += 1
|
|
98
|
+
return httpx.Response(500, json={"error": "server"})
|
|
99
|
+
|
|
100
|
+
request, sleeps = make(handler)
|
|
101
|
+
with pytest.raises(ServerError):
|
|
102
|
+
request("POST", "/requestor/queries", body={"x": 1})
|
|
103
|
+
assert calls["n"] == 1 # POST is never retried
|
|
104
|
+
assert sleeps == []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_transport_error_on_get_retries_then_raises_network():
|
|
108
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
109
|
+
raise httpx.ConnectError("boom")
|
|
110
|
+
|
|
111
|
+
request, sleeps = make(handler, max_retries=2)
|
|
112
|
+
with pytest.raises(NetworkError):
|
|
113
|
+
request("GET", "/requestor/stats")
|
|
114
|
+
assert len(sleeps) == 2 # retried twice, then NetworkError
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_timeout_raises_timeout_error_without_retry():
|
|
118
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
119
|
+
raise httpx.ReadTimeout("slow")
|
|
120
|
+
|
|
121
|
+
request, sleeps = make(handler, max_retries=2)
|
|
122
|
+
with pytest.raises(TimeoutError):
|
|
123
|
+
request("GET", "/requestor/stats")
|
|
124
|
+
assert sleeps == [] # timeout is not retried
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_get_backoff_values_without_retry_after():
|
|
128
|
+
calls = {"n": 0}
|
|
129
|
+
|
|
130
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
131
|
+
calls["n"] += 1
|
|
132
|
+
if calls["n"] < 3:
|
|
133
|
+
return httpx.Response(500, json={"error": "server"})
|
|
134
|
+
return httpx.Response(200, json={"ok": True})
|
|
135
|
+
|
|
136
|
+
request, sleeps = make(handler)
|
|
137
|
+
assert request("GET", "/requestor/stats") == {"ok": True}
|
|
138
|
+
assert sleeps == [1.0, 2.0] # _backoff(1)=1.0, _backoff(2)=2.0
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from getabrain.resources.queries import QueriesResource
|
|
4
|
+
from getabrain.errors import TimeoutError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Recorder:
|
|
8
|
+
def __init__(self, returns):
|
|
9
|
+
self.returns = list(returns)
|
|
10
|
+
self.calls = []
|
|
11
|
+
|
|
12
|
+
def __call__(self, method, path, *, body=None, params=None):
|
|
13
|
+
self.calls.append((method, path, body, params))
|
|
14
|
+
return self.returns.pop(0)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_create_posts_input():
|
|
18
|
+
rec = Recorder([{"id": "q1", "status": "active"}])
|
|
19
|
+
q = QueriesResource(rec).create(type="yes_no", title="ok?", content_data={"question": "ok?"},
|
|
20
|
+
required_responses=1, bid_amount_cents=5)
|
|
21
|
+
assert q["id"] == "q1"
|
|
22
|
+
method, path, body, _ = rec.calls[0]
|
|
23
|
+
assert method == "POST" and path == "/requestor/queries"
|
|
24
|
+
assert body["type"] == "yes_no" and body["required_responses"] == 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_get_url_encodes_id():
|
|
28
|
+
rec = Recorder([{"id": "a/b"}])
|
|
29
|
+
QueriesResource(rec).get("a/b")
|
|
30
|
+
assert rec.calls[0][1] == "/requestor/queries/a%2Fb"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_list_passes_params():
|
|
34
|
+
rec = Recorder([{"queries": []}])
|
|
35
|
+
QueriesResource(rec).list(status="active", limit=10)
|
|
36
|
+
_, path, _, params = rec.calls[0]
|
|
37
|
+
assert path == "/requestor/queries"
|
|
38
|
+
assert params == {"status": "active", "limit": 10, "offset": None}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_cancel_puts_action():
|
|
42
|
+
rec = Recorder([{"success": True, "refund_amount_cents": 50}])
|
|
43
|
+
out = QueriesResource(rec).cancel("q1")
|
|
44
|
+
method, path, body, _ = rec.calls[0]
|
|
45
|
+
assert method == "PUT" and path == "/requestor/queries/q1"
|
|
46
|
+
assert body == {"action": "cancel"}
|
|
47
|
+
assert out["refund_amount_cents"] == 50
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_wait_returns_when_enough_responses():
|
|
51
|
+
rec = Recorder([
|
|
52
|
+
{"status": "active", "completed_responses": 0, "required_responses": 2, "responses": []},
|
|
53
|
+
{"status": "active", "completed_responses": 2, "required_responses": 2,
|
|
54
|
+
"responses": [{"id": "r1"}, {"id": "r2"}]},
|
|
55
|
+
])
|
|
56
|
+
sleeps = []
|
|
57
|
+
out = QueriesResource(rec).wait_for_responses(
|
|
58
|
+
"q1", min_responses=2, poll_interval_s=5, sleep=lambda s: sleeps.append(s),
|
|
59
|
+
)
|
|
60
|
+
assert len(out) == 2
|
|
61
|
+
assert sleeps == [5] # slept once between the two polls
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_wait_returns_on_terminal_status():
|
|
65
|
+
rec = Recorder([{"status": "cancelled", "completed_responses": 0,
|
|
66
|
+
"required_responses": 5, "responses": []}])
|
|
67
|
+
out = QueriesResource(rec).wait_for_responses("q1", min_responses=5, sleep=lambda s: None)
|
|
68
|
+
assert out == []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_wait_uses_len_responses_when_count_absent():
|
|
72
|
+
rec = Recorder([
|
|
73
|
+
{"status": "active", "required_responses": 2, "responses": [{"id": "r1"}, {"id": "r2"}]},
|
|
74
|
+
])
|
|
75
|
+
out = QueriesResource(rec).wait_for_responses("q1", min_responses=2, sleep=lambda s: None)
|
|
76
|
+
assert len(out) == 2 # no completed_responses key -> falls back to len(responses)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
import pytest as _pytest
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@_pytest.mark.parametrize("terminal", ["completed", "cancelled", "failed", "expired"])
|
|
83
|
+
def test_wait_returns_on_each_terminal_status(terminal):
|
|
84
|
+
rec = Recorder([{"status": terminal, "completed_responses": 0,
|
|
85
|
+
"required_responses": 5, "responses": []}])
|
|
86
|
+
out = QueriesResource(rec).wait_for_responses("q1", min_responses=5, sleep=lambda s: None)
|
|
87
|
+
assert out == []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_wait_raises_timeout_when_deadline_passes():
|
|
91
|
+
rec = Recorder([
|
|
92
|
+
{"status": "active", "completed_responses": 0, "required_responses": 9, "responses": []}
|
|
93
|
+
for _ in range(10)
|
|
94
|
+
])
|
|
95
|
+
clock = {"t": 0.0}
|
|
96
|
+
|
|
97
|
+
def now():
|
|
98
|
+
clock["t"] += 10.0 # each call jumps 10s
|
|
99
|
+
return clock["t"]
|
|
100
|
+
|
|
101
|
+
with pytest.raises(TimeoutError):
|
|
102
|
+
QueriesResource(rec).wait_for_responses(
|
|
103
|
+
"q1", min_responses=9, timeout_s=5, poll_interval_s=1,
|
|
104
|
+
sleep=lambda s: None, now=now,
|
|
105
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from getabrain.types import QUERY_TYPES, Query, AbTestContent, RatingResult
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_query_types_has_the_thirteen():
|
|
5
|
+
assert len(QUERY_TYPES) == 13
|
|
6
|
+
for t in (
|
|
7
|
+
"text", "multiple_choice", "rating_scale", "image_comparison", "ranking",
|
|
8
|
+
"yes_no", "sentiment", "image_selection", "free_form_text", "video_review",
|
|
9
|
+
"audio_review", "image_analysis", "ab_test",
|
|
10
|
+
):
|
|
11
|
+
assert t in QUERY_TYPES
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_typeddicts_are_constructible_as_plain_dicts():
|
|
15
|
+
# TypedDicts are dicts at runtime; this just guards the names exist + import cleanly.
|
|
16
|
+
content: AbTestContent = {
|
|
17
|
+
"question": "Which is better?",
|
|
18
|
+
"variant_a": {"description": "A"},
|
|
19
|
+
"variant_b": {"description": "B"},
|
|
20
|
+
}
|
|
21
|
+
assert content["variant_a"]["description"] == "A"
|
|
22
|
+
q: Query = {"id": "q1", "type": "ab_test", "title": "t", "status": "active",
|
|
23
|
+
"required_responses": 1, "bid_amount_cents": 5, "total_cost_cents": 5,
|
|
24
|
+
"created_at": "now"}
|
|
25
|
+
assert q["status"] == "active"
|
|
26
|
+
r: RatingResult = {"response_id": "r1", "score": 5, "worker_new_quality_score": 4.8,
|
|
27
|
+
"worker_suspended": False}
|
|
28
|
+
assert r["score"] == 5
|