nonecap 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """Official Python client for the NoneCap hCaptcha solving API.
2
+
3
+ >>> from nonecap import NoneCap
4
+ >>> nc = NoneCap(api_key="nc_live_...")
5
+ >>> solve = nc.solve(type="hcaptcha", sitekey="...", url="https://example.com")
6
+ >>> solve.token
7
+ 'P1_...'
8
+ """
9
+
10
+ from ._client import AsyncNoneCap, NoneCap
11
+ from ._errors import (
12
+ APIConnectionError,
13
+ APIError,
14
+ APITimeoutError,
15
+ AuthenticationError,
16
+ ConflictError,
17
+ InsufficientCreditsError,
18
+ NoneCapError,
19
+ NotFoundError,
20
+ PermissionDeniedError,
21
+ RateLimitError,
22
+ SolveFailedError,
23
+ SolveTimeoutError,
24
+ ValidationError,
25
+ )
26
+ from ._types import (
27
+ TERMINAL_STATUSES,
28
+ Account,
29
+ Proxy,
30
+ Solve,
31
+ SolveError,
32
+ SolvePage,
33
+ SolveStatus,
34
+ SolveType,
35
+ )
36
+ from ._version import __version__
37
+
38
+ __all__ = [
39
+ "__version__",
40
+ # clients
41
+ "NoneCap",
42
+ "AsyncNoneCap",
43
+ # types
44
+ "Solve",
45
+ "SolveError",
46
+ "SolvePage",
47
+ "Account",
48
+ "Proxy",
49
+ "SolveType",
50
+ "SolveStatus",
51
+ "TERMINAL_STATUSES",
52
+ # errors
53
+ "NoneCapError",
54
+ "AuthenticationError",
55
+ "PermissionDeniedError",
56
+ "InsufficientCreditsError",
57
+ "ValidationError",
58
+ "NotFoundError",
59
+ "ConflictError",
60
+ "RateLimitError",
61
+ "APIError",
62
+ "APIConnectionError",
63
+ "APITimeoutError",
64
+ "SolveFailedError",
65
+ "SolveTimeoutError",
66
+ ]
nonecap/_client.py ADDED
@@ -0,0 +1,696 @@
1
+ """Sync and async clients for the NoneCap API.
2
+
3
+ Both clients expose the same surface:
4
+
5
+ - ``client.solve(...)`` — submit a captcha and wait for the token (the
6
+ convenient path; long-polls under the hood).
7
+ - ``client.solves.create / retrieve / cancel / list / list_all`` — the raw
8
+ resource methods, mapping one to one to the REST API.
9
+ - ``client.me()`` — account info and credit balance.
10
+
11
+ ``rqdata`` is required for ``type="hcaptcha_enterprise"`` and optional for
12
+ ``type="hcaptcha"``; the ``@overload`` signatures enforce that in mypy and
13
+ pyright, and a runtime check backs it up for untyped callers.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ from collections.abc import AsyncIterator, Iterator
20
+ from typing import (
21
+ Any,
22
+ Literal,
23
+ Optional,
24
+ Union,
25
+ overload,
26
+ )
27
+
28
+ import httpx
29
+
30
+ from ._errors import (
31
+ APIConnectionError,
32
+ APIError,
33
+ APITimeoutError,
34
+ SolveFailedError,
35
+ SolveTimeoutError,
36
+ ValidationError,
37
+ error_from_response,
38
+ )
39
+ from ._types import Account, Proxy, Solve, SolvePage, SolveStatus, SolveType
40
+ from ._version import __version__
41
+
42
+ DEFAULT_BASE_URL = "https://api.nonecap.com"
43
+ DEFAULT_REQUEST_TIMEOUT = 100.0
44
+ """Per-request timeout (seconds): just above the API's 90s long-poll window."""
45
+ DEFAULT_SOLVE_TIMEOUT = 180.0
46
+ """Default overall budget (seconds) for the ``solve()`` helper."""
47
+ _MAX_WAIT_SECONDS = 90
48
+ """The API caps server-side long-poll at 90 seconds."""
49
+
50
+ _USER_AGENT = f"nonecap-python/{__version__}"
51
+
52
+
53
+ def _wait_seconds(deadline: float) -> int:
54
+ """The ``wait`` value for the next long-poll: whole seconds until
55
+ ``deadline``, clamped to the server's 1-90 window. The floor of 1 keeps
56
+ the param valid, so callers must decide whether the deadline has passed
57
+ with the clock, not with this return value."""
58
+ remaining = int(deadline - time.monotonic()) + 1
59
+ return max(1, min(_MAX_WAIT_SECONDS, remaining))
60
+
61
+
62
+ def _request_timeout_for_wait(wait: Optional[int], default: float) -> float:
63
+ """Give the socket a margin beyond the server's long-poll window."""
64
+ if wait is None:
65
+ return default
66
+ return float(wait) + 15.0
67
+
68
+
69
+ def _build_solve_body(
70
+ *,
71
+ type: SolveType,
72
+ sitekey: str,
73
+ url: str,
74
+ rqdata: Optional[str],
75
+ user_agent: Optional[str],
76
+ proxy: Union[Proxy, str, None],
77
+ webhook_url: Optional[str],
78
+ ) -> dict[str, Any]:
79
+ if type == "hcaptcha_enterprise" and not rqdata:
80
+ raise ValidationError(
81
+ "rqdata is required for hcaptcha_enterprise solves.",
82
+ code="validation_error",
83
+ param="rqdata",
84
+ )
85
+ body: dict[str, Any] = {"type": type, "sitekey": sitekey, "url": url}
86
+ if rqdata is not None:
87
+ body["rqdata"] = rqdata
88
+ if user_agent is not None:
89
+ body["user_agent"] = user_agent
90
+ if proxy is not None:
91
+ body["proxy"] = proxy
92
+ if webhook_url is not None:
93
+ body["webhook_url"] = webhook_url
94
+ return body
95
+
96
+
97
+ def _list_params(
98
+ *,
99
+ limit: Optional[int],
100
+ starting_after: Optional[str],
101
+ status: Optional[SolveStatus],
102
+ type: Optional[SolveType],
103
+ ) -> dict[str, Any]:
104
+ params: dict[str, Any] = {}
105
+ if limit is not None:
106
+ params["limit"] = limit
107
+ if starting_after is not None:
108
+ params["starting_after"] = starting_after
109
+ if status is not None:
110
+ params["status"] = status
111
+ if type is not None:
112
+ params["type"] = type
113
+ return params
114
+
115
+
116
+ def _process_response(response: httpx.Response) -> Any:
117
+ """Parse a response, mapping non-2xx envelopes to typed errors.
118
+
119
+ 202 is a success here: the API returns it for a solve that is still
120
+ pending/solving, with the solve resource as the body.
121
+ """
122
+ try:
123
+ payload = response.json() if response.content else None
124
+ except ValueError:
125
+ raise APIError(
126
+ f"Unexpected non-JSON response (HTTP {response.status_code}): "
127
+ f"{response.text[:200]}",
128
+ status=response.status_code,
129
+ ) from None
130
+
131
+ if response.status_code < 400:
132
+ return payload
133
+
134
+ error = (payload or {}).get("error") if isinstance(payload, dict) else None
135
+ error = error if isinstance(error, dict) else {}
136
+ raise error_from_response(
137
+ response.status_code,
138
+ error.get("code"),
139
+ error.get("message") or f"HTTP {response.status_code}",
140
+ error.get("param"),
141
+ )
142
+
143
+
144
+ class _BaseClient:
145
+ def __init__(self, *, api_key: str, base_url: Optional[str], timeout: float) -> None:
146
+ if not api_key:
147
+ raise ValueError(
148
+ "A NoneCap API key is required. Pass it as NoneCap(api_key=...)."
149
+ )
150
+ self._api_key = api_key
151
+ self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
152
+ self._timeout = timeout
153
+
154
+ def _url(self, path: str) -> str:
155
+ return self._base_url + path
156
+
157
+ @property
158
+ def _headers(self) -> dict[str, str]:
159
+ return {
160
+ "Authorization": f"Bearer {self._api_key}",
161
+ "Accept": "application/json",
162
+ "User-Agent": _USER_AGENT,
163
+ }
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Sync
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ class Solves:
172
+ """Operations on solves (sync). Reached as ``client.solves``."""
173
+
174
+ def __init__(self, client: NoneCap) -> None:
175
+ self._client = client
176
+
177
+ @overload
178
+ def create(
179
+ self,
180
+ *,
181
+ type: Literal["hcaptcha"],
182
+ sitekey: str,
183
+ url: str,
184
+ rqdata: Optional[str] = None,
185
+ user_agent: Optional[str] = None,
186
+ proxy: Union[Proxy, str, None] = None,
187
+ webhook_url: Optional[str] = None,
188
+ wait: Optional[int] = None,
189
+ ) -> Solve: ...
190
+
191
+ @overload
192
+ def create(
193
+ self,
194
+ *,
195
+ type: Literal["hcaptcha_enterprise"],
196
+ sitekey: str,
197
+ url: str,
198
+ rqdata: str,
199
+ user_agent: Optional[str] = None,
200
+ proxy: Union[Proxy, str, None] = None,
201
+ webhook_url: Optional[str] = None,
202
+ wait: Optional[int] = None,
203
+ ) -> Solve: ...
204
+
205
+ def create(
206
+ self,
207
+ *,
208
+ type: SolveType,
209
+ sitekey: str,
210
+ url: str,
211
+ rqdata: Optional[str] = None,
212
+ user_agent: Optional[str] = None,
213
+ proxy: Union[Proxy, str, None] = None,
214
+ webhook_url: Optional[str] = None,
215
+ wait: Optional[int] = None,
216
+ ) -> Solve:
217
+ """Submit a solve. Pass ``wait`` (1-90 seconds) to hold the connection
218
+ open until it finishes instead of returning a pending solve."""
219
+ body = _build_solve_body(
220
+ type=type,
221
+ sitekey=sitekey,
222
+ url=url,
223
+ rqdata=rqdata,
224
+ user_agent=user_agent,
225
+ proxy=proxy,
226
+ webhook_url=webhook_url,
227
+ )
228
+ payload = self._client._request(
229
+ "POST",
230
+ "/v1/solves",
231
+ params={"wait": wait} if wait is not None else None,
232
+ json=body,
233
+ wait=wait,
234
+ )
235
+ return Solve._from_dict(payload)
236
+
237
+ def retrieve(self, solve_id: str, *, wait: Optional[int] = None) -> Solve:
238
+ """Fetch a solve by id. Pass ``wait`` to long-poll until it finishes."""
239
+ payload = self._client._request(
240
+ "GET",
241
+ f"/v1/solves/{solve_id}",
242
+ params={"wait": wait} if wait is not None else None,
243
+ wait=wait,
244
+ )
245
+ return Solve._from_dict(payload)
246
+
247
+ def cancel(self, solve_id: str) -> Solve:
248
+ """Cancel a pending or in-flight solve. Cancelled solves are never charged."""
249
+ payload = self._client._request("DELETE", f"/v1/solves/{solve_id}")
250
+ return Solve._from_dict(payload)
251
+
252
+ def list(
253
+ self,
254
+ *,
255
+ limit: Optional[int] = None,
256
+ starting_after: Optional[str] = None,
257
+ status: Optional[SolveStatus] = None,
258
+ type: Optional[SolveType] = None,
259
+ ) -> SolvePage:
260
+ """Fetch one page of solves, newest first."""
261
+ payload = self._client._request(
262
+ "GET",
263
+ "/v1/solves",
264
+ params=_list_params(
265
+ limit=limit, starting_after=starting_after, status=status, type=type
266
+ ),
267
+ )
268
+ return SolvePage._from_dict(payload)
269
+
270
+ def list_all(
271
+ self,
272
+ *,
273
+ limit: Optional[int] = None,
274
+ status: Optional[SolveStatus] = None,
275
+ type: Optional[SolveType] = None,
276
+ ) -> Iterator[Solve]:
277
+ """Iterate every solve across pages, newest first.
278
+
279
+ >>> for solve in client.solves.list_all():
280
+ ... print(solve.id, solve.status)
281
+ """
282
+ cursor: Optional[str] = None
283
+ while True:
284
+ page = self.list(limit=limit, starting_after=cursor, status=status, type=type)
285
+ yield from page.data
286
+ if not page.has_more or not page.data:
287
+ return
288
+ cursor = page.data[-1].id
289
+
290
+
291
+ class NoneCap(_BaseClient):
292
+ """The NoneCap API client (sync).
293
+
294
+ >>> from nonecap import NoneCap
295
+ >>> nc = NoneCap(api_key="nc_live_...")
296
+ >>> solve = nc.solve(type="hcaptcha", sitekey="...", url="https://example.com")
297
+ >>> solve.token
298
+ 'P1_...'
299
+ """
300
+
301
+ def __init__(
302
+ self,
303
+ *,
304
+ api_key: str,
305
+ base_url: Optional[str] = None,
306
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
307
+ http_client: Optional[httpx.Client] = None,
308
+ ) -> None:
309
+ super().__init__(api_key=api_key, base_url=base_url, timeout=timeout)
310
+ self._http = http_client or httpx.Client()
311
+ self._owns_http = http_client is None
312
+ self.solves = Solves(self)
313
+
314
+ def _request(
315
+ self,
316
+ method: str,
317
+ path: str,
318
+ *,
319
+ params: Optional[dict[str, Any]] = None,
320
+ json: Optional[dict[str, Any]] = None,
321
+ wait: Optional[int] = None,
322
+ ) -> Any:
323
+ try:
324
+ response = self._http.request(
325
+ method,
326
+ self._url(path),
327
+ params=params,
328
+ json=json,
329
+ headers=self._headers,
330
+ timeout=_request_timeout_for_wait(wait, self._timeout),
331
+ )
332
+ except httpx.TimeoutException as exc:
333
+ raise APITimeoutError(f"Request to {path} timed out: {exc}") from exc
334
+ except httpx.HTTPError as exc:
335
+ raise APIConnectionError(
336
+ f"Could not reach the NoneCap API at {self._base_url}: {exc}"
337
+ ) from exc
338
+ return _process_response(response)
339
+
340
+ @overload
341
+ def solve(
342
+ self,
343
+ *,
344
+ type: Literal["hcaptcha"],
345
+ sitekey: str,
346
+ url: str,
347
+ rqdata: Optional[str] = None,
348
+ user_agent: Optional[str] = None,
349
+ proxy: Union[Proxy, str, None] = None,
350
+ webhook_url: Optional[str] = None,
351
+ timeout: float = DEFAULT_SOLVE_TIMEOUT,
352
+ ) -> Solve: ...
353
+
354
+ @overload
355
+ def solve(
356
+ self,
357
+ *,
358
+ type: Literal["hcaptcha_enterprise"],
359
+ sitekey: str,
360
+ url: str,
361
+ rqdata: str,
362
+ user_agent: Optional[str] = None,
363
+ proxy: Union[Proxy, str, None] = None,
364
+ webhook_url: Optional[str] = None,
365
+ timeout: float = DEFAULT_SOLVE_TIMEOUT,
366
+ ) -> Solve: ...
367
+
368
+ def solve(
369
+ self,
370
+ *,
371
+ type: SolveType,
372
+ sitekey: str,
373
+ url: str,
374
+ rqdata: Optional[str] = None,
375
+ user_agent: Optional[str] = None,
376
+ proxy: Union[Proxy, str, None] = None,
377
+ webhook_url: Optional[str] = None,
378
+ timeout: float = DEFAULT_SOLVE_TIMEOUT,
379
+ ) -> Solve:
380
+ """Submit a solve and wait for it to finish, returning the solved solve.
381
+
382
+ Uses the server's long-poll under the hood and keeps polling until the
383
+ solve is terminal or ``timeout`` seconds elapse. Raises
384
+ :class:`SolveFailedError` if the solve fails/expires/is cancelled, or
385
+ :class:`SolveTimeoutError` on timeout.
386
+ """
387
+ deadline = time.monotonic() + timeout
388
+ # 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.
392
+ body = _build_solve_body(
393
+ type=type,
394
+ sitekey=sitekey,
395
+ url=url,
396
+ rqdata=rqdata,
397
+ user_agent=user_agent,
398
+ proxy=proxy,
399
+ webhook_url=webhook_url,
400
+ )
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
416
+
417
+ def me(self) -> Account:
418
+ """Fetch your account, including the current credit balance."""
419
+ return Account._from_dict(self._request("GET", "/v1/me"))
420
+
421
+ def close(self) -> None:
422
+ """Close the underlying HTTP client (only if this client created it)."""
423
+ if self._owns_http:
424
+ self._http.close()
425
+
426
+ def __enter__(self) -> NoneCap:
427
+ return self
428
+
429
+ def __exit__(self, *exc_info: object) -> None:
430
+ self.close()
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # Async
435
+ # ---------------------------------------------------------------------------
436
+
437
+
438
+ class AsyncSolves:
439
+ """Operations on solves (async). Reached as ``client.solves``."""
440
+
441
+ def __init__(self, client: AsyncNoneCap) -> None:
442
+ self._client = client
443
+
444
+ @overload
445
+ async def create(
446
+ self,
447
+ *,
448
+ type: Literal["hcaptcha"],
449
+ sitekey: str,
450
+ url: str,
451
+ rqdata: Optional[str] = None,
452
+ user_agent: Optional[str] = None,
453
+ proxy: Union[Proxy, str, None] = None,
454
+ webhook_url: Optional[str] = None,
455
+ wait: Optional[int] = None,
456
+ ) -> Solve: ...
457
+
458
+ @overload
459
+ async def create(
460
+ self,
461
+ *,
462
+ type: Literal["hcaptcha_enterprise"],
463
+ sitekey: str,
464
+ url: str,
465
+ rqdata: str,
466
+ user_agent: Optional[str] = None,
467
+ proxy: Union[Proxy, str, None] = None,
468
+ webhook_url: Optional[str] = None,
469
+ wait: Optional[int] = None,
470
+ ) -> Solve: ...
471
+
472
+ async def create(
473
+ self,
474
+ *,
475
+ type: SolveType,
476
+ sitekey: str,
477
+ url: str,
478
+ rqdata: Optional[str] = None,
479
+ user_agent: Optional[str] = None,
480
+ proxy: Union[Proxy, str, None] = None,
481
+ webhook_url: Optional[str] = None,
482
+ wait: Optional[int] = None,
483
+ ) -> Solve:
484
+ """Submit a solve. Pass ``wait`` (1-90 seconds) to hold the connection
485
+ open until it finishes instead of returning a pending solve."""
486
+ body = _build_solve_body(
487
+ type=type,
488
+ sitekey=sitekey,
489
+ url=url,
490
+ rqdata=rqdata,
491
+ user_agent=user_agent,
492
+ proxy=proxy,
493
+ webhook_url=webhook_url,
494
+ )
495
+ payload = await self._client._request(
496
+ "POST",
497
+ "/v1/solves",
498
+ params={"wait": wait} if wait is not None else None,
499
+ json=body,
500
+ wait=wait,
501
+ )
502
+ return Solve._from_dict(payload)
503
+
504
+ async def retrieve(self, solve_id: str, *, wait: Optional[int] = None) -> Solve:
505
+ """Fetch a solve by id. Pass ``wait`` to long-poll until it finishes."""
506
+ payload = await self._client._request(
507
+ "GET",
508
+ f"/v1/solves/{solve_id}",
509
+ params={"wait": wait} if wait is not None else None,
510
+ wait=wait,
511
+ )
512
+ return Solve._from_dict(payload)
513
+
514
+ async def cancel(self, solve_id: str) -> Solve:
515
+ """Cancel a pending or in-flight solve. Cancelled solves are never charged."""
516
+ payload = await self._client._request("DELETE", f"/v1/solves/{solve_id}")
517
+ return Solve._from_dict(payload)
518
+
519
+ async def list(
520
+ self,
521
+ *,
522
+ limit: Optional[int] = None,
523
+ starting_after: Optional[str] = None,
524
+ status: Optional[SolveStatus] = None,
525
+ type: Optional[SolveType] = None,
526
+ ) -> SolvePage:
527
+ """Fetch one page of solves, newest first."""
528
+ payload = await self._client._request(
529
+ "GET",
530
+ "/v1/solves",
531
+ params=_list_params(
532
+ limit=limit, starting_after=starting_after, status=status, type=type
533
+ ),
534
+ )
535
+ return SolvePage._from_dict(payload)
536
+
537
+ async def list_all(
538
+ self,
539
+ *,
540
+ limit: Optional[int] = None,
541
+ status: Optional[SolveStatus] = None,
542
+ type: Optional[SolveType] = None,
543
+ ) -> AsyncIterator[Solve]:
544
+ """Iterate every solve across pages, newest first.
545
+
546
+ >>> async for solve in client.solves.list_all():
547
+ ... print(solve.id, solve.status)
548
+ """
549
+ cursor: Optional[str] = None
550
+ while True:
551
+ page = await self.list(
552
+ limit=limit, starting_after=cursor, status=status, type=type
553
+ )
554
+ for solve in page.data:
555
+ yield solve
556
+ if not page.has_more or not page.data:
557
+ return
558
+ cursor = page.data[-1].id
559
+
560
+
561
+ class AsyncNoneCap(_BaseClient):
562
+ """The NoneCap API client (async).
563
+
564
+ >>> from nonecap import AsyncNoneCap
565
+ >>> async with AsyncNoneCap(api_key="nc_live_...") as nc:
566
+ ... solve = await nc.solve(type="hcaptcha", sitekey="...", url="https://example.com")
567
+ """
568
+
569
+ def __init__(
570
+ self,
571
+ *,
572
+ api_key: str,
573
+ base_url: Optional[str] = None,
574
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
575
+ http_client: Optional[httpx.AsyncClient] = None,
576
+ ) -> None:
577
+ super().__init__(api_key=api_key, base_url=base_url, timeout=timeout)
578
+ self._http = http_client or httpx.AsyncClient()
579
+ self._owns_http = http_client is None
580
+ self.solves = AsyncSolves(self)
581
+
582
+ async def _request(
583
+ self,
584
+ method: str,
585
+ path: str,
586
+ *,
587
+ params: Optional[dict[str, Any]] = None,
588
+ json: Optional[dict[str, Any]] = None,
589
+ wait: Optional[int] = None,
590
+ ) -> Any:
591
+ try:
592
+ response = await self._http.request(
593
+ method,
594
+ self._url(path),
595
+ params=params,
596
+ json=json,
597
+ headers=self._headers,
598
+ timeout=_request_timeout_for_wait(wait, self._timeout),
599
+ )
600
+ except httpx.TimeoutException as exc:
601
+ raise APITimeoutError(f"Request to {path} timed out: {exc}") from exc
602
+ except httpx.HTTPError as exc:
603
+ raise APIConnectionError(
604
+ f"Could not reach the NoneCap API at {self._base_url}: {exc}"
605
+ ) from exc
606
+ return _process_response(response)
607
+
608
+ @overload
609
+ async def solve(
610
+ self,
611
+ *,
612
+ type: Literal["hcaptcha"],
613
+ sitekey: str,
614
+ url: str,
615
+ rqdata: Optional[str] = None,
616
+ user_agent: Optional[str] = None,
617
+ proxy: Union[Proxy, str, None] = None,
618
+ webhook_url: Optional[str] = None,
619
+ timeout: float = DEFAULT_SOLVE_TIMEOUT,
620
+ ) -> Solve: ...
621
+
622
+ @overload
623
+ async def solve(
624
+ self,
625
+ *,
626
+ type: Literal["hcaptcha_enterprise"],
627
+ sitekey: str,
628
+ url: str,
629
+ rqdata: str,
630
+ user_agent: Optional[str] = None,
631
+ proxy: Union[Proxy, str, None] = None,
632
+ webhook_url: Optional[str] = None,
633
+ timeout: float = DEFAULT_SOLVE_TIMEOUT,
634
+ ) -> Solve: ...
635
+
636
+ async def solve(
637
+ self,
638
+ *,
639
+ type: SolveType,
640
+ sitekey: str,
641
+ url: str,
642
+ rqdata: Optional[str] = None,
643
+ user_agent: Optional[str] = None,
644
+ proxy: Union[Proxy, str, None] = None,
645
+ webhook_url: Optional[str] = None,
646
+ timeout: float = DEFAULT_SOLVE_TIMEOUT,
647
+ ) -> Solve:
648
+ """Submit a solve and wait for it to finish, returning the solved solve.
649
+
650
+ Uses the server's long-poll under the hood and keeps polling until the
651
+ solve is terminal or ``timeout`` seconds elapse. Raises
652
+ :class:`SolveFailedError` if the solve fails/expires/is cancelled, or
653
+ :class:`SolveTimeoutError` on timeout.
654
+ """
655
+ deadline = time.monotonic() + timeout
656
+ # Same shape as the sync client: build the body directly instead of
657
+ # dispatching through the overloaded create() with union-typed args.
658
+ body = _build_solve_body(
659
+ type=type,
660
+ sitekey=sitekey,
661
+ url=url,
662
+ rqdata=rqdata,
663
+ user_agent=user_agent,
664
+ proxy=proxy,
665
+ webhook_url=webhook_url,
666
+ )
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
682
+
683
+ async def me(self) -> Account:
684
+ """Fetch your account, including the current credit balance."""
685
+ return Account._from_dict(await self._request("GET", "/v1/me"))
686
+
687
+ async def close(self) -> None:
688
+ """Close the underlying HTTP client (only if this client created it)."""
689
+ if self._owns_http:
690
+ await self._http.aclose()
691
+
692
+ async def __aenter__(self) -> AsyncNoneCap:
693
+ return self
694
+
695
+ async def __aexit__(self, *exc_info: object) -> None:
696
+ await self.close()
nonecap/_errors.py ADDED
@@ -0,0 +1,112 @@
1
+ """The error tree. Catch :class:`NoneCapError` for everything this library
2
+ throws, or a subclass for one specific failure."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import TYPE_CHECKING, Optional
7
+
8
+ if TYPE_CHECKING:
9
+ from ._types import Solve
10
+
11
+
12
+ class NoneCapError(Exception):
13
+ """Base class for every error raised by this library."""
14
+
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ *,
19
+ code: Optional[str] = None,
20
+ status: Optional[int] = None,
21
+ param: Optional[str] = None,
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.message = message
25
+ self.code = code
26
+ """Machine-readable error code from the API envelope, when there is one."""
27
+ self.status = status
28
+ """HTTP status, when the error came from a response."""
29
+ self.param = param
30
+ """The request field that was rejected, for validation errors."""
31
+
32
+
33
+ class AuthenticationError(NoneCapError):
34
+ """401 — the API key is missing, malformed, or revoked."""
35
+
36
+
37
+ class PermissionDeniedError(NoneCapError):
38
+ """403 — the key is valid but not allowed to do this (scope or locked account)."""
39
+
40
+
41
+ class InsufficientCreditsError(NoneCapError):
42
+ """402 — the account is out of credits."""
43
+
44
+
45
+ class ValidationError(NoneCapError):
46
+ """422 / 400 — the request was rejected. ``param`` names the offending field."""
47
+
48
+
49
+ class NotFoundError(NoneCapError):
50
+ """404 — no such resource."""
51
+
52
+
53
+ class ConflictError(NoneCapError):
54
+ """409 — the solve is already in a terminal state."""
55
+
56
+
57
+ class RateLimitError(NoneCapError):
58
+ """429 — too many concurrent solves, or rate limited. Back off and retry."""
59
+
60
+
61
+ class APIError(NoneCapError):
62
+ """5xx, or a response that was not the expected shape."""
63
+
64
+
65
+ class APIConnectionError(NoneCapError):
66
+ """The request never reached the API (DNS, TCP, TLS, offline)."""
67
+
68
+
69
+ class APITimeoutError(APIConnectionError):
70
+ """A single HTTP request exceeded its timeout."""
71
+
72
+
73
+ class SolveTimeoutError(NoneCapError):
74
+ """Raised by ``solve()`` when the overall timeout elapses first."""
75
+
76
+
77
+ class SolveFailedError(NoneCapError):
78
+ """Raised by ``solve()`` when a solve reaches a terminal state without a
79
+ token: ``failed``, ``expired``, or ``cancelled``. The full solve is attached
80
+ as ``.solve`` so you can inspect ``solve.error`` and the timings."""
81
+
82
+ def __init__(self, solve: Solve) -> None:
83
+ detail = f"{solve.error.code}: {solve.error.message}" if solve.error else solve.status
84
+ super().__init__(f"Solve {solve.id} {solve.status} ({detail})")
85
+ self.solve = solve
86
+
87
+
88
+ def error_from_response(
89
+ status: int,
90
+ code: Optional[str],
91
+ message: str,
92
+ param: Optional[str],
93
+ ) -> NoneCapError:
94
+ """Map an API error envelope (plus HTTP status) to the right subclass."""
95
+ cls: type[NoneCapError]
96
+ if code == "unauthorized":
97
+ cls = AuthenticationError
98
+ elif code in ("forbidden", "account_locked"):
99
+ cls = PermissionDeniedError
100
+ elif code == "insufficient_credits":
101
+ cls = InsufficientCreditsError
102
+ elif code in ("invalid_request", "validation_error"):
103
+ cls = ValidationError
104
+ elif code == "not_found":
105
+ cls = NotFoundError
106
+ elif code == "conflict":
107
+ cls = ConflictError
108
+ elif code in ("rate_limited", "concurrency_limit_exceeded", "ext_daily_limit"):
109
+ cls = RateLimitError
110
+ else:
111
+ cls = APIError
112
+ return cls(message, code=code, status=status, param=param)
nonecap/_types.py ADDED
@@ -0,0 +1,127 @@
1
+ """Wire types for the NoneCap API.
2
+
3
+ Field names are snake_case and mirror the JSON on the wire exactly, so what
4
+ you read in the docs is what you access in code. Parsers pick known keys and
5
+ ignore unknown ones, so new server-side fields never break old clients.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any, Literal, Optional, TypedDict
12
+
13
+ SolveType = Literal["hcaptcha", "hcaptcha_enterprise"]
14
+ """Captcha type a solve targets."""
15
+
16
+ SolveStatus = Literal["pending", "solving", "solved", "failed", "cancelled", "expired"]
17
+ """Lifecycle of a solve. ``solved``/``failed``/``cancelled``/``expired`` are terminal."""
18
+
19
+ TERMINAL_STATUSES: frozenset[str] = frozenset({"solved", "failed", "cancelled", "expired"})
20
+ """The statuses a solve can never leave."""
21
+
22
+
23
+ class Proxy(TypedDict, total=False):
24
+ """A proxy the solve should egress through."""
25
+
26
+ scheme: str
27
+ host: str
28
+ port: str | int
29
+ username: str
30
+ password: str
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SolveError:
35
+ """The error attached to a solve that did not succeed."""
36
+
37
+ code: str
38
+ message: str
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Solve:
43
+ """A solve resource, exactly as the API returns it."""
44
+
45
+ id: str
46
+ object: str
47
+ type: SolveType
48
+ status: SolveStatus
49
+ sitekey: str
50
+ url: str
51
+ token: Optional[str]
52
+ """The captcha token once ``status == "solved"``, otherwise None."""
53
+ error: Optional[SolveError]
54
+ """Set when the solve did not succeed, otherwise None."""
55
+ credits_charged: Optional[int]
56
+ """Credits charged for this solve. Only successful solves are charged."""
57
+ proxy_bytes: Optional[int]
58
+ """Bytes that egressed through the metered proxy, or None if none was used."""
59
+ created_at: str
60
+ started_at: Optional[str]
61
+ finished_at: Optional[str]
62
+ queue_ms: Optional[int]
63
+ """Milliseconds the solve waited in the queue before a worker picked it up."""
64
+ resolve_ms: Optional[int]
65
+ """Milliseconds of actual solving."""
66
+
67
+ @property
68
+ def is_terminal(self) -> bool:
69
+ """Whether the solve has reached a final state."""
70
+ return self.status in TERMINAL_STATUSES
71
+
72
+ @classmethod
73
+ def _from_dict(cls, data: dict[str, Any]) -> Solve:
74
+ raw_error = data.get("error")
75
+ return cls(
76
+ id=data["id"],
77
+ object=data.get("object", "solve"),
78
+ type=data["type"],
79
+ status=data["status"],
80
+ sitekey=data.get("sitekey", ""),
81
+ url=data.get("url", ""),
82
+ token=data.get("token"),
83
+ error=SolveError(code=raw_error["code"], message=raw_error["message"])
84
+ if raw_error
85
+ else None,
86
+ credits_charged=data.get("credits_charged"),
87
+ proxy_bytes=data.get("proxy_bytes"),
88
+ created_at=data.get("created_at", ""),
89
+ started_at=data.get("started_at"),
90
+ finished_at=data.get("finished_at"),
91
+ queue_ms=data.get("queue_ms"),
92
+ resolve_ms=data.get("resolve_ms"),
93
+ )
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class SolvePage:
98
+ """One page of solves, newest first."""
99
+
100
+ data: list[Solve]
101
+ has_more: bool
102
+
103
+ @classmethod
104
+ def _from_dict(cls, payload: dict[str, Any]) -> SolvePage:
105
+ return cls(
106
+ data=[Solve._from_dict(item) for item in payload.get("data", [])],
107
+ has_more=bool(payload.get("has_more", False)),
108
+ )
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class Account:
113
+ """Your account, including the current credit balance."""
114
+
115
+ id: str
116
+ email: str
117
+ credits_balance: int
118
+ created_at: str
119
+
120
+ @classmethod
121
+ def _from_dict(cls, data: dict[str, Any]) -> Account:
122
+ return cls(
123
+ id=data["id"],
124
+ email=data.get("email", ""),
125
+ credits_balance=int(data.get("credits_balance", 0)),
126
+ created_at=data.get("created_at", ""),
127
+ )
nonecap/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
nonecap/py.typed ADDED
File without changes
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: nonecap
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the NoneCap hCaptcha solving API.
5
+ Project-URL: Homepage, https://nonecap.com
6
+ Project-URL: Documentation, https://nonecap.com/api-reference
7
+ Project-URL: Repository, https://github.com/nonecap/nonecap-py
8
+ Project-URL: Issues, https://github.com/nonecap/nonecap-py/issues
9
+ Author: Lunium
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api-client,captcha,captcha-solver,hcaptcha,hcaptcha-solver,nonecap,sdk
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: httpx>=0.24
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.13; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
28
+ Requires-Dist: pytest>=8; extra == 'dev'
29
+ Requires-Dist: ruff>=0.8; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ <h1 align="center">nonecap</h1>
33
+
34
+ <p align="center">
35
+ <a href="https://github.com/nonecap/nonecap-py/actions/workflows/ci.yml"><img src="https://github.com/nonecap/nonecap-py/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
36
+ <a href="https://pypi.org/project/nonecap/"><img src="https://img.shields.io/pypi/v/nonecap.svg" alt="PyPI"></a>
37
+ <a href="https://pypi.org/project/nonecap/"><img src="https://img.shields.io/pypi/pyversions/nonecap.svg" alt="Python versions"></a>
38
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
39
+ </p>
40
+
41
+ <p align="center">Official Python client for the <a href="https://nonecap.com">NoneCap</a> hCaptcha solving API.</p>
42
+
43
+ Submit a captcha, get back a token. The client handles the polling, the timeouts, and the error cases so you don't write the request loop yourself. Sync and async, fully typed.
44
+
45
+ ## Install
46
+
47
+ ```sh
48
+ pip install nonecap
49
+ ```
50
+
51
+ Python 3.9+. The only dependency is [httpx](https://www.python-httpx.org/).
52
+
53
+ ## Quick start
54
+
55
+ Grab an API key from [dashboard.nonecap.com](https://dashboard.nonecap.com), then:
56
+
57
+ ```python
58
+ from nonecap import NoneCap
59
+
60
+ nc = NoneCap(api_key="nc_live_...")
61
+
62
+ solve = nc.solve(
63
+ type="hcaptcha",
64
+ sitekey="10000000-ffff-ffff-ffff-000000000001",
65
+ url="https://example.com/login",
66
+ )
67
+
68
+ print(solve.token) # the hCaptcha token, ready to submit
69
+ ```
70
+
71
+ `solve()` submits the captcha and waits until it's done, using the API's long-poll so you aren't hammering it with requests. It returns the solved solve, or raises if the solve fails or your timeout runs out.
72
+
73
+ ## Async
74
+
75
+ Same surface, `await`ed. Use it as an async context manager so the connection pool gets cleaned up:
76
+
77
+ ```python
78
+ import asyncio
79
+ from nonecap import AsyncNoneCap
80
+
81
+ async def main() -> None:
82
+ async with AsyncNoneCap(api_key="nc_live_...") as nc:
83
+ solve = await nc.solve(type="hcaptcha", sitekey="...", url="https://example.com")
84
+ print(solve.token)
85
+
86
+ asyncio.run(main())
87
+ ```
88
+
89
+ ## Handling failures
90
+
91
+ Every error this library raises extends `NoneCapError`, so you can catch the whole family or pick out the one you care about.
92
+
93
+ ```python
94
+ from nonecap import (
95
+ NoneCap,
96
+ SolveFailedError,
97
+ InsufficientCreditsError,
98
+ RateLimitError,
99
+ )
100
+
101
+ try:
102
+ solve = nc.solve(type="hcaptcha", sitekey=sitekey, url=url)
103
+ except SolveFailedError as err:
104
+ print("Could not solve it:", err.solve.error.code if err.solve.error else "?")
105
+ except InsufficientCreditsError:
106
+ print("Out of credits. Top up at dashboard.nonecap.com")
107
+ except RateLimitError:
108
+ print("Too many solves in flight, back off and retry")
109
+ ```
110
+
111
+ The subclasses are `AuthenticationError` (401), `PermissionDeniedError` (403), `InsufficientCreditsError` (402), `ValidationError` (422/400, with a `param` naming the bad field), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429), `APIError` (5xx), `APIConnectionError` and `APITimeoutError` (the request never landed), and `SolveTimeoutError` (your `solve()` budget ran out). `SolveFailedError` carries the full `solve` so you can read the underlying error code and the timings.
112
+
113
+ ## Enterprise captchas
114
+
115
+ For `hcaptcha_enterprise`, `rqdata` is required. The `@overload` signatures enforce that in mypy and pyright, so leaving it out fails your type check, and a runtime check backs it up before any network call:
116
+
117
+ ```python
118
+ solve = nc.solve(
119
+ type="hcaptcha_enterprise",
120
+ sitekey=sitekey,
121
+ url=url,
122
+ rqdata="...", # required for enterprise
123
+ )
124
+ ```
125
+
126
+ ## Proxies
127
+
128
+ Pass a proxy as a dict or a URL string. The solve runs through it, and the bytes are metered back on the solve.
129
+
130
+ ```python
131
+ nc.solve(
132
+ type="hcaptcha",
133
+ sitekey=sitekey,
134
+ url=url,
135
+ proxy={"scheme": "http", "host": "1.2.3.4", "port": 8080, "username": "u", "password": "p"},
136
+ # or: proxy="http://u:p@1.2.3.4:8080"
137
+ )
138
+ ```
139
+
140
+ ## Lower-level API
141
+
142
+ `solve()` is the convenient path. When you want control over submission and polling, the resource methods map one to one to the REST API:
143
+
144
+ ```python
145
+ # Submit without waiting: returns immediately with a pending solve
146
+ pending = nc.solves.create(type="hcaptcha", sitekey=sitekey, url=url)
147
+
148
+ # Submit and hold the connection up to 30s for it to finish
149
+ maybe_done = nc.solves.create(type="hcaptcha", sitekey=sitekey, url=url, wait=30)
150
+
151
+ # Poll one solve, long-polling up to 30s
152
+ solve = nc.solves.retrieve(pending.id, wait=30)
153
+
154
+ # Cancel a pending or in-flight solve
155
+ nc.solves.cancel(pending.id)
156
+
157
+ # List a page of solves
158
+ page = nc.solves.list(limit=50, status="solved")
159
+
160
+ # Or iterate every solve, newest first
161
+ for s in nc.solves.list_all():
162
+ print(s.id, s.status)
163
+
164
+ # Your account and credit balance
165
+ me = nc.me()
166
+ print(me.credits_balance)
167
+ ```
168
+
169
+ On `AsyncNoneCap` the same methods are coroutines, and `list_all()` is an async iterator (`async for s in nc.solves.list_all()`).
170
+
171
+ ## Configuration
172
+
173
+ ```python
174
+ NoneCap(
175
+ api_key="nc_live_...", # required
176
+ base_url="https://api.nonecap.com", # override if you need to
177
+ timeout=100.0, # per HTTP request, seconds
178
+ http_client=my_httpx_client, # inject your own httpx.Client
179
+ )
180
+ ```
181
+
182
+ `solve()` takes its own `timeout` (seconds, default 180) for the overall wait.
183
+
184
+ ## Typing
185
+
186
+ The package ships a `py.typed` marker and full inline annotations. Solves come back as frozen dataclasses with the exact field names the API uses (`solve.token`, `solve.credits_charged`, `solve.queue_ms`), so what you read in the [API reference](https://nonecap.com/api-reference) is what you get in code.
187
+
188
+ ## License
189
+
190
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ nonecap/__init__.py,sha256=y5acaR3Vuo7M2XTlwIUr0RiXoDVUb6vokkpDtkLEy-w,1331
2
+ nonecap/_client.py,sha256=7wsFElFydzgPF6EB9GBCz63nMPr1ONHush9whXRYwKw,22313
3
+ nonecap/_errors.py,sha256=iebsKERVgKmt03KvWzsXRPQZGVM4EvH3qJTdIYK4fiY,3476
4
+ nonecap/_types.py,sha256=YdFXAY-R4KIWybO5PjSdPdApgeLgNgUDOgrQZQPQgxg,3873
5
+ nonecap/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
6
+ nonecap/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ nonecap-0.1.0.dist-info/METADATA,sha256=3Hw0Tub5tJxixETEdSsTejvSzq2BYL_pkHW_SHa37T0,6812
8
+ nonecap-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ nonecap-0.1.0.dist-info/licenses/LICENSE,sha256=u5uegtPBAfHVIaKG03gcXYSOhKo8MI2_LwnxSTnZI38,1063
10
+ nonecap-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lunium
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.