sursolver 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.
sursolver/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """Official Python client for the SurSolver captcha-solving API."""
2
+
3
+ from ._async_client import AsyncSurSolver
4
+ from ._client import SurSolver
5
+ from ._core import (
6
+ DEFAULT_BASE_URL,
7
+ DEFAULT_MAX_SUBMIT_RETRIES,
8
+ DEFAULT_POLL_INTERVAL,
9
+ DEFAULT_REQUEST_TIMEOUT,
10
+ DEFAULT_SOLVE_TIMEOUT,
11
+ SUBMIT_RETRY_JITTER_MS,
12
+ )
13
+ from ._errors import (
14
+ ApiError,
15
+ AuthenticationError,
16
+ ConfigurationError,
17
+ InsufficientBalanceError,
18
+ InvalidTaskError,
19
+ NoSlotAvailableError,
20
+ Operation,
21
+ ProtocolError,
22
+ SolveTimeoutError,
23
+ SurSolverError,
24
+ TaskFailedError,
25
+ TaskNotFoundError,
26
+ TransportError,
27
+ )
28
+ from ._types import (
29
+ AsyncClock,
30
+ AsyncTransport,
31
+ Clock,
32
+ HttpRequest,
33
+ HttpResponse,
34
+ ServiceInfo,
35
+ Solution,
36
+ SolveResult,
37
+ Task,
38
+ TaskResult,
39
+ TaskTypeCatalog,
40
+ TaskTypeInfo,
41
+ Transport,
42
+ )
43
+ from ._version import VERSION
44
+ from .task_types import ErrorCode, TaskType
45
+
46
+ __version__ = VERSION
47
+
48
+ __all__ = [
49
+ "DEFAULT_BASE_URL",
50
+ "DEFAULT_MAX_SUBMIT_RETRIES",
51
+ "DEFAULT_POLL_INTERVAL",
52
+ "DEFAULT_REQUEST_TIMEOUT",
53
+ "DEFAULT_SOLVE_TIMEOUT",
54
+ "SUBMIT_RETRY_JITTER_MS",
55
+ "VERSION",
56
+ "ApiError",
57
+ "AsyncClock",
58
+ "AsyncSurSolver",
59
+ "AsyncTransport",
60
+ "AuthenticationError",
61
+ "Clock",
62
+ "ConfigurationError",
63
+ "ErrorCode",
64
+ "HttpRequest",
65
+ "HttpResponse",
66
+ "InsufficientBalanceError",
67
+ "InvalidTaskError",
68
+ "NoSlotAvailableError",
69
+ "Operation",
70
+ "ProtocolError",
71
+ "ServiceInfo",
72
+ "Solution",
73
+ "SolveResult",
74
+ "SolveTimeoutError",
75
+ "SurSolver",
76
+ "SurSolverError",
77
+ "Task",
78
+ "TaskFailedError",
79
+ "TaskNotFoundError",
80
+ "TaskResult",
81
+ "TaskType",
82
+ "TaskTypeCatalog",
83
+ "TaskTypeInfo",
84
+ "Transport",
85
+ "TransportError",
86
+ ]
@@ -0,0 +1,191 @@
1
+ """Asynchronous client (asyncio)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import random as _random
7
+ from collections.abc import Callable
8
+ from types import TracebackType
9
+ from typing import Any, Self
10
+
11
+ import httpx
12
+
13
+ from . import _core
14
+ from ._errors import NoSlotAvailableError, Operation, SolveTimeoutError, SurSolverError
15
+ from ._types import (
16
+ AsyncClock,
17
+ AsyncTransport,
18
+ HttpRequest,
19
+ HttpResponse,
20
+ ServiceInfo,
21
+ SolveResult,
22
+ Task,
23
+ TaskResult,
24
+ TaskTypeCatalog,
25
+ )
26
+
27
+
28
+ class _AsyncSystemClock:
29
+ def now(self) -> float:
30
+ return asyncio.get_running_loop().time()
31
+
32
+ async def sleep(self, seconds: float) -> None:
33
+ await asyncio.sleep(seconds)
34
+
35
+
36
+ class AsyncSurSolver:
37
+ """asyncio client for the SurSolver solver API; same behaviour as ``SurSolver``.
38
+
39
+ Cancel a call the asyncio way (``task.cancel()``, ``asyncio.timeout``): the call raises
40
+ ``asyncio.CancelledError`` and never claims the task failed. Use as an async context
41
+ manager (or call ``aclose()``) to release the default transport's connection pool.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ api_key: str,
47
+ *,
48
+ base_url: str = _core.DEFAULT_BASE_URL,
49
+ request_timeout: float | None = None,
50
+ poll_interval: float | None = None,
51
+ solve_timeout: float | None = None,
52
+ max_submit_retries: int | None = None,
53
+ app_id: str | None = None,
54
+ user_agent_suffix: str | None = None,
55
+ transport: AsyncTransport | None = None,
56
+ clock: AsyncClock | None = None,
57
+ random: Callable[[], float] | None = None,
58
+ ) -> None:
59
+ self._settings = _core.build_settings(
60
+ api_key,
61
+ base_url=base_url,
62
+ request_timeout=request_timeout,
63
+ poll_interval=poll_interval,
64
+ solve_timeout=solve_timeout,
65
+ max_submit_retries=max_submit_retries,
66
+ app_id=app_id,
67
+ user_agent_suffix=user_agent_suffix,
68
+ )
69
+ self._http: httpx.AsyncClient | None = None
70
+ if transport is None:
71
+ self._http = httpx.AsyncClient(follow_redirects=False)
72
+ transport = self._httpx_send
73
+ self._transport = transport
74
+ self._clock: AsyncClock = clock if clock is not None else _AsyncSystemClock()
75
+ self._random = random if random is not None else _random.random
76
+
77
+ def __repr__(self) -> str:
78
+ return f"AsyncSurSolver(base_url={self._settings.base_url!r})"
79
+
80
+ async def __aenter__(self) -> Self:
81
+ return self
82
+
83
+ async def __aexit__(
84
+ self,
85
+ exc_type: type[BaseException] | None,
86
+ exc: BaseException | None,
87
+ tb: TracebackType | None,
88
+ ) -> None:
89
+ await self.aclose()
90
+
91
+ async def aclose(self) -> None:
92
+ if self._http is not None:
93
+ await self._http.aclose()
94
+
95
+ async def create_task(self, task: Task) -> str:
96
+ """Submit a task and return its id. The task is charged from this point. Never retried."""
97
+ payload = _core.create_task_payload(self._settings, task)
98
+ body = await self._solver_call("createTask", "/createTask", payload)
99
+ return _core.task_id_from(body)
100
+
101
+ async def get_task_result(self, task_id: str) -> TaskResult:
102
+ """Read a task's state once. A terminal failure raises ``TaskFailedError``."""
103
+ payload = {"clientKey": self._settings.api_key, "taskId": task_id}
104
+ body = await self._solver_call("getTaskResult", "/getTaskResult", payload, task_id)
105
+ return _core.task_result_from(body)
106
+
107
+ async def get_balance(self) -> float:
108
+ """Cash balance in USD as the JSON number the API sends (display only)."""
109
+ body = await self._solver_call("getBalance", "/getBalance", {"clientKey": self._settings.api_key})
110
+ return _core.balance_from(body)
111
+
112
+ async def solve(
113
+ self,
114
+ task: Task,
115
+ *,
116
+ poll_interval: float | None = None,
117
+ solve_timeout: float | None = None,
118
+ on_task_created: Callable[[str], None] | None = None,
119
+ ) -> SolveResult:
120
+ """Create a task and poll it until it is ready, failed, or the deadline passes.
121
+
122
+ Same rules as ``SurSolver.solve``. Pass ``on_task_created`` to keep the task id when
123
+ the call is cancelled.
124
+ """
125
+ interval = _core.positive_seconds("poll_interval", poll_interval, self._settings.poll_interval)
126
+ timeout = _core.positive_seconds("solve_timeout", solve_timeout, self._settings.solve_timeout)
127
+ deadline = self._clock.now() + timeout
128
+
129
+ retries = 0
130
+ while True:
131
+ try:
132
+ task_id = await self.create_task(task)
133
+ break
134
+ except NoSlotAvailableError:
135
+ if retries >= self._settings.max_submit_retries:
136
+ raise
137
+ retries += 1
138
+ await self._clock.sleep(_core.submit_retry_delay(interval, self._random()))
139
+ if on_task_created is not None:
140
+ on_task_created(task_id)
141
+
142
+ while True:
143
+ last_error: SurSolverError | None = None
144
+ try:
145
+ result = await self.get_task_result(task_id)
146
+ if result.status == "ready" and result.solution is not None:
147
+ return SolveResult(task_id=task_id, solution=result.solution)
148
+ except SurSolverError as error:
149
+ if not _core.is_transient_poll_error(error):
150
+ raise
151
+ last_error = error
152
+ await self._clock.sleep(interval)
153
+ if self._clock.now() >= deadline:
154
+ raise SolveTimeoutError(task_id, timeout, last_error)
155
+
156
+ async def list_task_types(self) -> TaskTypeCatalog:
157
+ """The public task-type catalog: accepted names, fields, proxy modes and result keys."""
158
+ return _core.task_types_from(await self._catalog_call("listTaskTypes", "/v1/task-types"))
159
+
160
+ async def list_services(self) -> list[ServiceInfo]:
161
+ """Active services with their current USD price per task (decimal strings)."""
162
+ return _core.services_from(await self._catalog_call("listServices", "/v1/services"))
163
+
164
+ async def _httpx_send(self, request: HttpRequest) -> HttpResponse:
165
+ assert self._http is not None
166
+ response = await self._http.request(
167
+ request.method,
168
+ request.url,
169
+ headers=dict(request.headers),
170
+ content=request.body,
171
+ timeout=request.timeout,
172
+ )
173
+ return HttpResponse(
174
+ status=response.status_code, body=response.content, headers=dict(response.headers)
175
+ )
176
+
177
+ async def _send(self, operation: Operation, request: HttpRequest) -> HttpResponse:
178
+ try:
179
+ return await self._transport(request)
180
+ except Exception as error:
181
+ raise _core.transport_failure(operation, error, request.timeout) from error
182
+
183
+ async def _solver_call(
184
+ self, operation: Operation, path: str, payload: dict[str, Any], task_id: str | None = None
185
+ ) -> dict[str, Any]:
186
+ response = await self._send(operation, _core.solver_request(self._settings, path, payload))
187
+ return _core.parse_solver(operation, response, task_id)
188
+
189
+ async def _catalog_call(self, operation: Operation, path: str) -> dict[str, Any]:
190
+ response = await self._send(operation, _core.catalog_request(self._settings, path))
191
+ return _core.parse_catalog(operation, response)
sursolver/_client.py ADDED
@@ -0,0 +1,194 @@
1
+ """Synchronous client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random as _random
6
+ import time
7
+ from collections.abc import Callable
8
+ from types import TracebackType
9
+ from typing import Any, Self
10
+
11
+ import httpx
12
+
13
+ from . import _core
14
+ from ._errors import NoSlotAvailableError, Operation, SolveTimeoutError, SurSolverError
15
+ from ._types import (
16
+ Clock,
17
+ HttpRequest,
18
+ HttpResponse,
19
+ ServiceInfo,
20
+ SolveResult,
21
+ Task,
22
+ TaskResult,
23
+ TaskTypeCatalog,
24
+ Transport,
25
+ )
26
+
27
+
28
+ class _SystemClock:
29
+ def now(self) -> float:
30
+ return time.monotonic()
31
+
32
+ def sleep(self, seconds: float) -> None:
33
+ time.sleep(seconds)
34
+
35
+
36
+ class SurSolver:
37
+ """Synchronous client for the SurSolver solver API.
38
+
39
+ The API key travels only in request bodies and never appears in errors or ``repr``.
40
+ Durations are seconds. Use as a context manager (or call ``close()``) to release the
41
+ connection pool of the default transport.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ api_key: str,
47
+ *,
48
+ base_url: str = _core.DEFAULT_BASE_URL,
49
+ request_timeout: float | None = None,
50
+ poll_interval: float | None = None,
51
+ solve_timeout: float | None = None,
52
+ max_submit_retries: int | None = None,
53
+ app_id: str | None = None,
54
+ user_agent_suffix: str | None = None,
55
+ transport: Transport | None = None,
56
+ clock: Clock | None = None,
57
+ random: Callable[[], float] | None = None,
58
+ ) -> None:
59
+ self._settings = _core.build_settings(
60
+ api_key,
61
+ base_url=base_url,
62
+ request_timeout=request_timeout,
63
+ poll_interval=poll_interval,
64
+ solve_timeout=solve_timeout,
65
+ max_submit_retries=max_submit_retries,
66
+ app_id=app_id,
67
+ user_agent_suffix=user_agent_suffix,
68
+ )
69
+ self._http: httpx.Client | None = None
70
+ if transport is None:
71
+ self._http = httpx.Client(follow_redirects=False)
72
+ transport = self._httpx_send
73
+ self._transport = transport
74
+ self._clock: Clock = clock if clock is not None else _SystemClock()
75
+ self._random = random if random is not None else _random.random
76
+
77
+ def __repr__(self) -> str:
78
+ return f"SurSolver(base_url={self._settings.base_url!r})"
79
+
80
+ def __enter__(self) -> Self:
81
+ return self
82
+
83
+ def __exit__(
84
+ self,
85
+ exc_type: type[BaseException] | None,
86
+ exc: BaseException | None,
87
+ tb: TracebackType | None,
88
+ ) -> None:
89
+ self.close()
90
+
91
+ def close(self) -> None:
92
+ if self._http is not None:
93
+ self._http.close()
94
+
95
+ def create_task(self, task: Task) -> str:
96
+ """Submit a task and return its id. The task is charged from this point. Never retried."""
97
+ payload = _core.create_task_payload(self._settings, task)
98
+ body = self._solver_call("createTask", "/createTask", payload)
99
+ return _core.task_id_from(body)
100
+
101
+ def get_task_result(self, task_id: str) -> TaskResult:
102
+ """Read a task's state once. A terminal failure raises ``TaskFailedError``."""
103
+ payload = {"clientKey": self._settings.api_key, "taskId": task_id}
104
+ return _core.task_result_from(self._solver_call("getTaskResult", "/getTaskResult", payload, task_id))
105
+
106
+ def get_balance(self) -> float:
107
+ """Cash balance in USD as the JSON number the API sends: display it, do not do money
108
+ arithmetic on it (the dashboard's ``GET /v1/balance`` has the exact decimal)."""
109
+ body = self._solver_call("getBalance", "/getBalance", {"clientKey": self._settings.api_key})
110
+ return _core.balance_from(body)
111
+
112
+ def solve(
113
+ self,
114
+ task: Task,
115
+ *,
116
+ poll_interval: float | None = None,
117
+ solve_timeout: float | None = None,
118
+ on_task_created: Callable[[str], None] | None = None,
119
+ ) -> SolveResult:
120
+ """Create a task and poll it until it is ready, failed, or the deadline passes.
121
+
122
+ ``create_task`` is resubmitted only after ``ERROR_NO_SLOT_AVAILABLE`` (nothing was
123
+ created), at most ``max_submit_retries`` times; any other submit failure is raised at
124
+ once. Polls failing at the transport level or with ``ERROR_INTERNAL_ERROR`` are retried
125
+ until the deadline; any other error ends the call and carries the task id. On the
126
+ deadline ``SolveTimeoutError`` is raised; the task may still finish.
127
+ """
128
+ interval = _core.positive_seconds("poll_interval", poll_interval, self._settings.poll_interval)
129
+ timeout = _core.positive_seconds("solve_timeout", solve_timeout, self._settings.solve_timeout)
130
+ deadline = self._clock.now() + timeout
131
+
132
+ retries = 0
133
+ while True:
134
+ try:
135
+ task_id = self.create_task(task)
136
+ break
137
+ except NoSlotAvailableError:
138
+ if retries >= self._settings.max_submit_retries:
139
+ raise
140
+ retries += 1
141
+ self._clock.sleep(_core.submit_retry_delay(interval, self._random()))
142
+ if on_task_created is not None:
143
+ on_task_created(task_id)
144
+
145
+ while True:
146
+ last_error: SurSolverError | None = None
147
+ try:
148
+ result = self.get_task_result(task_id)
149
+ if result.status == "ready" and result.solution is not None:
150
+ return SolveResult(task_id=task_id, solution=result.solution)
151
+ except SurSolverError as error:
152
+ if not _core.is_transient_poll_error(error):
153
+ raise
154
+ last_error = error
155
+ self._clock.sleep(interval)
156
+ if self._clock.now() >= deadline:
157
+ raise SolveTimeoutError(task_id, timeout, last_error)
158
+
159
+ def list_task_types(self) -> TaskTypeCatalog:
160
+ """The public task-type catalog: accepted names, fields, proxy modes and result keys."""
161
+ return _core.task_types_from(self._catalog_call("listTaskTypes", "/v1/task-types"))
162
+
163
+ def list_services(self) -> list[ServiceInfo]:
164
+ """Active services with their current USD price per task (decimal strings)."""
165
+ return _core.services_from(self._catalog_call("listServices", "/v1/services"))
166
+
167
+ def _httpx_send(self, request: HttpRequest) -> HttpResponse:
168
+ assert self._http is not None
169
+ response = self._http.request(
170
+ request.method,
171
+ request.url,
172
+ headers=dict(request.headers),
173
+ content=request.body,
174
+ timeout=request.timeout,
175
+ )
176
+ return HttpResponse(
177
+ status=response.status_code, body=response.content, headers=dict(response.headers)
178
+ )
179
+
180
+ def _send(self, operation: Operation, request: HttpRequest) -> HttpResponse:
181
+ try:
182
+ return self._transport(request)
183
+ except Exception as error:
184
+ raise _core.transport_failure(operation, error, request.timeout) from error
185
+
186
+ def _solver_call(
187
+ self, operation: Operation, path: str, payload: dict[str, Any], task_id: str | None = None
188
+ ) -> dict[str, Any]:
189
+ response = self._send(operation, _core.solver_request(self._settings, path, payload))
190
+ return _core.parse_solver(operation, response, task_id)
191
+
192
+ def _catalog_call(self, operation: Operation, path: str) -> dict[str, Any]:
193
+ response = self._send(operation, _core.catalog_request(self._settings, path))
194
+ return _core.parse_catalog(operation, response)