nonecap 0.2.0__tar.gz → 0.4.0__tar.gz

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