huntapi 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.
huntapi/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """Official Python SDK for the HuntAPI API."""
2
+
3
+ from ._endpoints import ENDPOINTS
4
+ from ._version import __version__
5
+ from .client import (
6
+ DEFAULT_BASE_URL,
7
+ DEFAULT_CONCURRENCY,
8
+ DEFAULT_RETRY_DELAY,
9
+ DEFAULT_TIMEOUT,
10
+ MAX_RETRIES,
11
+ RETRY_BACKOFF_FACTOR,
12
+ AsyncHuntApiClient,
13
+ HuntApiClient,
14
+ )
15
+ from .errors import (
16
+ HuntApiAPIError,
17
+ HuntApiConfigurationError,
18
+ HuntApiError,
19
+ HuntApiJobTimeoutError,
20
+ HuntApiResponseDecodingError,
21
+ HuntApiSerializationError,
22
+ HuntApiTransportError,
23
+ )
24
+ from .models import (
25
+ APIResponse,
26
+ BatchResult,
27
+ BinaryResponse,
28
+ EndpointDefinition,
29
+ HTTPMethod,
30
+ JsonResponse,
31
+ RawResponse,
32
+ RequestOptions,
33
+ )
34
+
35
+ __all__ = [
36
+ "APIResponse",
37
+ "AsyncHuntApiClient",
38
+ "BatchResult",
39
+ "BinaryResponse",
40
+ "DEFAULT_BASE_URL",
41
+ "DEFAULT_CONCURRENCY",
42
+ "DEFAULT_RETRY_DELAY",
43
+ "DEFAULT_TIMEOUT",
44
+ "MAX_RETRIES",
45
+ "RETRY_BACKOFF_FACTOR",
46
+ "ENDPOINTS",
47
+ "EndpointDefinition",
48
+ "HTTPMethod",
49
+ "JsonResponse",
50
+ "RawResponse",
51
+ "RequestOptions",
52
+ "HuntApiAPIError",
53
+ "HuntApiClient",
54
+ "HuntApiConfigurationError",
55
+ "HuntApiError",
56
+ "HuntApiJobTimeoutError",
57
+ "HuntApiResponseDecodingError",
58
+ "HuntApiSerializationError",
59
+ "HuntApiTransportError",
60
+ "__version__",
61
+ ]
huntapi/_endpoints.py ADDED
@@ -0,0 +1,50 @@
1
+ """Generated from the HuntAPI OpenAPI document. Do not edit by hand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .models import EndpointDefinition
6
+
7
+ ENDPOINTS: dict[str, EndpointDefinition] = {
8
+ "audio_download": EndpointDefinition(
9
+ path="/v1/audio/download",
10
+ methods=(
11
+ "GET",
12
+ "POST",
13
+ ),
14
+ response="json",
15
+ required=("query",),
16
+ ),
17
+ "jobs": EndpointDefinition(
18
+ path="/v1/jobs/{id}",
19
+ methods=("GET",),
20
+ response="json",
21
+ required=("id",),
22
+ ),
23
+ "media_metadata": EndpointDefinition(
24
+ path="/v1/media/metadata",
25
+ methods=("GET",),
26
+ response="json",
27
+ required=("query",),
28
+ ),
29
+ "stats": EndpointDefinition(
30
+ path="/v1/stats",
31
+ methods=("GET",),
32
+ response="json",
33
+ required=(),
34
+ ),
35
+ "usage": EndpointDefinition(
36
+ path="/v1/usage",
37
+ methods=("GET",),
38
+ response="json",
39
+ required=(),
40
+ ),
41
+ "video_download": EndpointDefinition(
42
+ path="/v1/video/download",
43
+ methods=(
44
+ "GET",
45
+ "POST",
46
+ ),
47
+ response="json",
48
+ required=("query",),
49
+ ),
50
+ }
huntapi/_operations.py ADDED
@@ -0,0 +1,380 @@
1
+ """Generated from the HuntAPI OpenAPI document. Do not edit by hand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, cast
6
+
7
+ from .models import APIResponse, JsonResponse, RequestOptions
8
+
9
+
10
+ class _SyncOperations:
11
+ """Generated HuntAPI endpoint methods."""
12
+
13
+ __slots__ = ()
14
+
15
+ def _call(
16
+ self, name: str, params: dict[str, Any], options: RequestOptions | None
17
+ ) -> APIResponse:
18
+ raise NotImplementedError
19
+
20
+ def audio_download(
21
+ self,
22
+ *,
23
+ query: str,
24
+ download_type: str | None = None,
25
+ max_duration: int | float | None = None,
26
+ audio_quality: str | None = None,
27
+ audio_format: str | None = None,
28
+ webhook_url: str | None = None,
29
+ webhook_authorization: str | None = None,
30
+ fields: str | None = None,
31
+ is_async: bool | None = None,
32
+ soft_fail: bool | None = None,
33
+ request_options: RequestOptions | None = None,
34
+ ) -> JsonResponse:
35
+ """Audio Download. Download audio from a URL via HuntAPI and receive a job id. Credit cost: 1 credit per call.
36
+
37
+ ``GET / POST /v1/audio/download``
38
+
39
+ Args:
40
+ query: URL to download audio from.
41
+ download_type: Desired download type. Supported values: audio (default).
42
+ max_duration: Maximum duration of the downloaded content in seconds. If the audio is longer than this value, only the content up to this duration will be downloaded.
43
+ audio_quality: Desired audio quality.
44
+ audio_format: Desired output format for the audio.
45
+ webhook_url: URL of the webhook where results will be sent. When specified, the system will attempt to send the response to this URL up to 3 times with a 5-second delay between each attempt. The results will be s…
46
+ webhook_authorization: Authorization header for the webhook. This parameter allows you to add authentication to your webhook endpoint.
47
+ fields: Comma-separated list of fields to keep in the response, with dot notation for nested fields (e.g. name,location.city). Does not change the credit cost.
48
+ is_async: Process the request asynchronously: the response is a job_id to poll on /v1/jobs/{id} (see waitForJob).
49
+ soft_fail: Return HTTP 200 even when an error occurs; the error is then in the response body.
50
+ """
51
+ return cast(
52
+ JsonResponse,
53
+ self._call(
54
+ "audio_download",
55
+ {
56
+ "query": query,
57
+ "download_type": download_type,
58
+ "max_duration": max_duration,
59
+ "audio_quality": audio_quality,
60
+ "audio_format": audio_format,
61
+ "webhook_url": webhook_url,
62
+ "webhook_authorization": webhook_authorization,
63
+ "fields": fields,
64
+ "is_async": is_async,
65
+ "soft_fail": soft_fail,
66
+ },
67
+ request_options,
68
+ ),
69
+ )
70
+
71
+ def jobs(
72
+ self,
73
+ *,
74
+ id: str,
75
+ request_options: RequestOptions | None = None,
76
+ ) -> JsonResponse:
77
+ """Jobs. Poll status, progress (0–100), and result of an async HuntAPI job.
78
+
79
+ ``GET /v1/jobs/{id}``
80
+
81
+ Args:
82
+ id: Job UUID returned as job_id by async endpoints such as /v1/video/download and /v1/audio/download.
83
+ """
84
+ return cast(JsonResponse, self._call("jobs", {"id": id}, request_options))
85
+
86
+ def media_metadata(
87
+ self,
88
+ *,
89
+ query: str,
90
+ fields: str | None = None,
91
+ is_async: bool | None = None,
92
+ soft_fail: bool | None = None,
93
+ request_options: RequestOptions | None = None,
94
+ ) -> JsonResponse:
95
+ """Media Metadata. Extract metadata from a media URL without downloading the file. Credit cost: 1 credit per call.
96
+
97
+ ``GET /v1/media/metadata``
98
+
99
+ Args:
100
+ query: Media URL to inspect (YouTube, Instagram, TikTok, Vimeo, X, Facebook, Twitch, Reddit, etc.).
101
+ fields: Comma-separated list of fields to keep in the response, with dot notation for nested fields (e.g. name,location.city). Does not change the credit cost.
102
+ is_async: Process the request asynchronously: the response is a job_id to poll on /v1/jobs/{id} (see waitForJob).
103
+ soft_fail: Return HTTP 200 even when an error occurs; the error is then in the response body.
104
+ """
105
+ return cast(
106
+ JsonResponse,
107
+ self._call(
108
+ "media_metadata",
109
+ {"query": query, "fields": fields, "is_async": is_async, "soft_fail": soft_fail},
110
+ request_options,
111
+ ),
112
+ )
113
+
114
+ def stats(
115
+ self,
116
+ *,
117
+ engine: str | None = None,
118
+ path: str | None = None,
119
+ request_options: RequestOptions | None = None,
120
+ ) -> JsonResponse:
121
+ """Stats. Infrastructure health for an engine or endpoint over the last hour. Not scoped to your account or tenant. Enabled per account: HTTP 403 means contact support to activate it.
122
+
123
+ ``GET /v1/stats``
124
+
125
+ Args:
126
+ engine: API family to check, for example video. Provide at least engine or path.
127
+ path: Specific endpoint to check, for example /v1/video/download. If you also pass engine, use an endpoint from that API.
128
+ """
129
+ return cast(
130
+ JsonResponse, self._call("stats", {"engine": engine, "path": path}, request_options)
131
+ )
132
+
133
+ def usage(
134
+ self,
135
+ *,
136
+ request_options: RequestOptions | None = None,
137
+ ) -> JsonResponse:
138
+ """Usage. Retrieve real-time usage statistics and credit balance for your Huntapi account.
139
+
140
+ ``GET /v1/usage``
141
+ """
142
+ return cast(JsonResponse, self._call("usage", {}, request_options))
143
+
144
+ def video_download(
145
+ self,
146
+ *,
147
+ query: str,
148
+ download_type: str | None = None,
149
+ max_duration: int | float | None = None,
150
+ video_quality: str | None = None,
151
+ video_format: str | None = None,
152
+ webhook_url: str | None = None,
153
+ webhook_authorization: str | None = None,
154
+ fields: str | None = None,
155
+ is_async: bool | None = None,
156
+ soft_fail: bool | None = None,
157
+ request_options: RequestOptions | None = None,
158
+ ) -> JsonResponse:
159
+ """Video Download. Credit cost: 1 credit per call.
160
+
161
+ ``GET / POST /v1/video/download``
162
+
163
+ Args:
164
+ query: Video URL to download.
165
+ download_type: Desired download type. You have three options:
166
+ max_duration: Maximum duration of the downloaded content in seconds. If the video/audio is longer than this value, only the content up to this duration will be downloaded.
167
+ video_quality: Desired video quality. Available options:
168
+ video_format: Desired output format for the video. Available options:
169
+ webhook_url: URL of the webhook where results will be sent. When specified, the system will attempt to send the response to this URL up to 3 times with a 5-second delay between each attempt. The results will be s…
170
+ webhook_authorization: Authorization header for the webhook. This parameter allows you to add authentication to your webhook endpoint. This value will be included in the Authorization header when making the webhook request…
171
+ fields: Comma-separated list of fields to keep in the response, with dot notation for nested fields (e.g. name,location.city). Does not change the credit cost.
172
+ is_async: Process the request asynchronously: the response is a job_id to poll on /v1/jobs/{id} (see waitForJob).
173
+ soft_fail: Return HTTP 200 even when an error occurs; the error is then in the response body.
174
+ """
175
+ return cast(
176
+ JsonResponse,
177
+ self._call(
178
+ "video_download",
179
+ {
180
+ "query": query,
181
+ "download_type": download_type,
182
+ "max_duration": max_duration,
183
+ "video_quality": video_quality,
184
+ "video_format": video_format,
185
+ "webhook_url": webhook_url,
186
+ "webhook_authorization": webhook_authorization,
187
+ "fields": fields,
188
+ "is_async": is_async,
189
+ "soft_fail": soft_fail,
190
+ },
191
+ request_options,
192
+ ),
193
+ )
194
+
195
+
196
+ class _AsyncOperations:
197
+ """Generated HuntAPI endpoint methods."""
198
+
199
+ __slots__ = ()
200
+
201
+ async def _call(
202
+ self, name: str, params: dict[str, Any], options: RequestOptions | None
203
+ ) -> APIResponse:
204
+ raise NotImplementedError
205
+
206
+ async def audio_download(
207
+ self,
208
+ *,
209
+ query: str,
210
+ download_type: str | None = None,
211
+ max_duration: int | float | None = None,
212
+ audio_quality: str | None = None,
213
+ audio_format: str | None = None,
214
+ webhook_url: str | None = None,
215
+ webhook_authorization: str | None = None,
216
+ fields: str | None = None,
217
+ is_async: bool | None = None,
218
+ soft_fail: bool | None = None,
219
+ request_options: RequestOptions | None = None,
220
+ ) -> JsonResponse:
221
+ """Audio Download. Download audio from a URL via HuntAPI and receive a job id. Credit cost: 1 credit per call.
222
+
223
+ ``GET / POST /v1/audio/download``
224
+
225
+ Args:
226
+ query: URL to download audio from.
227
+ download_type: Desired download type. Supported values: audio (default).
228
+ max_duration: Maximum duration of the downloaded content in seconds. If the audio is longer than this value, only the content up to this duration will be downloaded.
229
+ audio_quality: Desired audio quality.
230
+ audio_format: Desired output format for the audio.
231
+ webhook_url: URL of the webhook where results will be sent. When specified, the system will attempt to send the response to this URL up to 3 times with a 5-second delay between each attempt. The results will be s…
232
+ webhook_authorization: Authorization header for the webhook. This parameter allows you to add authentication to your webhook endpoint.
233
+ fields: Comma-separated list of fields to keep in the response, with dot notation for nested fields (e.g. name,location.city). Does not change the credit cost.
234
+ is_async: Process the request asynchronously: the response is a job_id to poll on /v1/jobs/{id} (see waitForJob).
235
+ soft_fail: Return HTTP 200 even when an error occurs; the error is then in the response body.
236
+ """
237
+ return cast(
238
+ JsonResponse,
239
+ await self._call(
240
+ "audio_download",
241
+ {
242
+ "query": query,
243
+ "download_type": download_type,
244
+ "max_duration": max_duration,
245
+ "audio_quality": audio_quality,
246
+ "audio_format": audio_format,
247
+ "webhook_url": webhook_url,
248
+ "webhook_authorization": webhook_authorization,
249
+ "fields": fields,
250
+ "is_async": is_async,
251
+ "soft_fail": soft_fail,
252
+ },
253
+ request_options,
254
+ ),
255
+ )
256
+
257
+ async def jobs(
258
+ self,
259
+ *,
260
+ id: str,
261
+ request_options: RequestOptions | None = None,
262
+ ) -> JsonResponse:
263
+ """Jobs. Poll status, progress (0–100), and result of an async HuntAPI job.
264
+
265
+ ``GET /v1/jobs/{id}``
266
+
267
+ Args:
268
+ id: Job UUID returned as job_id by async endpoints such as /v1/video/download and /v1/audio/download.
269
+ """
270
+ return cast(JsonResponse, await self._call("jobs", {"id": id}, request_options))
271
+
272
+ async def media_metadata(
273
+ self,
274
+ *,
275
+ query: str,
276
+ fields: str | None = None,
277
+ is_async: bool | None = None,
278
+ soft_fail: bool | None = None,
279
+ request_options: RequestOptions | None = None,
280
+ ) -> JsonResponse:
281
+ """Media Metadata. Extract metadata from a media URL without downloading the file. Credit cost: 1 credit per call.
282
+
283
+ ``GET /v1/media/metadata``
284
+
285
+ Args:
286
+ query: Media URL to inspect (YouTube, Instagram, TikTok, Vimeo, X, Facebook, Twitch, Reddit, etc.).
287
+ fields: Comma-separated list of fields to keep in the response, with dot notation for nested fields (e.g. name,location.city). Does not change the credit cost.
288
+ is_async: Process the request asynchronously: the response is a job_id to poll on /v1/jobs/{id} (see waitForJob).
289
+ soft_fail: Return HTTP 200 even when an error occurs; the error is then in the response body.
290
+ """
291
+ return cast(
292
+ JsonResponse,
293
+ await self._call(
294
+ "media_metadata",
295
+ {"query": query, "fields": fields, "is_async": is_async, "soft_fail": soft_fail},
296
+ request_options,
297
+ ),
298
+ )
299
+
300
+ async def stats(
301
+ self,
302
+ *,
303
+ engine: str | None = None,
304
+ path: str | None = None,
305
+ request_options: RequestOptions | None = None,
306
+ ) -> JsonResponse:
307
+ """Stats. Infrastructure health for an engine or endpoint over the last hour. Not scoped to your account or tenant. Enabled per account: HTTP 403 means contact support to activate it.
308
+
309
+ ``GET /v1/stats``
310
+
311
+ Args:
312
+ engine: API family to check, for example video. Provide at least engine or path.
313
+ path: Specific endpoint to check, for example /v1/video/download. If you also pass engine, use an endpoint from that API.
314
+ """
315
+ return cast(
316
+ JsonResponse,
317
+ await self._call("stats", {"engine": engine, "path": path}, request_options),
318
+ )
319
+
320
+ async def usage(
321
+ self,
322
+ *,
323
+ request_options: RequestOptions | None = None,
324
+ ) -> JsonResponse:
325
+ """Usage. Retrieve real-time usage statistics and credit balance for your Huntapi account.
326
+
327
+ ``GET /v1/usage``
328
+ """
329
+ return cast(JsonResponse, await self._call("usage", {}, request_options))
330
+
331
+ async def video_download(
332
+ self,
333
+ *,
334
+ query: str,
335
+ download_type: str | None = None,
336
+ max_duration: int | float | None = None,
337
+ video_quality: str | None = None,
338
+ video_format: str | None = None,
339
+ webhook_url: str | None = None,
340
+ webhook_authorization: str | None = None,
341
+ fields: str | None = None,
342
+ is_async: bool | None = None,
343
+ soft_fail: bool | None = None,
344
+ request_options: RequestOptions | None = None,
345
+ ) -> JsonResponse:
346
+ """Video Download. Credit cost: 1 credit per call.
347
+
348
+ ``GET / POST /v1/video/download``
349
+
350
+ Args:
351
+ query: Video URL to download.
352
+ download_type: Desired download type. You have three options:
353
+ max_duration: Maximum duration of the downloaded content in seconds. If the video/audio is longer than this value, only the content up to this duration will be downloaded.
354
+ video_quality: Desired video quality. Available options:
355
+ video_format: Desired output format for the video. Available options:
356
+ webhook_url: URL of the webhook where results will be sent. When specified, the system will attempt to send the response to this URL up to 3 times with a 5-second delay between each attempt. The results will be s…
357
+ webhook_authorization: Authorization header for the webhook. This parameter allows you to add authentication to your webhook endpoint. This value will be included in the Authorization header when making the webhook request…
358
+ fields: Comma-separated list of fields to keep in the response, with dot notation for nested fields (e.g. name,location.city). Does not change the credit cost.
359
+ is_async: Process the request asynchronously: the response is a job_id to poll on /v1/jobs/{id} (see waitForJob).
360
+ soft_fail: Return HTTP 200 even when an error occurs; the error is then in the response body.
361
+ """
362
+ return cast(
363
+ JsonResponse,
364
+ await self._call(
365
+ "video_download",
366
+ {
367
+ "query": query,
368
+ "download_type": download_type,
369
+ "max_duration": max_duration,
370
+ "video_quality": video_quality,
371
+ "video_format": video_format,
372
+ "webhook_url": webhook_url,
373
+ "webhook_authorization": webhook_authorization,
374
+ "fields": fields,
375
+ "is_async": is_async,
376
+ "soft_fail": soft_fail,
377
+ },
378
+ request_options,
379
+ ),
380
+ )
huntapi/_response.py ADDED
@@ -0,0 +1,82 @@
1
+ """Buffered HTTP response preservation and decoding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from types import MappingProxyType
7
+ from typing import Any, Literal
8
+
9
+ import httpx
10
+
11
+ from .errors import HuntApiAPIError, HuntApiResponseDecodingError
12
+ from .models import APIResponse, BinaryResponse, JsonResponse, RawResponse
13
+
14
+ _ERROR_MESSAGE_KEYS = ("error", "message", "error_message", "detail")
15
+
16
+
17
+ def create_raw_response(response: httpx.Response, body: bytes) -> RawResponse:
18
+ grouped: dict[str, list[str]] = {}
19
+ for name, value in response.headers.multi_items():
20
+ grouped.setdefault(name.lower(), []).append(value)
21
+ return RawResponse(
22
+ status=response.status_code,
23
+ status_text=response.reason_phrase,
24
+ headers=MappingProxyType({name: tuple(values) for name, values in grouped.items()}),
25
+ content_type=response.headers.get("content-type"),
26
+ body=body,
27
+ )
28
+
29
+
30
+ def create_api_error(raw_response: RawResponse) -> HuntApiAPIError:
31
+ parsed, value = _try_parse_json(raw_response.body)
32
+ message = None
33
+ if parsed and isinstance(value, dict):
34
+ for key in _ERROR_MESSAGE_KEYS:
35
+ candidate = value.get(key)
36
+ if isinstance(candidate, str) and candidate:
37
+ message = candidate
38
+ break
39
+ return HuntApiAPIError(
40
+ raw_response=raw_response,
41
+ response_body=value if parsed else None,
42
+ error_message=message,
43
+ )
44
+
45
+
46
+ def decode_success_response(
47
+ raw_response: RawResponse, expected: Literal["json", "any"]
48
+ ) -> APIResponse:
49
+ if not _is_json_media_type(raw_response.content_type):
50
+ if expected == "json":
51
+ raise HuntApiResponseDecodingError(
52
+ "HuntAPI returned a successful non-JSON response for a JSON operation.",
53
+ raw_response,
54
+ TypeError(f"Unexpected content type: {raw_response.content_type}."),
55
+ )
56
+ return BinaryResponse(
57
+ bytes=raw_response.body,
58
+ content_type=raw_response.content_type,
59
+ raw_response=raw_response,
60
+ )
61
+ parsed, value = _try_parse_json(raw_response.body)
62
+ if not parsed:
63
+ raise HuntApiResponseDecodingError(
64
+ "HuntAPI returned a successful JSON response that could not be decoded.",
65
+ raw_response,
66
+ value if isinstance(value, Exception) else None,
67
+ )
68
+ return JsonResponse(data=value, raw_response=raw_response)
69
+
70
+
71
+ def _is_json_media_type(content_type: str | None) -> bool:
72
+ if content_type is None:
73
+ return False
74
+ media_type = content_type.split(";", 1)[0].strip().lower()
75
+ return media_type == "application/json" or media_type.endswith("+json")
76
+
77
+
78
+ def _try_parse_json(body: bytes) -> tuple[bool, Any]:
79
+ try:
80
+ return True, json.loads(body)
81
+ except (UnicodeDecodeError, json.JSONDecodeError) as cause:
82
+ return False, cause
@@ -0,0 +1,109 @@
1
+ """Request serialization: path filling, query pairs and JSON bodies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+ from urllib.parse import quote, urlencode
12
+
13
+ from .errors import HuntApiSerializationError
14
+ from .models import HTTPMethod
15
+
16
+ _PATH_PARAM = re.compile(r"\{([^}]+)\}")
17
+ # The API reads the global parameters from the query string, whatever the method.
18
+ QUERY_STRING_PARAMS = ("fields", "is_async", "soft_fail")
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class SerializedRequest:
23
+ path: str
24
+ query: list[tuple[str, str]]
25
+ body: dict[str, Any] | None
26
+
27
+
28
+ def serialize_request(
29
+ method: HTTPMethod, path_template: str, params: Mapping[str, Any] | None
30
+ ) -> SerializedRequest:
31
+ """Fill ``{name}`` path segments, then send the rest as query pairs (GET) or a JSON body (POST).
32
+
33
+ On POST, the global parameters (``fields``, ``is_async``, ``soft_fail``) still travel in the query string.
34
+
35
+ ``None`` values and empty sequences are omitted; ``False`` and ``0`` are sent.
36
+ """
37
+
38
+ if params is not None and not isinstance(params, Mapping):
39
+ raise HuntApiSerializationError("Request parameters must be a mapping.")
40
+ remaining = {name: value for name, value in (params or {}).items() if value is not None}
41
+
42
+ def fill(match: re.Match[str]) -> str:
43
+ name = match.group(1)
44
+ if name not in remaining:
45
+ raise HuntApiSerializationError(f'The "{name}" path parameter is required.', name)
46
+ return quote(to_wire_string(name, remaining.pop(name)), safe="")
47
+
48
+ path = _PATH_PARAM.sub(fill, path_template)
49
+
50
+ if method == "GET":
51
+ query: list[tuple[str, str]] = []
52
+ for name, value in remaining.items():
53
+ if isinstance(value, (list, tuple)):
54
+ query.extend(
55
+ (name, to_wire_string(name, item)) for item in value if item is not None
56
+ )
57
+ else:
58
+ query.append((name, to_wire_string(name, value)))
59
+ return SerializedRequest(path=path, query=query, body=None)
60
+
61
+ query = [
62
+ (name, to_wire_string(name, remaining.pop(name)))
63
+ for name in QUERY_STRING_PARAMS
64
+ if name in remaining
65
+ ]
66
+ for name, value in remaining.items():
67
+ if isinstance(value, float) and not math.isfinite(value):
68
+ raise HuntApiSerializationError(
69
+ f'The "{name}" parameter must be a finite number.', name
70
+ )
71
+ return SerializedRequest(path=path, query=query, body=remaining)
72
+
73
+
74
+ def to_wire_string(name: str, value: Any) -> str:
75
+ if isinstance(value, bool):
76
+ return "true" if value else "false"
77
+ if isinstance(value, str):
78
+ return value
79
+ if isinstance(value, int):
80
+ return str(value)
81
+ if isinstance(value, float):
82
+ if not math.isfinite(value):
83
+ raise HuntApiSerializationError(
84
+ f'The "{name}" parameter must be a finite number.', name
85
+ )
86
+ return str(int(value)) if value.is_integer() else repr(value)
87
+ if isinstance(value, (Mapping, list, tuple)):
88
+ try:
89
+ return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
90
+ except (TypeError, ValueError) as cause:
91
+ raise HuntApiSerializationError(
92
+ f'The "{name}" parameter could not be serialized.', name
93
+ ) from cause
94
+ raise HuntApiSerializationError(
95
+ f'The "{name}" parameter has an unsupported type ({type(value).__name__}).', name
96
+ )
97
+
98
+
99
+ def encode_query(pairs: list[tuple[str, str]]) -> str:
100
+ return urlencode(pairs, quote_via=quote)
101
+
102
+
103
+ def encode_json_body(body: Mapping[str, Any]) -> bytes:
104
+ try:
105
+ return json.dumps(body, ensure_ascii=False, allow_nan=False).encode("utf-8")
106
+ except (TypeError, ValueError) as cause:
107
+ raise HuntApiSerializationError(
108
+ "The request body could not be serialized as JSON."
109
+ ) from cause
huntapi/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"