internetdata 2.7.0__tar.gz → 2.7.2__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.
- {internetdata-2.7.0 → internetdata-2.7.2}/PKG-INFO +1 -1
- {internetdata-2.7.0 → internetdata-2.7.2}/spec/openapi.yaml +5 -2
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/__init__.py +1 -1
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_core.py +78 -73
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/api_keys/account_rotate_apikey.py +12 -4
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/aio.py +4 -5
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/client.py +4 -5
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/errors.py +58 -14
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/models.py +35 -18
- {internetdata-2.7.0 → internetdata-2.7.2}/tests/helpers.py +5 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/tests/test_client.py +218 -2
- {internetdata-2.7.0 → internetdata-2.7.2}/tests/test_conformance.py +24 -1
- {internetdata-2.7.0 → internetdata-2.7.2}/.gitignore +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/LICENSE +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/README.md +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/pyproject.toml +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/scripts/download-spec.sh +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/scripts/generate.sh +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/scripts/publish.sh +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/scripts/v2_subset.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/__init__.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/__init__.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/account/__init__.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/account/account_identity.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/api_keys/__init__.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/api_keys/account_create_apikey.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/api_keys/account_list_apikeys.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/api_keys/account_reveal_apikey.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/api_keys/account_revoke_apikey.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/authorization/__init__.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/authorization/oauth_authorize.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/authorization/oauth_device_authorization.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/authorization/oauth_metadata.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/authorization/oauth_revoke.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/authorization/oauth_token.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/organization/__init__.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/organization/account_org.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/organization/account_org_members.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/client.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/errors.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/__init__.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_create_apikey_request.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_created_apikey.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_org.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_org_ref.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_org_wrap.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_rc.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_revealed_apikey.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_user.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/apikey_detail.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/apikey_list.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_format.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata_sample_size.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_version.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/db_checksums.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/device_authorization.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/device_authorization_request.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/download.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/download_outcome.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/error.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/identity.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/oauth_authorize_code_challenge_method.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/oauth_authorize_response_type.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/oauth_error.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/oauth_metadata.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/oauth_revoke_response_200.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/revoke_request.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/standing.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/token_request.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/token_response.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/types.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/py.typed +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/testdata/testdata.json +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/tests/conftest.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/tests/test_download.py +0 -0
- {internetdata-2.7.0 → internetdata-2.7.2}/tests/test_oauth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: internetdata
|
|
3
|
-
Version: 2.7.
|
|
3
|
+
Version: 2.7.2
|
|
4
4
|
Summary: Official Python client library for the InternetData API. Download and verify licensed IP datasets.
|
|
5
5
|
Project-URL: Homepage, https://internetdata.io
|
|
6
6
|
Project-URL: Documentation, https://docs.internetdata.io
|
|
@@ -12,7 +12,7 @@ info:
|
|
|
12
12
|
email: support@internetdata.io
|
|
13
13
|
url: https://internetdata.io
|
|
14
14
|
termsOfService: https://internetdata.io/terms
|
|
15
|
-
version: 2026.10.
|
|
15
|
+
version: 2026.10.09
|
|
16
16
|
servers:
|
|
17
17
|
- url: https://internetdata.io
|
|
18
18
|
tags:
|
|
@@ -501,10 +501,13 @@ paths:
|
|
|
501
501
|
summary: Rotate
|
|
502
502
|
description: |
|
|
503
503
|
Replaces the secret behind a key, keeping its id, name and settings.
|
|
504
|
-
The previous secret stops working immediately.
|
|
504
|
+
The previous secret stops working immediately. The new secret comes
|
|
505
|
+
back in the answer, so this needs `apikeys.reveal` as well as
|
|
506
|
+
`apikeys.manage`.
|
|
505
507
|
security:
|
|
506
508
|
- BearerAuth:
|
|
507
509
|
- apikeys.manage
|
|
510
|
+
- apikeys.reveal
|
|
508
511
|
parameters:
|
|
509
512
|
- $ref: '#/components/parameters/ApikeyIdParam'
|
|
510
513
|
responses:
|
|
@@ -14,7 +14,7 @@ import os
|
|
|
14
14
|
import secrets
|
|
15
15
|
import threading
|
|
16
16
|
import time
|
|
17
|
-
from collections.abc import
|
|
17
|
+
from collections.abc import Callable, Iterator
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from types import ModuleType
|
|
20
20
|
from typing import IO, Any, TypeVar, cast
|
|
@@ -23,7 +23,6 @@ from urllib.parse import quote
|
|
|
23
23
|
import httpx
|
|
24
24
|
|
|
25
25
|
from ._generated.client import AuthenticatedClient, Client
|
|
26
|
-
from ._generated.types import Response
|
|
27
26
|
from .errors import InternetDataError, error_from_response, oauth_error_from
|
|
28
27
|
from .models import Database, Download, Pkce, to_database, to_download
|
|
29
28
|
|
|
@@ -81,10 +80,11 @@ def build_client(
|
|
|
81
80
|
`set_httpx_client()`, which silently bypasses auth: the generated client only adds
|
|
82
81
|
the `Authorization` header when it CONSTRUCTS the httpx client itself.
|
|
83
82
|
"""
|
|
83
|
+
api_key = check_api_key(api_key)
|
|
84
84
|
httpx_args: dict[str, Any] = {}
|
|
85
85
|
if transport is not None:
|
|
86
86
|
httpx_args["transport"] = transport
|
|
87
|
-
if
|
|
87
|
+
if api_key is None:
|
|
88
88
|
return cast(
|
|
89
89
|
AuthenticatedClient,
|
|
90
90
|
Client(base_url=base_url, timeout=httpx.Timeout(timeout), httpx_args=httpx_args),
|
|
@@ -132,6 +132,25 @@ def build_async_transfer_client(
|
|
|
132
132
|
)
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
def check_api_key(api_key: str | None) -> str | None:
|
|
136
|
+
"""`api_key` without the blanks around it, or None when nothing else is left.
|
|
137
|
+
|
|
138
|
+
Refused where it is SET when a control character remains: httpx refuses a header
|
|
139
|
+
holding a CR, LF or NUL, so every call failed as a retried `network` error with nothing
|
|
140
|
+
sent, while a tab or DEL inside the key went out on the wire. Untrimmed, a key of blanks
|
|
141
|
+
was sent as one rather than as no key, and failed every call the same way (2.7.1,
|
|
142
|
+
measured 2026-10-12).
|
|
143
|
+
"""
|
|
144
|
+
if api_key is None:
|
|
145
|
+
return None
|
|
146
|
+
if not isinstance(api_key, str):
|
|
147
|
+
raise TypeError(f"api_key must be a string or None, not {type(api_key).__name__}")
|
|
148
|
+
key = api_key.strip()
|
|
149
|
+
if any(ord(char) < 0x20 or ord(char) == 0x7F for char in key):
|
|
150
|
+
raise ValueError("api_key holds a control character")
|
|
151
|
+
return key or None
|
|
152
|
+
|
|
153
|
+
|
|
135
154
|
def check_timeout(timeout: float | None) -> float | None:
|
|
136
155
|
"""`timeout`, once it is a bound an attempt can meet.
|
|
137
156
|
|
|
@@ -218,67 +237,51 @@ def part_file(destination: str | os.PathLike[str]) -> Iterator[IO[bytes]]:
|
|
|
218
237
|
raise
|
|
219
238
|
|
|
220
239
|
|
|
221
|
-
def send(call: Callable[[], Response[Any]]) -> Response[Any]:
|
|
222
|
-
"""One generated endpoint call.
|
|
223
|
-
|
|
224
|
-
The generated code eagerly decodes the body of every DOCUMENTED status into its model
|
|
225
|
-
before anything here sees the response, and it raises three different ways doing it: a
|
|
226
|
-
503 carrying an intermediary's HTML error page is a `ValueError` out of `json()`, a
|
|
227
|
-
200 missing a required key is a `KeyError`, and a `standing` this pinned spec predates
|
|
228
|
-
is a `ValueError` out of an enum constructor. All three are the server sending
|
|
229
|
-
something this client cannot read, which is a failed request rather than a bug in the
|
|
230
|
-
caller's code, so all three become the one error type here.
|
|
231
|
-
|
|
232
|
-
The call does nothing but build one generated request, send it and hand the answer to
|
|
233
|
-
the generated decoder, which is what keeps this catch from swallowing a fault of our own.
|
|
234
|
-
An argument is converted before it, so a format this client does not publish is the
|
|
235
|
-
caller's `ValueError` rather than the server's failure.
|
|
236
|
-
"""
|
|
237
|
-
try:
|
|
238
|
-
return call()
|
|
239
|
-
except (KeyError, TypeError, ValueError) as exc:
|
|
240
|
-
raise malformed(exc) from exc
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
async def send_async(call: Callable[[], Awaitable[Response[Any]]]) -> Response[Any]:
|
|
244
|
-
"""`send`, awaited."""
|
|
245
|
-
try:
|
|
246
|
-
return await call()
|
|
247
|
-
except (KeyError, TypeError, ValueError) as exc:
|
|
248
|
-
raise malformed(exc) from exc
|
|
249
|
-
|
|
250
|
-
|
|
251
240
|
def request(
|
|
252
241
|
endpoint: ModuleType, client: AuthenticatedClient, bound: float | None, **params: Any
|
|
253
|
-
) -> Response
|
|
254
|
-
"""
|
|
255
|
-
|
|
242
|
+
) -> httpx.Response:
|
|
243
|
+
"""One attempt at one generated endpoint, finished within `bound` seconds, or unbounded
|
|
244
|
+
when that is None, and its answer checked by `decoded`.
|
|
256
245
|
|
|
257
246
|
Assembled from the endpoint module's `_get_kwargs` and `_build_response` because its
|
|
258
247
|
`sync_detailed` sends through httpx alone, and httpx has no bound on the attempt.
|
|
259
248
|
"""
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
req = http.build_request(**endpoint._get_kwargs(**params), timeout=httpx.Timeout(bound))
|
|
264
|
-
res = exchange(http, req, bound)
|
|
265
|
-
return cast(Response[Any], endpoint._build_response(client=client, response=res))
|
|
266
|
-
|
|
267
|
-
return send(call)
|
|
249
|
+
http = client.get_httpx_client()
|
|
250
|
+
req = http.build_request(**endpoint._get_kwargs(**params), timeout=httpx.Timeout(bound))
|
|
251
|
+
return decoded(endpoint, client, exchange(http, req, bound))
|
|
268
252
|
|
|
269
253
|
|
|
270
254
|
async def request_async(
|
|
271
255
|
endpoint: ModuleType, client: AuthenticatedClient, bound: float | None, **params: Any
|
|
272
|
-
) -> Response
|
|
256
|
+
) -> httpx.Response:
|
|
273
257
|
"""`request`, awaited."""
|
|
258
|
+
http = client.get_async_httpx_client()
|
|
259
|
+
req = http.build_request(**endpoint._get_kwargs(**params), timeout=httpx.Timeout(bound))
|
|
260
|
+
return decoded(endpoint, client, await exchange_async(http, req, bound))
|
|
274
261
|
|
|
275
|
-
async def call() -> Response[Any]:
|
|
276
|
-
http = client.get_async_httpx_client()
|
|
277
|
-
req = http.build_request(**endpoint._get_kwargs(**params), timeout=httpx.Timeout(bound))
|
|
278
|
-
res = await exchange_async(http, req, bound)
|
|
279
|
-
return cast(Response[Any], endpoint._build_response(client=client, response=res))
|
|
280
262
|
|
|
281
|
-
|
|
263
|
+
def decoded(
|
|
264
|
+
endpoint: ModuleType, client: AuthenticatedClient, res: httpx.Response
|
|
265
|
+
) -> httpx.Response:
|
|
266
|
+
"""`res`, once the generated decoder has read it, where it is a 2xx.
|
|
267
|
+
|
|
268
|
+
The generated code eagerly decodes the body of every DOCUMENTED status into its model,
|
|
269
|
+
and it raises three different ways doing it: a body that is not JSON is a `ValueError`
|
|
270
|
+
out of `json()`, an object missing a required key a `KeyError`, and a `standing` this
|
|
271
|
+
pinned spec predates a `ValueError` out of an enum constructor. On a 2xx each is the
|
|
272
|
+
server sending an answer this client cannot read, so each is the one error type here,
|
|
273
|
+
a retried `server_error` carrying the status.
|
|
274
|
+
|
|
275
|
+
Any other status is left to `unwrap`, which classifies it by the status alone, whatever
|
|
276
|
+
its body. Decoded too, a proxy's HTML page on a 401, a 404 or an empty 403 was a
|
|
277
|
+
retried `server_error` with no status, sent three times (2.7.1, measured 2026-10-12).
|
|
278
|
+
"""
|
|
279
|
+
if 200 <= res.status_code < 300:
|
|
280
|
+
try:
|
|
281
|
+
endpoint._build_response(client=client, response=res)
|
|
282
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
283
|
+
raise malformed(exc, res.status_code) from exc
|
|
284
|
+
return res
|
|
282
285
|
|
|
283
286
|
|
|
284
287
|
def exchange(http: httpx.Client, req: httpx.Request, bound: float | None) -> httpx.Response:
|
|
@@ -458,16 +461,16 @@ class AsyncClock:
|
|
|
458
461
|
await asyncio.sleep(seconds)
|
|
459
462
|
|
|
460
463
|
|
|
461
|
-
def malformed(exc: Exception) -> InternetDataError:
|
|
462
|
-
return InternetDataError("server_error", f"malformed response from the API: {exc}")
|
|
464
|
+
def malformed(exc: Exception, status: int) -> InternetDataError:
|
|
465
|
+
return InternetDataError("server_error", f"malformed response from the API: {exc}", status)
|
|
463
466
|
|
|
464
467
|
|
|
465
|
-
def unwrap(res: Response
|
|
468
|
+
def unwrap(res: httpx.Response) -> dict[str, Any]:
|
|
466
469
|
"""The response body as it came off the wire, or the failure it describes."""
|
|
467
470
|
body = _decode(res.content)
|
|
468
|
-
status =
|
|
471
|
+
status = res.status_code
|
|
469
472
|
if not 200 <= status < 300:
|
|
470
|
-
raise error_from_response(status,
|
|
473
|
+
raise error_from_response(status, res.headers, body)
|
|
471
474
|
if not isinstance(body, dict):
|
|
472
475
|
raise InternetDataError("server_error", "the API answered with a non-object body", status)
|
|
473
476
|
return body
|
|
@@ -484,23 +487,27 @@ def as_error(exc: InternetDataError | httpx.HTTPError) -> InternetDataError:
|
|
|
484
487
|
return InternetDataError("network", str(exc) or type(exc).__name__)
|
|
485
488
|
|
|
486
489
|
|
|
487
|
-
def parse_body(
|
|
490
|
+
def parse_body(res: httpx.Response, parse: Callable[[dict[str, Any]], T]) -> T:
|
|
488
491
|
"""A served body through the model layer, with a malformed one reported as the
|
|
489
|
-
server's failure rather than as a traceback out of a dataclass
|
|
492
|
+
server's failure, carrying its status, rather than as a traceback out of a dataclass
|
|
493
|
+
constructor."""
|
|
494
|
+
body = unwrap(res)
|
|
490
495
|
try:
|
|
491
496
|
return parse(body)
|
|
492
|
-
|
|
493
|
-
|
|
497
|
+
# AttributeError too: metadata's `schema` or `sample` as an array instead of an object
|
|
498
|
+
# escaped 2.7.1 raw, out of `.items()` (measured 2026-10-12).
|
|
499
|
+
except (AttributeError, KeyError, TypeError, ValueError) as exc:
|
|
500
|
+
raise malformed(exc, res.status_code) from exc
|
|
494
501
|
|
|
495
502
|
|
|
496
|
-
def redirect_location(res: Response
|
|
503
|
+
def redirect_location(res: httpx.Response) -> str:
|
|
497
504
|
"""Where a `302` points, or whatever the API said instead."""
|
|
498
|
-
location: str | None =
|
|
499
|
-
if
|
|
505
|
+
location: str | None = res.headers.get("location")
|
|
506
|
+
if res.status_code == 302 and location:
|
|
500
507
|
return location
|
|
501
508
|
unwrap(res)
|
|
502
509
|
raise InternetDataError(
|
|
503
|
-
"server_error", "expected a redirect to object storage",
|
|
510
|
+
"server_error", "expected a redirect to object storage", res.status_code
|
|
504
511
|
)
|
|
505
512
|
|
|
506
513
|
|
|
@@ -519,13 +526,18 @@ def downloads_of(body: dict[str, Any]) -> list[Download]:
|
|
|
519
526
|
|
|
520
527
|
|
|
521
528
|
def checksums_of(body: dict[str, Any]) -> dict[str, str]:
|
|
522
|
-
"""The digests, unwrapped one level past the envelope
|
|
529
|
+
"""The digests, unwrapped one level past the envelope, the four the spec requires
|
|
530
|
+
among them as strings.
|
|
523
531
|
|
|
524
532
|
The response carries `id` and `format` beside them, so reading a top-level `sha256`
|
|
525
533
|
finds nothing. That exact mistake shipped in another binding's 1.0.x, which is why
|
|
526
534
|
the shared corpus pins the depth.
|
|
527
535
|
"""
|
|
528
|
-
|
|
536
|
+
digests = dict(body["checksums"])
|
|
537
|
+
for algorithm in ("md5", "sha1", "sha256", "sha512"):
|
|
538
|
+
if not isinstance(digests[algorithm], str):
|
|
539
|
+
raise TypeError(f"the {algorithm} checksum is not a string: {digests[algorithm]!r}")
|
|
540
|
+
return digests
|
|
529
541
|
|
|
530
542
|
|
|
531
543
|
def retry_delay(err: InternetDataError, attempt: int, retries: int) -> float | None:
|
|
@@ -596,13 +608,6 @@ def _deadline_passed(bound: float) -> InternetDataError:
|
|
|
596
608
|
return InternetDataError("network", f"the request did not complete within {bound:g} seconds")
|
|
597
609
|
|
|
598
610
|
|
|
599
|
-
# The generated Response declares a plain MutableMapping, but always carries httpx's
|
|
600
|
-
# case-insensitive Headers. Rebuilding one keeps a header lookup case-blind whichever it
|
|
601
|
-
# turns out to be, which matters for `Retry-After`.
|
|
602
|
-
def _headers(res: Response[Any]) -> httpx.Headers:
|
|
603
|
-
return httpx.Headers(res.headers)
|
|
604
|
-
|
|
605
|
-
|
|
606
611
|
def _decode(content: bytes) -> Any:
|
|
607
612
|
try:
|
|
608
613
|
return json.loads(content)
|
|
@@ -74,7 +74,9 @@ def sync_detailed(
|
|
|
74
74
|
"""Rotate
|
|
75
75
|
|
|
76
76
|
Replaces the secret behind a key, keeping its id, name and settings.
|
|
77
|
-
The previous secret stops working immediately.
|
|
77
|
+
The previous secret stops working immediately. The new secret comes
|
|
78
|
+
back in the answer, so this needs `apikeys.reveal` as well as
|
|
79
|
+
`apikeys.manage`.
|
|
78
80
|
|
|
79
81
|
Args:
|
|
80
82
|
id (UUID):
|
|
@@ -106,7 +108,9 @@ def sync(
|
|
|
106
108
|
"""Rotate
|
|
107
109
|
|
|
108
110
|
Replaces the secret behind a key, keeping its id, name and settings.
|
|
109
|
-
The previous secret stops working immediately.
|
|
111
|
+
The previous secret stops working immediately. The new secret comes
|
|
112
|
+
back in the answer, so this needs `apikeys.reveal` as well as
|
|
113
|
+
`apikeys.manage`.
|
|
110
114
|
|
|
111
115
|
Args:
|
|
112
116
|
id (UUID):
|
|
@@ -133,7 +137,9 @@ async def asyncio_detailed(
|
|
|
133
137
|
"""Rotate
|
|
134
138
|
|
|
135
139
|
Replaces the secret behind a key, keeping its id, name and settings.
|
|
136
|
-
The previous secret stops working immediately.
|
|
140
|
+
The previous secret stops working immediately. The new secret comes
|
|
141
|
+
back in the answer, so this needs `apikeys.reveal` as well as
|
|
142
|
+
`apikeys.manage`.
|
|
137
143
|
|
|
138
144
|
Args:
|
|
139
145
|
id (UUID):
|
|
@@ -163,7 +169,9 @@ async def asyncio(
|
|
|
163
169
|
"""Rotate
|
|
164
170
|
|
|
165
171
|
Replaces the secret behind a key, keeping its id, name and settings.
|
|
166
|
-
The previous secret stops working immediately.
|
|
172
|
+
The previous secret stops working immediately. The new secret comes
|
|
173
|
+
back in the answer, so this needs `apikeys.reveal` as well as
|
|
174
|
+
`apikeys.manage`.
|
|
167
175
|
|
|
168
176
|
Args:
|
|
169
177
|
id (UUID):
|
|
@@ -48,7 +48,6 @@ from ._core import (
|
|
|
48
48
|
request_async,
|
|
49
49
|
retry_delay,
|
|
50
50
|
storage_refusal,
|
|
51
|
-
unwrap,
|
|
52
51
|
)
|
|
53
52
|
from ._generated.api.database_v_2 import (
|
|
54
53
|
database_checksum_v2,
|
|
@@ -172,7 +171,7 @@ class AsyncDatabaseApi:
|
|
|
172
171
|
|
|
173
172
|
async def call() -> builtins.list[Database]:
|
|
174
173
|
res = await request_async(list_databases, self._client, self._bound(timeout))
|
|
175
|
-
return parse_body(
|
|
174
|
+
return parse_body(res, databases_of)
|
|
176
175
|
|
|
177
176
|
return await self._retrying(call)
|
|
178
177
|
|
|
@@ -186,7 +185,7 @@ class AsyncDatabaseApi:
|
|
|
186
185
|
res = await request_async(
|
|
187
186
|
database_metadata_v2, self._client, self._bound(timeout), id=database_id
|
|
188
187
|
)
|
|
189
|
-
return parse_body(
|
|
188
|
+
return parse_body(res, to_metadata)
|
|
190
189
|
|
|
191
190
|
return await self._retrying(call)
|
|
192
191
|
|
|
@@ -206,7 +205,7 @@ class AsyncDatabaseApi:
|
|
|
206
205
|
id=database_id,
|
|
207
206
|
format_=DatabaseFormat(format),
|
|
208
207
|
)
|
|
209
|
-
return parse_body(
|
|
208
|
+
return parse_body(res, checksums_of)
|
|
210
209
|
|
|
211
210
|
return await self._retrying(call)
|
|
212
211
|
|
|
@@ -222,7 +221,7 @@ class AsyncDatabaseApi:
|
|
|
222
221
|
res = await request_async(
|
|
223
222
|
list_downloads, self._client, self._bound(timeout), limit=limit
|
|
224
223
|
)
|
|
225
|
-
return parse_body(
|
|
224
|
+
return parse_body(res, downloads_of)
|
|
226
225
|
|
|
227
226
|
return await self._retrying(call)
|
|
228
227
|
|
|
@@ -43,7 +43,6 @@ from ._core import (
|
|
|
43
43
|
request,
|
|
44
44
|
retry_delay,
|
|
45
45
|
storage_refusal,
|
|
46
|
-
unwrap,
|
|
47
46
|
)
|
|
48
47
|
from ._generated.api.database_v_2 import (
|
|
49
48
|
database_checksum_v2,
|
|
@@ -184,7 +183,7 @@ class DatabaseApi:
|
|
|
184
183
|
|
|
185
184
|
def call() -> builtins.list[Database]:
|
|
186
185
|
res = request(list_databases, self._client, self._bound(timeout))
|
|
187
|
-
return parse_body(
|
|
186
|
+
return parse_body(res, databases_of)
|
|
188
187
|
|
|
189
188
|
return self._retrying(call)
|
|
190
189
|
|
|
@@ -199,7 +198,7 @@ class DatabaseApi:
|
|
|
199
198
|
|
|
200
199
|
def call() -> DatabaseMetadata:
|
|
201
200
|
res = request(database_metadata_v2, self._client, self._bound(timeout), id=database_id)
|
|
202
|
-
return parse_body(
|
|
201
|
+
return parse_body(res, to_metadata)
|
|
203
202
|
|
|
204
203
|
return self._retrying(call)
|
|
205
204
|
|
|
@@ -222,7 +221,7 @@ class DatabaseApi:
|
|
|
222
221
|
id=database_id,
|
|
223
222
|
format_=DatabaseFormat(format),
|
|
224
223
|
)
|
|
225
|
-
return parse_body(
|
|
224
|
+
return parse_body(res, checksums_of)
|
|
226
225
|
|
|
227
226
|
return self._retrying(call)
|
|
228
227
|
|
|
@@ -239,7 +238,7 @@ class DatabaseApi:
|
|
|
239
238
|
|
|
240
239
|
def call() -> builtins.list[Download]:
|
|
241
240
|
res = request(list_downloads, self._client, self._bound(timeout), limit=limit)
|
|
242
|
-
return parse_body(
|
|
241
|
+
return parse_body(res, downloads_of)
|
|
243
242
|
|
|
244
243
|
return self._retrying(call)
|
|
245
244
|
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import datetime
|
|
6
|
-
import email.utils
|
|
7
6
|
import math
|
|
7
|
+
import re
|
|
8
8
|
from typing import Any, Literal
|
|
9
9
|
|
|
10
10
|
import httpx
|
|
@@ -170,21 +170,65 @@ def _message_of(body: Any) -> str | None:
|
|
|
170
170
|
return None
|
|
171
171
|
|
|
172
172
|
|
|
173
|
+
# `Retry-After` is a count of seconds or an HTTP date in one of RFC 9110's three forms,
|
|
174
|
+
# always GMT, and nothing else: `float()` also read `+1`, `1_0`, `1e3` and `1.5` as counts,
|
|
175
|
+
# and `email.utils` read a date in any zone, without its day name or with a two-digit year,
|
|
176
|
+
# so `1_0` held a call ten seconds and a `PST` date eight hours (2.7.1, measured 2026-10-12).
|
|
177
|
+
_SECONDS = re.compile(r"[0-9]+")
|
|
178
|
+
_MONTH = r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"
|
|
179
|
+
_CLOCK = r"([0-9]{2}):([0-9]{2}):([0-9]{2})"
|
|
180
|
+
_IMF_FIXDATE = re.compile(
|
|
181
|
+
rf"(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), ([0-9]{{2}}) {_MONTH} ([0-9]{{4}}) {_CLOCK} GMT"
|
|
182
|
+
)
|
|
183
|
+
_RFC850_DATE = re.compile(
|
|
184
|
+
rf"(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), ([0-9]{{2}})-{_MONTH}-([0-9]{{2}}) {_CLOCK} GMT"
|
|
185
|
+
)
|
|
186
|
+
_ASCTIME_DATE = re.compile(
|
|
187
|
+
rf"(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) {_MONTH} ([0-9]{{2}}| [0-9]) {_CLOCK} ([0-9]{{4}})"
|
|
188
|
+
)
|
|
189
|
+
_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
190
|
+
|
|
191
|
+
|
|
173
192
|
def _parse_retry_after(value: str | None) -> float | None:
|
|
174
|
-
if value is None
|
|
193
|
+
if value is None:
|
|
175
194
|
return None
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
# The header also permits an HTTP date.
|
|
183
|
-
try:
|
|
184
|
-
when = email.utils.parsedate_to_datetime(value)
|
|
185
|
-
except (TypeError, ValueError):
|
|
195
|
+
value = value.strip(" \t")
|
|
196
|
+
if _SECONDS.fullmatch(value):
|
|
197
|
+
# A count of any length: float() reads one past its range as inf, never raising.
|
|
198
|
+
return float(value)
|
|
199
|
+
when = _http_date(value)
|
|
200
|
+
if when is None:
|
|
186
201
|
return None
|
|
187
|
-
if when.tzinfo is None:
|
|
188
|
-
when = when.replace(tzinfo=datetime.UTC)
|
|
189
202
|
delta = (when - datetime.datetime.now(datetime.UTC)).total_seconds()
|
|
190
203
|
return max(0.0, math.ceil(delta))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# The moment an HTTP date names, or None for anything else, a day no calendar has included.
|
|
207
|
+
def _http_date(value: str) -> datetime.datetime | None:
|
|
208
|
+
if match := _IMF_FIXDATE.fullmatch(value):
|
|
209
|
+
day, month, year, hour, minute, second = match.groups()
|
|
210
|
+
full_year = int(year)
|
|
211
|
+
elif match := _RFC850_DATE.fullmatch(value):
|
|
212
|
+
day, month, year, hour, minute, second = match.groups()
|
|
213
|
+
# RFC 9110: a two-digit year more than 50 years ahead is the most recent past one.
|
|
214
|
+
this_year = datetime.datetime.now(datetime.UTC).year
|
|
215
|
+
full_year = this_year - this_year % 100 + int(year)
|
|
216
|
+
if full_year > this_year + 50:
|
|
217
|
+
full_year -= 100
|
|
218
|
+
elif match := _ASCTIME_DATE.fullmatch(value):
|
|
219
|
+
month, day, hour, minute, second, year = match.groups()
|
|
220
|
+
full_year = int(year)
|
|
221
|
+
else:
|
|
222
|
+
return None
|
|
223
|
+
try:
|
|
224
|
+
return datetime.datetime(
|
|
225
|
+
full_year,
|
|
226
|
+
_MONTHS.index(month) + 1,
|
|
227
|
+
int(day),
|
|
228
|
+
int(hour),
|
|
229
|
+
int(minute),
|
|
230
|
+
int(second),
|
|
231
|
+
tzinfo=datetime.UTC,
|
|
232
|
+
)
|
|
233
|
+
except ValueError:
|
|
234
|
+
return None
|
|
@@ -175,36 +175,36 @@ class Download:
|
|
|
175
175
|
|
|
176
176
|
def to_database(body: dict[str, Any]) -> Database:
|
|
177
177
|
return Database(
|
|
178
|
-
base=body
|
|
179
|
-
name=body
|
|
180
|
-
summary=body
|
|
181
|
-
standing=body
|
|
178
|
+
base=_present(body, "base"),
|
|
179
|
+
name=_present(body, "name"),
|
|
180
|
+
summary=_present(body, "summary"),
|
|
181
|
+
standing=_present(body, "standing"),
|
|
182
182
|
license_type=body["license_type"],
|
|
183
183
|
starts=_datetime(body["starts"]),
|
|
184
184
|
expires=_datetime(body["expires"]),
|
|
185
185
|
renews_at=_datetime(body["renews_at"]),
|
|
186
186
|
notice_due_at=_datetime(body["notice_due_at"]),
|
|
187
|
-
versions=tuple(to_version(v) for v in body
|
|
187
|
+
versions=tuple(to_version(v) for v in _present(body, "versions")),
|
|
188
188
|
raw=body,
|
|
189
|
-
open=body
|
|
189
|
+
open=_present(body, "open"),
|
|
190
190
|
)
|
|
191
191
|
|
|
192
192
|
|
|
193
193
|
def to_version(body: dict[str, Any]) -> DatabaseVersion:
|
|
194
194
|
return DatabaseVersion(
|
|
195
|
-
id=body
|
|
196
|
-
version=body
|
|
197
|
-
summary=body
|
|
198
|
-
formats=tuple(body
|
|
195
|
+
id=_present(body, "id"),
|
|
196
|
+
version=_present(body, "version"),
|
|
197
|
+
summary=_present(body, "summary"),
|
|
198
|
+
formats=tuple(_present(body, "formats")),
|
|
199
199
|
sample_formats=_optional_tuple(body.get("sample_formats")),
|
|
200
200
|
)
|
|
201
201
|
|
|
202
202
|
|
|
203
203
|
def to_metadata(body: dict[str, Any]) -> DatabaseMetadata:
|
|
204
204
|
return DatabaseMetadata(
|
|
205
|
-
id=body
|
|
205
|
+
id=_present(body, "id"),
|
|
206
206
|
updated=datetime.date.fromisoformat(body["updated"]),
|
|
207
|
-
entries=body
|
|
207
|
+
entries=_present(body, "entries"),
|
|
208
208
|
schema={
|
|
209
209
|
fmt: tuple(to_column(c) for c in columns) for fmt, columns in body["schema"].items()
|
|
210
210
|
},
|
|
@@ -218,25 +218,42 @@ def to_metadata(body: dict[str, Any]) -> DatabaseMetadata:
|
|
|
218
218
|
|
|
219
219
|
|
|
220
220
|
def to_column(body: dict[str, Any]) -> MetadataColumn:
|
|
221
|
-
return MetadataColumn(
|
|
221
|
+
return MetadataColumn(
|
|
222
|
+
name=_present(body, "name"),
|
|
223
|
+
type=_present(body, "type"),
|
|
224
|
+
description=body.get("description"),
|
|
225
|
+
)
|
|
222
226
|
|
|
223
227
|
|
|
224
228
|
def to_download(body: dict[str, Any]) -> Download:
|
|
225
229
|
return Download(
|
|
226
|
-
dataset_id=body
|
|
227
|
-
format=body
|
|
228
|
-
outcome=body
|
|
230
|
+
dataset_id=_present(body, "dataset_id"),
|
|
231
|
+
format=_present(body, "format"),
|
|
232
|
+
outcome=_present(body, "outcome"),
|
|
229
233
|
created=datetime.datetime.fromisoformat(body["created"]),
|
|
230
234
|
bytes=body["bytes"],
|
|
231
235
|
http_status=body["http_status"],
|
|
232
236
|
apikey_id=body["apikey_id"],
|
|
233
237
|
client_ip=body["client_ip"],
|
|
234
238
|
user_agent=body["user_agent"],
|
|
235
|
-
sample=body
|
|
236
|
-
open=body
|
|
239
|
+
sample=_present(body, "sample"),
|
|
240
|
+
open=_present(body, "open"),
|
|
237
241
|
)
|
|
238
242
|
|
|
239
243
|
|
|
244
|
+
def _present(body: dict[str, Any], name: str) -> Any:
|
|
245
|
+
"""A member the spec requires and never sends as null.
|
|
246
|
+
|
|
247
|
+
A null one is the server's failure, raised for the caller to report as one, rather
|
|
248
|
+
than handed back in the answer: 2.7.1 listed a family whose `base` was null as a
|
|
249
|
+
family with no base (measured 2026-10-12).
|
|
250
|
+
"""
|
|
251
|
+
value = body[name]
|
|
252
|
+
if value is None:
|
|
253
|
+
raise ValueError(f"{name} is null")
|
|
254
|
+
return value
|
|
255
|
+
|
|
256
|
+
|
|
240
257
|
def _optional_tuple(value: Any) -> tuple[Any, ...] | None:
|
|
241
258
|
return tuple(value) if value is not None else None
|
|
242
259
|
|
|
@@ -301,6 +301,11 @@ class Stub:
|
|
|
301
301
|
route = self.routes.get(request.url.path)
|
|
302
302
|
if route is None:
|
|
303
303
|
return httpx.Response(404, json={"rc": "UNKNOWN_DATASET"})
|
|
304
|
+
if "raw" in route:
|
|
305
|
+
# Bytes as they are, for a body that is not JSON at all.
|
|
306
|
+
return httpx.Response(
|
|
307
|
+
route.get("status", 200), content=route["raw"], headers=route.get("headers")
|
|
308
|
+
)
|
|
304
309
|
return httpx.Response(
|
|
305
310
|
route.get("status", 200), json=self._body(route), headers=route.get("headers")
|
|
306
311
|
)
|
|
@@ -9,7 +9,7 @@ import inspect
|
|
|
9
9
|
import time
|
|
10
10
|
from collections.abc import Callable
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from typing import Any, get_args
|
|
12
|
+
from typing import Any, cast, get_args
|
|
13
13
|
|
|
14
14
|
import attrs
|
|
15
15
|
import httpx
|
|
@@ -18,6 +18,7 @@ from helpers import (
|
|
|
18
18
|
API,
|
|
19
19
|
API_KEY,
|
|
20
20
|
CHECKSUM_PATH,
|
|
21
|
+
DOWNLOAD_PATH,
|
|
21
22
|
DOWNLOADS_PATH,
|
|
22
23
|
LIST_PATH,
|
|
23
24
|
METADATA_PATH,
|
|
@@ -60,6 +61,7 @@ from internetdata._generated.models.download import Download as WireDownload
|
|
|
60
61
|
from internetdata._generated.models.download_outcome import DownloadOutcome as WireDownloadOutcome
|
|
61
62
|
from internetdata._generated.models.oauth_metadata import OauthMetadata as WireOauthMetadata
|
|
62
63
|
from internetdata._generated.models.token_response import TokenResponse as WireTokenResponse
|
|
64
|
+
from internetdata.errors import error_from_response
|
|
63
65
|
|
|
64
66
|
METADATA = {
|
|
65
67
|
"id": "bogon_ip_v1",
|
|
@@ -319,6 +321,157 @@ def test_a_malformed_body_is_the_servers_failure_not_a_traceback(
|
|
|
319
321
|
assert "malformed response" in caught.value.message
|
|
320
322
|
|
|
321
323
|
|
|
324
|
+
JSON_CALL_PATHS = {
|
|
325
|
+
"list": LIST_PATH,
|
|
326
|
+
"metadata": METADATA_PATH,
|
|
327
|
+
"checksums": CHECKSUM_PATH,
|
|
328
|
+
"downloads": DOWNLOADS_PATH,
|
|
329
|
+
"download_url": DOWNLOAD_PATH,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
UNREADABLE_2XX = {
|
|
333
|
+
"html": b"<html><body>Bad gateway</body></html>",
|
|
334
|
+
"cut off": b'{"databases": [',
|
|
335
|
+
"empty": b"",
|
|
336
|
+
"null": b"null",
|
|
337
|
+
"array": b"[]",
|
|
338
|
+
"empty object": b"{}",
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@pytest.mark.parametrize("body", UNREADABLE_2XX)
|
|
343
|
+
@pytest.mark.parametrize("call", JSON_CALLS)
|
|
344
|
+
def test_a_2xx_the_call_cannot_read_is_a_retried_server_error_with_its_status(
|
|
345
|
+
make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, call: str, body: str
|
|
346
|
+
) -> None:
|
|
347
|
+
"""Retried, but with no status, through 2.7.1. For `download_url` a 200 stands for the
|
|
348
|
+
302 it did not send."""
|
|
349
|
+
monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
|
|
350
|
+
stub = Stub({JSON_CALL_PATHS[call]: {"raw": UNREADABLE_2XX[body]}})
|
|
351
|
+
client = make_client(transport=stub.transport, retries=2)
|
|
352
|
+
|
|
353
|
+
with pytest.raises(InternetDataError) as caught:
|
|
354
|
+
JSON_CALLS[call](client)
|
|
355
|
+
|
|
356
|
+
assert caught.value.kind == "server_error"
|
|
357
|
+
assert caught.value.status == 200
|
|
358
|
+
assert len(stub.requests) == 3
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _without(member: str, entry: dict[str, Any]) -> dict[str, Any]:
|
|
362
|
+
return {**entry, member: None}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _version_without(member: str) -> dict[str, Any]:
|
|
366
|
+
family = database("bogon_ip")
|
|
367
|
+
return {**family, "versions": [_without(member, family["versions"][0])]}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
DIGESTS = {"md5": "m", "sha1": "s1", "sha256": "s256", "sha512": "s512"}
|
|
371
|
+
|
|
372
|
+
# Each member the spec requires and never sends as null, sent as null; and metadata's two
|
|
373
|
+
# maps sent as arrays, which escaped 2.7.1 as a raw AttributeError.
|
|
374
|
+
UNREADABLE_MEMBERS: dict[str, tuple[str, dict[str, Any]]] = {
|
|
375
|
+
**{
|
|
376
|
+
f"list {m}": (LIST_PATH, {"databases": [_without(m, database("bogon_ip"))]})
|
|
377
|
+
for m in ("base", "name", "summary", "standing", "open", "versions")
|
|
378
|
+
},
|
|
379
|
+
**{
|
|
380
|
+
f"list version {m}": (LIST_PATH, {"databases": [_version_without(m)]})
|
|
381
|
+
for m in ("id", "version", "summary", "formats")
|
|
382
|
+
},
|
|
383
|
+
**{
|
|
384
|
+
f"metadata {m}": (METADATA_PATH, _without(m, METADATA))
|
|
385
|
+
for m in ("id", "updated", "entries", "schema", "size")
|
|
386
|
+
},
|
|
387
|
+
"metadata column name": (
|
|
388
|
+
METADATA_PATH,
|
|
389
|
+
{**METADATA, "schema": {"csvgz": [{"name": None, "type": "string"}]}},
|
|
390
|
+
),
|
|
391
|
+
"metadata schema array": (METADATA_PATH, {**METADATA, "schema": []}),
|
|
392
|
+
"metadata sample array": (METADATA_PATH, {**METADATA, "sample": []}),
|
|
393
|
+
**{
|
|
394
|
+
f"downloads {m}": (
|
|
395
|
+
DOWNLOADS_PATH,
|
|
396
|
+
{"downloads": [_without(m, cast(list[dict[str, Any]], DOWNLOADS["downloads"])[0])]},
|
|
397
|
+
)
|
|
398
|
+
for m in ("dataset_id", "format", "outcome", "sample", "open", "created")
|
|
399
|
+
},
|
|
400
|
+
**{
|
|
401
|
+
f"checksums {m}": (
|
|
402
|
+
CHECKSUM_PATH,
|
|
403
|
+
{"id": "bogon_ip_v1", "format": "csvgz", "checksums": _without(m, DIGESTS)},
|
|
404
|
+
)
|
|
405
|
+
for m in ("md5", "sha256")
|
|
406
|
+
},
|
|
407
|
+
"checksums number": (
|
|
408
|
+
CHECKSUM_PATH,
|
|
409
|
+
{"id": "bogon_ip_v1", "format": "csvgz", "checksums": {**DIGESTS, "sha1": 5}},
|
|
410
|
+
),
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
MEMBER_CALLS = {LIST_PATH: "list", METADATA_PATH: "metadata", DOWNLOADS_PATH: "downloads"}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@pytest.mark.parametrize("case", UNREADABLE_MEMBERS)
|
|
417
|
+
def test_an_answer_missing_what_the_spec_requires_is_a_retried_server_error(
|
|
418
|
+
make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, case: str
|
|
419
|
+
) -> None:
|
|
420
|
+
"""2.7.1 handed a null required member back in the answer, and raised a raw
|
|
421
|
+
AttributeError for metadata's `schema` or `sample` sent as an array."""
|
|
422
|
+
monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
|
|
423
|
+
path, body = UNREADABLE_MEMBERS[case]
|
|
424
|
+
stub = Stub({path: {"body": body}})
|
|
425
|
+
client = make_client(transport=stub.transport, retries=2)
|
|
426
|
+
|
|
427
|
+
with pytest.raises(InternetDataError) as caught:
|
|
428
|
+
JSON_CALLS[MEMBER_CALLS.get(path, "checksums")](client)
|
|
429
|
+
|
|
430
|
+
assert (caught.value.kind, caught.value.status) == ("server_error", 200)
|
|
431
|
+
assert len(stub.requests) == 3
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@pytest.mark.parametrize(
|
|
435
|
+
("status", "body", "kind"),
|
|
436
|
+
[
|
|
437
|
+
(401, b"<html>401</html>", "unauthorized"),
|
|
438
|
+
(401, b"{}", "unauthorized"),
|
|
439
|
+
(403, b"", "forbidden"),
|
|
440
|
+
(404, b"<html>Not Found</html>", "bad_request"),
|
|
441
|
+
(400, b"null", "bad_request"),
|
|
442
|
+
],
|
|
443
|
+
)
|
|
444
|
+
@pytest.mark.parametrize("call", JSON_CALLS)
|
|
445
|
+
def test_a_4xx_is_classified_by_its_status_whatever_its_body(
|
|
446
|
+
make_client: ClientFactory, call: str, status: int, body: bytes, kind: str
|
|
447
|
+
) -> None:
|
|
448
|
+
"""A proxy's page on a 4xx is still the client's error, sent once. Read through the
|
|
449
|
+
generated decoder, it was a retried server error with no status (2.7.1)."""
|
|
450
|
+
stub = Stub({JSON_CALL_PATHS[call]: {"status": status, "raw": body}})
|
|
451
|
+
client = make_client(transport=stub.transport, retries=2)
|
|
452
|
+
|
|
453
|
+
with pytest.raises(InternetDataError) as caught:
|
|
454
|
+
JSON_CALLS[call](client)
|
|
455
|
+
|
|
456
|
+
assert (caught.value.kind, caught.value.status) == (kind, status)
|
|
457
|
+
assert len(stub.requests) == 1
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@pytest.mark.parametrize("call", JSON_CALLS)
|
|
461
|
+
def test_a_5xx_page_is_a_retried_server_error_with_its_status(
|
|
462
|
+
make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, call: str
|
|
463
|
+
) -> None:
|
|
464
|
+
monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
|
|
465
|
+
stub = Stub({JSON_CALL_PATHS[call]: {"status": 503, "raw": b"<html>503</html>"}})
|
|
466
|
+
client = make_client(transport=stub.transport, retries=2)
|
|
467
|
+
|
|
468
|
+
with pytest.raises(InternetDataError) as caught:
|
|
469
|
+
JSON_CALLS[call](client)
|
|
470
|
+
|
|
471
|
+
assert (caught.value.kind, caught.value.status) == ("server_error", 503)
|
|
472
|
+
assert len(stub.requests) == 3
|
|
473
|
+
|
|
474
|
+
|
|
322
475
|
def test_a_server_error_is_retried_and_then_reported(make_client: ClientFactory) -> None:
|
|
323
476
|
stub = Stub({LIST_PATH: {"status": 503, "body": {"rc": "UNAVAILABLE"}}})
|
|
324
477
|
client = make_client(transport=stub.transport, retries=2)
|
|
@@ -374,7 +527,7 @@ def test_a_rate_limit_is_retried_and_honors_retry_after(make_client: ClientFacto
|
|
|
374
527
|
|
|
375
528
|
|
|
376
529
|
@pytest.mark.parametrize(
|
|
377
|
-
"retry_after", ["2147484", "9223372036854775807", "
|
|
530
|
+
"retry_after", ["2147484", "9223372036854775807", "9" * 400, "Fri, 31 Dec 9999 23:59:59 GMT"]
|
|
378
531
|
)
|
|
379
532
|
def test_a_retry_after_past_the_ceiling_waits_the_backoff_and_stays_a_throttle(
|
|
380
533
|
make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, retry_after: str
|
|
@@ -393,6 +546,69 @@ def test_a_retry_after_past_the_ceiling_waits_the_backoff_and_stays_a_throttle(
|
|
|
393
546
|
assert len(stub.requests) == 2
|
|
394
547
|
|
|
395
548
|
|
|
549
|
+
@pytest.mark.parametrize(
|
|
550
|
+
"retry_after",
|
|
551
|
+
[
|
|
552
|
+
"-1",
|
|
553
|
+
"x",
|
|
554
|
+
"tomorrow",
|
|
555
|
+
"+1",
|
|
556
|
+
"0x10",
|
|
557
|
+
"1_0",
|
|
558
|
+
"1e3",
|
|
559
|
+
"1.5",
|
|
560
|
+
"inf",
|
|
561
|
+
"1e400",
|
|
562
|
+
"2026-10-12T06:00:00Z",
|
|
563
|
+
"Mon, 12 Oct 2026 06:00:00 +0500",
|
|
564
|
+
"Mon, 12 Oct 2026 06:00:00 PST",
|
|
565
|
+
"12 Oct 2026 06:00:00 GMT",
|
|
566
|
+
"Mon, 12 Oct 26 06:00:00 GMT",
|
|
567
|
+
"Tue, 31 Nov 2026 10:00:00 GMT",
|
|
568
|
+
"Mon, 12 Oct 2026 25:61:61 GMT",
|
|
569
|
+
],
|
|
570
|
+
)
|
|
571
|
+
def test_a_retry_after_that_is_neither_seconds_nor_an_http_date_is_a_spent_quota(
|
|
572
|
+
make_client: ClientFactory, retry_after: str
|
|
573
|
+
) -> None:
|
|
574
|
+
"""`float()` and `email.utils` read `+1` as a second, `1_0` as ten, `1e3` as a thousand
|
|
575
|
+
and a date in any zone (2.7.1). RFC 9110 admits digits, or a date in one of three
|
|
576
|
+
forms, in GMT."""
|
|
577
|
+
headers = {"Retry-After": retry_after}
|
|
578
|
+
stub = Stub({LIST_PATH: {"status": 429, "body": {"rc": "LIMITED"}, "headers": headers}})
|
|
579
|
+
# No retries, so a value misread as a long wait fails here rather than holding the run.
|
|
580
|
+
client = make_client(transport=stub.transport, retries=0)
|
|
581
|
+
|
|
582
|
+
with pytest.raises(InternetDataError) as caught:
|
|
583
|
+
client.database.list()
|
|
584
|
+
|
|
585
|
+
assert caught.value.kind == "quota_exceeded"
|
|
586
|
+
assert caught.value.retry_after_seconds is None
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
@pytest.mark.parametrize(
|
|
590
|
+
"form",
|
|
591
|
+
[
|
|
592
|
+
lambda when: when.strftime("%a, %d %b %Y %H:%M:%S GMT"),
|
|
593
|
+
lambda when: when.strftime("%A, %d-%b-%y %H:%M:%S GMT"),
|
|
594
|
+
lambda when: when.strftime("%a %b ") + f"{when.day:2d}" + when.strftime(" %H:%M:%S %Y"),
|
|
595
|
+
],
|
|
596
|
+
ids=["imf-fixdate", "rfc850", "asctime"],
|
|
597
|
+
)
|
|
598
|
+
def test_a_retry_after_date_is_read_in_each_of_rfc_9110s_three_forms(
|
|
599
|
+
form: Callable[[datetime.datetime], str],
|
|
600
|
+
) -> None:
|
|
601
|
+
# Computed as the case runs: a date made up front can have passed by the time it is read.
|
|
602
|
+
when = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=100)
|
|
603
|
+
headers = httpx.Headers({"Retry-After": form(when)})
|
|
604
|
+
|
|
605
|
+
err = error_from_response(429, headers, None)
|
|
606
|
+
|
|
607
|
+
assert err.kind == "rate_limited"
|
|
608
|
+
assert err.retry_after_seconds is not None
|
|
609
|
+
assert 98 <= err.retry_after_seconds <= 101
|
|
610
|
+
|
|
611
|
+
|
|
396
612
|
def test_a_retry_after_is_waited_as_given_up_to_the_ceiling() -> None:
|
|
397
613
|
def throttled(seconds: float) -> InternetDataError:
|
|
398
614
|
return InternetDataError("rate_limited", "RATE_LIMITED", 429, seconds)
|
|
@@ -245,7 +245,7 @@ def test_the_api_key_reaches_the_wire_under_the_bearer_scheme(
|
|
|
245
245
|
assert stub.requests[0].headers.get("authorization") == f"Bearer {API_KEY}"
|
|
246
246
|
|
|
247
247
|
|
|
248
|
-
@pytest.mark.parametrize("api_key", [None, ""])
|
|
248
|
+
@pytest.mark.parametrize("api_key", [None, "", " ", "\t", "\r\n"])
|
|
249
249
|
def test_a_keyless_client_sends_no_authorization_header(
|
|
250
250
|
make_client: ClientFactory, api_key: str | None
|
|
251
251
|
) -> None:
|
|
@@ -260,3 +260,26 @@ def test_a_keyless_client_sends_no_authorization_header(
|
|
|
260
260
|
client.database.list()
|
|
261
261
|
|
|
262
262
|
assert "authorization" not in stub.requests[0].headers
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_the_api_key_is_trimmed(make_client: ClientFactory) -> None:
|
|
266
|
+
"""A key pasted with a line ending around it went out padded, which httpx refuses, so
|
|
267
|
+
every call failed as a retried network error with nothing sent (2.7.1)."""
|
|
268
|
+
stub = Stub({LIST_PATH: {"body": {"databases": []}}})
|
|
269
|
+
client = make_client(transport=stub.transport, api_key=f" {API_KEY}\r\n")
|
|
270
|
+
|
|
271
|
+
client.database.list()
|
|
272
|
+
|
|
273
|
+
assert stub.requests[0].headers.get("authorization") == f"Bearer {API_KEY}"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@pytest.mark.parametrize(
|
|
277
|
+
"api_key", ["key\r\nX-Injected: 1", "ke\ty", "key\x00", "key\x7f", "k\ney"]
|
|
278
|
+
)
|
|
279
|
+
def test_a_control_character_in_the_api_key_is_refused_where_it_is_set(
|
|
280
|
+
make_client: ClientFactory, api_key: str
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Accepted, a CR, LF or NUL failed every call as a retried network error with nothing
|
|
283
|
+
sent, and a tab or DEL went out on the wire inside the header (2.7.1)."""
|
|
284
|
+
with pytest.raises(ValueError, match="control character"):
|
|
285
|
+
make_client(api_key=api_key)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/account/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/api_keys/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/authorization/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/database_v_2/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/organization/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_org_ref.py
RENAMED
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_org_wrap.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_user.py
RENAMED
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/apikey_detail.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_format.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_version.py
RENAMED
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/db_checksums.py
RENAMED
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/device_authorization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/download_outcome.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/oauth_metadata.py
RENAMED
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/revoke_request.py
RENAMED
|
File without changes
|
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/token_request.py
RENAMED
|
File without changes
|
{internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/token_response.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|