internetdata 2.7.1__tar.gz → 2.7.3__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.
Files changed (95) hide show
  1. {internetdata-2.7.1 → internetdata-2.7.3}/PKG-INFO +1 -1
  2. {internetdata-2.7.1 → internetdata-2.7.3}/spec/openapi.yaml +12 -15
  3. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/__init__.py +1 -1
  4. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_core.py +78 -73
  5. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -4
  6. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database.py +6 -7
  7. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/download.py +1 -3
  8. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/aio.py +4 -5
  9. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/client.py +4 -5
  10. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/errors.py +58 -14
  11. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/models.py +40 -24
  12. {internetdata-2.7.1 → internetdata-2.7.3}/tests/helpers.py +5 -0
  13. {internetdata-2.7.1 → internetdata-2.7.3}/tests/test_client.py +218 -2
  14. {internetdata-2.7.1 → internetdata-2.7.3}/tests/test_conformance.py +24 -1
  15. {internetdata-2.7.1 → internetdata-2.7.3}/.gitignore +0 -0
  16. {internetdata-2.7.1 → internetdata-2.7.3}/LICENSE +0 -0
  17. {internetdata-2.7.1 → internetdata-2.7.3}/README.md +0 -0
  18. {internetdata-2.7.1 → internetdata-2.7.3}/pyproject.toml +0 -0
  19. {internetdata-2.7.1 → internetdata-2.7.3}/scripts/download-spec.sh +0 -0
  20. {internetdata-2.7.1 → internetdata-2.7.3}/scripts/generate.sh +0 -0
  21. {internetdata-2.7.1 → internetdata-2.7.3}/scripts/publish.sh +0 -0
  22. {internetdata-2.7.1 → internetdata-2.7.3}/scripts/v2_subset.py +0 -0
  23. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/__init__.py +0 -0
  24. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/__init__.py +0 -0
  25. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/account/__init__.py +0 -0
  26. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/account/account_identity.py +0 -0
  27. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/api_keys/__init__.py +0 -0
  28. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/api_keys/account_create_apikey.py +0 -0
  29. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/api_keys/account_list_apikeys.py +0 -0
  30. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/api_keys/account_reveal_apikey.py +0 -0
  31. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/api_keys/account_revoke_apikey.py +0 -0
  32. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/api_keys/account_rotate_apikey.py +0 -0
  33. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/authorization/__init__.py +0 -0
  34. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/authorization/oauth_authorize.py +0 -0
  35. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/authorization/oauth_device_authorization.py +0 -0
  36. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/authorization/oauth_metadata.py +0 -0
  37. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/authorization/oauth_revoke.py +0 -0
  38. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/authorization/oauth_token.py +0 -0
  39. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
  40. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
  41. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
  42. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
  43. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
  44. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/organization/__init__.py +0 -0
  45. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/organization/account_org.py +0 -0
  46. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/api/organization/account_org_members.py +0 -0
  47. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/client.py +0 -0
  48. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/errors.py +0 -0
  49. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/__init__.py +0 -0
  50. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/account_create_apikey_request.py +0 -0
  51. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/account_created_apikey.py +0 -0
  52. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/account_org.py +0 -0
  53. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/account_org_ref.py +0 -0
  54. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/account_org_wrap.py +0 -0
  55. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/account_rc.py +0 -0
  56. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/account_revealed_apikey.py +0 -0
  57. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/account_user.py +0 -0
  58. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/apikey_detail.py +0 -0
  59. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/apikey_list.py +0 -0
  60. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
  61. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database_format.py +0 -0
  62. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
  63. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
  64. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
  65. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database_metadata.py +0 -0
  66. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
  67. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
  68. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
  69. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database_metadata_sample_size.py +0 -0
  70. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
  71. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
  72. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/database_version.py +0 -0
  73. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/db_checksums.py +0 -0
  74. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/device_authorization.py +0 -0
  75. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/device_authorization_request.py +0 -0
  76. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/download_outcome.py +0 -0
  77. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/error.py +0 -0
  78. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/identity.py +0 -0
  79. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
  80. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
  81. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/oauth_authorize_code_challenge_method.py +0 -0
  82. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/oauth_authorize_response_type.py +0 -0
  83. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/oauth_error.py +0 -0
  84. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/oauth_metadata.py +0 -0
  85. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/oauth_revoke_response_200.py +0 -0
  86. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/revoke_request.py +0 -0
  87. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/standing.py +0 -0
  88. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/token_request.py +0 -0
  89. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/models/token_response.py +0 -0
  90. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/_generated/types.py +0 -0
  91. {internetdata-2.7.1 → internetdata-2.7.3}/src/internetdata/py.typed +0 -0
  92. {internetdata-2.7.1 → internetdata-2.7.3}/testdata/testdata.json +0 -0
  93. {internetdata-2.7.1 → internetdata-2.7.3}/tests/conftest.py +0 -0
  94. {internetdata-2.7.1 → internetdata-2.7.3}/tests/test_download.py +0 -0
  95. {internetdata-2.7.1 → internetdata-2.7.3}/tests/test_oauth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: internetdata
