freshguard 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.
- freshguard-0.1.0/PKG-INFO +94 -0
- freshguard-0.1.0/README.md +83 -0
- freshguard-0.1.0/freshguard/__init__.py +50 -0
- freshguard-0.1.0/freshguard/_core.py +152 -0
- freshguard-0.1.0/freshguard/_http.py +75 -0
- freshguard-0.1.0/freshguard/async_client.py +171 -0
- freshguard-0.1.0/freshguard/client.py +181 -0
- freshguard-0.1.0/freshguard/errors.py +60 -0
- freshguard-0.1.0/freshguard/models.py +116 -0
- freshguard-0.1.0/freshguard/py.typed +0 -0
- freshguard-0.1.0/freshguard.egg-info/PKG-INFO +94 -0
- freshguard-0.1.0/freshguard.egg-info/SOURCES.txt +20 -0
- freshguard-0.1.0/freshguard.egg-info/dependency_links.txt +1 -0
- freshguard-0.1.0/freshguard.egg-info/requires.txt +5 -0
- freshguard-0.1.0/freshguard.egg-info/top_level.txt +1 -0
- freshguard-0.1.0/pyproject.toml +29 -0
- freshguard-0.1.0/setup.cfg +4 -0
- freshguard-0.1.0/tests/test_async_client.py +68 -0
- freshguard-0.1.0/tests/test_client.py +196 -0
- freshguard-0.1.0/tests/test_httpx_transport.py +46 -0
- freshguard-0.1.0/tests/test_models.py +39 -0
- freshguard-0.1.0/tests/test_package.py +17 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: freshguard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the FreshGuard stale-answer detection API
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx<1,>=0.27
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
10
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
11
|
+
|
|
12
|
+
# FreshGuard Python SDK
|
|
13
|
+
|
|
14
|
+
A thin client for the FreshGuard API. It sends an AI answer to the server, which checks its factual
|
|
15
|
+
claims against current web sources and returns `fresh`, `stale`, `potentially_stale` or
|
|
16
|
+
`unverifiable`. All verification logic runs on the server.
|
|
17
|
+
|
|
18
|
+
Full API contract and chatbot integration plan: `docs/INTEGRATION_GUIDE.md`.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
From the repo root, in your app's virtual environment:
|
|
23
|
+
|
|
24
|
+
```powershell
|
|
25
|
+
pip install -e ./sdk
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Requires Python 3.9+ (installs `httpx`).
|
|
29
|
+
|
|
30
|
+
## Use
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from freshguard import FreshGuard
|
|
34
|
+
|
|
35
|
+
fg = FreshGuard(base_url="http://localhost:8000", api_key="dev-key")
|
|
36
|
+
|
|
37
|
+
result = fg.verify(
|
|
38
|
+
question="Who is the CEO of Twitter?",
|
|
39
|
+
answer="Jack Dorsey is the CEO of Twitter.",
|
|
40
|
+
wait_seconds=90,
|
|
41
|
+
)
|
|
42
|
+
print(result.label, result.confidence) # "stale" 0.8
|
|
43
|
+
print(result.suggested_correction)
|
|
44
|
+
print(result.should_warn) # True for stale / potentially_stale
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Verification takes 10-60+ seconds, so in a chat app reply first and verify in the background:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
job = fg.submit(question, answer, metadata={"message_id": "m-7"}) # returns at once
|
|
51
|
+
...
|
|
52
|
+
result = fg.get(job.id) # status: pending | running | completed | failed
|
|
53
|
+
result = fg.wait(job.id, timeout=90) # or block, polling every 2 s
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
See `examples/chatbot_pattern.py`. Async version: `from freshguard import AsyncFreshGuard` with
|
|
57
|
+
`async with AsyncFreshGuard(...) as fg:` and `await` on every method.
|
|
58
|
+
|
|
59
|
+
## Methods
|
|
60
|
+
|
|
61
|
+
| Method | Does |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `verify(question, answer, wait_seconds=60, metadata=None)` | submit and wait; returns the result, or raises `VerificationTimeout` |
|
|
64
|
+
| `submit(question, answer, metadata=None)` | start a job, returns a `pending` result at once |
|
|
65
|
+
| `get(id)` | current state of a job |
|
|
66
|
+
| `wait(id, timeout=90, poll_interval=2)` | poll until finished; raises `VerificationTimeout` (job keeps running) |
|
|
67
|
+
| `list(limit=20, label=None, before=None)` | recent results, newest first, with paging cursor `next_before` |
|
|
68
|
+
| `health()` | `True` if the server is reachable |
|
|
69
|
+
|
|
70
|
+
A job that finished but could not be checked (providers down) is **returned** with
|
|
71
|
+
`status == "failed"` and `.error` set; it is not raised.
|
|
72
|
+
|
|
73
|
+
## Errors
|
|
74
|
+
|
|
75
|
+
All subclass `FreshGuardError`: `AuthError` (401), `NotFoundError` (404), `ValidationError`
|
|
76
|
+
(422, or a bad argument), `RateLimitError` (429, has `.retry_after`), `ServerError` (5xx),
|
|
77
|
+
`ConnectionFailed`, `VerificationTimeout`. Each has `.status`, `.code` and `.request_id` when known.
|
|
78
|
+
Reads (`get`, `wait`, `list`) are retried twice on network errors and 5xx; `submit` and `verify` are
|
|
79
|
+
never retried, because a retry could create a duplicate job.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
try:
|
|
83
|
+
result = fg.verify(question, answer, wait_seconds=60)
|
|
84
|
+
except FreshGuardError:
|
|
85
|
+
result = None # show the answer without a badge
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Test
|
|
89
|
+
|
|
90
|
+
```powershell
|
|
91
|
+
cd sdk
|
|
92
|
+
pip install -e ".[dev]"
|
|
93
|
+
python -m pytest -q
|
|
94
|
+
```
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# FreshGuard Python SDK
|
|
2
|
+
|
|
3
|
+
A thin client for the FreshGuard API. It sends an AI answer to the server, which checks its factual
|
|
4
|
+
claims against current web sources and returns `fresh`, `stale`, `potentially_stale` or
|
|
5
|
+
`unverifiable`. All verification logic runs on the server.
|
|
6
|
+
|
|
7
|
+
Full API contract and chatbot integration plan: `docs/INTEGRATION_GUIDE.md`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
From the repo root, in your app's virtual environment:
|
|
12
|
+
|
|
13
|
+
```powershell
|
|
14
|
+
pip install -e ./sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Python 3.9+ (installs `httpx`).
|
|
18
|
+
|
|
19
|
+
## Use
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from freshguard import FreshGuard
|
|
23
|
+
|
|
24
|
+
fg = FreshGuard(base_url="http://localhost:8000", api_key="dev-key")
|
|
25
|
+
|
|
26
|
+
result = fg.verify(
|
|
27
|
+
question="Who is the CEO of Twitter?",
|
|
28
|
+
answer="Jack Dorsey is the CEO of Twitter.",
|
|
29
|
+
wait_seconds=90,
|
|
30
|
+
)
|
|
31
|
+
print(result.label, result.confidence) # "stale" 0.8
|
|
32
|
+
print(result.suggested_correction)
|
|
33
|
+
print(result.should_warn) # True for stale / potentially_stale
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Verification takes 10-60+ seconds, so in a chat app reply first and verify in the background:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
job = fg.submit(question, answer, metadata={"message_id": "m-7"}) # returns at once
|
|
40
|
+
...
|
|
41
|
+
result = fg.get(job.id) # status: pending | running | completed | failed
|
|
42
|
+
result = fg.wait(job.id, timeout=90) # or block, polling every 2 s
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
See `examples/chatbot_pattern.py`. Async version: `from freshguard import AsyncFreshGuard` with
|
|
46
|
+
`async with AsyncFreshGuard(...) as fg:` and `await` on every method.
|
|
47
|
+
|
|
48
|
+
## Methods
|
|
49
|
+
|
|
50
|
+
| Method | Does |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `verify(question, answer, wait_seconds=60, metadata=None)` | submit and wait; returns the result, or raises `VerificationTimeout` |
|
|
53
|
+
| `submit(question, answer, metadata=None)` | start a job, returns a `pending` result at once |
|
|
54
|
+
| `get(id)` | current state of a job |
|
|
55
|
+
| `wait(id, timeout=90, poll_interval=2)` | poll until finished; raises `VerificationTimeout` (job keeps running) |
|
|
56
|
+
| `list(limit=20, label=None, before=None)` | recent results, newest first, with paging cursor `next_before` |
|
|
57
|
+
| `health()` | `True` if the server is reachable |
|
|
58
|
+
|
|
59
|
+
A job that finished but could not be checked (providers down) is **returned** with
|
|
60
|
+
`status == "failed"` and `.error` set; it is not raised.
|
|
61
|
+
|
|
62
|
+
## Errors
|
|
63
|
+
|
|
64
|
+
All subclass `FreshGuardError`: `AuthError` (401), `NotFoundError` (404), `ValidationError`
|
|
65
|
+
(422, or a bad argument), `RateLimitError` (429, has `.retry_after`), `ServerError` (5xx),
|
|
66
|
+
`ConnectionFailed`, `VerificationTimeout`. Each has `.status`, `.code` and `.request_id` when known.
|
|
67
|
+
Reads (`get`, `wait`, `list`) are retried twice on network errors and 5xx; `submit` and `verify` are
|
|
68
|
+
never retried, because a retry could create a duplicate job.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
try:
|
|
72
|
+
result = fg.verify(question, answer, wait_seconds=60)
|
|
73
|
+
except FreshGuardError:
|
|
74
|
+
result = None # show the answer without a badge
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Test
|
|
78
|
+
|
|
79
|
+
```powershell
|
|
80
|
+
cd sdk
|
|
81
|
+
pip install -e ".[dev]"
|
|
82
|
+
python -m pytest -q
|
|
83
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""FreshGuard Python SDK: a thin client for the FreshGuard stale-answer detection API."""
|
|
2
|
+
|
|
3
|
+
from freshguard.async_client import AsyncFreshGuard
|
|
4
|
+
from freshguard.client import FreshGuard
|
|
5
|
+
from freshguard.errors import (
|
|
6
|
+
AuthError,
|
|
7
|
+
ConnectionFailed,
|
|
8
|
+
FreshGuardError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
ServerError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
VerificationTimeout,
|
|
14
|
+
)
|
|
15
|
+
from freshguard.models import (
|
|
16
|
+
FRESH,
|
|
17
|
+
LABELS,
|
|
18
|
+
POTENTIALLY_STALE,
|
|
19
|
+
STALE,
|
|
20
|
+
UNVERIFIABLE,
|
|
21
|
+
Claim,
|
|
22
|
+
Evidence,
|
|
23
|
+
Page,
|
|
24
|
+
Verification,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"FRESH",
|
|
31
|
+
"LABELS",
|
|
32
|
+
"POTENTIALLY_STALE",
|
|
33
|
+
"STALE",
|
|
34
|
+
"UNVERIFIABLE",
|
|
35
|
+
"AsyncFreshGuard",
|
|
36
|
+
"AuthError",
|
|
37
|
+
"Claim",
|
|
38
|
+
"ConnectionFailed",
|
|
39
|
+
"Evidence",
|
|
40
|
+
"FreshGuard",
|
|
41
|
+
"FreshGuardError",
|
|
42
|
+
"NotFoundError",
|
|
43
|
+
"Page",
|
|
44
|
+
"RateLimitError",
|
|
45
|
+
"ServerError",
|
|
46
|
+
"ValidationError",
|
|
47
|
+
"Verification",
|
|
48
|
+
"VerificationTimeout",
|
|
49
|
+
"__version__",
|
|
50
|
+
]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Shared, I/O-free logic for the sync and async clients: request building, response parsing,
|
|
2
|
+
error mapping, retry timing. Easy to test without a network."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from freshguard.errors import (
|
|
10
|
+
AuthError,
|
|
11
|
+
FreshGuardError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ServerError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
)
|
|
17
|
+
from freshguard.models import Verification
|
|
18
|
+
|
|
19
|
+
MAX_SERVER_WAIT = 120 # the API caps ?wait=true&timeout= at this many seconds
|
|
20
|
+
DEFAULT_POLL_INTERVAL = 2.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Response:
|
|
25
|
+
status: int
|
|
26
|
+
body: Any # parsed JSON, or None if the body was not JSON
|
|
27
|
+
headers: dict[str, str] = field(default_factory=dict) # lower-case names
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Transport(Protocol):
|
|
31
|
+
"""Sync transport. Raises `ConnectionFailed` on network errors and timeouts."""
|
|
32
|
+
|
|
33
|
+
def request(
|
|
34
|
+
self,
|
|
35
|
+
method: str,
|
|
36
|
+
url: str,
|
|
37
|
+
*,
|
|
38
|
+
headers: dict[str, str],
|
|
39
|
+
json: Any = None,
|
|
40
|
+
params: dict[str, Any] | None = None,
|
|
41
|
+
timeout: float,
|
|
42
|
+
) -> Response: ...
|
|
43
|
+
|
|
44
|
+
def close(self) -> None: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AsyncTransport(Protocol):
|
|
48
|
+
async def request(
|
|
49
|
+
self,
|
|
50
|
+
method: str,
|
|
51
|
+
url: str,
|
|
52
|
+
*,
|
|
53
|
+
headers: dict[str, str],
|
|
54
|
+
json: Any = None,
|
|
55
|
+
params: dict[str, Any] | None = None,
|
|
56
|
+
timeout: float,
|
|
57
|
+
) -> Response: ...
|
|
58
|
+
|
|
59
|
+
async def aclose(self) -> None: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_headers(api_key: str | None) -> dict[str, str]:
|
|
63
|
+
headers = {"accept": "application/json", "user-agent": "freshguard-python/0.1"}
|
|
64
|
+
if api_key:
|
|
65
|
+
headers["x-api-key"] = api_key
|
|
66
|
+
return headers
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def submit_body(
|
|
70
|
+
question: str,
|
|
71
|
+
answer: str,
|
|
72
|
+
answer_generated_at: Any = None,
|
|
73
|
+
metadata: dict[str, Any] | None = None,
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
"""Validate the obvious mistakes locally and build the JSON body."""
|
|
76
|
+
if not isinstance(question, str) or not question.strip():
|
|
77
|
+
raise ValidationError("question must be a non-empty string.")
|
|
78
|
+
if not isinstance(answer, str) or not answer.strip():
|
|
79
|
+
raise ValidationError("answer must be a non-empty string.")
|
|
80
|
+
body: dict[str, Any] = {"question": question, "answer": answer}
|
|
81
|
+
if answer_generated_at is not None:
|
|
82
|
+
stamp = answer_generated_at
|
|
83
|
+
body["answer_generated_at"] = stamp.isoformat() if hasattr(stamp, "isoformat") else stamp
|
|
84
|
+
if metadata:
|
|
85
|
+
body["metadata"] = {str(k): str(v) for k, v in metadata.items()}
|
|
86
|
+
return body
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def raise_for_response(response: Response) -> None:
|
|
90
|
+
"""Map a non-2xx response to the right exception. Does nothing for 2xx."""
|
|
91
|
+
if 200 <= response.status < 300:
|
|
92
|
+
return
|
|
93
|
+
request_id = response.headers.get("x-request-id")
|
|
94
|
+
code = message = None
|
|
95
|
+
if isinstance(response.body, dict) and isinstance(response.body.get("error"), dict):
|
|
96
|
+
code = response.body["error"].get("code")
|
|
97
|
+
message = response.body["error"].get("message")
|
|
98
|
+
message = message or f"HTTP {response.status}"
|
|
99
|
+
extra = {"status": response.status, "code": code, "request_id": request_id}
|
|
100
|
+
if response.status == 401:
|
|
101
|
+
raise AuthError(message, **extra)
|
|
102
|
+
if response.status == 404:
|
|
103
|
+
raise NotFoundError(message, **extra)
|
|
104
|
+
if response.status == 422:
|
|
105
|
+
raise ValidationError(message, **extra)
|
|
106
|
+
if response.status == 429:
|
|
107
|
+
raise RateLimitError(message, retry_after=_retry_after(response), **extra)
|
|
108
|
+
if response.status >= 500:
|
|
109
|
+
raise ServerError(message, **extra)
|
|
110
|
+
raise FreshGuardError(message, **extra)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _retry_after(response: Response) -> float | None:
|
|
114
|
+
try:
|
|
115
|
+
return float(response.headers.get("retry-after", ""))
|
|
116
|
+
except ValueError:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def expect_json(response: Response) -> dict[str, Any]:
|
|
121
|
+
if not isinstance(response.body, dict):
|
|
122
|
+
raise ServerError(
|
|
123
|
+
"The server returned something that is not FreshGuard JSON.",
|
|
124
|
+
status=response.status,
|
|
125
|
+
request_id=response.headers.get("x-request-id"),
|
|
126
|
+
)
|
|
127
|
+
return response.body
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_verification(response: Response) -> Verification:
|
|
131
|
+
data = expect_json(response)
|
|
132
|
+
if "id" not in data:
|
|
133
|
+
raise ServerError("Response has no 'id'.", status=response.status)
|
|
134
|
+
return Verification.from_dict(data)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def should_retry(response: Response) -> bool:
|
|
138
|
+
return response.status >= 500
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def backoff_seconds(attempt: int) -> float:
|
|
142
|
+
"""0.5s, 1s, 2s ... for retry number `attempt` (0-based)."""
|
|
143
|
+
return 0.5 * (2**attempt)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def list_params(limit: int, label: str | None, before: str | None) -> dict[str, Any]:
|
|
147
|
+
params: dict[str, Any] = {"limit": limit}
|
|
148
|
+
if label:
|
|
149
|
+
params["label"] = label
|
|
150
|
+
if before:
|
|
151
|
+
params["before"] = before
|
|
152
|
+
return params
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""httpx-backed transports. The only module that imports httpx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from freshguard._core import Response
|
|
10
|
+
from freshguard.errors import ConnectionFailed
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _response(resp: httpx.Response) -> Response:
|
|
14
|
+
try:
|
|
15
|
+
body = resp.json()
|
|
16
|
+
except ValueError:
|
|
17
|
+
body = None
|
|
18
|
+
return Response(resp.status_code, body, {k.lower(): v for k, v in resp.headers.items()})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _connection_error(exc: httpx.HTTPError) -> ConnectionFailed:
|
|
22
|
+
kind = "timed out" if isinstance(exc, httpx.TimeoutException) else "network error"
|
|
23
|
+
return ConnectionFailed(f"Could not reach the FreshGuard server ({kind}).")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HttpxTransport:
|
|
27
|
+
def __init__(self, client: httpx.Client | None = None) -> None:
|
|
28
|
+
self._client = client or httpx.Client()
|
|
29
|
+
|
|
30
|
+
def request(
|
|
31
|
+
self,
|
|
32
|
+
method: str,
|
|
33
|
+
url: str,
|
|
34
|
+
*,
|
|
35
|
+
headers: dict[str, str],
|
|
36
|
+
json: Any = None,
|
|
37
|
+
params: dict[str, Any] | None = None,
|
|
38
|
+
timeout: float,
|
|
39
|
+
) -> Response:
|
|
40
|
+
try:
|
|
41
|
+
resp = self._client.request(
|
|
42
|
+
method, url, headers=headers, json=json, params=params, timeout=timeout
|
|
43
|
+
)
|
|
44
|
+
except httpx.HTTPError as exc:
|
|
45
|
+
raise _connection_error(exc) from None
|
|
46
|
+
return _response(resp)
|
|
47
|
+
|
|
48
|
+
def close(self) -> None:
|
|
49
|
+
self._client.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HttpxAsyncTransport:
|
|
53
|
+
def __init__(self, client: httpx.AsyncClient | None = None) -> None:
|
|
54
|
+
self._client = client or httpx.AsyncClient()
|
|
55
|
+
|
|
56
|
+
async def request(
|
|
57
|
+
self,
|
|
58
|
+
method: str,
|
|
59
|
+
url: str,
|
|
60
|
+
*,
|
|
61
|
+
headers: dict[str, str],
|
|
62
|
+
json: Any = None,
|
|
63
|
+
params: dict[str, Any] | None = None,
|
|
64
|
+
timeout: float,
|
|
65
|
+
) -> Response:
|
|
66
|
+
try:
|
|
67
|
+
resp = await self._client.request(
|
|
68
|
+
method, url, headers=headers, json=json, params=params, timeout=timeout
|
|
69
|
+
)
|
|
70
|
+
except httpx.HTTPError as exc:
|
|
71
|
+
raise _connection_error(exc) from None
|
|
72
|
+
return _response(resp)
|
|
73
|
+
|
|
74
|
+
async def aclose(self) -> None:
|
|
75
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Asynchronous client (same methods as `FreshGuard`, awaitable)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from freshguard import _core
|
|
11
|
+
from freshguard._core import AsyncTransport, Response
|
|
12
|
+
from freshguard.errors import ConnectionFailed, VerificationTimeout
|
|
13
|
+
from freshguard.models import Page, Verification
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncFreshGuard:
|
|
17
|
+
"""async with AsyncFreshGuard(base_url=..., api_key=...) as fg:
|
|
18
|
+
result = await fg.verify(question="...", answer="...")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
base_url: str = "http://localhost:8000",
|
|
24
|
+
api_key: str | None = None,
|
|
25
|
+
*,
|
|
26
|
+
timeout: float = 30.0,
|
|
27
|
+
max_retries: int = 2,
|
|
28
|
+
transport: AsyncTransport | None = None,
|
|
29
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
30
|
+
clock: Callable[[], float] = time.monotonic,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._base = base_url.rstrip("/")
|
|
33
|
+
self._headers = _core.build_headers(api_key)
|
|
34
|
+
self._timeout = timeout
|
|
35
|
+
self._max_retries = max(0, max_retries)
|
|
36
|
+
self._sleep = sleep
|
|
37
|
+
self._clock = clock
|
|
38
|
+
if transport is None:
|
|
39
|
+
from freshguard._http import HttpxAsyncTransport
|
|
40
|
+
|
|
41
|
+
transport = HttpxAsyncTransport()
|
|
42
|
+
self._transport = transport
|
|
43
|
+
|
|
44
|
+
async def __aenter__(self) -> AsyncFreshGuard:
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
48
|
+
await self.aclose()
|
|
49
|
+
|
|
50
|
+
async def aclose(self) -> None:
|
|
51
|
+
await self._transport.aclose()
|
|
52
|
+
|
|
53
|
+
async def health(self) -> bool:
|
|
54
|
+
try:
|
|
55
|
+
return (await self._send("GET", "/health", retry=False)).status == 200
|
|
56
|
+
except ConnectionFailed:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
async def submit(
|
|
60
|
+
self,
|
|
61
|
+
question: str,
|
|
62
|
+
answer: str,
|
|
63
|
+
*,
|
|
64
|
+
answer_generated_at: Any = None,
|
|
65
|
+
metadata: dict[str, Any] | None = None,
|
|
66
|
+
) -> Verification:
|
|
67
|
+
body = _core.submit_body(question, answer, answer_generated_at, metadata)
|
|
68
|
+
resp = await self._send("POST", "/v1/verifications", json=body, retry=False)
|
|
69
|
+
_core.raise_for_response(resp)
|
|
70
|
+
return _core.parse_verification(resp)
|
|
71
|
+
|
|
72
|
+
async def reverify(self, verification_id: str) -> Verification:
|
|
73
|
+
"""Async twin of `FreshGuard.reverify`."""
|
|
74
|
+
resp = await self._send(
|
|
75
|
+
"POST", f"/v1/verifications/{verification_id}/reverify", retry=False
|
|
76
|
+
)
|
|
77
|
+
_core.raise_for_response(resp)
|
|
78
|
+
return _core.parse_verification(resp)
|
|
79
|
+
|
|
80
|
+
async def get(self, verification_id: str) -> Verification:
|
|
81
|
+
resp = await self._send("GET", f"/v1/verifications/{verification_id}")
|
|
82
|
+
_core.raise_for_response(resp)
|
|
83
|
+
return _core.parse_verification(resp)
|
|
84
|
+
|
|
85
|
+
async def wait(
|
|
86
|
+
self,
|
|
87
|
+
verification_id: str,
|
|
88
|
+
timeout: float = 90.0,
|
|
89
|
+
poll_interval: float = _core.DEFAULT_POLL_INTERVAL,
|
|
90
|
+
) -> Verification:
|
|
91
|
+
started = self._clock()
|
|
92
|
+
while True:
|
|
93
|
+
current = await self.get(verification_id)
|
|
94
|
+
if current.is_done:
|
|
95
|
+
return current
|
|
96
|
+
remaining = timeout - (self._clock() - started)
|
|
97
|
+
if remaining <= 0:
|
|
98
|
+
raise VerificationTimeout(verification_id, timeout)
|
|
99
|
+
await self._sleep(min(poll_interval, remaining))
|
|
100
|
+
|
|
101
|
+
async def verify(
|
|
102
|
+
self,
|
|
103
|
+
question: str,
|
|
104
|
+
answer: str,
|
|
105
|
+
*,
|
|
106
|
+
wait_seconds: float = 60.0,
|
|
107
|
+
answer_generated_at: Any = None,
|
|
108
|
+
metadata: dict[str, Any] | None = None,
|
|
109
|
+
) -> Verification:
|
|
110
|
+
body = _core.submit_body(question, answer, answer_generated_at, metadata)
|
|
111
|
+
if wait_seconds > _core.MAX_SERVER_WAIT:
|
|
112
|
+
pending = await self.submit(question, answer, metadata=metadata)
|
|
113
|
+
return await self.wait(pending.id, timeout=wait_seconds)
|
|
114
|
+
server_wait = max(1, int(wait_seconds))
|
|
115
|
+
resp = await self._send(
|
|
116
|
+
"POST",
|
|
117
|
+
"/v1/verifications",
|
|
118
|
+
json=body,
|
|
119
|
+
params={"wait": "true", "timeout": server_wait},
|
|
120
|
+
timeout=max(self._timeout, server_wait + 10.0),
|
|
121
|
+
retry=False,
|
|
122
|
+
)
|
|
123
|
+
_core.raise_for_response(resp)
|
|
124
|
+
result = _core.parse_verification(resp)
|
|
125
|
+
if resp.status == 200 or result.is_done:
|
|
126
|
+
return result
|
|
127
|
+
raise VerificationTimeout(result.id, float(server_wait))
|
|
128
|
+
|
|
129
|
+
async def list(
|
|
130
|
+
self, *, limit: int = 20, label: str | None = None, before: str | None = None
|
|
131
|
+
) -> Page:
|
|
132
|
+
resp = await self._send(
|
|
133
|
+
"GET", "/v1/verifications", params=_core.list_params(limit, label, before)
|
|
134
|
+
)
|
|
135
|
+
_core.raise_for_response(resp)
|
|
136
|
+
data = _core.expect_json(resp)
|
|
137
|
+
items = tuple(Verification.from_dict(d) for d in data.get("items") or ())
|
|
138
|
+
return Page(items=items, next_before=data.get("next_before"))
|
|
139
|
+
|
|
140
|
+
async def _send(
|
|
141
|
+
self,
|
|
142
|
+
method: str,
|
|
143
|
+
path: str,
|
|
144
|
+
*,
|
|
145
|
+
json: Any = None,
|
|
146
|
+
params: dict[str, Any] | None = None,
|
|
147
|
+
timeout: float | None = None,
|
|
148
|
+
retry: bool = True,
|
|
149
|
+
) -> Response:
|
|
150
|
+
attempts = 1 + (self._max_retries if retry else 0)
|
|
151
|
+
for attempt in range(attempts):
|
|
152
|
+
last = attempt == attempts - 1
|
|
153
|
+
try:
|
|
154
|
+
resp = await self._transport.request(
|
|
155
|
+
method,
|
|
156
|
+
self._base + path,
|
|
157
|
+
headers=self._headers,
|
|
158
|
+
json=json,
|
|
159
|
+
params=params,
|
|
160
|
+
timeout=timeout or self._timeout,
|
|
161
|
+
)
|
|
162
|
+
except ConnectionFailed:
|
|
163
|
+
if last:
|
|
164
|
+
raise
|
|
165
|
+
await self._sleep(_core.backoff_seconds(attempt))
|
|
166
|
+
continue
|
|
167
|
+
if not last and _core.should_retry(resp):
|
|
168
|
+
await self._sleep(_core.backoff_seconds(attempt))
|
|
169
|
+
continue
|
|
170
|
+
return resp
|
|
171
|
+
raise AssertionError("unreachable")
|