nonecap 0.2.0__tar.gz → 0.4.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.
- {nonecap-0.2.0 → nonecap-0.4.0}/PKG-INFO +54 -2
- {nonecap-0.2.0 → nonecap-0.4.0}/README.md +53 -1
- {nonecap-0.2.0 → nonecap-0.4.0}/src/nonecap/__init__.py +22 -1
- {nonecap-0.2.0 → nonecap-0.4.0}/src/nonecap/_client.py +199 -2
- {nonecap-0.2.0 → nonecap-0.4.0}/src/nonecap/_errors.py +9 -4
- nonecap-0.4.0/src/nonecap/_types.py +265 -0
- nonecap-0.4.0/src/nonecap/_version.py +1 -0
- nonecap-0.4.0/tests/test_feedback.py +227 -0
- {nonecap-0.2.0 → nonecap-0.4.0}/tests/typing_assertions.py +24 -0
- nonecap-0.2.0/src/nonecap/_types.py +0 -131
- nonecap-0.2.0/src/nonecap/_version.py +0 -1
- {nonecap-0.2.0 → nonecap-0.4.0}/.github/workflows/ci.yml +0 -0
- {nonecap-0.2.0 → nonecap-0.4.0}/.gitignore +0 -0
- {nonecap-0.2.0 → nonecap-0.4.0}/LICENSE +0 -0
- {nonecap-0.2.0 → nonecap-0.4.0}/pyproject.toml +0 -0
- {nonecap-0.2.0 → nonecap-0.4.0}/src/nonecap/py.typed +0 -0
- {nonecap-0.2.0 → nonecap-0.4.0}/tests/__init__.py +0 -0
- {nonecap-0.2.0 → nonecap-0.4.0}/tests/conftest.py +0 -0
- {nonecap-0.2.0 → nonecap-0.4.0}/tests/test_async_client.py +0 -0
- {nonecap-0.2.0 → nonecap-0.4.0}/tests/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonecap
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Official Python client for the NoneCap hCaptcha solving API.
|
|
5
5
|
Project-URL: Homepage, https://nonecap.com
|
|
6
6
|
Project-URL: Documentation, https://nonecap.com/api-reference
|
|
@@ -52,7 +52,7 @@ Python 3.9+. The only dependency is [httpx](https://www.python-httpx.org/).
|
|
|
52
52
|
|
|
53
53
|
## Quick start
|
|
54
54
|
|
|
55
|
-
Grab an API key from [dashboard.nonecap.com](https://dashboard.nonecap.com), then:
|
|
55
|
+
Grab an API key from [dashboard.nonecap.com](https://dashboard.nonecap.com/signup?utm_source=pypi&utm_medium=sdk-readme), then:
|
|
56
56
|
|
|
57
57
|
```python
|
|
58
58
|
from nonecap import NoneCap
|
|
@@ -128,6 +128,22 @@ async with AsyncNoneCap(api_key="nc_live_...") as nc:
|
|
|
128
128
|
|
|
129
129
|
Cancelled solves are never charged, and a solve you simply abandon expires uncharged at the server deadline — nothing is billed unless a solve actually succeeds. So `cancel()` is for cleanup and early-stop, not cost protection.
|
|
130
130
|
|
|
131
|
+
### Cleaning up after a `solve()` timeout
|
|
132
|
+
|
|
133
|
+
`solve()` blocks until the solve settles, so it gives you no handle to cancel a solve while it's still running — for that, use `solves.start()` above. The one thing the `solve()` path offers is cleanup *after* a timeout: when `solve()` raises `SolveTimeoutError`, the error usually carries the in-flight `solve_id` (and last-known `solve`), so you can cancel the solve the wait gave up on. Guard on `solve_id` being present — if the very first submission times out at the transport level, no id has been assigned yet, so `solve_id` is `None`.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from nonecap import SolveTimeoutError
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
nc.solve(type="hcaptcha", sitekey=sitekey, url=url)
|
|
140
|
+
except SolveTimeoutError as err:
|
|
141
|
+
if err.solve_id is not None:
|
|
142
|
+
nc.solves.cancel(err.solve_id)
|
|
143
|
+
else:
|
|
144
|
+
raise
|
|
145
|
+
```
|
|
146
|
+
|
|
131
147
|
## Handling failures
|
|
132
148
|
|
|
133
149
|
Every error this library raises extends `NoneCapError`, so you can catch the whole family or pick out the one you care about.
|
|
@@ -181,6 +197,42 @@ nc.solve(
|
|
|
181
197
|
)
|
|
182
198
|
```
|
|
183
199
|
|
|
200
|
+
## Reporting token acceptance
|
|
201
|
+
|
|
202
|
+
A token that hCaptcha blessed can still be refused by the site you send it to. Tell us what happened and we tune minting against your real acceptance rate — it's free, and it's the fastest way to get a regression on your sitekey noticed.
|
|
203
|
+
|
|
204
|
+
Keep the `solve_id` next to the token you submit downstream, then report the verdict:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
solve = nc.solve(type="hcaptcha", sitekey=sitekey, url=url)
|
|
208
|
+
ok = submit_to_the_site_you_are_automating(solve.token)
|
|
209
|
+
|
|
210
|
+
nc.feedback.report(
|
|
211
|
+
solve.id,
|
|
212
|
+
outcome="accepted" if ok else "rejected",
|
|
213
|
+
reason=None if ok else "session invalidated", # optional, the code your target returned
|
|
214
|
+
context=None if ok else "3rd retry, rotating residential pool", # optional, anything you think would help us diagnose it
|
|
215
|
+
)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
At volume, buffer the verdicts and flush them in one call. Reports over 500 are split across requests for you:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
batch = nc.feedback.report_many([
|
|
222
|
+
{"solve_id": "solve_01J...", "outcome": "accepted"},
|
|
223
|
+
{"solve_id": "solve_01J...", "outcome": "rejected", "reason": "...", "context": "..."},
|
|
224
|
+
])
|
|
225
|
+
|
|
226
|
+
# Items resolve independently, so the call succeeds even when some are
|
|
227
|
+
# rejected — check `failed` rather than relying on a raised error.
|
|
228
|
+
if batch.failed:
|
|
229
|
+
print([r for r in batch.results if r.status == "error"])
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`outcome` is one of `accepted`, `rejected`, `unknown` (submitted, verdict unclear), `unused` (never submitted), or `error` (downstream broke for a non-token reason). Only `accepted` and `rejected` count toward the acceptance rate.
|
|
233
|
+
|
|
234
|
+
Reporting the same solve again corrects the earlier verdict, so retries and late fixes are safe. You can report any of your own solved solves within ~30 days of the solve; corrections to something you already reported are never cut off by that window. On `AsyncNoneCap` both methods are coroutines.
|
|
235
|
+
|
|
184
236
|
## Lower-level API
|
|
185
237
|
|
|
186
238
|
`solve()` is the convenient path. When you want control over submission and polling, the resource methods map one to one to the REST API:
|
|
@@ -21,7 +21,7 @@ Python 3.9+. The only dependency is [httpx](https://www.python-httpx.org/).
|
|
|
21
21
|
|
|
22
22
|
## Quick start
|
|
23
23
|
|
|
24
|
-
Grab an API key from [dashboard.nonecap.com](https://dashboard.nonecap.com), then:
|
|
24
|
+
Grab an API key from [dashboard.nonecap.com](https://dashboard.nonecap.com/signup?utm_source=pypi&utm_medium=sdk-readme), then:
|
|
25
25
|
|
|
26
26
|
```python
|
|
27
27
|
from nonecap import NoneCap
|
|
@@ -97,6 +97,22 @@ async with AsyncNoneCap(api_key="nc_live_...") as nc:
|
|
|
97
97
|
|
|
98
98
|
Cancelled solves are never charged, and a solve you simply abandon expires uncharged at the server deadline — nothing is billed unless a solve actually succeeds. So `cancel()` is for cleanup and early-stop, not cost protection.
|
|
99
99
|
|
|
100
|
+
### Cleaning up after a `solve()` timeout
|
|
101
|
+
|
|
102
|
+
`solve()` blocks until the solve settles, so it gives you no handle to cancel a solve while it's still running — for that, use `solves.start()` above. The one thing the `solve()` path offers is cleanup *after* a timeout: when `solve()` raises `SolveTimeoutError`, the error usually carries the in-flight `solve_id` (and last-known `solve`), so you can cancel the solve the wait gave up on. Guard on `solve_id` being present — if the very first submission times out at the transport level, no id has been assigned yet, so `solve_id` is `None`.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from nonecap import SolveTimeoutError
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
nc.solve(type="hcaptcha", sitekey=sitekey, url=url)
|
|
109
|
+
except SolveTimeoutError as err:
|
|
110
|
+
if err.solve_id is not None:
|
|
111
|
+
nc.solves.cancel(err.solve_id)
|
|
112
|
+
else:
|
|
113
|
+
raise
|
|
114
|
+
```
|
|
115
|
+
|
|
100
116
|
## Handling failures
|
|
101
117
|
|
|
102
118
|
Every error this library raises extends `NoneCapError`, so you can catch the whole family or pick out the one you care about.
|
|
@@ -150,6 +166,42 @@ nc.solve(
|
|
|
150
166
|
)
|
|
151
167
|
```
|
|
152
168
|
|
|
169
|
+
## Reporting token acceptance
|
|
170
|
+
|
|
171
|
+
A token that hCaptcha blessed can still be refused by the site you send it to. Tell us what happened and we tune minting against your real acceptance rate — it's free, and it's the fastest way to get a regression on your sitekey noticed.
|
|
172
|
+
|
|
173
|
+
Keep the `solve_id` next to the token you submit downstream, then report the verdict:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
solve = nc.solve(type="hcaptcha", sitekey=sitekey, url=url)
|
|
177
|
+
ok = submit_to_the_site_you_are_automating(solve.token)
|
|
178
|
+
|
|
179
|
+
nc.feedback.report(
|
|
180
|
+
solve.id,
|
|
181
|
+
outcome="accepted" if ok else "rejected",
|
|
182
|
+
reason=None if ok else "session invalidated", # optional, the code your target returned
|
|
183
|
+
context=None if ok else "3rd retry, rotating residential pool", # optional, anything you think would help us diagnose it
|
|
184
|
+
)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
At volume, buffer the verdicts and flush them in one call. Reports over 500 are split across requests for you:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
batch = nc.feedback.report_many([
|
|
191
|
+
{"solve_id": "solve_01J...", "outcome": "accepted"},
|
|
192
|
+
{"solve_id": "solve_01J...", "outcome": "rejected", "reason": "...", "context": "..."},
|
|
193
|
+
])
|
|
194
|
+
|
|
195
|
+
# Items resolve independently, so the call succeeds even when some are
|
|
196
|
+
# rejected — check `failed` rather than relying on a raised error.
|
|
197
|
+
if batch.failed:
|
|
198
|
+
print([r for r in batch.results if r.status == "error"])
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`outcome` is one of `accepted`, `rejected`, `unknown` (submitted, verdict unclear), `unused` (never submitted), or `error` (downstream broke for a non-token reason). Only `accepted` and `rejected` count toward the acceptance rate.
|
|
202
|
+
|
|
203
|
+
Reporting the same solve again corrects the earlier verdict, so retries and late fixes are safe. You can report any of your own solved solves within ~30 days of the solve; corrections to something you already reported are never cut off by that window. On `AsyncNoneCap` both methods are coroutines.
|
|
204
|
+
|
|
153
205
|
## Lower-level API
|
|
154
206
|
|
|
155
207
|
`solve()` is the convenient path. When you want control over submission and polling, the resource methods map one to one to the REST API:
|
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
'P1_...'
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
from ._client import
|
|
10
|
+
from ._client import (
|
|
11
|
+
FEEDBACK_BATCH_MAX,
|
|
12
|
+
AsyncNoneCap,
|
|
13
|
+
AsyncSolveHandle,
|
|
14
|
+
NoneCap,
|
|
15
|
+
SolveHandle,
|
|
16
|
+
)
|
|
11
17
|
from ._errors import (
|
|
12
18
|
APIConnectionError,
|
|
13
19
|
APIError,
|
|
@@ -26,6 +32,13 @@ from ._errors import (
|
|
|
26
32
|
from ._types import (
|
|
27
33
|
TERMINAL_STATUSES,
|
|
28
34
|
Account,
|
|
35
|
+
Feedback,
|
|
36
|
+
FeedbackBatch,
|
|
37
|
+
FeedbackItemError,
|
|
38
|
+
FeedbackOutcome,
|
|
39
|
+
FeedbackReport,
|
|
40
|
+
FeedbackResult,
|
|
41
|
+
FeedbackStatus,
|
|
29
42
|
Proxy,
|
|
30
43
|
Solve,
|
|
31
44
|
SolveError,
|
|
@@ -52,6 +65,14 @@ __all__ = [
|
|
|
52
65
|
"SolveType",
|
|
53
66
|
"SolveStatus",
|
|
54
67
|
"TERMINAL_STATUSES",
|
|
68
|
+
"Feedback",
|
|
69
|
+
"FeedbackReport",
|
|
70
|
+
"FeedbackBatch",
|
|
71
|
+
"FeedbackResult",
|
|
72
|
+
"FeedbackItemError",
|
|
73
|
+
"FeedbackOutcome",
|
|
74
|
+
"FeedbackStatus",
|
|
75
|
+
"FEEDBACK_BATCH_MAX",
|
|
55
76
|
# errors
|
|
56
77
|
"NoneCapError",
|
|
57
78
|
"AuthenticationError",
|
|
@@ -6,6 +6,8 @@ Both clients expose the same surface:
|
|
|
6
6
|
convenient path; long-polls under the hood).
|
|
7
7
|
- ``client.solves.create / retrieve / cancel / list / list_all`` — the raw
|
|
8
8
|
resource methods, mapping one to one to the REST API.
|
|
9
|
+
- ``client.feedback.report / report_many`` — tell us whether the tokens we
|
|
10
|
+
minted were accepted downstream.
|
|
9
11
|
- ``client.me()`` — account info and credit balance.
|
|
10
12
|
|
|
11
13
|
``rqdata`` is required for ``type="hcaptcha_enterprise"`` and optional for
|
|
@@ -17,7 +19,8 @@ from __future__ import annotations
|
|
|
17
19
|
|
|
18
20
|
import asyncio
|
|
19
21
|
import time
|
|
20
|
-
from collections.abc import AsyncIterator, Iterator
|
|
22
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
23
|
+
from datetime import datetime
|
|
21
24
|
from typing import (
|
|
22
25
|
Any,
|
|
23
26
|
Literal,
|
|
@@ -38,7 +41,18 @@ from ._errors import (
|
|
|
38
41
|
ValidationError,
|
|
39
42
|
error_from_response,
|
|
40
43
|
)
|
|
41
|
-
from ._types import
|
|
44
|
+
from ._types import (
|
|
45
|
+
Account,
|
|
46
|
+
Feedback,
|
|
47
|
+
FeedbackBatch,
|
|
48
|
+
FeedbackOutcome,
|
|
49
|
+
FeedbackReport,
|
|
50
|
+
Proxy,
|
|
51
|
+
Solve,
|
|
52
|
+
SolvePage,
|
|
53
|
+
SolveStatus,
|
|
54
|
+
SolveType,
|
|
55
|
+
)
|
|
42
56
|
from ._version import __version__
|
|
43
57
|
|
|
44
58
|
DEFAULT_BASE_URL = "https://api.nonecap.com"
|
|
@@ -48,6 +62,8 @@ DEFAULT_SOLVE_TIMEOUT = 180.0
|
|
|
48
62
|
"""Default overall budget (seconds) for the ``solve()`` helper."""
|
|
49
63
|
_MAX_WAIT_SECONDS = 90
|
|
50
64
|
"""The API caps server-side long-poll at 90 seconds."""
|
|
65
|
+
FEEDBACK_BATCH_MAX = 500
|
|
66
|
+
"""The API caps one ``POST /v1/feedback`` call at 500 items."""
|
|
51
67
|
|
|
52
68
|
_USER_AGENT = f"nonecap-python/{__version__}"
|
|
53
69
|
|
|
@@ -115,6 +131,55 @@ def _list_params(
|
|
|
115
131
|
return params
|
|
116
132
|
|
|
117
133
|
|
|
134
|
+
def _feedback_fields(
|
|
135
|
+
outcome: FeedbackOutcome,
|
|
136
|
+
reason: Optional[str],
|
|
137
|
+
context: Optional[str],
|
|
138
|
+
reported_at: Union[str, datetime, None],
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
"""The body of one report. A ``datetime`` is sent as an ISO string; pass a
|
|
141
|
+
timezone-aware one, since the server reads a naive timestamp as its own."""
|
|
142
|
+
body: dict[str, Any] = {"outcome": outcome}
|
|
143
|
+
if reason is not None:
|
|
144
|
+
body["reason"] = reason
|
|
145
|
+
if context is not None:
|
|
146
|
+
body["context"] = context
|
|
147
|
+
if reported_at is not None:
|
|
148
|
+
body["reported_at"] = (
|
|
149
|
+
reported_at.isoformat() if isinstance(reported_at, datetime) else reported_at
|
|
150
|
+
)
|
|
151
|
+
return body
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _feedback_chunks(reports: Sequence[FeedbackReport]) -> Iterator[list[dict[str, Any]]]:
|
|
155
|
+
"""Split reports into request-sized batches, preserving order."""
|
|
156
|
+
for start in range(0, len(reports), FEEDBACK_BATCH_MAX):
|
|
157
|
+
yield [
|
|
158
|
+
{
|
|
159
|
+
"solve_id": report["solve_id"],
|
|
160
|
+
**_feedback_fields(
|
|
161
|
+
report["outcome"],
|
|
162
|
+
report.get("reason"),
|
|
163
|
+
report.get("context"),
|
|
164
|
+
report.get("reported_at"),
|
|
165
|
+
),
|
|
166
|
+
}
|
|
167
|
+
for report in reports[start : start + FEEDBACK_BATCH_MAX]
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _merged_batch(parts: list[FeedbackBatch]) -> FeedbackBatch:
|
|
172
|
+
"""Fold the replies to a chunked ``report_many`` into one result."""
|
|
173
|
+
return FeedbackBatch(
|
|
174
|
+
object="feedback_batch",
|
|
175
|
+
recorded=sum(part.recorded for part in parts),
|
|
176
|
+
updated=sum(part.updated for part in parts),
|
|
177
|
+
unchanged=sum(part.unchanged for part in parts),
|
|
178
|
+
failed=sum(part.failed for part in parts),
|
|
179
|
+
results=[result for part in parts for result in part.results],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
118
183
|
def _process_response(response: httpx.Response) -> Any:
|
|
119
184
|
"""Parse a response, mapping non-2xx envelopes to typed errors.
|
|
120
185
|
|
|
@@ -427,6 +492,77 @@ class SolveHandle:
|
|
|
427
492
|
return solve
|
|
428
493
|
|
|
429
494
|
|
|
495
|
+
class Feedbacks:
|
|
496
|
+
"""Reporting what your downstream target did with the tokens we minted
|
|
497
|
+
(sync). Reached as ``client.feedback``.
|
|
498
|
+
|
|
499
|
+
Feedback is free, own-solves-only, and idempotent per solve id: reporting
|
|
500
|
+
the same solve again corrects the earlier verdict. Only solves that
|
|
501
|
+
produced a token can be reported, and only within the API's reporting
|
|
502
|
+
window (~30 days from the solve); a correction to something you already
|
|
503
|
+
reported is never cut off by that window.
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
def __init__(self, client: NoneCap) -> None:
|
|
507
|
+
self._client = client
|
|
508
|
+
|
|
509
|
+
def report(
|
|
510
|
+
self,
|
|
511
|
+
solve_id: str,
|
|
512
|
+
*,
|
|
513
|
+
outcome: FeedbackOutcome,
|
|
514
|
+
reason: Optional[str] = None,
|
|
515
|
+
context: Optional[str] = None,
|
|
516
|
+
reported_at: Union[str, datetime, None] = None,
|
|
517
|
+
) -> Feedback:
|
|
518
|
+
"""Report one solve's downstream outcome and return what was recorded.
|
|
519
|
+
|
|
520
|
+
Unlike :meth:`report_many`, a rejected report raises:
|
|
521
|
+
:class:`NotFoundError` if the solve is not yours or never minted a
|
|
522
|
+
token, :class:`ValidationError` if a field is wrong or the reporting
|
|
523
|
+
window has closed.
|
|
524
|
+
|
|
525
|
+
>>> nc.feedback.report(solve.id, outcome="rejected", reason="...")
|
|
526
|
+
"""
|
|
527
|
+
payload = self._client._request(
|
|
528
|
+
"POST",
|
|
529
|
+
f"/v1/solves/{solve_id}/feedback",
|
|
530
|
+
json=_feedback_fields(outcome, reason, context, reported_at),
|
|
531
|
+
)
|
|
532
|
+
return Feedback._from_dict(payload)
|
|
533
|
+
|
|
534
|
+
def report_many(self, reports: Sequence[FeedbackReport]) -> FeedbackBatch:
|
|
535
|
+
"""Report many verdicts at once — the path to use at volume. Buffer
|
|
536
|
+
verdicts as you learn them and flush them here.
|
|
537
|
+
|
|
538
|
+
The call succeeds even when individual items are rejected: each is
|
|
539
|
+
resolved on its own so one stale id never discards the rest. Check
|
|
540
|
+
``batch.failed`` rather than relying on a raised error.
|
|
541
|
+
|
|
542
|
+
More than ``FEEDBACK_BATCH_MAX`` reports are split across sequential
|
|
543
|
+
requests and merged into one result, in request order. An empty
|
|
544
|
+
sequence is a no-op that returns an empty batch without a request.
|
|
545
|
+
|
|
546
|
+
>>> batch = nc.feedback.report_many(
|
|
547
|
+
... [{"solve_id": "solve_01J...", "outcome": "accepted"}]
|
|
548
|
+
... )
|
|
549
|
+
>>> [r for r in batch.results if r.status == "error"]
|
|
550
|
+
[]
|
|
551
|
+
"""
|
|
552
|
+
# Chunks go in order and the server upserts last-write-wins, so a solve
|
|
553
|
+
# reported twice still ends at its last value. Duplicates split across a
|
|
554
|
+
# chunk boundary each get written, though, so the earlier one reports
|
|
555
|
+
# "recorded"/"updated" where a single request would say "unchanged".
|
|
556
|
+
return _merged_batch(
|
|
557
|
+
[
|
|
558
|
+
FeedbackBatch._from_dict(
|
|
559
|
+
self._client._request("POST", "/v1/feedback", json={"feedback": chunk})
|
|
560
|
+
)
|
|
561
|
+
for chunk in _feedback_chunks(reports)
|
|
562
|
+
]
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
430
566
|
class NoneCap(_BaseClient):
|
|
431
567
|
"""The NoneCap API client (sync).
|
|
432
568
|
|
|
@@ -449,6 +585,7 @@ class NoneCap(_BaseClient):
|
|
|
449
585
|
self._http = http_client or httpx.Client()
|
|
450
586
|
self._owns_http = http_client is None
|
|
451
587
|
self.solves = Solves(self)
|
|
588
|
+
self.feedback = Feedbacks(self)
|
|
452
589
|
|
|
453
590
|
def _request(
|
|
454
591
|
self,
|
|
@@ -856,6 +993,65 @@ class AsyncSolveHandle:
|
|
|
856
993
|
return solve
|
|
857
994
|
|
|
858
995
|
|
|
996
|
+
class AsyncFeedbacks:
|
|
997
|
+
"""Reporting what your downstream target did with the tokens we minted
|
|
998
|
+
(async). Reached as ``client.feedback``.
|
|
999
|
+
|
|
1000
|
+
Feedback is free, own-solves-only, and idempotent per solve id: reporting
|
|
1001
|
+
the same solve again corrects the earlier verdict. Only solves that
|
|
1002
|
+
produced a token can be reported, and only within the API's reporting
|
|
1003
|
+
window (~30 days from the solve); a correction to something you already
|
|
1004
|
+
reported is never cut off by that window.
|
|
1005
|
+
"""
|
|
1006
|
+
|
|
1007
|
+
def __init__(self, client: AsyncNoneCap) -> None:
|
|
1008
|
+
self._client = client
|
|
1009
|
+
|
|
1010
|
+
async def report(
|
|
1011
|
+
self,
|
|
1012
|
+
solve_id: str,
|
|
1013
|
+
*,
|
|
1014
|
+
outcome: FeedbackOutcome,
|
|
1015
|
+
reason: Optional[str] = None,
|
|
1016
|
+
context: Optional[str] = None,
|
|
1017
|
+
reported_at: Union[str, datetime, None] = None,
|
|
1018
|
+
) -> Feedback:
|
|
1019
|
+
"""Report one solve's downstream outcome and return what was recorded.
|
|
1020
|
+
|
|
1021
|
+
Unlike :meth:`report_many`, a rejected report raises:
|
|
1022
|
+
:class:`NotFoundError` if the solve is not yours or never minted a
|
|
1023
|
+
token, :class:`ValidationError` if a field is wrong or the reporting
|
|
1024
|
+
window has closed.
|
|
1025
|
+
"""
|
|
1026
|
+
payload = await self._client._request(
|
|
1027
|
+
"POST",
|
|
1028
|
+
f"/v1/solves/{solve_id}/feedback",
|
|
1029
|
+
json=_feedback_fields(outcome, reason, context, reported_at),
|
|
1030
|
+
)
|
|
1031
|
+
return Feedback._from_dict(payload)
|
|
1032
|
+
|
|
1033
|
+
async def report_many(self, reports: Sequence[FeedbackReport]) -> FeedbackBatch:
|
|
1034
|
+
"""Report many verdicts at once — the path to use at volume. Buffer
|
|
1035
|
+
verdicts as you learn them and flush them here.
|
|
1036
|
+
|
|
1037
|
+
The call succeeds even when individual items are rejected: each is
|
|
1038
|
+
resolved on its own so one stale id never discards the rest. Check
|
|
1039
|
+
``batch.failed`` rather than relying on a raised error.
|
|
1040
|
+
|
|
1041
|
+
More than ``FEEDBACK_BATCH_MAX`` reports are split across sequential
|
|
1042
|
+
requests and merged into one result, in request order. An empty
|
|
1043
|
+
sequence is a no-op that returns an empty batch without a request.
|
|
1044
|
+
"""
|
|
1045
|
+
# Same last-write-wins caveat across a chunk boundary as the sync client.
|
|
1046
|
+
parts = []
|
|
1047
|
+
for chunk in _feedback_chunks(reports):
|
|
1048
|
+
payload = await self._client._request(
|
|
1049
|
+
"POST", "/v1/feedback", json={"feedback": chunk}
|
|
1050
|
+
)
|
|
1051
|
+
parts.append(FeedbackBatch._from_dict(payload))
|
|
1052
|
+
return _merged_batch(parts)
|
|
1053
|
+
|
|
1054
|
+
|
|
859
1055
|
class AsyncNoneCap(_BaseClient):
|
|
860
1056
|
"""The NoneCap API client (async).
|
|
861
1057
|
|
|
@@ -876,6 +1072,7 @@ class AsyncNoneCap(_BaseClient):
|
|
|
876
1072
|
self._http = http_client or httpx.AsyncClient()
|
|
877
1073
|
self._owns_http = http_client is None
|
|
878
1074
|
self.solves = AsyncSolves(self)
|
|
1075
|
+
self.feedback = AsyncFeedbacks(self)
|
|
879
1076
|
|
|
880
1077
|
async def _request(
|
|
881
1078
|
self,
|
|
@@ -116,15 +116,20 @@ def error_from_response(
|
|
|
116
116
|
cls = AuthenticationError
|
|
117
117
|
elif code in ("forbidden", "account_locked"):
|
|
118
118
|
cls = PermissionDeniedError
|
|
119
|
-
elif code
|
|
119
|
+
elif code in ("insufficient_credits", "key_credit_limit_exceeded"):
|
|
120
120
|
cls = InsufficientCreditsError
|
|
121
|
-
elif code in ("invalid_request", "validation_error"):
|
|
121
|
+
elif code in ("invalid_request", "validation_error", "expired_window"):
|
|
122
122
|
cls = ValidationError
|
|
123
|
-
elif code
|
|
123
|
+
elif code in ("not_found", "not_eligible"):
|
|
124
124
|
cls = NotFoundError
|
|
125
125
|
elif code == "conflict":
|
|
126
126
|
cls = ConflictError
|
|
127
|
-
elif code in (
|
|
127
|
+
elif code in (
|
|
128
|
+
"rate_limited",
|
|
129
|
+
"concurrency_limit_exceeded",
|
|
130
|
+
"ext_daily_limit",
|
|
131
|
+
"sitekey_rate_limited",
|
|
132
|
+
):
|
|
128
133
|
cls = RateLimitError
|
|
129
134
|
else:
|
|
130
135
|
cls = APIError
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Wire types for the NoneCap API.
|
|
2
|
+
|
|
3
|
+
Field names are snake_case and mirror the JSON on the wire exactly, so what
|
|
4
|
+
you read in the docs is what you access in code. Parsers pick known keys and
|
|
5
|
+
ignore unknown ones, so new server-side fields never break old clients.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any, Literal, Optional, TypedDict, Union
|
|
13
|
+
|
|
14
|
+
SolveType = Literal["hcaptcha", "hcaptcha_enterprise"]
|
|
15
|
+
"""Captcha type a solve targets."""
|
|
16
|
+
|
|
17
|
+
SolveStatus = Literal["pending", "solving", "solved", "failed", "cancelled", "expired"]
|
|
18
|
+
"""Lifecycle of a solve. ``solved``/``failed``/``cancelled``/``expired`` are terminal."""
|
|
19
|
+
|
|
20
|
+
TERMINAL_STATUSES: frozenset[str] = frozenset({"solved", "failed", "cancelled", "expired"})
|
|
21
|
+
"""The statuses a solve can never leave."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Proxy(TypedDict, total=False):
|
|
25
|
+
"""A proxy the solve should egress through."""
|
|
26
|
+
|
|
27
|
+
scheme: str
|
|
28
|
+
host: str
|
|
29
|
+
port: str | int
|
|
30
|
+
username: str
|
|
31
|
+
password: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class SolveError:
|
|
36
|
+
"""The error attached to a solve that did not succeed."""
|
|
37
|
+
|
|
38
|
+
code: str
|
|
39
|
+
message: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Solve:
|
|
44
|
+
"""A solve resource, exactly as the API returns it."""
|
|
45
|
+
|
|
46
|
+
id: str
|
|
47
|
+
object: str
|
|
48
|
+
type: SolveType
|
|
49
|
+
status: SolveStatus
|
|
50
|
+
sitekey: str
|
|
51
|
+
url: str
|
|
52
|
+
token: Optional[str]
|
|
53
|
+
"""The captcha token once ``status == "solved"``, otherwise None."""
|
|
54
|
+
error: Optional[SolveError]
|
|
55
|
+
"""Set when the solve did not succeed, otherwise None."""
|
|
56
|
+
credits_charged: Optional[int]
|
|
57
|
+
"""Credits charged for this solve. Only successful solves are charged."""
|
|
58
|
+
proxy_bytes: Optional[int]
|
|
59
|
+
"""Bytes that egressed through the metered proxy, or None if none was used."""
|
|
60
|
+
created_at: str
|
|
61
|
+
started_at: Optional[str]
|
|
62
|
+
finished_at: Optional[str]
|
|
63
|
+
queue_ms: Optional[int]
|
|
64
|
+
"""Milliseconds the solve waited in the queue before a worker picked it up."""
|
|
65
|
+
resolve_ms: Optional[int]
|
|
66
|
+
"""Milliseconds of actual solving."""
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_terminal(self) -> bool:
|
|
70
|
+
"""Whether the solve has reached a final state."""
|
|
71
|
+
return self.status in TERMINAL_STATUSES
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def _from_dict(cls, data: dict[str, Any]) -> Solve:
|
|
75
|
+
raw_error = data.get("error")
|
|
76
|
+
return cls(
|
|
77
|
+
id=data["id"],
|
|
78
|
+
object=data.get("object", "solve"),
|
|
79
|
+
type=data["type"],
|
|
80
|
+
status=data["status"],
|
|
81
|
+
sitekey=data.get("sitekey", ""),
|
|
82
|
+
url=data.get("url", ""),
|
|
83
|
+
token=data.get("token"),
|
|
84
|
+
error=SolveError(code=raw_error["code"], message=raw_error["message"])
|
|
85
|
+
if raw_error
|
|
86
|
+
else None,
|
|
87
|
+
credits_charged=data.get("credits_charged"),
|
|
88
|
+
proxy_bytes=data.get("proxy_bytes"),
|
|
89
|
+
created_at=data.get("created_at", ""),
|
|
90
|
+
started_at=data.get("started_at"),
|
|
91
|
+
finished_at=data.get("finished_at"),
|
|
92
|
+
queue_ms=data.get("queue_ms"),
|
|
93
|
+
resolve_ms=data.get("resolve_ms"),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class SolvePage:
|
|
99
|
+
"""One page of solves, newest first."""
|
|
100
|
+
|
|
101
|
+
object: str
|
|
102
|
+
data: list[Solve]
|
|
103
|
+
has_more: bool
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def _from_dict(cls, payload: dict[str, Any]) -> SolvePage:
|
|
107
|
+
return cls(
|
|
108
|
+
object=payload.get("object", "list"),
|
|
109
|
+
data=[Solve._from_dict(item) for item in payload.get("data", [])],
|
|
110
|
+
has_more=bool(payload.get("has_more", False)),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
FeedbackOutcome = Literal["accepted", "rejected", "unknown", "unused", "error"]
|
|
115
|
+
"""What the downstream target did with a solve's token.
|
|
116
|
+
|
|
117
|
+
``accepted`` and ``rejected`` are the quality signal and the only two that
|
|
118
|
+
count toward the acceptance rate. ``unknown`` (submitted, verdict
|
|
119
|
+
undetermined), ``unused`` (never submitted — expired, aborted, deduped) and
|
|
120
|
+
``error`` (downstream broke for a non-token reason) are recorded but kept out
|
|
121
|
+
of the denominator, so a client-side outage can't look like a regression.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
FeedbackStatus = Literal["recorded", "updated", "unchanged", "error"]
|
|
125
|
+
"""What happened to one reported item: first report, overwrote an earlier one,
|
|
126
|
+
identical to what was stored (no write), or rejected."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _FeedbackReportRequired(TypedDict):
|
|
130
|
+
solve_id: str
|
|
131
|
+
outcome: FeedbackOutcome
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class FeedbackReport(_FeedbackReportRequired, total=False):
|
|
135
|
+
"""One verdict to report, as passed to ``client.feedback.report_many``.
|
|
136
|
+
|
|
137
|
+
``solve_id`` is the id from ``solves.create`` / ``solve()``, and must be
|
|
138
|
+
your own solve that produced a token. ``reason`` is the code or message your
|
|
139
|
+
target gave you when it refused the token (e.g. ``"invalid-response"``),
|
|
140
|
+
truncated to 512 chars server-side. ``context`` is optional free text with no
|
|
141
|
+
schema — anything about the attempt you think would help us diagnose it,
|
|
142
|
+
truncated to 2000 chars. Neither is parsed; they are what a human reads when
|
|
143
|
+
you raise a ticket, and they carry the half of a rejection NoneCap cannot
|
|
144
|
+
observe on its own. ``reported_at`` is advisory only — the server stamps its
|
|
145
|
+
own timestamps.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
reason: Optional[str]
|
|
149
|
+
context: Optional[str]
|
|
150
|
+
reported_at: Union[str, datetime, None]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class Feedback:
|
|
155
|
+
"""A recorded feedback resource."""
|
|
156
|
+
|
|
157
|
+
object: str
|
|
158
|
+
solve_id: str
|
|
159
|
+
outcome: FeedbackOutcome
|
|
160
|
+
reason: Optional[str]
|
|
161
|
+
context: Optional[str]
|
|
162
|
+
reported_at: Optional[str]
|
|
163
|
+
report_count: int
|
|
164
|
+
"""How many times this solve's verdict has been written. 1 on first report."""
|
|
165
|
+
created_at: str
|
|
166
|
+
updated_at: str
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def _from_dict(cls, data: dict[str, Any]) -> Feedback:
|
|
170
|
+
return cls(
|
|
171
|
+
object=data.get("object", "feedback"),
|
|
172
|
+
solve_id=data["solve_id"],
|
|
173
|
+
outcome=data["outcome"],
|
|
174
|
+
reason=data.get("reason"),
|
|
175
|
+
context=data.get("context"),
|
|
176
|
+
reported_at=data.get("reported_at"),
|
|
177
|
+
report_count=int(data.get("report_count", 1)),
|
|
178
|
+
created_at=data.get("created_at", ""),
|
|
179
|
+
updated_at=data.get("updated_at", ""),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class FeedbackItemError:
|
|
185
|
+
"""Why one item of a batch was rejected."""
|
|
186
|
+
|
|
187
|
+
code: str
|
|
188
|
+
message: str
|
|
189
|
+
param: Optional[str]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class FeedbackResult:
|
|
194
|
+
"""One item's result, in the same position as the report you sent."""
|
|
195
|
+
|
|
196
|
+
solve_id: str
|
|
197
|
+
status: FeedbackStatus
|
|
198
|
+
error: Optional[FeedbackItemError]
|
|
199
|
+
"""Set only when ``status == "error"``."""
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def _from_dict(cls, data: dict[str, Any]) -> FeedbackResult:
|
|
203
|
+
raw_error = data.get("error")
|
|
204
|
+
return cls(
|
|
205
|
+
solve_id=data.get("solve_id", ""),
|
|
206
|
+
status=data["status"],
|
|
207
|
+
error=FeedbackItemError(
|
|
208
|
+
code=raw_error.get("code", ""),
|
|
209
|
+
message=raw_error.get("message", ""),
|
|
210
|
+
param=raw_error.get("param"),
|
|
211
|
+
)
|
|
212
|
+
if raw_error
|
|
213
|
+
else None,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class FeedbackBatch:
|
|
219
|
+
"""The result of ``client.feedback.report_many``.
|
|
220
|
+
|
|
221
|
+
Items are resolved independently, so one bad solve id never discards the
|
|
222
|
+
rest — which also means a broken integration reports zero failures at the
|
|
223
|
+
HTTP level. Check ``failed`` (or scan ``results``), not just the absence of
|
|
224
|
+
a raised error.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
object: str
|
|
228
|
+
recorded: int
|
|
229
|
+
updated: int
|
|
230
|
+
unchanged: int
|
|
231
|
+
failed: int
|
|
232
|
+
results: list[FeedbackResult]
|
|
233
|
+
"""One entry per report you sent, in request order."""
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def _from_dict(cls, payload: dict[str, Any]) -> FeedbackBatch:
|
|
237
|
+
return cls(
|
|
238
|
+
object=payload.get("object", "feedback_batch"),
|
|
239
|
+
recorded=int(payload.get("recorded", 0)),
|
|
240
|
+
updated=int(payload.get("updated", 0)),
|
|
241
|
+
unchanged=int(payload.get("unchanged", 0)),
|
|
242
|
+
failed=int(payload.get("failed", 0)),
|
|
243
|
+
results=[FeedbackResult._from_dict(item) for item in payload.get("results", [])],
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(frozen=True)
|
|
248
|
+
class Account:
|
|
249
|
+
"""Your account, including the current credit balance."""
|
|
250
|
+
|
|
251
|
+
object: str
|
|
252
|
+
id: str
|
|
253
|
+
email: str
|
|
254
|
+
credits_balance: int
|
|
255
|
+
created_at: str
|
|
256
|
+
|
|
257
|
+
@classmethod
|
|
258
|
+
def _from_dict(cls, data: dict[str, Any]) -> Account:
|
|
259
|
+
return cls(
|
|
260
|
+
object=data.get("object", "account"),
|
|
261
|
+
id=data["id"],
|
|
262
|
+
email=data.get("email", ""),
|
|
263
|
+
credits_balance=int(data.get("credits_balance", 0)),
|
|
264
|
+
created_at=data.get("created_at", ""),
|
|
265
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from nonecap import (
|
|
10
|
+
FEEDBACK_BATCH_MAX,
|
|
11
|
+
AsyncNoneCap,
|
|
12
|
+
FeedbackReport,
|
|
13
|
+
NoneCap,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from .conftest import Script, error_payload
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def client_for(script: Script) -> NoneCap:
|
|
22
|
+
return NoneCap(api_key="nc_test", http_client=httpx.Client(transport=script.transport))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def async_client_for(script: Script) -> AsyncNoneCap:
|
|
26
|
+
return AsyncNoneCap(
|
|
27
|
+
api_key="nc_test", http_client=httpx.AsyncClient(transport=script.transport)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def feedback_payload(**overrides: Any) -> dict[str, Any]:
|
|
32
|
+
base: dict[str, Any] = {
|
|
33
|
+
"object": "feedback",
|
|
34
|
+
"solve_id": "solve_1",
|
|
35
|
+
"outcome": "accepted",
|
|
36
|
+
"reason": None,
|
|
37
|
+
"context": None,
|
|
38
|
+
"reported_at": None,
|
|
39
|
+
"report_count": 1,
|
|
40
|
+
"created_at": "2026-08-01T00:00:00Z",
|
|
41
|
+
"updated_at": "2026-08-01T00:00:00Z",
|
|
42
|
+
}
|
|
43
|
+
base.update(overrides)
|
|
44
|
+
return base
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def batch_payload(request: httpx.Request) -> httpx.Response:
|
|
48
|
+
"""Reply to a batch request with every item recorded."""
|
|
49
|
+
import json
|
|
50
|
+
|
|
51
|
+
items = json.loads(request.content)["feedback"]
|
|
52
|
+
return httpx.Response(
|
|
53
|
+
200,
|
|
54
|
+
json={
|
|
55
|
+
"object": "feedback_batch",
|
|
56
|
+
"recorded": len(items),
|
|
57
|
+
"updated": 0,
|
|
58
|
+
"unchanged": 0,
|
|
59
|
+
"failed": 0,
|
|
60
|
+
"results": [
|
|
61
|
+
{"solve_id": item["solve_id"], "status": "recorded", "error": None}
|
|
62
|
+
for item in items
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestReport:
|
|
69
|
+
def test_posts_to_the_solves_feedback_path(self) -> None:
|
|
70
|
+
script = Script((200, feedback_payload(outcome="rejected", context="3rd retry")))
|
|
71
|
+
nc = client_for(script)
|
|
72
|
+
result = nc.feedback.report(
|
|
73
|
+
"solve_1", outcome="rejected", reason="invalid-response", context="3rd retry"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
assert script.requests[0].method == "POST"
|
|
77
|
+
assert script.requests[0].url.path == "/v1/solves/solve_1/feedback"
|
|
78
|
+
assert script.body_of(0) == {
|
|
79
|
+
"outcome": "rejected",
|
|
80
|
+
"reason": "invalid-response",
|
|
81
|
+
"context": "3rd retry",
|
|
82
|
+
}
|
|
83
|
+
assert result.outcome == "rejected"
|
|
84
|
+
assert result.context == "3rd retry"
|
|
85
|
+
|
|
86
|
+
def test_omits_fields_the_caller_left_unset(self) -> None:
|
|
87
|
+
script = Script((200, feedback_payload()))
|
|
88
|
+
client_for(script).feedback.report("solve_1", outcome="accepted")
|
|
89
|
+
assert script.body_of(0) == {"outcome": "accepted"}
|
|
90
|
+
|
|
91
|
+
def test_sends_an_empty_context(self) -> None:
|
|
92
|
+
# `if context:` would drop an empty string, which is a deliberate "I have
|
|
93
|
+
# nothing to add" rather than an omission. Only None means unset.
|
|
94
|
+
script = Script((200, feedback_payload(context="")))
|
|
95
|
+
client_for(script).feedback.report("solve_1", outcome="accepted", context="")
|
|
96
|
+
assert script.body_of(0) == {"outcome": "accepted", "context": ""}
|
|
97
|
+
|
|
98
|
+
def test_serializes_a_datetime_reported_at(self) -> None:
|
|
99
|
+
script = Script((200, feedback_payload()))
|
|
100
|
+
client_for(script).feedback.report(
|
|
101
|
+
"solve_1",
|
|
102
|
+
outcome="accepted",
|
|
103
|
+
reported_at=datetime(2026, 8, 1, 14, 3, tzinfo=timezone.utc),
|
|
104
|
+
)
|
|
105
|
+
assert script.body_of(0)["reported_at"] == "2026-08-01T14:03:00+00:00"
|
|
106
|
+
|
|
107
|
+
def test_raises_not_found_for_an_unreportable_solve(self) -> None:
|
|
108
|
+
script = Script((404, error_payload("not_found", "solve not found or not reportable")))
|
|
109
|
+
with pytest.raises(NotFoundError):
|
|
110
|
+
client_for(script).feedback.report("solve_x", outcome="accepted")
|
|
111
|
+
|
|
112
|
+
def test_raises_validation_error_once_the_window_closed(self) -> None:
|
|
113
|
+
script = Script(
|
|
114
|
+
(422, error_payload("expired_window", "solve is too old", param="solve_id"))
|
|
115
|
+
)
|
|
116
|
+
with pytest.raises(ValidationError) as excinfo:
|
|
117
|
+
client_for(script).feedback.report("solve_old", outcome="accepted")
|
|
118
|
+
assert excinfo.value.code == "expired_window"
|
|
119
|
+
assert excinfo.value.param == "solve_id"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TestReportMany:
|
|
123
|
+
def test_posts_one_batch_under_the_wire_format(self) -> None:
|
|
124
|
+
script = Script(batch_payload)
|
|
125
|
+
batch = client_for(script).feedback.report_many(
|
|
126
|
+
[
|
|
127
|
+
{"solve_id": "solve_1", "outcome": "accepted"},
|
|
128
|
+
{"solve_id": "solve_2", "outcome": "rejected", "context": "3rd retry"},
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
assert len(script.requests) == 1
|
|
133
|
+
assert script.requests[0].url.path == "/v1/feedback"
|
|
134
|
+
assert script.body_of(0) == {
|
|
135
|
+
"feedback": [
|
|
136
|
+
{"solve_id": "solve_1", "outcome": "accepted"},
|
|
137
|
+
{"solve_id": "solve_2", "outcome": "rejected", "context": "3rd retry"},
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
assert batch.recorded == 2
|
|
141
|
+
assert [r.solve_id for r in batch.results] == ["solve_1", "solve_2"]
|
|
142
|
+
|
|
143
|
+
def test_empty_sequence_makes_no_request(self) -> None:
|
|
144
|
+
script = Script((500, {}))
|
|
145
|
+
batch = client_for(script).feedback.report_many([])
|
|
146
|
+
assert script.requests == []
|
|
147
|
+
assert (batch.recorded, batch.updated, batch.unchanged, batch.failed) == (0, 0, 0, 0)
|
|
148
|
+
assert batch.results == []
|
|
149
|
+
|
|
150
|
+
def test_splits_over_the_batch_cap_and_merges_in_order(self) -> None:
|
|
151
|
+
reports: list[FeedbackReport] = [
|
|
152
|
+
{"solve_id": f"solve_{i}", "outcome": "accepted"}
|
|
153
|
+
for i in range(FEEDBACK_BATCH_MAX + 3)
|
|
154
|
+
]
|
|
155
|
+
script = Script(batch_payload)
|
|
156
|
+
batch = client_for(script).feedback.report_many(reports)
|
|
157
|
+
|
|
158
|
+
assert len(script.requests) == 2
|
|
159
|
+
assert len(script.body_of(0)["feedback"]) == FEEDBACK_BATCH_MAX
|
|
160
|
+
assert len(script.body_of(1)["feedback"]) == 3
|
|
161
|
+
assert batch.recorded == FEEDBACK_BATCH_MAX + 3
|
|
162
|
+
assert [r.solve_id for r in batch.results] == [r["solve_id"] for r in reports]
|
|
163
|
+
|
|
164
|
+
def test_does_not_raise_when_individual_items_fail(self) -> None:
|
|
165
|
+
script = Script(
|
|
166
|
+
(
|
|
167
|
+
200,
|
|
168
|
+
{
|
|
169
|
+
"object": "feedback_batch",
|
|
170
|
+
"recorded": 1,
|
|
171
|
+
"updated": 0,
|
|
172
|
+
"unchanged": 0,
|
|
173
|
+
"failed": 1,
|
|
174
|
+
"results": [
|
|
175
|
+
{"solve_id": "solve_1", "status": "recorded", "error": None},
|
|
176
|
+
{
|
|
177
|
+
"solve_id": "solve_x",
|
|
178
|
+
"status": "error",
|
|
179
|
+
"error": {
|
|
180
|
+
"code": "not_eligible",
|
|
181
|
+
"message": "solve not found or not reportable",
|
|
182
|
+
"param": "solve_id",
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
batch = client_for(script).feedback.report_many(
|
|
190
|
+
[
|
|
191
|
+
{"solve_id": "solve_1", "outcome": "accepted"},
|
|
192
|
+
{"solve_id": "solve_x", "outcome": "accepted"},
|
|
193
|
+
]
|
|
194
|
+
)
|
|
195
|
+
assert batch.failed == 1
|
|
196
|
+
assert batch.results[1].error is not None
|
|
197
|
+
assert batch.results[1].error.code == "not_eligible"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TestAsyncFeedback:
|
|
201
|
+
async def test_report(self) -> None:
|
|
202
|
+
script = Script((200, feedback_payload(outcome="unused")))
|
|
203
|
+
async with async_client_for(script) as nc:
|
|
204
|
+
result = await nc.feedback.report("solve_1", outcome="unused")
|
|
205
|
+
assert script.requests[0].url.path == "/v1/solves/solve_1/feedback"
|
|
206
|
+
assert script.body_of(0) == {"outcome": "unused"}
|
|
207
|
+
assert result.outcome == "unused"
|
|
208
|
+
|
|
209
|
+
async def test_report_many_splits_over_the_batch_cap(self) -> None:
|
|
210
|
+
reports: list[FeedbackReport] = [
|
|
211
|
+
{"solve_id": f"solve_{i}", "outcome": "accepted"}
|
|
212
|
+
for i in range(FEEDBACK_BATCH_MAX + 1)
|
|
213
|
+
]
|
|
214
|
+
script = Script(batch_payload)
|
|
215
|
+
async with async_client_for(script) as nc:
|
|
216
|
+
batch = await nc.feedback.report_many(reports)
|
|
217
|
+
|
|
218
|
+
assert len(script.requests) == 2
|
|
219
|
+
assert batch.recorded == FEEDBACK_BATCH_MAX + 1
|
|
220
|
+
assert [r.solve_id for r in batch.results] == [r["solve_id"] for r in reports]
|
|
221
|
+
|
|
222
|
+
async def test_report_many_empty_makes_no_request(self) -> None:
|
|
223
|
+
script = Script((500, {}))
|
|
224
|
+
async with async_client_for(script) as nc:
|
|
225
|
+
batch = await nc.feedback.report_many([])
|
|
226
|
+
assert script.requests == []
|
|
227
|
+
assert batch.results == []
|
|
@@ -15,6 +15,9 @@ from typing import Optional
|
|
|
15
15
|
from nonecap import (
|
|
16
16
|
AsyncNoneCap,
|
|
17
17
|
AsyncSolveHandle,
|
|
18
|
+
Feedback,
|
|
19
|
+
FeedbackBatch,
|
|
20
|
+
FeedbackReport,
|
|
18
21
|
NoneCap,
|
|
19
22
|
Solve,
|
|
20
23
|
SolveHandle,
|
|
@@ -51,6 +54,27 @@ async def positive_cases_async() -> None:
|
|
|
51
54
|
_c: Solve = await handle.cancel()
|
|
52
55
|
|
|
53
56
|
|
|
57
|
+
def feedback_cases() -> None:
|
|
58
|
+
_f: Feedback = nc.feedback.report("solve_1", outcome="accepted")
|
|
59
|
+
nc.feedback.report("solve_1", outcome="rejected", reason="why", context="ctx")
|
|
60
|
+
reports: list[FeedbackReport] = [
|
|
61
|
+
{"solve_id": "solve_1", "outcome": "accepted"},
|
|
62
|
+
{"solve_id": "solve_2", "outcome": "rejected", "reason": "why", "context": "ctx"},
|
|
63
|
+
]
|
|
64
|
+
_b: FeedbackBatch = nc.feedback.report_many(reports)
|
|
65
|
+
# An unknown outcome must not type-check.
|
|
66
|
+
nc.feedback.report("solve_1", outcome="maybe") # type: ignore[arg-type]
|
|
67
|
+
# A report missing `outcome` must not type-check.
|
|
68
|
+
_bad: FeedbackReport = {"solve_id": "solve_1"} # type: ignore[typeddict-item]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def feedback_cases_async() -> None:
|
|
72
|
+
_f: Feedback = await anc.feedback.report("solve_1", outcome="unused")
|
|
73
|
+
_b: FeedbackBatch = await anc.feedback.report_many(
|
|
74
|
+
[{"solve_id": "solve_1", "outcome": "accepted"}]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
54
78
|
def negative_cases() -> None:
|
|
55
79
|
# Enterprise without rqdata must not type-check.
|
|
56
80
|
nc.solve(type="hcaptcha_enterprise", sitekey="s", url="u") # type: ignore[call-overload]
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
"""Wire types for the NoneCap API.
|
|
2
|
-
|
|
3
|
-
Field names are snake_case and mirror the JSON on the wire exactly, so what
|
|
4
|
-
you read in the docs is what you access in code. Parsers pick known keys and
|
|
5
|
-
ignore unknown ones, so new server-side fields never break old clients.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
from dataclasses import dataclass
|
|
11
|
-
from typing import Any, Literal, Optional, TypedDict
|
|
12
|
-
|
|
13
|
-
SolveType = Literal["hcaptcha", "hcaptcha_enterprise"]
|
|
14
|
-
"""Captcha type a solve targets."""
|
|
15
|
-
|
|
16
|
-
SolveStatus = Literal["pending", "solving", "solved", "failed", "cancelled", "expired"]
|
|
17
|
-
"""Lifecycle of a solve. ``solved``/``failed``/``cancelled``/``expired`` are terminal."""
|
|
18
|
-
|
|
19
|
-
TERMINAL_STATUSES: frozenset[str] = frozenset({"solved", "failed", "cancelled", "expired"})
|
|
20
|
-
"""The statuses a solve can never leave."""
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class Proxy(TypedDict, total=False):
|
|
24
|
-
"""A proxy the solve should egress through."""
|
|
25
|
-
|
|
26
|
-
scheme: str
|
|
27
|
-
host: str
|
|
28
|
-
port: str | int
|
|
29
|
-
username: str
|
|
30
|
-
password: str
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@dataclass(frozen=True)
|
|
34
|
-
class SolveError:
|
|
35
|
-
"""The error attached to a solve that did not succeed."""
|
|
36
|
-
|
|
37
|
-
code: str
|
|
38
|
-
message: str
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@dataclass(frozen=True)
|
|
42
|
-
class Solve:
|
|
43
|
-
"""A solve resource, exactly as the API returns it."""
|
|
44
|
-
|
|
45
|
-
id: str
|
|
46
|
-
object: str
|
|
47
|
-
type: SolveType
|
|
48
|
-
status: SolveStatus
|
|
49
|
-
sitekey: str
|
|
50
|
-
url: str
|
|
51
|
-
token: Optional[str]
|
|
52
|
-
"""The captcha token once ``status == "solved"``, otherwise None."""
|
|
53
|
-
error: Optional[SolveError]
|
|
54
|
-
"""Set when the solve did not succeed, otherwise None."""
|
|
55
|
-
credits_charged: Optional[int]
|
|
56
|
-
"""Credits charged for this solve. Only successful solves are charged."""
|
|
57
|
-
proxy_bytes: Optional[int]
|
|
58
|
-
"""Bytes that egressed through the metered proxy, or None if none was used."""
|
|
59
|
-
created_at: str
|
|
60
|
-
started_at: Optional[str]
|
|
61
|
-
finished_at: Optional[str]
|
|
62
|
-
queue_ms: Optional[int]
|
|
63
|
-
"""Milliseconds the solve waited in the queue before a worker picked it up."""
|
|
64
|
-
resolve_ms: Optional[int]
|
|
65
|
-
"""Milliseconds of actual solving."""
|
|
66
|
-
|
|
67
|
-
@property
|
|
68
|
-
def is_terminal(self) -> bool:
|
|
69
|
-
"""Whether the solve has reached a final state."""
|
|
70
|
-
return self.status in TERMINAL_STATUSES
|
|
71
|
-
|
|
72
|
-
@classmethod
|
|
73
|
-
def _from_dict(cls, data: dict[str, Any]) -> Solve:
|
|
74
|
-
raw_error = data.get("error")
|
|
75
|
-
return cls(
|
|
76
|
-
id=data["id"],
|
|
77
|
-
object=data.get("object", "solve"),
|
|
78
|
-
type=data["type"],
|
|
79
|
-
status=data["status"],
|
|
80
|
-
sitekey=data.get("sitekey", ""),
|
|
81
|
-
url=data.get("url", ""),
|
|
82
|
-
token=data.get("token"),
|
|
83
|
-
error=SolveError(code=raw_error["code"], message=raw_error["message"])
|
|
84
|
-
if raw_error
|
|
85
|
-
else None,
|
|
86
|
-
credits_charged=data.get("credits_charged"),
|
|
87
|
-
proxy_bytes=data.get("proxy_bytes"),
|
|
88
|
-
created_at=data.get("created_at", ""),
|
|
89
|
-
started_at=data.get("started_at"),
|
|
90
|
-
finished_at=data.get("finished_at"),
|
|
91
|
-
queue_ms=data.get("queue_ms"),
|
|
92
|
-
resolve_ms=data.get("resolve_ms"),
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
@dataclass(frozen=True)
|
|
97
|
-
class SolvePage:
|
|
98
|
-
"""One page of solves, newest first."""
|
|
99
|
-
|
|
100
|
-
object: str
|
|
101
|
-
data: list[Solve]
|
|
102
|
-
has_more: bool
|
|
103
|
-
|
|
104
|
-
@classmethod
|
|
105
|
-
def _from_dict(cls, payload: dict[str, Any]) -> SolvePage:
|
|
106
|
-
return cls(
|
|
107
|
-
object=payload.get("object", "list"),
|
|
108
|
-
data=[Solve._from_dict(item) for item in payload.get("data", [])],
|
|
109
|
-
has_more=bool(payload.get("has_more", False)),
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
@dataclass(frozen=True)
|
|
114
|
-
class Account:
|
|
115
|
-
"""Your account, including the current credit balance."""
|
|
116
|
-
|
|
117
|
-
object: str
|
|
118
|
-
id: str
|
|
119
|
-
email: str
|
|
120
|
-
credits_balance: int
|
|
121
|
-
created_at: str
|
|
122
|
-
|
|
123
|
-
@classmethod
|
|
124
|
-
def _from_dict(cls, data: dict[str, Any]) -> Account:
|
|
125
|
-
return cls(
|
|
126
|
-
object=data.get("object", "account"),
|
|
127
|
-
id=data["id"],
|
|
128
|
-
email=data.get("email", ""),
|
|
129
|
-
credits_balance=int(data.get("credits_balance", 0)),
|
|
130
|
-
created_at=data.get("created_at", ""),
|
|
131
|
-
)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|