3
- Version: 2.7.1
3
+ Version: 2.7.3
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.09
15
+ version: 2026.10.13
16
16
  servers:
17
17
  - url: https://internetdata.io
18
18
  tags:
@@ -29,8 +29,9 @@ tags:
29
29
 
30
30
  Access is granted by CONTRACT, one dataset family at a time: there is no
31
31
  self-service purchase and no subscription behind it. The Open databases
32
- are the exception: any organization downloads one with its key, licensed
33
- or not, under CC BY-SA 4.0. `list` marks them `open`.
32
+ are licensed to every organization from the start, a `standard` license
33
+ to each, and are free under CC BY-SA 4.0 besides. `list` marks them
34
+ `open`.
34
35
 
35
36
  A successful download answers `302` with a time-limited URL pointing
36
37
  straight at object storage. Follow the redirect (`curl -L`); the link
@@ -60,7 +61,6 @@ paths:
60
61
  The whole published catalog, with your organization's license beside
61
62
  each entry, so `standing` says whether a database is yours today
62
63
  (`licensed`), was (`expired`), or has never been bought (`unlicensed`).
63
- An `open` database downloads whatever its `standing`.
64
64
  security:
65
65
  - ApiKeyQueryAuth: []
66
66
  - ApiKeyHeaderAuth: []
@@ -902,8 +902,7 @@ components:
902
902
  description: |
903
903
  Where your license for a database family stands today. `licensed` is a
904
904
  live grant, `expired` one whose term has ended, and `unlicensed` a
905
- database published but never bought. An Open database (`open`)
906
- downloads whatever this says.
905
+ database published but never bought.
907
906
  enum:
908
907
  - licensed
909
908
  - expired
@@ -942,11 +941,11 @@ components:
942
941
  open:
943
942
  type: boolean
944
943
  description: |
945
- An Open database: any organization downloads it, and fetches its
946
- checksums, with no license, under CC BY-SA 4.0 (credit
947
- InternetData, https://internetdata.io, and share what you build
948
- from it under the same license). `standing` still reports your
949
- own license, which grants more where you hold one.
944
+ An Open database: free under CC BY-SA 4.0 (credit InternetData,
945
+ https://internetdata.io, and share what you build from it under
946
+ the same license). Every organization holds a `standard` license
947
+ to it from the start, which `standing` and `license_type` report
948
+ like any other.
950
949
  license_type:
951
950
  type: string
952
951
  nullable: true
@@ -1056,9 +1055,7 @@ components:
1056
1055
  open:
1057
1056
  type: boolean
1058
1057
  description: |
1059
- Taken under the Open license rather than one of your licenses: an
1060
- Open database downloaded while your organization held no license
1061
- in term for it.
1058
+ A download of an Open database.
1062
1059
  bytes:
1063
1060
  type: integer
1064
1061
  format: int64
@@ -1647,7 +1644,7 @@ components:
1647
1644
  V2NotLicensed:
1648
1645
  description: |
1649
1646
  Your organization holds no license for this database, or its term has
1650
- ended. Never for an Open database.
1647
+ ended.
1651
1648
  `rc`: `NOT_LICENSED`, `LICENSE_EXPIRED`.
1652
1649
  content:
1653
1650
  application/json:
@@ -39,7 +39,7 @@ from .models import (
39
39
  TokenResponse,
40
40
  )
41
41
 
42
- __version__ = "2.7.1"
42
+ __version__ = "2.7.3"
43
43
 
44
44
  __all__ = [
45
45
  "DATABASE_FORMATS",
@@ -14,7 +14,7 @@ import os
14
14
  import secrets
15
15
  import threading
16
16
  import time
17
- from collections.abc import Awaitable, Callable, Iterator
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 not api_key:
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[Any]:
254
- """`send` for one generated endpoint, one attempt of it finished within `bound` seconds,
255
- or unbounded when that is None.
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
- def call() -> Response[Any]:
262
- http = client.get_httpx_client()
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[Any]:
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
- return await send_async(call)
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[Any]) -> dict[str, Any]:
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 = int(res.status_code)
471
+ status = res.status_code
469
472
  if not 200 <= status < 300:
470
- raise error_from_response(status, _headers(res), body)
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(body: dict[str, Any], parse: Callable[[dict[str, Any]], T]) -> T:
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 constructor."""
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
- except (KeyError, TypeError, ValueError) as exc:
493
- raise malformed(exc) from exc
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[Any]) -> str:
503
+ def redirect_location(res: httpx.Response) -> str:
497
504
  """Where a `302` points, or whatever the API said instead."""
