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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonecap
3
- Version: 0.1.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 create(): the union-typed passthrough args defeat
390
- # overload resolution, and the runtime rqdata check lives in
391
- # _build_solve_body either way.
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
- wait = _wait_seconds(deadline)
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 create() with union-typed args.
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
- wait = _wait_seconds(deadline)
668
- payload = await self._request(
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 elapses first."""
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 nonecap import AsyncNoneCap, NoneCap
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