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/_core.py ADDED
@@ -0,0 +1,362 @@
1
+ """Validation, request building and response classification shared by both clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+ from urllib.parse import urlsplit
10
+
11
+ import httpx
12
+
13
+ from ._errors import (
14
+ ApiError,
15
+ ConfigurationError,
16
+ Operation,
17
+ ProtocolError,
18
+ SurSolverError,
19
+ TransportError,
20
+ api_error_for,
21
+ )
22
+ from ._types import (
23
+ HttpRequest,
24
+ HttpResponse,
25
+ ServiceInfo,
26
+ TaskResult,
27
+ TaskTypeCatalog,
28
+ TaskTypeInfo,
29
+ )
30
+ from ._version import VERSION
31
+
32
+ DEFAULT_BASE_URL = "https://api.sursolver.com"
33
+ DEFAULT_REQUEST_TIMEOUT = 30.0
34
+ DEFAULT_POLL_INTERVAL = 3.0
35
+ DEFAULT_SOLVE_TIMEOUT = 210.0
36
+ DEFAULT_MAX_SUBMIT_RETRIES = 1
37
+ SUBMIT_RETRY_JITTER_MS = 1000
38
+ """Upper bound of the random delay added to a submit retry, so parallel callers spread out."""
39
+
40
+ _LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class Settings:
45
+ api_key: str
46
+ base_url: str
47
+ request_timeout: float
48
+ poll_interval: float
49
+ solve_timeout: float
50
+ max_submit_retries: int
51
+ app_id: str | None
52
+ user_agent: str
53
+
54
+
55
+ def positive_seconds(name: str, value: float | None, fallback: float) -> float:
56
+ if value is None:
57
+ return fallback
58
+ if (
59
+ isinstance(value, bool)
60
+ or not isinstance(value, (int, float))
61
+ or not math.isfinite(value)
62
+ or value <= 0
63
+ ):
64
+ raise ConfigurationError(f"{name} must be a positive number of seconds")
65
+ return float(value)
66
+
67
+
68
+ def _normalize_base_url(raw: str) -> str:
69
+ parts = urlsplit(raw)
70
+ loopback_http = parts.scheme == "http" and parts.hostname in _LOOPBACK_HOSTS
71
+ if parts.scheme != "https" and not loopback_http:
72
+ raise ConfigurationError("base_url must use https:// (plain http:// only for localhost)")
73
+ if not parts.netloc:
74
+ raise ConfigurationError("base_url has no host")
75
+ if parts.username is not None or parts.password is not None or parts.query or parts.fragment:
76
+ raise ConfigurationError("base_url must not carry credentials, a query or a fragment")
77
+ return f"{parts.scheme}://{parts.netloc}{parts.path.rstrip('/')}"
78
+
79
+
80
+ def build_settings(
81
+ api_key: str,
82
+ *,
83
+ base_url: str,
84
+ request_timeout: float | None,
85
+ poll_interval: float | None,
86
+ solve_timeout: float | None,
87
+ max_submit_retries: int | None,
88
+ app_id: str | None,
89
+ user_agent_suffix: str | None,
90
+ ) -> Settings:
91
+ if not isinstance(api_key, str) or api_key.strip() == "":
92
+ raise ConfigurationError("api_key must be a non-empty string")
93
+ if api_key.strip() != api_key:
94
+ raise ConfigurationError("api_key has leading or trailing whitespace")
95
+ retries = DEFAULT_MAX_SUBMIT_RETRIES if max_submit_retries is None else max_submit_retries
96
+ if isinstance(retries, bool) or not isinstance(retries, int) or retries < 0:
97
+ raise ConfigurationError("max_submit_retries must be a non-negative integer")
98
+ if user_agent_suffix is not None and not (
99
+ user_agent_suffix and all(" " <= char <= "~" for char in user_agent_suffix)
100
+ ):
101
+ raise ConfigurationError("user_agent_suffix must be printable ASCII")
102
+ user_agent = f"sursolver-python/{VERSION}"
103
+ if user_agent_suffix is not None:
104
+ user_agent = f"{user_agent} {user_agent_suffix}"
105
+ trimmed_app_id = app_id.strip() if app_id is not None else ""
106
+ return Settings(
107
+ api_key=api_key,
108
+ base_url=_normalize_base_url(base_url),
109
+ request_timeout=positive_seconds("request_timeout", request_timeout, DEFAULT_REQUEST_TIMEOUT),
110
+ poll_interval=positive_seconds("poll_interval", poll_interval, DEFAULT_POLL_INTERVAL),
111
+ solve_timeout=positive_seconds("solve_timeout", solve_timeout, DEFAULT_SOLVE_TIMEOUT),
112
+ max_submit_retries=retries,
113
+ app_id=trimmed_app_id or None,
114
+ user_agent=user_agent,
115
+ )
116
+
117
+
118
+ def solver_request(settings: Settings, path: str, payload: dict[str, Any]) -> HttpRequest:
119
+ return HttpRequest(
120
+ method="POST",
121
+ url=f"{settings.base_url}{path}",
122
+ headers={
123
+ "accept": "application/json",
124
+ "content-type": "application/json",
125
+ "user-agent": settings.user_agent,
126
+ },
127
+ body=json.dumps(payload).encode("utf-8"),
128
+ timeout=settings.request_timeout,
129
+ )
130
+
131
+
132
+ def catalog_request(settings: Settings, path: str) -> HttpRequest:
133
+ return HttpRequest(
134
+ method="GET",
135
+ url=f"{settings.base_url}{path}",
136
+ headers={"accept": "application/json", "user-agent": settings.user_agent},
137
+ body=None,
138
+ timeout=settings.request_timeout,
139
+ )
140
+
141
+
142
+ def create_task_payload(settings: Settings, task: Any) -> dict[str, Any]:
143
+ payload: dict[str, Any] = {"clientKey": settings.api_key, "task": task}
144
+ if settings.app_id is not None:
145
+ payload["appId"] = settings.app_id
146
+ return payload
147
+
148
+
149
+ def transport_failure(operation: Operation, error: Exception, timeout: float) -> TransportError:
150
+ """A transport that raised: no response arrived."""
151
+ if isinstance(error, (httpx.TimeoutException, TimeoutError)):
152
+ return TransportError(operation, f"request timed out after {timeout:g} s")
153
+ return TransportError(operation, "connection failed")
154
+
155
+
156
+ def _load_json(body: bytes) -> Any:
157
+ if not body:
158
+ return None
159
+ try:
160
+ return json.loads(body)
161
+ except ValueError:
162
+ return None
163
+
164
+
165
+ def _is_integral(value: Any) -> bool:
166
+ if isinstance(value, bool):
167
+ return False
168
+ return isinstance(value, int) or (isinstance(value, float) and value.is_integer())
169
+
170
+
171
+ def _checked(operation: Operation, response: HttpResponse) -> Any:
172
+ """Reject non-200 statuses; return the parsed JSON body (None when not JSON)."""
173
+ parsed = _load_json(response.body)
174
+ if response.status != 200:
175
+ envelope = parsed.get("error") if isinstance(parsed, dict) else None
176
+ envelope = envelope if isinstance(envelope, dict) else {}
177
+ code = envelope.get("code")
178
+ server_code = code if isinstance(code, str) else None
179
+ retry_after = envelope.get("retry_after_seconds")
180
+ reason = f"HTTP {response.status}" + ("" if server_code is None else f" ({server_code})")
181
+ raise TransportError(
182
+ operation,
183
+ reason,
184
+ status=response.status,
185
+ server_code=server_code,
186
+ retry_after_seconds=retry_after
187
+ if isinstance(retry_after, (int, float)) and not isinstance(retry_after, bool)
188
+ else None,
189
+ )
190
+ return parsed
191
+
192
+
193
+ def parse_solver(operation: Operation, response: HttpResponse, task_id: str | None = None) -> dict[str, Any]:
194
+ """Return the ``errorId: 0`` body of a solver call or raise the classified error."""
195
+ body = _checked(operation, response)
196
+ if not isinstance(body, dict) or not _is_integral(body.get("errorId")):
197
+ what = "not JSON" if body is None else "not the solver envelope"
198
+ raise TransportError(operation, f"HTTP 200 body is {what}", status=200)
199
+ error_id = body["errorId"]
200
+ if error_id == 0:
201
+ return body
202
+ if error_id != 1:
203
+ raise ProtocolError(operation, f"errorId {error_id}")
204
+ code = body.get("errorCode")
205
+ if not isinstance(code, str):
206
+ raise ProtocolError(operation, "errorId 1 without a string errorCode")
207
+ description = body.get("errorDescription")
208
+ supported = body.get("supportedUserAgents")
209
+ raise api_error_for(
210
+ operation,
211
+ code,
212
+ description if isinstance(description, str) else "",
213
+ task_id=task_id,
214
+ supported_user_agents=[ua for ua in supported if isinstance(ua, str)]
215
+ if isinstance(supported, list)
216
+ else (),
217
+ )
218
+
219
+
220
+ def parse_catalog(operation: Operation, response: HttpResponse) -> dict[str, Any]:
221
+ """Return ``data`` of a ``{"success": true, "data": {...}}`` envelope."""
222
+ body = _checked(operation, response)
223
+ if (
224
+ not isinstance(body, dict)
225
+ or body.get("success") is not True
226
+ or not isinstance(body.get("data"), dict)
227
+ ):
228
+ raise TransportError(operation, "HTTP 200 body is not the API envelope", status=200)
229
+ data: dict[str, Any] = body["data"]
230
+ return data
231
+
232
+
233
+ def task_id_from(body: dict[str, Any]) -> str:
234
+ task_id = body.get("taskId")
235
+ if not isinstance(task_id, str) or task_id == "":
236
+ raise ProtocolError("createTask", "errorId 0 without a taskId")
237
+ return task_id
238
+
239
+
240
+ def task_result_from(body: dict[str, Any]) -> TaskResult:
241
+ status = body.get("status")
242
+ if status == "processing":
243
+ return TaskResult(status="processing")
244
+ if status == "ready":
245
+ solution = body.get("solution")
246
+ if not isinstance(solution, dict):
247
+ raise ProtocolError("getTaskResult", "status ready without a solution object")
248
+ return TaskResult(status="ready", solution=solution)
249
+ raise ProtocolError("getTaskResult", f"unknown status {json.dumps(status)}")
250
+
251
+
252
+ def balance_from(body: dict[str, Any]) -> float:
253
+ balance = body.get("balance")
254
+ if isinstance(balance, bool) or not isinstance(balance, (int, float)) or not math.isfinite(balance):
255
+ raise ProtocolError("getBalance", "errorId 0 without a numeric balance")
256
+ return balance
257
+
258
+
259
+ class _Entry:
260
+ """Typed reads of one catalog entry; a missing or mistyped field is a ProtocolError."""
261
+
262
+ def __init__(self, operation: Operation, value: Any) -> None:
263
+ if not isinstance(value, dict):
264
+ raise ProtocolError(operation, "a catalog entry is not an object")
265
+ self._operation = operation
266
+ self._value: dict[str, Any] = value
267
+
268
+ def _fail(self, key: str, expected: str) -> ProtocolError:
269
+ return ProtocolError(self._operation, f"catalog field {key} is not {expected}")
270
+
271
+ def text(self, key: str) -> str:
272
+ value = self._value.get(key)
273
+ if not isinstance(value, str):
274
+ raise self._fail(key, "a string")
275
+ return value
276
+
277
+ def optional_text(self, key: str) -> str | None:
278
+ value = self._value.get(key)
279
+ if value is not None and not isinstance(value, str):
280
+ raise self._fail(key, "a string or null")
281
+ return value
282
+
283
+ def integer(self, key: str) -> int:
284
+ value = self._value.get(key)
285
+ if isinstance(value, bool) or not isinstance(value, int):
286
+ raise self._fail(key, "an integer")
287
+ return value
288
+
289
+ def optional_integer(self, key: str) -> int | None:
290
+ return None if self._value.get(key) is None else self.integer(key)
291
+
292
+ def texts(self, key: str) -> tuple[str, ...]:
293
+ value = self._value.get(key)
294
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
295
+ raise self._fail(key, "a list of strings")
296
+ return tuple(value)
297
+
298
+ def groups(self, key: str) -> tuple[tuple[str, ...], ...]:
299
+ value = self._value.get(key)
300
+ if not isinstance(value, list) or not all(
301
+ isinstance(group, list) and all(isinstance(item, str) for item in group) for group in value
302
+ ):
303
+ raise self._fail(key, "a list of string lists")
304
+ return tuple(tuple(group) for group in value)
305
+
306
+
307
+ def task_types_from(data: dict[str, Any]) -> TaskTypeCatalog:
308
+ entries, codes = data.get("task_types"), data.get("error_codes")
309
+ if not isinstance(entries, list) or not isinstance(codes, list):
310
+ raise ProtocolError("listTaskTypes", "data without task_types and error_codes arrays")
311
+ task_types = []
312
+ for raw in entries:
313
+ entry = _Entry("listTaskTypes", raw)
314
+ task_types.append(
315
+ TaskTypeInfo(
316
+ page_slug=entry.optional_text("page_slug"),
317
+ display_name=entry.text("display_name"),
318
+ task_type=entry.text("task_type"),
319
+ task_type_proxyless=entry.optional_text("task_type_proxyless"),
320
+ aliases=entry.texts("aliases"),
321
+ proxy=entry.text("proxy"),
322
+ required=entry.groups("required"),
323
+ any_of=entry.groups("any_of"),
324
+ optional=entry.texts("optional"),
325
+ solution_keys=entry.texts("solution_keys"),
326
+ )
327
+ )
328
+ if not all(isinstance(code, str) for code in codes):
329
+ raise ProtocolError("listTaskTypes", "error_codes is not a list of strings")
330
+ return TaskTypeCatalog(task_types=tuple(task_types), error_codes=tuple(codes))
331
+
332
+
333
+ def services_from(data: dict[str, Any]) -> list[ServiceInfo]:
334
+ services = data.get("services")
335
+ if not isinstance(services, list):
336
+ raise ProtocolError("listServices", "data without a services array")
337
+ result = []
338
+ for raw in services:
339
+ entry = _Entry("listServices", raw)
340
+ result.append(
341
+ ServiceInfo(
342
+ id=entry.integer("id"),
343
+ slug=entry.text("slug"),
344
+ display_name=entry.text("display_name"),
345
+ price_per_request=entry.text("price_per_request"),
346
+ status=entry.text("status"),
347
+ avg_latency_ms=entry.optional_integer("avg_latency_ms"),
348
+ )
349
+ )
350
+ return result
351
+
352
+
353
+ def submit_retry_delay(poll_interval: float, random_value: float) -> float:
354
+ """Wait before resubmitting after ERROR_NO_SLOT_AVAILABLE: one poll interval plus jitter."""
355
+ return poll_interval + math.floor(random_value * SUBMIT_RETRY_JITTER_MS) / 1000
356
+
357
+
358
+ def is_transient_poll_error(error: SurSolverError) -> bool:
359
+ """Poll failures that say nothing about the task and may be retried inside the deadline."""
360
+ return isinstance(error, TransportError) or (
361
+ isinstance(error, ApiError) and error.code == "ERROR_INTERNAL_ERROR"
362
+ )
sursolver/_errors.py ADDED
@@ -0,0 +1,163 @@
1
+ """Error types. Cancellation is never wrapped: it stays ``asyncio.CancelledError``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Literal
7
+
8
+ Operation = Literal["createTask", "getTaskResult", "getBalance", "listTaskTypes", "listServices"]
9
+
10
+
11
+ class SurSolverError(Exception):
12
+ """Base class of every error this package raises."""
13
+
14
+ operation: Operation | None
15
+
16
+ def __init__(self, message: str, operation: Operation | None = None) -> None:
17
+ super().__init__(message)
18
+ self.operation = operation
19
+
20
+
21
+ class ConfigurationError(SurSolverError, ValueError):
22
+ """The client was built with an unusable option. Nothing was sent."""
23
+
24
+
25
+ class TransportError(SurSolverError):
26
+ """No usable answer: connection failure, timeout, a non-200 status, or a body that is not
27
+ the API's envelope. It says nothing about the task.
28
+
29
+ ``may_have_created_task`` is true for ``create_task``: the request may have been accepted
30
+ and charged before the answer was lost. Check ``get_balance()`` before submitting again;
31
+ the API has no idempotency.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ operation: Operation,
37
+ reason: str,
38
+ *,
39
+ status: int | None = None,
40
+ server_code: str | None = None,
41
+ retry_after_seconds: int | float | None = None,
42
+ ) -> None:
43
+ self.may_have_created_task = operation == "createTask"
44
+ note = (
45
+ " The task may have been created and charged; check get_balance() before submitting again."
46
+ if self.may_have_created_task
47
+ else ""
48
+ )
49
+ super().__init__(f"{operation} failed: {reason}.{note}", operation)
50
+ self.status = status
51
+ self.server_code = server_code
52
+ self.retry_after_seconds = retry_after_seconds
53
+
54
+
55
+ class ProtocolError(SurSolverError):
56
+ """HTTP 200 JSON whose shape the API contract does not allow."""
57
+
58
+ def __init__(self, operation: Operation, detail: str) -> None:
59
+ super().__init__(f"{operation} returned an unexpected response: {detail}", operation)
60
+
61
+
62
+ class ApiError(SurSolverError):
63
+ """The API answered ``errorId: 1``. Subclasses group the codes callers act on.
64
+
65
+ ``code`` is the ``errorCode`` exactly as sent (including codes newer than this package);
66
+ ``retryable`` is true only for ``ERROR_NO_SLOT_AVAILABLE``; ``supported_user_agents`` is
67
+ the list sent with ``ERROR_WRONG_USERAGENT`` and empty otherwise.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ operation: Operation,
73
+ code: str,
74
+ description: str,
75
+ *,
76
+ task_id: str | None = None,
77
+ supported_user_agents: Iterable[str] = (),
78
+ ) -> None:
79
+ task = "" if task_id is None else f" (task {task_id})"
80
+ super().__init__(f"{code}: {description}{task}", operation)
81
+ self.code = code
82
+ self.description = description
83
+ self.retryable = code == "ERROR_NO_SLOT_AVAILABLE"
84
+ self.task_id = task_id
85
+ self.supported_user_agents: tuple[str, ...] = tuple(supported_user_agents)
86
+
87
+
88
+ class AuthenticationError(ApiError):
89
+ """``ERROR_WRONG_CLIENT_KEY`` or ``ERROR_KEY_DOES_NOT_EXIST``: fix the API key."""
90
+
91
+
92
+ class InvalidTaskError(ApiError):
93
+ """``create_task`` refused the task itself: ``ERROR_INVALID_REQUEST``,
94
+ ``ERROR_TYPE_NOT_SUPPORTED`` or ``ERROR_WRONG_USERAGENT`` (see ``supported_user_agents``).
95
+ Nothing was created or charged."""
96
+
97
+
98
+ class InsufficientBalanceError(ApiError):
99
+ """``ERROR_ZERO_BALANCE``: the balance or plan cannot cover the task. Nothing was charged."""
100
+
101
+
102
+ class NoSlotAvailableError(ApiError):
103
+ """``ERROR_NO_SLOT_AVAILABLE``: the account's open tasks fill its thread limit, or the
104
+ service is briefly not admitting work. Nothing was created; submitting later is safe."""
105
+
106
+
107
+ class TaskNotFoundError(ApiError):
108
+ """``ERROR_TASK_NOT_FOUND``: unknown task id, or one owned by another account."""
109
+
110
+
111
+ class TaskFailedError(ApiError):
112
+ """The task ended without a result: ``ERROR_FAILED``, ``ERROR_TASK_TIMEOUT``,
113
+ ``ERROR_TASK_EXPIRED``, or ``ERROR_WRONG_USERAGENT`` after acceptance. Final for this
114
+ task; the API returns a failed task's charge once."""
115
+
116
+
117
+ class SolveTimeoutError(SurSolverError):
118
+ """``solve()`` reached its deadline. The task may still finish and stays charged: poll it
119
+ with ``get_task_result(error.task_id)`` if you still need the result. ``last_error`` is the
120
+ final poll's transient failure, when there was one."""
121
+
122
+ def __init__(self, task_id: str, timeout: float, last_error: SurSolverError | None = None) -> None:
123
+ super().__init__(
124
+ f"task {task_id} was not ready within {timeout:g} s; it may still finish and stays charged",
125
+ "getTaskResult",
126
+ )
127
+ self.task_id = task_id
128
+ self.last_error = last_error
129
+ self.__cause__ = last_error
130
+
131
+
132
+ _TASK_FAILURE_CODES = frozenset(
133
+ {"ERROR_FAILED", "ERROR_TASK_TIMEOUT", "ERROR_TASK_EXPIRED", "ERROR_WRONG_USERAGENT"}
134
+ )
135
+
136
+ _CLASS_BY_CODE: dict[str, type[ApiError]] = {
137
+ "ERROR_WRONG_CLIENT_KEY": AuthenticationError,
138
+ "ERROR_KEY_DOES_NOT_EXIST": AuthenticationError,
139
+ "ERROR_INVALID_REQUEST": InvalidTaskError,
140
+ "ERROR_TYPE_NOT_SUPPORTED": InvalidTaskError,
141
+ "ERROR_WRONG_USERAGENT": InvalidTaskError,
142
+ "ERROR_ZERO_BALANCE": InsufficientBalanceError,
143
+ "ERROR_NO_SLOT_AVAILABLE": NoSlotAvailableError,
144
+ "ERROR_TASK_NOT_FOUND": TaskNotFoundError,
145
+ }
146
+
147
+
148
+ def api_error_for(
149
+ operation: Operation,
150
+ code: str,
151
+ description: str,
152
+ *,
153
+ task_id: str | None = None,
154
+ supported_user_agents: Iterable[str] = (),
155
+ ) -> ApiError:
156
+ """The error class for an ``errorId: 1`` answer: it depends on the operation and the code."""
157
+ if operation == "getTaskResult" and code in _TASK_FAILURE_CODES:
158
+ error_class: type[ApiError] = TaskFailedError
159
+ else:
160
+ error_class = _CLASS_BY_CODE.get(code, ApiError)
161
+ return error_class(
162
+ operation, code, description, task_id=task_id, supported_user_agents=supported_user_agents
163
+ )
sursolver/_types.py ADDED
@@ -0,0 +1,105 @@
1
+ """Public data types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable, Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Literal, Protocol
8
+
9
+ Task = Mapping[str, Any]
10
+ """A task object: ``type`` plus that type's fields, sent verbatim. Browser task types require
11
+ ``userAgent`` (the Windows Chrome User-Agent of the client that will use the result)."""
12
+
13
+ Solution = dict[str, Any]
14
+ """A task's result. Keys differ per type (``token``, ``gRecaptchaResponse``, ``cf_clearance``,
15
+ ``cookies``, ...) and are passed through untouched. Tokens and cookies are single-use."""
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class TaskResult:
20
+ status: Literal["processing", "ready"]
21
+ solution: Solution | None = None
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class SolveResult:
26
+ task_id: str
27
+ solution: Solution
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class TaskTypeInfo:
32
+ """One entry of ``GET /v1/task-types``."""
33
+
34
+ page_slug: str | None
35
+ display_name: str
36
+ task_type: str
37
+ """Canonical name (the proxy-capable one when a ProxyLess twin exists)."""
38
+ task_type_proxyless: str | None
39
+ aliases: tuple[str, ...]
40
+ proxy: str
41
+ """``none``, ``optional`` or ``required``."""
42
+ required: tuple[tuple[str, ...], ...]
43
+ """Groups of accepted spellings; supply one name per group."""
44
+ any_of: tuple[tuple[str, ...], ...]
45
+ """Alternatives; supply every field of at least one."""
46
+ optional: tuple[str, ...]
47
+ solution_keys: tuple[str, ...]
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class TaskTypeCatalog:
52
+ task_types: tuple[TaskTypeInfo, ...]
53
+ error_codes: tuple[str, ...]
54
+ """The complete ``errorCode`` vocabulary of the solver endpoints."""
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class ServiceInfo:
59
+ """One entry of ``GET /v1/services``."""
60
+
61
+ id: int
62
+ slug: str
63
+ display_name: str
64
+ price_per_request: str
65
+ """USD price per task as a decimal string, e.g. ``"0.0006"``."""
66
+ status: str
67
+ avg_latency_ms: int | None
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class HttpRequest:
72
+ method: Literal["GET", "POST"]
73
+ url: str
74
+ headers: Mapping[str, str]
75
+ body: bytes | None
76
+ timeout: float
77
+ """Seconds."""
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class HttpResponse:
82
+ status: int
83
+ body: bytes
84
+ headers: Mapping[str, str] = field(default_factory=dict)
85
+
86
+
87
+ Transport = Callable[[HttpRequest], HttpResponse]
88
+ """Sends one request. Return any status; raise only when no response arrived. Must not follow
89
+ redirects."""
90
+
91
+ AsyncTransport = Callable[[HttpRequest], Awaitable[HttpResponse]]
92
+
93
+
94
+ class Clock(Protocol):
95
+ def now(self) -> float:
96
+ """Seconds; only differences are used."""
97
+ ...
98
+
99
+ def sleep(self, seconds: float) -> None: ...
100
+
101
+
102
+ class AsyncClock(Protocol):
103
+ def now(self) -> float: ...
104
+
105
+ async def sleep(self, seconds: float) -> None: ...
sursolver/_version.py ADDED
@@ -0,0 +1,2 @@
1
+ VERSION = "0.1.0"
2
+ """Package version (single source: pyproject reads it); sent in the User-Agent header."""
sursolver/py.typed ADDED
File without changes