498
- location: str | None = _headers(res).get("location")
499
- if int(res.status_code) == 302 and location:
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", int(res.status_code)
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
- return dict(body["checksums"])
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)
@@ -59,7 +59,6 @@ def sync_detailed(
59
59
  The whole published catalog, with your organization's license beside
60
60
  each entry, so `standing` says whether a database is yours today
61
61
  (`licensed`), was (`expired`), or has never been bought (`unlicensed`).
62
- An `open` database downloads whatever its `standing`.
63
62
 
64
63
  Raises:
65
64
  errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
@@ -87,7 +86,6 @@ def sync(
87
86
  The whole published catalog, with your organization's license beside
88
87
  each entry, so `standing` says whether a database is yours today
89
88
  (`licensed`), was (`expired`), or has never been bought (`unlicensed`).
90
- An `open` database downloads whatever its `standing`.
91
89
 
92
90
  Raises:
93
91
  errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
@@ -111,7 +109,6 @@ async def asyncio_detailed(
111
109
  The whole published catalog, with your organization's license beside
112
110
  each entry, so `standing` says whether a database is yours today
113
111
  (`licensed`), was (`expired`), or has never been bought (`unlicensed`).
114
- An `open` database downloads whatever its `standing`.
115
112
 
116
113
  Raises:
117
114
  errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
@@ -137,7 +134,6 @@ async def asyncio(
137
134
  The whole published catalog, with your organization's license beside
138
135
  each entry, so `standing` says whether a database is yours today
139
136
  (`licensed`), was (`expired`), or has never been bought (`unlicensed`).
140
- An `open` database downloads whatever its `standing`.
141
137
 
142
138
  Raises:
143
139
  errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
@@ -31,13 +31,12 @@ class Database:
31
31
  summary (str): One line on what the newest version contains.
32
32
  standing (Standing): Where your license for a database family stands today. `licensed` is a
33
33
  live grant, `expired` one whose term has ended, and `unlicensed` a
34
- database published but never bought. An Open database (`open`)
35
- downloads whatever this says.
36
- open_ (bool): An Open database: any organization downloads it, and fetches its
37
- checksums, with no license, under CC BY-SA 4.0 (credit
38
- InternetData, https://internetdata.io, and share what you build
39
- from it under the same license). `standing` still reports your
40
- own license, which grants more where you hold one.
34
+ database published but never bought.
35
+ open_ (bool): An Open database: free under CC BY-SA 4.0 (credit InternetData,
36
+ https://internetdata.io, and share what you build from it under
37
+ the same license). Every organization holds a `standard` license
38
+ to it from the start, which `standing` and `license_type` report
39
+ like any other.
41
40
  license_type (DatabaseLicenseTypeType1 | DatabaseLicenseTypeType2Type1 | DatabaseLicenseTypeType3Type1 | None):
42
41
  What your license permits you to do with the data. Null when there
43
42
  is no license, which is every family with standing `unlicensed`.
@@ -21,9 +21,7 @@ class Download:
21
21
  format_ (str):
22
22
  outcome (DownloadOutcome):
23
23
  sample (bool): The evaluation sample rather than the database itself.
24
- open_ (bool): Taken under the Open license rather than one of your licenses: an
25
- Open database downloaded while your organization held no license
26
- in term for it.
24
+ open_ (bool): A download of an Open database.
27
25
  bytes_ (int | None): Object size at redirect time, NOT bytes delivered: the transfer is a
28
26
  presigned redirect straight to object storage, so we never observe
29
27
  how much of it was taken.
@@ -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(unwrap(res), databases_of)
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(unwrap(res), to_metadata)
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(unwrap(res), checksums_of)
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(unwrap(res), downloads_of)
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(unwrap(res), databases_of)
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(unwrap(res), to_metadata)
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(unwrap(res), checksums_of)
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(unwrap(res), downloads_of)
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 or value.strip() == "":
193
+ if value is None:
175
194
  return None
176
- try:
177
- seconds = float(value)
178
- except ValueError:
179
- pass
180
- else:
181
- return seconds if seconds >= 0 else None
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