nonecap 0.1.0__tar.gz → 0.2.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.1.0 → nonecap-0.2.0}/PKG-INFO +45 -1
- {nonecap-0.1.0 → nonecap-0.2.0}/README.md +44 -0
- {nonecap-0.1.0 → nonecap-0.2.0}/src/nonecap/__init__.py +4 -1
- {nonecap-0.1.0 → nonecap-0.2.0}/src/nonecap/_client.py +324 -36
- {nonecap-0.1.0 → nonecap-0.2.0}/src/nonecap/_errors.py +20 -1
- {nonecap-0.1.0 → nonecap-0.2.0}/src/nonecap/_types.py +4 -0
- nonecap-0.2.0/src/nonecap/_version.py +1 -0
- nonecap-0.2.0/tests/test_async_client.py +241 -0
- {nonecap-0.1.0 → nonecap-0.2.0}/tests/test_client.py +123 -0
- {nonecap-0.1.0 → nonecap-0.2.0}/tests/typing_assertions.py +30 -1
- nonecap-0.1.0/src/nonecap/_version.py +0 -1
- nonecap-0.1.0/tests/test_async_client.py +0 -94
- {nonecap-0.1.0 → nonecap-0.2.0}/.github/workflows/ci.yml +0 -0
- {nonecap-0.1.0 → nonecap-0.2.0}/.gitignore +0 -0
- {nonecap-0.1.0 → nonecap-0.2.0}/LICENSE +0 -0
- {nonecap-0.1.0 → nonecap-0.2.0}/pyproject.toml +0 -0
- {nonecap-0.1.0 → nonecap-0.2.0}/src/nonecap/py.typed +0 -0
- {nonecap-0.1.0 → nonecap-0.2.0}/tests/__init__.py +0 -0
- {nonecap-0.1.0 → nonecap-0.2.0}/tests/conftest.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonecap
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -86,6 +86,48 @@ async def main() -> None:
|
|
|
86
86
|
asyncio.run(main())
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
## Cancelling a solve
|
|
90
|
+
|
|
91
|
+
`solve()` is the simple path when you just want a token. When you need to hold a reference you can cancel — for clean shutdown, freeing a worker slot, or stopping early — use `solves.start()`. It submits the solve and hands back a `SolveHandle` right away, with the `id` already populated, instead of blocking on the result:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
handle = nc.solves.start(type="hcaptcha", sitekey=sitekey, url=url)
|
|
95
|
+
print(handle.id) # available immediately
|
|
96
|
+
|
|
97
|
+
# Wait for it, just like solve():
|
|
98
|
+
solve = handle.result(timeout=120)
|
|
99
|
+
print(solve.token)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Holding the handle lets you stop a solve you no longer need — say, on shutdown or when a parallel attempt already won — instead of waiting on its result:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
handle = nc.solves.start(type="hcaptcha", sitekey=sitekey, url=url)
|
|
106
|
+
|
|
107
|
+
# ... elsewhere / later, to stop it:
|
|
108
|
+
handle.cancel()
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`handle.result()` long-polls until the solve finishes and returns it, raising `SolveFailedError` / `SolveTimeoutError` like `solve()` does. The *terminal* outcome is memoized, so once the solve has settled, calling `result()` again replays it for free; a `SolveTimeoutError` is not memoized, so you can call `result()` again with a larger timeout to keep waiting. `handle.cancel()` stops a pending or in-flight solve and returns its final state — if the solve already finished, that's not an error, you just get the completed solve back.
|
|
112
|
+
|
|
113
|
+
The async client mirrors this — `await` the start, the result, and the cancel:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
async with AsyncNoneCap(api_key="nc_live_...") as nc:
|
|
117
|
+
handle = await nc.solves.start(type="hcaptcha", sitekey=sitekey, url=url)
|
|
118
|
+
print(handle.id)
|
|
119
|
+
|
|
120
|
+
# Either wait for the outcome ...
|
|
121
|
+
solve = await handle.result(timeout=120)
|
|
122
|
+
print(solve.token)
|
|
123
|
+
|
|
124
|
+
# ... or, on another handle, cancel it instead of awaiting:
|
|
125
|
+
other = await nc.solves.start(type="hcaptcha", sitekey=sitekey, url=url)
|
|
126
|
+
await other.cancel()
|
|
127
|
+
```
|
|
128
|
+
|
|
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
|
+
|
|
89
131
|
## Handling failures
|
|
90
132
|
|
|
91
133
|
Every error this library raises extends `NoneCapError`, so you can catch the whole family or pick out the one you care about.
|
|
@@ -134,6 +176,8 @@ nc.solve(
|
|
|
134
176
|
url=url,
|
|
135
177
|
proxy={"scheme": "http", "host": "1.2.3.4", "port": 8080, "username": "u", "password": "p"},
|
|
136
178
|
# or: proxy="http://u:p@1.2.3.4:8080"
|
|
179
|
+
# scheme can be http, https, socks5, socks5h, or socks4 (default http)
|
|
180
|
+
# e.g. proxy="socks5://u:p@1.2.3.4:1080"
|
|
137
181
|
)
|
|
138
182
|
```
|
|
139
183
|
|
|
@@ -55,6 +55,48 @@ async def main() -> None:
|
|
|
55
55
|
asyncio.run(main())
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
## Cancelling a solve
|
|
59
|
+
|
|
60
|
+
`solve()` is the simple path when you just want a token. When you need to hold a reference you can cancel — for clean shutdown, freeing a worker slot, or stopping early — use `solves.start()`. It submits the solve and hands back a `SolveHandle` right away, with the `id` already populated, instead of blocking on the result:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
handle = nc.solves.start(type="hcaptcha", sitekey=sitekey, url=url)
|
|
64
|
+
print(handle.id) # available immediately
|
|
65
|
+
|
|
66
|
+
# Wait for it, just like solve():
|
|
67
|
+
solve = handle.result(timeout=120)
|
|
68
|
+
print(solve.token)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Holding the handle lets you stop a solve you no longer need — say, on shutdown or when a parallel attempt already won — instead of waiting on its result:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
handle = nc.solves.start(type="hcaptcha", sitekey=sitekey, url=url)
|
|
75
|
+
|
|
76
|
+
# ... elsewhere / later, to stop it:
|
|
77
|
+
handle.cancel()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`handle.result()` long-polls until the solve finishes and returns it, raising `SolveFailedError` / `SolveTimeoutError` like `solve()` does. The *terminal* outcome is memoized, so once the solve has settled, calling `result()` again replays it for free; a `SolveTimeoutError` is not memoized, so you can call `result()` again with a larger timeout to keep waiting. `handle.cancel()` stops a pending or in-flight solve and returns its final state — if the solve already finished, that's not an error, you just get the completed solve back.
|
|
81
|
+
|
|
82
|
+
The async client mirrors this — `await` the start, the result, and the cancel:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
async with AsyncNoneCap(api_key="nc_live_...") as nc:
|
|
86
|
+
handle = await nc.solves.start(type="hcaptcha", sitekey=sitekey, url=url)
|
|
87
|
+
print(handle.id)
|
|
88
|
+
|
|
89
|
+
# Either wait for the outcome ...
|
|
90
|
+
solve = await handle.result(timeout=120)
|
|
91
|
+
print(solve.token)
|
|
92
|
+
|
|
93
|
+
# ... or, on another handle, cancel it instead of awaiting:
|
|
94
|
+
other = await nc.solves.start(type="hcaptcha", sitekey=sitekey, url=url)
|
|
95
|
+
await other.cancel()
|
|
96
|
+
```
|
|
97
|
+
|
|
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
|
+
|
|
58
100
|
## Handling failures
|
|
59
101
|
|
|
60
102
|
Every error this library raises extends `NoneCapError`, so you can catch the whole family or pick out the one you care about.
|
|
@@ -103,6 +145,8 @@ nc.solve(
|
|
|
103
145
|
url=url,
|
|
104
146
|
proxy={"scheme": "http", "host": "1.2.3.4", "port": 8080, "username": "u", "password": "p"},
|
|
105
147
|
# or: proxy="http://u:p@1.2.3.4:8080"
|
|
148
|
+
# scheme can be http, https, socks5, socks5h, or socks4 (default http)
|
|
149
|
+
# e.g. proxy="socks5://u:p@1.2.3.4:1080"
|
|
106
150
|
)
|
|
107
151
|
```
|
|
108
152
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
'P1_...'
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
from ._client import AsyncNoneCap, NoneCap
|
|
10
|
+
from ._client import AsyncNoneCap, AsyncSolveHandle, NoneCap, SolveHandle
|
|
11
11
|
from ._errors import (
|
|
12
12
|
APIConnectionError,
|
|
13
13
|
APIError,
|
|
@@ -40,6 +40,9 @@ __all__ = [
|
|
|
40
40
|
# clients
|
|
41
41
|
"NoneCap",
|
|
42
42
|
"AsyncNoneCap",
|
|
43
|
+
# handles
|
|
44
|
+
"SolveHandle",
|
|
45
|
+
"AsyncSolveHandle",
|
|
43
46
|
# types
|
|
44
47
|
"Solve",
|
|
45
48
|
"SolveError",
|
|
@@ -15,6 +15,7 @@ pyright, and a runtime check backs it up for untyped callers.
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
import asyncio
|
|
18
19
|
import time
|
|
19
20
|
from collections.abc import AsyncIterator, Iterator
|
|
20
21
|
from typing import (
|
|
@@ -31,6 +32,7 @@ from ._errors import (
|
|
|
31
32
|
APIConnectionError,
|
|
32
33
|
APIError,
|
|
33
34
|
APITimeoutError,
|
|
35
|
+
ConflictError,
|
|
34
36
|
SolveFailedError,
|
|
35
37
|
SolveTimeoutError,
|
|
36
38
|
ValidationError,
|
|
@@ -234,6 +236,68 @@ class Solves:
|
|
|
234
236
|
)
|
|
235
237
|
return Solve._from_dict(payload)
|
|
236
238
|
|
|
239
|
+
@overload
|
|
240
|
+
def start(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
type: Literal["hcaptcha"],
|
|
244
|
+
sitekey: str,
|
|
245
|
+
url: str,
|
|
246
|
+
rqdata: Optional[str] = None,
|
|
247
|
+
user_agent: Optional[str] = None,
|
|
248
|
+
proxy: Union[Proxy, str, None] = None,
|
|
249
|
+
webhook_url: Optional[str] = None,
|
|
250
|
+
) -> SolveHandle: ...
|
|
251
|
+
|
|
252
|
+
@overload
|
|
253
|
+
def start(
|
|
254
|
+
self,
|
|
255
|
+
*,
|
|
256
|
+
type: Literal["hcaptcha_enterprise"],
|
|
257
|
+
sitekey: str,
|
|
258
|
+
url: str,
|
|
259
|
+
rqdata: str,
|
|
260
|
+
user_agent: Optional[str] = None,
|
|
261
|
+
proxy: Union[Proxy, str, None] = None,
|
|
262
|
+
webhook_url: Optional[str] = None,
|
|
263
|
+
) -> SolveHandle: ...
|
|
264
|
+
|
|
265
|
+
def start(
|
|
266
|
+
self,
|
|
267
|
+
*,
|
|
268
|
+
type: SolveType,
|
|
269
|
+
sitekey: str,
|
|
270
|
+
url: str,
|
|
271
|
+
rqdata: Optional[str] = None,
|
|
272
|
+
user_agent: Optional[str] = None,
|
|
273
|
+
proxy: Union[Proxy, str, None] = None,
|
|
274
|
+
webhook_url: Optional[str] = None,
|
|
275
|
+
) -> SolveHandle:
|
|
276
|
+
"""Submit a solve and return a :class:`SolveHandle` without waiting.
|
|
277
|
+
|
|
278
|
+
The handle's ``id`` is populated immediately, so you can later
|
|
279
|
+
:meth:`SolveHandle.cancel` the solve or block on its outcome with
|
|
280
|
+
:meth:`SolveHandle.result`. Use this over :meth:`NoneCap.solve` when you
|
|
281
|
+
need to hold a reference you can cancel — for clean shutdown, freeing a
|
|
282
|
+
worker slot, or stopping early.
|
|
283
|
+
"""
|
|
284
|
+
body = _build_solve_body(
|
|
285
|
+
type=type,
|
|
286
|
+
sitekey=sitekey,
|
|
287
|
+
url=url,
|
|
288
|
+
rqdata=rqdata,
|
|
289
|
+
user_agent=user_agent,
|
|
290
|
+
proxy=proxy,
|
|
291
|
+
webhook_url=webhook_url,
|
|
292
|
+
)
|
|
293
|
+
return self._start_from_body(body)
|
|
294
|
+
|
|
295
|
+
def _start_from_body(self, body: dict[str, Any]) -> SolveHandle:
|
|
296
|
+
"""Shared by :meth:`start` and :meth:`NoneCap.solve`: POST the solve
|
|
297
|
+
(without long-polling) and wrap the pending resource in a handle."""
|
|
298
|
+
payload = self._client._request("POST", "/v1/solves", json=body)
|
|
299
|
+
return SolveHandle(self, Solve._from_dict(payload))
|
|
300
|
+
|
|
237
301
|
def retrieve(self, solve_id: str, *, wait: Optional[int] = None) -> Solve:
|
|
238
302
|
"""Fetch a solve by id. Pass ``wait`` to long-poll until it finishes."""
|
|
239
303
|
payload = self._client._request(
|
|
@@ -288,6 +352,81 @@ class Solves:
|
|
|
288
352
|
cursor = page.data[-1].id
|
|
289
353
|
|
|
290
354
|
|
|
355
|
+
class SolveHandle:
|
|
356
|
+
"""A handle to a submitted solve, returned by :meth:`Solves.start`.
|
|
357
|
+
|
|
358
|
+
Like :class:`asyncio.Task`, this is a distinct object you hold onto — not
|
|
359
|
+
the result itself. ``id`` is available as soon as ``start()`` returns; call
|
|
360
|
+
:meth:`result` to block until the solve finishes, or :meth:`cancel` to stop
|
|
361
|
+
it early.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
def __init__(self, solves: Solves, solve: Solve) -> None:
|
|
365
|
+
self.id: str = solve.id
|
|
366
|
+
"""The solve's id, available immediately after ``start()``."""
|
|
367
|
+
self._solves = solves
|
|
368
|
+
self._solve = solve
|
|
369
|
+
self._settled: Optional[Solve] = solve if solve.is_terminal else None
|
|
370
|
+
|
|
371
|
+
def result(self, timeout: Optional[float] = None) -> Solve:
|
|
372
|
+
"""Wait for the solve to finish and return it.
|
|
373
|
+
|
|
374
|
+
Long-polls under the hood until the solve is terminal, exactly like
|
|
375
|
+
:meth:`NoneCap.solve`. ``timeout`` is the overall budget in seconds
|
|
376
|
+
(defaults to ``DEFAULT_SOLVE_TIMEOUT``). Raises :class:`SolveTimeoutError`
|
|
377
|
+
if the budget elapses first, or :class:`SolveFailedError` if the solve
|
|
378
|
+
ends without a token.
|
|
379
|
+
|
|
380
|
+
Only a *terminal* outcome is memoized: once the solve settles, later
|
|
381
|
+
calls replay it without another request. A :class:`SolveTimeoutError` is
|
|
382
|
+
not cached, so a later call with a larger ``timeout`` resumes polling.
|
|
383
|
+
"""
|
|
384
|
+
if self._settled is not None:
|
|
385
|
+
return self._result_from(self._settled)
|
|
386
|
+
budget = DEFAULT_SOLVE_TIMEOUT if timeout is None else timeout
|
|
387
|
+
deadline = time.monotonic() + budget
|
|
388
|
+
solve = self._solve
|
|
389
|
+
while not solve.is_terminal:
|
|
390
|
+
if time.monotonic() >= deadline:
|
|
391
|
+
raise SolveTimeoutError(
|
|
392
|
+
f"Solve {solve.id} did not finish within {budget:g}s "
|
|
393
|
+
f"(last status: {solve.status}).",
|
|
394
|
+
solve_id=solve.id,
|
|
395
|
+
solve=solve,
|
|
396
|
+
)
|
|
397
|
+
solve = self._solves.retrieve(solve.id, wait=_wait_seconds(deadline))
|
|
398
|
+
self._solve = solve
|
|
399
|
+
self._settled = solve
|
|
400
|
+
return self._result_from(solve)
|
|
401
|
+
|
|
402
|
+
def cancel(self) -> Solve:
|
|
403
|
+
"""Cancel the solve and return its resulting state. Cancelled solves are
|
|
404
|
+
never charged. If the solve has already finished, the server replies 409;
|
|
405
|
+
that is swallowed and the solve's current state is returned instead, so
|
|
406
|
+
cancelling a completed solve is never an error.
|
|
407
|
+
|
|
408
|
+
A terminal result (including ``cancelled``) is memoized as the handle's
|
|
409
|
+
settled outcome, so a later :meth:`result` reflects it instead of polling
|
|
410
|
+
— ``cancelled`` is terminal-but-not-solved, so ``result()`` then raises
|
|
411
|
+
:class:`SolveFailedError`.
|
|
412
|
+
"""
|
|
413
|
+
try:
|
|
414
|
+
solve = self._solves.cancel(self.id)
|
|
415
|
+
except ConflictError:
|
|
416
|
+
solve = self._solves.retrieve(self.id)
|
|
417
|
+
self._solve = solve
|
|
418
|
+
if solve.is_terminal:
|
|
419
|
+
self._settled = solve
|
|
420
|
+
return solve
|
|
421
|
+
|
|
422
|
+
@staticmethod
|
|
423
|
+
def _result_from(solve: Solve) -> Solve:
|
|
424
|
+
"""Map a settled (terminal) solve to ``result()``'s return/raise."""
|
|
425
|
+
if solve.status != "solved":
|
|
426
|
+
raise SolveFailedError(solve)
|
|
427
|
+
return solve
|
|
428
|
+
|
|
429
|
+
|
|
291
430
|
class NoneCap(_BaseClient):
|
|
292
431
|
"""The NoneCap API client (sync).
|
|
293
432
|
|
|
@@ -383,12 +522,14 @@ class NoneCap(_BaseClient):
|
|
|
383
522
|
solve is terminal or ``timeout`` seconds elapse. Raises
|
|
384
523
|
:class:`SolveFailedError` if the solve fails/expires/is cancelled, or
|
|
385
524
|
:class:`SolveTimeoutError` on timeout.
|
|
525
|
+
|
|
526
|
+
This is sugar for ``solves.start(...).result(timeout)``; reach for
|
|
527
|
+
:meth:`Solves.start` directly when you need a handle you can cancel.
|
|
386
528
|
"""
|
|
387
|
-
deadline = time.monotonic() + timeout
|
|
388
529
|
# Build the body directly rather than dispatching through the
|
|
389
|
-
# overloaded
|
|
390
|
-
#
|
|
391
|
-
#
|
|
530
|
+
# overloaded start(): the union-typed passthrough args defeat overload
|
|
531
|
+
# resolution, and the runtime rqdata check lives in _build_solve_body
|
|
532
|
+
# either way.
|
|
392
533
|
body = _build_solve_body(
|
|
393
534
|
type=type,
|
|
394
535
|
sitekey=sitekey,
|
|
@@ -398,21 +539,7 @@ class NoneCap(_BaseClient):
|
|
|
398
539
|
proxy=proxy,
|
|
399
540
|
webhook_url=webhook_url,
|
|
400
541
|
)
|
|
401
|
-
|
|
402
|
-
payload = self._request(
|
|
403
|
-
"POST", "/v1/solves", params={"wait": wait}, json=body, wait=wait
|
|
404
|
-
)
|
|
405
|
-
solve = Solve._from_dict(payload)
|
|
406
|
-
while not solve.is_terminal:
|
|
407
|
-
if time.monotonic() >= deadline:
|
|
408
|
-
raise SolveTimeoutError(
|
|
409
|
-
f"Solve {solve.id} did not finish within {timeout:g}s "
|
|
410
|
-
f"(last status: {solve.status})."
|
|
411
|
-
)
|
|
412
|
-
solve = self.solves.retrieve(solve.id, wait=_wait_seconds(deadline))
|
|
413
|
-
if solve.status != "solved":
|
|
414
|
-
raise SolveFailedError(solve)
|
|
415
|
-
return solve
|
|
542
|
+
return self.solves._start_from_body(body).result(timeout)
|
|
416
543
|
|
|
417
544
|
def me(self) -> Account:
|
|
418
545
|
"""Fetch your account, including the current credit balance."""
|
|
@@ -501,6 +628,68 @@ class AsyncSolves:
|
|
|
501
628
|
)
|
|
502
629
|
return Solve._from_dict(payload)
|
|
503
630
|
|
|
631
|
+
@overload
|
|
632
|
+
async def start(
|
|
633
|
+
self,
|
|
634
|
+
*,
|
|
635
|
+
type: Literal["hcaptcha"],
|
|
636
|
+
sitekey: str,
|
|
637
|
+
url: str,
|
|
638
|
+
rqdata: Optional[str] = None,
|
|
639
|
+
user_agent: Optional[str] = None,
|
|
640
|
+
proxy: Union[Proxy, str, None] = None,
|
|
641
|
+
webhook_url: Optional[str] = None,
|
|
642
|
+
) -> AsyncSolveHandle: ...
|
|
643
|
+
|
|
644
|
+
@overload
|
|
645
|
+
async def start(
|
|
646
|
+
self,
|
|
647
|
+
*,
|
|
648
|
+
type: Literal["hcaptcha_enterprise"],
|
|
649
|
+
sitekey: str,
|
|
650
|
+
url: str,
|
|
651
|
+
rqdata: str,
|
|
652
|
+
user_agent: Optional[str] = None,
|
|
653
|
+
proxy: Union[Proxy, str, None] = None,
|
|
654
|
+
webhook_url: Optional[str] = None,
|
|
655
|
+
) -> AsyncSolveHandle: ...
|
|
656
|
+
|
|
657
|
+
async def start(
|
|
658
|
+
self,
|
|
659
|
+
*,
|
|
660
|
+
type: SolveType,
|
|
661
|
+
sitekey: str,
|
|
662
|
+
url: str,
|
|
663
|
+
rqdata: Optional[str] = None,
|
|
664
|
+
user_agent: Optional[str] = None,
|
|
665
|
+
proxy: Union[Proxy, str, None] = None,
|
|
666
|
+
webhook_url: Optional[str] = None,
|
|
667
|
+
) -> AsyncSolveHandle:
|
|
668
|
+
"""Submit a solve and return an :class:`AsyncSolveHandle` without waiting.
|
|
669
|
+
|
|
670
|
+
The handle's ``id`` is populated immediately, so you can later
|
|
671
|
+
:meth:`AsyncSolveHandle.cancel` the solve or await its outcome with
|
|
672
|
+
:meth:`AsyncSolveHandle.result`. Use this over :meth:`AsyncNoneCap.solve`
|
|
673
|
+
when you need a handle you can cancel — for clean shutdown, freeing a
|
|
674
|
+
worker slot, or stopping early.
|
|
675
|
+
"""
|
|
676
|
+
body = _build_solve_body(
|
|
677
|
+
type=type,
|
|
678
|
+
sitekey=sitekey,
|
|
679
|
+
url=url,
|
|
680
|
+
rqdata=rqdata,
|
|
681
|
+
user_agent=user_agent,
|
|
682
|
+
proxy=proxy,
|
|
683
|
+
webhook_url=webhook_url,
|
|
684
|
+
)
|
|
685
|
+
return await self._start_from_body(body)
|
|
686
|
+
|
|
687
|
+
async def _start_from_body(self, body: dict[str, Any]) -> AsyncSolveHandle:
|
|
688
|
+
"""Shared by :meth:`start` and :meth:`AsyncNoneCap.solve`: POST the solve
|
|
689
|
+
(without long-polling) and wrap the pending resource in a handle."""
|
|
690
|
+
payload = await self._client._request("POST", "/v1/solves", json=body)
|
|
691
|
+
return AsyncSolveHandle(self, Solve._from_dict(payload))
|
|
692
|
+
|
|
504
693
|
async def retrieve(self, solve_id: str, *, wait: Optional[int] = None) -> Solve:
|
|
505
694
|
"""Fetch a solve by id. Pass ``wait`` to long-poll until it finishes."""
|
|
506
695
|
payload = await self._client._request(
|
|
@@ -558,6 +747,115 @@ class AsyncSolves:
|
|
|
558
747
|
cursor = page.data[-1].id
|
|
559
748
|
|
|
560
749
|
|
|
750
|
+
class AsyncSolveHandle:
|
|
751
|
+
"""A handle to a submitted solve, returned by ``await`` :meth:`AsyncSolves.start`.
|
|
752
|
+
|
|
753
|
+
Like :class:`asyncio.Task`, this is a distinct object you hold onto — not
|
|
754
|
+
the result itself. ``id`` is available as soon as ``start()`` resolves;
|
|
755
|
+
``await`` :meth:`result` to wait until the solve finishes, or ``await``
|
|
756
|
+
:meth:`cancel` to stop it early.
|
|
757
|
+
"""
|
|
758
|
+
|
|
759
|
+
def __init__(self, solves: AsyncSolves, solve: Solve) -> None:
|
|
760
|
+
self.id: str = solve.id
|
|
761
|
+
"""The solve's id, available immediately after ``start()``."""
|
|
762
|
+
self._solves = solves
|
|
763
|
+
self._solve = solve
|
|
764
|
+
self._settled: Optional[Solve] = solve if solve.is_terminal else None
|
|
765
|
+
self._inflight: Optional[asyncio.Future[Solve]] = None
|
|
766
|
+
|
|
767
|
+
async def result(self, timeout: Optional[float] = None) -> Solve:
|
|
768
|
+
"""Wait for the solve to finish and return it.
|
|
769
|
+
|
|
770
|
+
Long-polls under the hood until the solve is terminal, exactly like
|
|
771
|
+
:meth:`AsyncNoneCap.solve`. ``timeout`` is the overall budget in seconds
|
|
772
|
+
(defaults to ``DEFAULT_SOLVE_TIMEOUT``). Raises :class:`SolveTimeoutError`
|
|
773
|
+
if the budget elapses first, or :class:`SolveFailedError` if the solve
|
|
774
|
+
ends without a token.
|
|
775
|
+
|
|
776
|
+
Only a *terminal* outcome is memoized: once the solve settles, later
|
|
777
|
+
calls replay it without another request. A :class:`SolveTimeoutError` is
|
|
778
|
+
not cached, so a later call with a larger ``timeout`` resumes polling.
|
|
779
|
+
Concurrent calls share one in-flight poll rather than each opening their
|
|
780
|
+
own long-poll connection.
|
|
781
|
+
"""
|
|
782
|
+
if self._settled is not None:
|
|
783
|
+
return self._result_from(self._settled)
|
|
784
|
+
budget = DEFAULT_SOLVE_TIMEOUT if timeout is None else timeout
|
|
785
|
+
if self._inflight is None:
|
|
786
|
+
self._inflight = asyncio.ensure_future(self._poll(budget))
|
|
787
|
+
task = self._inflight
|
|
788
|
+
try:
|
|
789
|
+
solve = await task
|
|
790
|
+
except asyncio.CancelledError:
|
|
791
|
+
# cancel() cancels the shared poll once it has settled the handle;
|
|
792
|
+
# awaiters then replay the cached terminal outcome.
|
|
793
|
+
if task.cancelled() and self._settled is not None:
|
|
794
|
+
return self._result_from(self._settled)
|
|
795
|
+
if self._inflight is task:
|
|
796
|
+
self._inflight = None
|
|
797
|
+
raise
|
|
798
|
+
except SolveTimeoutError:
|
|
799
|
+
# Timeouts are retryable: drop the shared poll so a later call (with
|
|
800
|
+
# a larger budget) starts fresh instead of replaying the timeout.
|
|
801
|
+
if self._inflight is task:
|
|
802
|
+
self._inflight = None
|
|
803
|
+
raise
|
|
804
|
+
if self._inflight is task:
|
|
805
|
+
self._inflight = None
|
|
806
|
+
self._settled = solve
|
|
807
|
+
return self._result_from(solve)
|
|
808
|
+
|
|
809
|
+
async def _poll(self, budget: float) -> Solve:
|
|
810
|
+
"""The shared long-poll loop. Returns a terminal solve or raises
|
|
811
|
+
:class:`SolveTimeoutError`; never caches (the caller settles)."""
|
|
812
|
+
deadline = time.monotonic() + budget
|
|
813
|
+
solve = self._solve
|
|
814
|
+
while not solve.is_terminal:
|
|
815
|
+
if time.monotonic() >= deadline:
|
|
816
|
+
raise SolveTimeoutError(
|
|
817
|
+
f"Solve {solve.id} did not finish within {budget:g}s "
|
|
818
|
+
f"(last status: {solve.status}).",
|
|
819
|
+
solve_id=solve.id,
|
|
820
|
+
solve=solve,
|
|
821
|
+
)
|
|
822
|
+
solve = await self._solves.retrieve(solve.id, wait=_wait_seconds(deadline))
|
|
823
|
+
self._solve = solve
|
|
824
|
+
return solve
|
|
825
|
+
|
|
826
|
+
async def cancel(self) -> Solve:
|
|
827
|
+
"""Cancel the solve and return its resulting state. Cancelled solves are
|
|
828
|
+
never charged. If the solve has already finished, the server replies 409;
|
|
829
|
+
that is swallowed and the solve's current state is returned instead, so
|
|
830
|
+
cancelling a completed solve is never an error.
|
|
831
|
+
|
|
832
|
+
A terminal result (including ``cancelled``) is memoized as the handle's
|
|
833
|
+
settled outcome and any in-flight :meth:`result` poll is stopped, so an
|
|
834
|
+
awaiting or later ``result()`` settles to the cancelled solve instead of
|
|
835
|
+
polling on. ``cancelled`` is terminal-but-not-solved, so ``result()``
|
|
836
|
+
then raises :class:`SolveFailedError`.
|
|
837
|
+
"""
|
|
838
|
+
try:
|
|
839
|
+
solve = await self._solves.cancel(self.id)
|
|
840
|
+
except ConflictError:
|
|
841
|
+
solve = await self._solves.retrieve(self.id)
|
|
842
|
+
self._solve = solve
|
|
843
|
+
if solve.is_terminal:
|
|
844
|
+
self._settled = solve
|
|
845
|
+
inflight = self._inflight
|
|
846
|
+
self._inflight = None
|
|
847
|
+
if inflight is not None and not inflight.done():
|
|
848
|
+
inflight.cancel()
|
|
849
|
+
return solve
|
|
850
|
+
|
|
851
|
+
@staticmethod
|
|
852
|
+
def _result_from(solve: Solve) -> Solve:
|
|
853
|
+
"""Map a settled (terminal) solve to ``result()``'s return/raise."""
|
|
854
|
+
if solve.status != "solved":
|
|
855
|
+
raise SolveFailedError(solve)
|
|
856
|
+
return solve
|
|
857
|
+
|
|
858
|
+
|
|
561
859
|
class AsyncNoneCap(_BaseClient):
|
|
562
860
|
"""The NoneCap API client (async).
|
|
563
861
|
|
|
@@ -651,10 +949,13 @@ class AsyncNoneCap(_BaseClient):
|
|
|
651
949
|
solve is terminal or ``timeout`` seconds elapse. Raises
|
|
652
950
|
:class:`SolveFailedError` if the solve fails/expires/is cancelled, or
|
|
653
951
|
:class:`SolveTimeoutError` on timeout.
|
|
952
|
+
|
|
953
|
+
This is sugar for ``(await solves.start(...)).result(timeout)``; reach
|
|
954
|
+
for :meth:`AsyncSolves.start` directly when you need a handle you can
|
|
955
|
+
cancel.
|
|
654
956
|
"""
|
|
655
|
-
deadline = time.monotonic() + timeout
|
|
656
957
|
# Same shape as the sync client: build the body directly instead of
|
|
657
|
-
# dispatching through the overloaded
|
|
958
|
+
# dispatching through the overloaded start() with union-typed args.
|
|
658
959
|
body = _build_solve_body(
|
|
659
960
|
type=type,
|
|
660
961
|
sitekey=sitekey,
|
|
@@ -664,21 +965,8 @@ class AsyncNoneCap(_BaseClient):
|
|
|
664
965
|
proxy=proxy,
|
|
665
966
|
webhook_url=webhook_url,
|
|
666
967
|
)
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
"POST", "/v1/solves", params={"wait": wait}, json=body, wait=wait
|
|
670
|
-
)
|
|
671
|
-
solve = Solve._from_dict(payload)
|
|
672
|
-
while not solve.is_terminal:
|
|
673
|
-
if time.monotonic() >= deadline:
|
|
674
|
-
raise SolveTimeoutError(
|
|
675
|
-
f"Solve {solve.id} did not finish within {timeout:g}s "
|
|
676
|
-
f"(last status: {solve.status})."
|
|
677
|
-
)
|
|
678
|
-
solve = await self.solves.retrieve(solve.id, wait=_wait_seconds(deadline))
|
|
679
|
-
if solve.status != "solved":
|
|
680
|
-
raise SolveFailedError(solve)
|
|
681
|
-
return solve
|
|
968
|
+
handle = await self.solves._start_from_body(body)
|
|
969
|
+
return await handle.result(timeout)
|
|
682
970
|
|
|
683
971
|
async def me(self) -> Account:
|
|
684
972
|
"""Fetch your account, including the current credit balance."""
|
|
@@ -71,7 +71,26 @@ class APITimeoutError(APIConnectionError):
|
|
|
71
71
|
|
|
72
72
|
|
|
73
73
|
class SolveTimeoutError(NoneCapError):
|
|
74
|
-
"""Raised by ``solve()`` when the overall timeout
|
|
74
|
+
"""Raised by ``solve()`` / ``SolveHandle.result()`` when the overall timeout
|
|
75
|
+
elapses before the solve reaches a terminal state.
|
|
76
|
+
|
|
77
|
+
When the timeout originates from waiting on a specific solve, ``solve_id`` is
|
|
78
|
+
the solve's id and ``solve`` is its last-known state (still non-terminal), so
|
|
79
|
+
you can retry or inspect the latest status. Both are ``None`` only if the
|
|
80
|
+
error was constructed without a solve in hand."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
message: str,
|
|
85
|
+
*,
|
|
86
|
+
solve_id: Optional[str] = None,
|
|
87
|
+
solve: Optional[Solve] = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
super().__init__(message)
|
|
90
|
+
self.solve_id = solve_id
|
|
91
|
+
"""The id of the solve that did not finish in time, when known."""
|
|
92
|
+
self.solve = solve
|
|
93
|
+
"""The last-known (non-terminal) solve at the moment of timeout, when known."""
|
|
75
94
|
|
|
76
95
|
|
|
77
96
|
class SolveFailedError(NoneCapError):
|
|
@@ -97,12 +97,14 @@ class Solve:
|
|
|
97
97
|
class SolvePage:
|
|
98
98
|
"""One page of solves, newest first."""
|
|
99
99
|
|
|
100
|
+
object: str
|
|
100
101
|
data: list[Solve]
|
|
101
102
|
has_more: bool
|
|
102
103
|
|
|
103
104
|
@classmethod
|
|
104
105
|
def _from_dict(cls, payload: dict[str, Any]) -> SolvePage:
|
|
105
106
|
return cls(
|
|
107
|
+
object=payload.get("object", "list"),
|
|
106
108
|
data=[Solve._from_dict(item) for item in payload.get("data", [])],
|
|
107
109
|
has_more=bool(payload.get("has_more", False)),
|
|
108
110
|
)
|
|
@@ -112,6 +114,7 @@ class SolvePage:
|
|
|
112
114
|
class Account:
|
|
113
115
|
"""Your account, including the current credit balance."""
|
|
114
116
|
|
|
117
|
+
object: str
|
|
115
118
|
id: str
|
|
116
119
|
email: str
|
|
117
120
|
credits_balance: int
|
|
@@ -120,6 +123,7 @@ class Account:
|
|
|
120
123
|
@classmethod
|
|
121
124
|
def _from_dict(cls, data: dict[str, Any]) -> Account:
|
|
122
125
|
return cls(
|
|
126
|
+
object=data.get("object", "account"),
|
|
123
127
|
id=data["id"],
|
|
124
128
|
email=data.get("email", ""),
|
|
125
129
|
credits_balance=int(data.get("credits_balance", 0)),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from nonecap import AsyncNoneCap, SolveFailedError, SolveTimeoutError, ValidationError
|
|
10
|
+
|
|
11
|
+
from .conftest import Script, error_payload, solve_payload
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def client_for(script: Script) -> AsyncNoneCap:
|
|
15
|
+
return AsyncNoneCap(
|
|
16
|
+
api_key="nc_test",
|
|
17
|
+
http_client=httpx.AsyncClient(transport=script.transport),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def test_create_posts_with_bearer_auth() -> None:
|
|
22
|
+
script = Script((202, solve_payload()))
|
|
23
|
+
nc = client_for(script)
|
|
24
|
+
await nc.solves.create(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
25
|
+
request = script.requests[0]
|
|
26
|
+
assert request.method == "POST"
|
|
27
|
+
assert request.headers["Authorization"] == "Bearer nc_test"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def test_solve_polls_until_terminal() -> None:
|
|
31
|
+
script = Script(
|
|
32
|
+
(202, solve_payload(status="pending")),
|
|
33
|
+
(202, solve_payload(status="solving")),
|
|
34
|
+
(200, solve_payload(status="solved", token="TOK")),
|
|
35
|
+
)
|
|
36
|
+
nc = client_for(script)
|
|
37
|
+
solve = await nc.solve(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
38
|
+
assert solve.token == "TOK"
|
|
39
|
+
assert len(script.requests) == 3
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def test_solve_failed_raises_with_solve_attached() -> None:
|
|
43
|
+
failed = solve_payload(status="failed", error={"code": "unsolvable", "message": "no"})
|
|
44
|
+
script = Script((200, failed))
|
|
45
|
+
nc = client_for(script)
|
|
46
|
+
with pytest.raises(SolveFailedError) as exc_info:
|
|
47
|
+
await nc.solve(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
48
|
+
assert exc_info.value.solve.status == "failed"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def test_solve_times_out() -> None:
|
|
52
|
+
script = Script((202, solve_payload(status="solving")))
|
|
53
|
+
nc = client_for(script)
|
|
54
|
+
with pytest.raises(SolveTimeoutError):
|
|
55
|
+
await nc.solve(type="hcaptcha", sitekey="sk", url="https://e.com", timeout=0)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def test_enterprise_without_rqdata_raises_locally() -> None:
|
|
59
|
+
script = Script((202, solve_payload()))
|
|
60
|
+
nc = client_for(script)
|
|
61
|
+
with pytest.raises(ValidationError):
|
|
62
|
+
await nc.solves.create(type="hcaptcha_enterprise", sitekey="sk", url="https://e.com") # type: ignore[call-overload]
|
|
63
|
+
assert script.requests == []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def test_list_all_walks_pages() -> None:
|
|
67
|
+
page1 = {
|
|
68
|
+
"object": "list",
|
|
69
|
+
"data": [solve_payload(id="a"), solve_payload(id="b")],
|
|
70
|
+
"has_more": True,
|
|
71
|
+
}
|
|
72
|
+
page2 = {"object": "list", "data": [solve_payload(id="c")], "has_more": False}
|
|
73
|
+
script = Script((200, page1), (200, page2))
|
|
74
|
+
nc = client_for(script)
|
|
75
|
+
ids = [s.id async for s in nc.solves.list_all()]
|
|
76
|
+
assert ids == ["a", "b", "c"]
|
|
77
|
+
assert script.requests[1].url.params["starting_after"] == "b"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def test_me() -> None:
|
|
81
|
+
account = {
|
|
82
|
+
"object": "account",
|
|
83
|
+
"id": "u_1",
|
|
84
|
+
"email": "a@b.com",
|
|
85
|
+
"credits_balance": 7,
|
|
86
|
+
"created_at": "x",
|
|
87
|
+
}
|
|
88
|
+
script = Script((200, account))
|
|
89
|
+
nc = client_for(script)
|
|
90
|
+
me = await nc.me()
|
|
91
|
+
assert me.credits_balance == 7
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def test_start_returns_handle_then_result_polls() -> None:
|
|
95
|
+
script = Script(
|
|
96
|
+
(202, solve_payload(status="pending")),
|
|
97
|
+
(200, solve_payload(status="solved", token="TOK")),
|
|
98
|
+
)
|
|
99
|
+
nc = client_for(script)
|
|
100
|
+
handle = await nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
101
|
+
assert handle.id == "solve_1"
|
|
102
|
+
assert len(script.requests) == 1 # only the create POST
|
|
103
|
+
solve = await handle.result()
|
|
104
|
+
assert solve.token == "TOK"
|
|
105
|
+
assert len(script.requests) == 2
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def test_result_is_cached_when_awaited_twice() -> None:
|
|
109
|
+
script = Script(
|
|
110
|
+
(202, solve_payload(status="pending")),
|
|
111
|
+
(200, solve_payload(status="solved", token="TOK")),
|
|
112
|
+
)
|
|
113
|
+
nc = client_for(script)
|
|
114
|
+
handle = await nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
115
|
+
first = await handle.result()
|
|
116
|
+
count = len(script.requests)
|
|
117
|
+
second = await handle.result()
|
|
118
|
+
assert first is second
|
|
119
|
+
assert len(script.requests) == count
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def test_handle_cancel_after_terminal_swallows_conflict() -> None:
|
|
123
|
+
script = Script(
|
|
124
|
+
(202, solve_payload(status="pending")),
|
|
125
|
+
(409, error_payload("conflict")),
|
|
126
|
+
(200, solve_payload(status="solved", token="TOK")),
|
|
127
|
+
)
|
|
128
|
+
nc = client_for(script)
|
|
129
|
+
handle = await nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
130
|
+
solve = await handle.cancel()
|
|
131
|
+
assert solve.status == "solved"
|
|
132
|
+
assert script.requests[1].method == "DELETE"
|
|
133
|
+
assert script.requests[2].method == "GET"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def test_handle_result_times_out() -> None:
|
|
137
|
+
script = Script((202, solve_payload(status="solving")))
|
|
138
|
+
nc = client_for(script)
|
|
139
|
+
handle = await nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
140
|
+
with pytest.raises(SolveTimeoutError):
|
|
141
|
+
await handle.result(timeout=0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def test_timeout_carries_solve_id_and_solve() -> None:
|
|
145
|
+
script = Script((202, solve_payload(status="solving")))
|
|
146
|
+
nc = client_for(script)
|
|
147
|
+
handle = await nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
148
|
+
with pytest.raises(SolveTimeoutError) as exc_info:
|
|
149
|
+
await handle.result(timeout=0)
|
|
150
|
+
assert exc_info.value.solve_id == "solve_1"
|
|
151
|
+
assert exc_info.value.solve is not None
|
|
152
|
+
assert exc_info.value.solve.status == "solving"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def test_timeout_is_not_cached_and_resumes() -> None:
|
|
156
|
+
script = Script(
|
|
157
|
+
(202, solve_payload(status="solving")),
|
|
158
|
+
(200, solve_payload(status="solved", token="TOK")),
|
|
159
|
+
)
|
|
160
|
+
nc = client_for(script)
|
|
161
|
+
handle = await nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
162
|
+
with pytest.raises(SolveTimeoutError):
|
|
163
|
+
await handle.result(timeout=0)
|
|
164
|
+
assert len(script.requests) == 1 # the timeout never polled
|
|
165
|
+
solve = await handle.result(timeout=30)
|
|
166
|
+
assert solve.token == "TOK"
|
|
167
|
+
assert len(script.requests) == 2 # re-polled, did not replay the timeout
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def test_concurrent_result_calls_share_one_poll() -> None:
|
|
171
|
+
script = Script(
|
|
172
|
+
(202, solve_payload(status="pending")),
|
|
173
|
+
(200, solve_payload(status="solved", token="TOK")),
|
|
174
|
+
)
|
|
175
|
+
nc = client_for(script)
|
|
176
|
+
handle = await nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
177
|
+
first, second = await asyncio.gather(handle.result(), handle.result())
|
|
178
|
+
assert first is second # both observe the same settled outcome
|
|
179
|
+
assert first.token == "TOK"
|
|
180
|
+
# one POST + exactly one shared GET poll, not two.
|
|
181
|
+
assert len(script.requests) == 2
|
|
182
|
+
assert sum(1 for r in script.requests if r.method == "GET") == 1
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def test_cancel_then_result_settles_to_cancelled_without_polling() -> None:
|
|
186
|
+
script = Script(
|
|
187
|
+
(202, solve_payload(status="pending")),
|
|
188
|
+
(200, solve_payload(status="cancelled")),
|
|
189
|
+
)
|
|
190
|
+
nc = client_for(script)
|
|
191
|
+
handle = await nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
192
|
+
await handle.cancel()
|
|
193
|
+
with pytest.raises(SolveFailedError) as exc_info:
|
|
194
|
+
await handle.result()
|
|
195
|
+
assert exc_info.value.solve.status == "cancelled"
|
|
196
|
+
assert len(script.requests) == 2 # POST + DELETE only
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class _GatedTransport(httpx.AsyncBaseTransport):
|
|
200
|
+
"""Async transport whose GET polls block until ``release`` is set, so a
|
|
201
|
+
cancel() can land while a result() poll is genuinely in flight."""
|
|
202
|
+
|
|
203
|
+
def __init__(self) -> None:
|
|
204
|
+
self.release = asyncio.Event()
|
|
205
|
+
self.get_count = 0
|
|
206
|
+
self.requests: list[httpx.Request] = []
|
|
207
|
+
|
|
208
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
209
|
+
self.requests.append(request)
|
|
210
|
+
if request.method == "POST":
|
|
211
|
+
return httpx.Response(202, json=solve_payload(status="pending"))
|
|
212
|
+
if request.method == "DELETE":
|
|
213
|
+
return httpx.Response(200, json=solve_payload(status="cancelled"))
|
|
214
|
+
# GET poll: block so the test can interleave a cancel().
|
|
215
|
+
self.get_count += 1
|
|
216
|
+
await self.release.wait()
|
|
217
|
+
return httpx.Response(200, json=solve_payload(status="pending"))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def test_cancel_settles_a_result_already_awaiting() -> None:
|
|
221
|
+
transport = _GatedTransport()
|
|
222
|
+
nc = AsyncNoneCap(api_key="k", http_client=httpx.AsyncClient(transport=transport))
|
|
223
|
+
handle = await nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
224
|
+
waiter: asyncio.Task[Any] = asyncio.create_task(handle.result(timeout=30))
|
|
225
|
+
# Let result() start polling and block inside the gated GET.
|
|
226
|
+
while transport.get_count == 0:
|
|
227
|
+
await asyncio.sleep(0)
|
|
228
|
+
cancelled = await handle.cancel()
|
|
229
|
+
assert cancelled.status == "cancelled"
|
|
230
|
+
# The already-awaiting result() settles to the cancelled (terminal) solve.
|
|
231
|
+
with pytest.raises(SolveFailedError) as exc_info:
|
|
232
|
+
await waiter
|
|
233
|
+
assert exc_info.value.solve.status == "cancelled"
|
|
234
|
+
transport.release.set() # unblock any straggler so the client closes cleanly
|
|
235
|
+
await nc.close()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def test_async_context_manager_closes_owned_client() -> None:
|
|
239
|
+
async with AsyncNoneCap(api_key="k") as nc:
|
|
240
|
+
http = nc._http
|
|
241
|
+
assert http.is_closed
|
|
@@ -219,6 +219,129 @@ class TestSolveHelper:
|
|
|
219
219
|
nc.solve(type="hcaptcha", sitekey="sk", url="https://e.com", timeout=0)
|
|
220
220
|
|
|
221
221
|
|
|
222
|
+
class TestSolveHandle:
|
|
223
|
+
def test_start_returns_handle_with_id_without_polling(self) -> None:
|
|
224
|
+
script = Script((202, solve_payload(status="pending")))
|
|
225
|
+
nc = client_for(script)
|
|
226
|
+
handle = nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
227
|
+
assert handle.id == "solve_1"
|
|
228
|
+
assert len(script.requests) == 1 # only the create POST
|
|
229
|
+
assert script.requests[0].method == "POST"
|
|
230
|
+
|
|
231
|
+
def test_result_polls_until_terminal(self) -> None:
|
|
232
|
+
script = Script(
|
|
233
|
+
(202, solve_payload(status="pending")),
|
|
234
|
+
(202, solve_payload(status="solving")),
|
|
235
|
+
(200, solve_payload(status="solved", token="TOK")),
|
|
236
|
+
)
|
|
237
|
+
nc = client_for(script)
|
|
238
|
+
handle = nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
239
|
+
solve = handle.result()
|
|
240
|
+
assert solve.token == "TOK"
|
|
241
|
+
assert len(script.requests) == 3
|
|
242
|
+
assert script.requests[1].method == "GET"
|
|
243
|
+
|
|
244
|
+
def test_result_is_cached_when_called_twice(self) -> None:
|
|
245
|
+
script = Script(
|
|
246
|
+
(202, solve_payload(status="pending")),
|
|
247
|
+
(200, solve_payload(status="solved", token="TOK")),
|
|
248
|
+
)
|
|
249
|
+
nc = client_for(script)
|
|
250
|
+
handle = nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
251
|
+
first = handle.result()
|
|
252
|
+
count_after_first = len(script.requests)
|
|
253
|
+
second = handle.result()
|
|
254
|
+
assert first is second # same settled outcome, replayed
|
|
255
|
+
assert len(script.requests) == count_after_first # no extra request
|
|
256
|
+
|
|
257
|
+
def test_result_raises_solve_failed(self) -> None:
|
|
258
|
+
script = Script(
|
|
259
|
+
(202, solve_payload(status="pending")),
|
|
260
|
+
(200, solve_payload(status="failed", error={"code": "x", "message": "no"})),
|
|
261
|
+
)
|
|
262
|
+
nc = client_for(script)
|
|
263
|
+
handle = nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
264
|
+
with pytest.raises(SolveFailedError):
|
|
265
|
+
handle.result()
|
|
266
|
+
|
|
267
|
+
def test_result_times_out(self) -> None:
|
|
268
|
+
script = Script((202, solve_payload(status="solving")))
|
|
269
|
+
nc = client_for(script)
|
|
270
|
+
handle = nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
271
|
+
with pytest.raises(SolveTimeoutError):
|
|
272
|
+
handle.result(timeout=0)
|
|
273
|
+
|
|
274
|
+
def test_timeout_carries_solve_id_and_solve(self) -> None:
|
|
275
|
+
script = Script((202, solve_payload(status="solving")))
|
|
276
|
+
nc = client_for(script)
|
|
277
|
+
handle = nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
278
|
+
with pytest.raises(SolveTimeoutError) as exc_info:
|
|
279
|
+
handle.result(timeout=0)
|
|
280
|
+
assert exc_info.value.solve_id == "solve_1"
|
|
281
|
+
assert exc_info.value.solve is not None
|
|
282
|
+
assert exc_info.value.solve.status == "solving"
|
|
283
|
+
|
|
284
|
+
def test_timeout_is_not_cached_and_resumes(self) -> None:
|
|
285
|
+
# timeout=0 raises without polling; a later call resumes to terminal.
|
|
286
|
+
script = Script(
|
|
287
|
+
(202, solve_payload(status="solving")),
|
|
288
|
+
(200, solve_payload(status="solved", token="TOK")),
|
|
289
|
+
)
|
|
290
|
+
nc = client_for(script)
|
|
291
|
+
handle = nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
292
|
+
with pytest.raises(SolveTimeoutError):
|
|
293
|
+
handle.result(timeout=0)
|
|
294
|
+
assert len(script.requests) == 1 # only the POST; the timeout never polled
|
|
295
|
+
solve = handle.result(timeout=30)
|
|
296
|
+
assert solve.token == "TOK"
|
|
297
|
+
assert len(script.requests) == 2 # re-polled, did not replay the timeout
|
|
298
|
+
|
|
299
|
+
def test_cancel_then_result_settles_to_cancelled_without_polling(self) -> None:
|
|
300
|
+
script = Script(
|
|
301
|
+
(202, solve_payload(status="pending")),
|
|
302
|
+
(200, solve_payload(status="cancelled")),
|
|
303
|
+
)
|
|
304
|
+
nc = client_for(script)
|
|
305
|
+
handle = nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
306
|
+
handle.cancel()
|
|
307
|
+
with pytest.raises(SolveFailedError) as exc_info:
|
|
308
|
+
handle.result()
|
|
309
|
+
assert exc_info.value.solve.status == "cancelled"
|
|
310
|
+
assert len(script.requests) == 2 # POST + DELETE only, no extra poll
|
|
311
|
+
|
|
312
|
+
def test_cancel_deletes_and_returns_state(self) -> None:
|
|
313
|
+
script = Script(
|
|
314
|
+
(202, solve_payload(status="pending")),
|
|
315
|
+
(200, solve_payload(status="cancelled")),
|
|
316
|
+
)
|
|
317
|
+
nc = client_for(script)
|
|
318
|
+
handle = nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
319
|
+
solve = handle.cancel()
|
|
320
|
+
assert solve.status == "cancelled"
|
|
321
|
+
assert script.requests[1].method == "DELETE"
|
|
322
|
+
|
|
323
|
+
def test_cancel_after_terminal_swallows_conflict(self) -> None:
|
|
324
|
+
script = Script(
|
|
325
|
+
(202, solve_payload(status="pending")),
|
|
326
|
+
(409, error_payload("conflict")),
|
|
327
|
+
(200, solve_payload(status="solved", token="TOK")),
|
|
328
|
+
)
|
|
329
|
+
nc = client_for(script)
|
|
330
|
+
handle = nc.solves.start(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
331
|
+
solve = handle.cancel()
|
|
332
|
+
assert solve.status == "solved"
|
|
333
|
+
assert script.requests[1].method == "DELETE" # the 409
|
|
334
|
+
assert script.requests[2].method == "GET" # the retrieve fallback
|
|
335
|
+
|
|
336
|
+
def test_enterprise_without_rqdata_raises_locally(self) -> None:
|
|
337
|
+
script = Script((202, solve_payload()))
|
|
338
|
+
nc = client_for(script)
|
|
339
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
340
|
+
nc.solves.start(type="hcaptcha_enterprise", sitekey="sk", url="https://e.com") # type: ignore[call-overload] # noqa: E501
|
|
341
|
+
assert exc_info.value.param == "rqdata"
|
|
342
|
+
assert script.requests == []
|
|
343
|
+
|
|
344
|
+
|
|
222
345
|
class TestLifecycle:
|
|
223
346
|
def test_cancel_deletes(self) -> None:
|
|
224
347
|
script = Script((200, solve_payload(status="cancelled")))
|
|
@@ -10,7 +10,16 @@ the build.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
-
from
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from nonecap import (
|
|
16
|
+
AsyncNoneCap,
|
|
17
|
+
AsyncSolveHandle,
|
|
18
|
+
NoneCap,
|
|
19
|
+
Solve,
|
|
20
|
+
SolveHandle,
|
|
21
|
+
SolveTimeoutError,
|
|
22
|
+
)
|
|
14
23
|
|
|
15
24
|
nc = NoneCap(api_key="k")
|
|
16
25
|
anc = AsyncNoneCap(api_key="k")
|
|
@@ -23,21 +32,41 @@ def positive_cases() -> None:
|
|
|
23
32
|
# enterprise: rqdata provided.
|
|
24
33
|
nc.solve(type="hcaptcha_enterprise", sitekey="s", url="u", rqdata="r")
|
|
25
34
|
nc.solves.create(type="hcaptcha_enterprise", sitekey="s", url="u", rqdata="r")
|
|
35
|
+
# start() returns a handle; result()/cancel() return Solve.
|
|
36
|
+
handle: SolveHandle = nc.solves.start(type="hcaptcha", sitekey="s", url="u")
|
|
37
|
+
_id: str = handle.id
|
|
38
|
+
_r: Solve = handle.result()
|
|
39
|
+
_r2: Solve = handle.result(timeout=10.0)
|
|
40
|
+
_c: Solve = handle.cancel()
|
|
41
|
+
nc.solves.start(type="hcaptcha_enterprise", sitekey="s", url="u", rqdata="r")
|
|
26
42
|
|
|
27
43
|
|
|
28
44
|
async def positive_cases_async() -> None:
|
|
29
45
|
await anc.solve(type="hcaptcha", sitekey="s", url="u")
|
|
30
46
|
await anc.solves.create(type="hcaptcha_enterprise", sitekey="s", url="u", rqdata="r")
|
|
47
|
+
handle: AsyncSolveHandle = await anc.solves.start(type="hcaptcha", sitekey="s", url="u")
|
|
48
|
+
_id: str = handle.id
|
|
49
|
+
_r: Solve = await handle.result()
|
|
50
|
+
_r2: Solve = await handle.result(timeout=10.0)
|
|
51
|
+
_c: Solve = await handle.cancel()
|
|
31
52
|
|
|
32
53
|
|
|
33
54
|
def negative_cases() -> None:
|
|
34
55
|
# Enterprise without rqdata must not type-check.
|
|
35
56
|
nc.solve(type="hcaptcha_enterprise", sitekey="s", url="u") # type: ignore[call-overload]
|
|
36
57
|
nc.solves.create(type="hcaptcha_enterprise", sitekey="s", url="u") # type: ignore[call-overload]
|
|
58
|
+
nc.solves.start(type="hcaptcha_enterprise", sitekey="s", url="u") # type: ignore[call-overload]
|
|
37
59
|
# Unknown captcha type must not type-check.
|
|
38
60
|
nc.solve(type="recaptcha", sitekey="s", url="u") # type: ignore[call-overload]
|
|
39
61
|
|
|
40
62
|
|
|
63
|
+
def timeout_error_fields(err: SolveTimeoutError) -> None:
|
|
64
|
+
# SolveTimeoutError exposes the solve it timed out on.
|
|
65
|
+
_sid: Optional[str] = err.solve_id
|
|
66
|
+
_solve: Optional[Solve] = err.solve
|
|
67
|
+
|
|
68
|
+
|
|
41
69
|
async def negative_cases_async() -> None:
|
|
42
70
|
await anc.solve(type="hcaptcha_enterprise", sitekey="s", url="u") # type: ignore[call-overload]
|
|
43
71
|
await anc.solves.create(type="hcaptcha_enterprise", sitekey="s", url="u") # type: ignore[call-overload]
|
|
72
|
+
await anc.solves.start(type="hcaptcha_enterprise", sitekey="s", url="u") # type: ignore[call-overload]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import httpx
|
|
4
|
-
import pytest
|
|
5
|
-
|
|
6
|
-
from nonecap import AsyncNoneCap, SolveFailedError, SolveTimeoutError, ValidationError
|
|
7
|
-
|
|
8
|
-
from .conftest import Script, solve_payload
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def client_for(script: Script) -> AsyncNoneCap:
|
|
12
|
-
return AsyncNoneCap(
|
|
13
|
-
api_key="nc_test",
|
|
14
|
-
http_client=httpx.AsyncClient(transport=script.transport),
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
async def test_create_posts_with_bearer_auth() -> None:
|
|
19
|
-
script = Script((202, solve_payload()))
|
|
20
|
-
nc = client_for(script)
|
|
21
|
-
await nc.solves.create(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
22
|
-
request = script.requests[0]
|
|
23
|
-
assert request.method == "POST"
|
|
24
|
-
assert request.headers["Authorization"] == "Bearer nc_test"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
async def test_solve_polls_until_terminal() -> None:
|
|
28
|
-
script = Script(
|
|
29
|
-
(202, solve_payload(status="pending")),
|
|
30
|
-
(202, solve_payload(status="solving")),
|
|
31
|
-
(200, solve_payload(status="solved", token="TOK")),
|
|
32
|
-
)
|
|
33
|
-
nc = client_for(script)
|
|
34
|
-
solve = await nc.solve(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
35
|
-
assert solve.token == "TOK"
|
|
36
|
-
assert len(script.requests) == 3
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
async def test_solve_failed_raises_with_solve_attached() -> None:
|
|
40
|
-
failed = solve_payload(status="failed", error={"code": "unsolvable", "message": "no"})
|
|
41
|
-
script = Script((200, failed))
|
|
42
|
-
nc = client_for(script)
|
|
43
|
-
with pytest.raises(SolveFailedError) as exc_info:
|
|
44
|
-
await nc.solve(type="hcaptcha", sitekey="sk", url="https://e.com")
|
|
45
|
-
assert exc_info.value.solve.status == "failed"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
async def test_solve_times_out() -> None:
|
|
49
|
-
script = Script((202, solve_payload(status="solving")))
|
|
50
|
-
nc = client_for(script)
|
|
51
|
-
with pytest.raises(SolveTimeoutError):
|
|
52
|
-
await nc.solve(type="hcaptcha", sitekey="sk", url="https://e.com", timeout=0)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
async def test_enterprise_without_rqdata_raises_locally() -> None:
|
|
56
|
-
script = Script((202, solve_payload()))
|
|
57
|
-
nc = client_for(script)
|
|
58
|
-
with pytest.raises(ValidationError):
|
|
59
|
-
await nc.solves.create(type="hcaptcha_enterprise", sitekey="sk", url="https://e.com") # type: ignore[call-overload]
|
|
60
|
-
assert script.requests == []
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
async def test_list_all_walks_pages() -> None:
|
|
64
|
-
page1 = {
|
|
65
|
-
"object": "list",
|
|
66
|
-
"data": [solve_payload(id="a"), solve_payload(id="b")],
|
|
67
|
-
"has_more": True,
|
|
68
|
-
}
|
|
69
|
-
page2 = {"object": "list", "data": [solve_payload(id="c")], "has_more": False}
|
|
70
|
-
script = Script((200, page1), (200, page2))
|
|
71
|
-
nc = client_for(script)
|
|
72
|
-
ids = [s.id async for s in nc.solves.list_all()]
|
|
73
|
-
assert ids == ["a", "b", "c"]
|
|
74
|
-
assert script.requests[1].url.params["starting_after"] == "b"
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
async def test_me() -> None:
|
|
78
|
-
account = {
|
|
79
|
-
"object": "account",
|
|
80
|
-
"id": "u_1",
|
|
81
|
-
"email": "a@b.com",
|
|
82
|
-
"credits_balance": 7,
|
|
83
|
-
"created_at": "x",
|
|
84
|
-
}
|
|
85
|
-
script = Script((200, account))
|
|
86
|
-
nc = client_for(script)
|
|
87
|
-
me = await nc.me()
|
|
88
|
-
assert me.credits_balance == 7
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
async def test_async_context_manager_closes_owned_client() -> None:
|
|
92
|
-
async with AsyncNoneCap(api_key="k") as nc:
|
|
93
|
-
http = nc._http
|
|
94
|
-
assert http.is_closed
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|