internetdata 2.1.0__tar.gz → 2.3.0__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 (55) hide show
  1. {internetdata-2.1.0 → internetdata-2.3.0}/PKG-INFO +11 -4
  2. {internetdata-2.1.0 → internetdata-2.3.0}/README.md +10 -3
  3. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/__init__.py +7 -1
  4. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_core.py +25 -0
  5. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/aio.py +47 -13
  6. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/client.py +47 -13
  7. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/models.py +32 -5
  8. {internetdata-2.1.0 → internetdata-2.3.0}/testdata/testdata.json +15 -1
  9. {internetdata-2.1.0 → internetdata-2.3.0}/tests/helpers.py +19 -17
  10. {internetdata-2.1.0 → internetdata-2.3.0}/tests/test_client.py +180 -11
  11. {internetdata-2.1.0 → internetdata-2.3.0}/tests/test_conformance.py +53 -14
  12. {internetdata-2.1.0 → internetdata-2.3.0}/.gitignore +0 -0
  13. {internetdata-2.1.0 → internetdata-2.3.0}/LICENSE +0 -0
  14. {internetdata-2.1.0 → internetdata-2.3.0}/pyproject.toml +0 -0
  15. {internetdata-2.1.0 → internetdata-2.3.0}/scripts/download-spec.sh +0 -0
  16. {internetdata-2.1.0 → internetdata-2.3.0}/scripts/generate.sh +0 -0
  17. {internetdata-2.1.0 → internetdata-2.3.0}/scripts/publish.sh +0 -0
  18. {internetdata-2.1.0 → internetdata-2.3.0}/scripts/v2_subset.py +0 -0
  19. {internetdata-2.1.0 → internetdata-2.3.0}/spec/openapi.yaml +0 -0
  20. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/__init__.py +0 -0
  21. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/__init__.py +0 -0
  22. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
  23. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
  24. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
  25. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
  26. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
  27. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
  28. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/client.py +0 -0
  29. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/errors.py +0 -0
  30. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/__init__.py +0 -0
  31. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database.py +0 -0
  32. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
  33. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_format.py +0 -0
  34. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
  35. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
  36. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
  37. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_metadata.py +0 -0
  38. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
  39. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
  40. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
  41. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
  42. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
  43. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_version.py +0 -0
  44. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/db_checksums.py +0 -0
  45. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/download.py +0 -0
  46. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/download_outcome.py +0 -0
  47. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/error.py +0 -0
  48. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
  49. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
  50. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/standing.py +0 -0
  51. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/types.py +0 -0
  52. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/errors.py +0 -0
  53. {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/py.typed +0 -0
  54. {internetdata-2.1.0 → internetdata-2.3.0}/tests/conftest.py +0 -0
  55. {internetdata-2.1.0 → internetdata-2.3.0}/tests/test_download.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: internetdata
3
- Version: 2.1.0
3
+ Version: 2.3.0
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
@@ -72,7 +72,7 @@ with InternetData(api_key) as client:
72
72
 
73
73
  ### The catalog
74
74
 
75
- `list` answers database FAMILIES. A licence is held against a family, while a download names a specific version, so the ids the other calls take come from each family's `versions`:
75
+ `list` answers database FAMILIES. A license is held against a family, while a download names a specific version, so the ids the other calls take come from each family's `versions`:
76
76
 
77
77
  ```python
78
78
  for family in client.database.list():
@@ -82,7 +82,9 @@ for family in client.database.list():
82
82
  print(version.id, version.formats) # 'bogon_ip_v1' ('csvgz', 'mmdb')
83
83
  ```
84
84
 
85
- `standing` is `licensed`, `expired` or `unlicensed`, and `license_type` is what your licence lets you do with the data (`evaluation`, `standard`, `redistribute`, or `None` when there is no licence). A family you have never bought is still listed, as `unlicensed`, so you can see what else exists.
85
+ `standing` is `licensed`, `expired` or `unlicensed`, and `license_type` is what your license lets you do with the data (`evaluation`, `standard`, `redistribute`, or `None` when there is no license). A family you have never bought is still listed, as `unlicensed`, so you can see what else exists.
86
+
87
+ A rolling license also carries `renews_at`, when it next renews, and `notice_due_at`, the last day you can give notice of non-renewal for that term. Both are `None` when there is no license, when it has no defined term, or when `expires` sets a hard stop instead. `DATABASE_FORMATS`, `STANDINGS` and `LICENSE_TYPES` hold the published values at runtime, for checking one that came from a flag or a form before you make a call.
86
88
 
87
89
  ### What is inside a build
88
90
 
@@ -165,14 +167,19 @@ except InternetDataError as err:
165
167
 
166
168
  `kind` is one of `bad_request`, `unauthorized`, `forbidden`, `rate_limited`, `quota_exceeded`, `server_error` or `network`. `message` is the API's own result code, passed through as it was sent, so you can switch on `NOT_LICENSED` against `LICENSE_EXPIRED` without reading the status.
167
169
 
168
- A request that runs past its `timeout` fails with `network`, and is retried like any other network failure. The default is 30 seconds per attempt, body included, so a retried call can take longer in total. A database transfer is exempt, so `download` and `download_bytes` are never cut off part way through a large file. Set it on the client, in seconds, or pass `None` for no bound:
170
+ A request that runs past its `timeout` fails with `network`, and is retried like any other network failure. The default is 30 seconds per attempt, body included, so a retried call can take longer in total. A database transfer is exempt, so `download` and `download_bytes` are never cut off part way through a large file. Set it on the client, in seconds, or pass `None` for no bound. Anything else that is not a number greater than 0 raises `ValueError` where it is set:
169
171
 
170
172
  ```python
171
173
  client = InternetData(api_key, timeout=10)
174
+ catalog = client.database.list(timeout=2)
172
175
  ```
173
176
 
177
+ From 2.3.0, `list`, `metadata`, `checksums`, `downloads` and `download_url` each take a keyword-only `timeout` in seconds, bounding each attempt at that one call in place of the client's; `None` there means the client's own rather than no bound. `download` and `download_bytes` deliberately take none and raise `TypeError` if handed one, rather than accepting it and quietly doing nothing: a transfer runs to gigabytes and minutes, so any bound that suits a JSON call would abandon a healthy download. `download_url` does take one, because minting the link is an ordinary API request - it bounds that request, not whatever you do with the link afterwards.
178
+
174
179
  **Changed in 2.1.0:** the default was 10 seconds, and it bounded each read of a response rather than the whole attempt, so a response trickling in slowly could run past it for as long as the server kept sending.
175
180
 
181
+ **Changed in 2.2.0:** a timeout of 0 or less, NaN or a string used to be accepted, and failed every call.
182
+
176
183
  Note that `rate_limited` and `quota_exceeded` both arrive as HTTP 429 and are not the same thing. A rate limit is the API facing a traffic burst, and retrying later works; a spent quota needs your allowance raised or the window to roll over. The library retries rate limits for you, and server and network failures, but never a spent quota or anything else you sent.
177
184
 
178
185
  ## Other Libraries
@@ -38,7 +38,7 @@ with InternetData(api_key) as client:
38
38
 
39
39
  ### The catalog
40
40
 
41
- `list` answers database FAMILIES. A licence is held against a family, while a download names a specific version, so the ids the other calls take come from each family's `versions`:
41
+ `list` answers database FAMILIES. A license is held against a family, while a download names a specific version, so the ids the other calls take come from each family's `versions`:
42
42
 
43
43
  ```python
44
44
  for family in client.database.list():
@@ -48,7 +48,9 @@ for family in client.database.list():
48
48
  print(version.id, version.formats) # 'bogon_ip_v1' ('csvgz', 'mmdb')
49
49
  ```
50
50
 
51
- `standing` is `licensed`, `expired` or `unlicensed`, and `license_type` is what your licence lets you do with the data (`evaluation`, `standard`, `redistribute`, or `None` when there is no licence). A family you have never bought is still listed, as `unlicensed`, so you can see what else exists.
51
+ `standing` is `licensed`, `expired` or `unlicensed`, and `license_type` is what your license lets you do with the data (`evaluation`, `standard`, `redistribute`, or `None` when there is no license). A family you have never bought is still listed, as `unlicensed`, so you can see what else exists.
52
+
53
+ A rolling license also carries `renews_at`, when it next renews, and `notice_due_at`, the last day you can give notice of non-renewal for that term. Both are `None` when there is no license, when it has no defined term, or when `expires` sets a hard stop instead. `DATABASE_FORMATS`, `STANDINGS` and `LICENSE_TYPES` hold the published values at runtime, for checking one that came from a flag or a form before you make a call.
52
54
 
53
55
  ### What is inside a build
54
56
 
@@ -131,14 +133,19 @@ except InternetDataError as err:
131
133
 
132
134
  `kind` is one of `bad_request`, `unauthorized`, `forbidden`, `rate_limited`, `quota_exceeded`, `server_error` or `network`. `message` is the API's own result code, passed through as it was sent, so you can switch on `NOT_LICENSED` against `LICENSE_EXPIRED` without reading the status.
133
135
 
134
- A request that runs past its `timeout` fails with `network`, and is retried like any other network failure. The default is 30 seconds per attempt, body included, so a retried call can take longer in total. A database transfer is exempt, so `download` and `download_bytes` are never cut off part way through a large file. Set it on the client, in seconds, or pass `None` for no bound:
136
+ A request that runs past its `timeout` fails with `network`, and is retried like any other network failure. The default is 30 seconds per attempt, body included, so a retried call can take longer in total. A database transfer is exempt, so `download` and `download_bytes` are never cut off part way through a large file. Set it on the client, in seconds, or pass `None` for no bound. Anything else that is not a number greater than 0 raises `ValueError` where it is set:
135
137
 
136
138
  ```python
137
139
  client = InternetData(api_key, timeout=10)
140
+ catalog = client.database.list(timeout=2)
138
141
  ```
139
142
 
143
+ From 2.3.0, `list`, `metadata`, `checksums`, `downloads` and `download_url` each take a keyword-only `timeout` in seconds, bounding each attempt at that one call in place of the client's; `None` there means the client's own rather than no bound. `download` and `download_bytes` deliberately take none and raise `TypeError` if handed one, rather than accepting it and quietly doing nothing: a transfer runs to gigabytes and minutes, so any bound that suits a JSON call would abandon a healthy download. `download_url` does take one, because minting the link is an ordinary API request - it bounds that request, not whatever you do with the link afterwards.
144
+
140
145
  **Changed in 2.1.0:** the default was 10 seconds, and it bounded each read of a response rather than the whole attempt, so a response trickling in slowly could run past it for as long as the server kept sending.
141
146
 
147
+ **Changed in 2.2.0:** a timeout of 0 or less, NaN or a string used to be accepted, and failed every call.
148
+
142
149
  Note that `rate_limited` and `quota_exceeded` both arrive as HTTP 429 and are not the same thing. A rate limit is the API facing a traffic burst, and retrying later works; a spent quota needs your allowance raised or the window to roll over. The library retries rate limits for you, and server and network failures, but never a spent quota or anything else you sent.
143
150
 
144
151
  ## Other Libraries
@@ -15,6 +15,9 @@ from .aio import AsyncDatabaseApi, AsyncInternetData
15
15
  from .client import DatabaseApi, InternetData
16
16
  from .errors import ErrorKind, InternetDataError
17
17
  from .models import (
18
+ DATABASE_FORMATS,
19
+ LICENSE_TYPES,
20
+ STANDINGS,
18
21
  Database,
19
22
  DatabaseMetadata,
20
23
  DatabaseVersion,
@@ -26,10 +29,13 @@ from .models import (
26
29
  Standing,
27
30
  )
28
31
 
29
- __version__ = "2.1.0"
32
+ __version__ = "2.3.0"
30
33
 
31
34
  __all__ = [
35
+ "DATABASE_FORMATS",
32
36
  "DEFAULT_BASE_URL",
37
+ "LICENSE_TYPES",
38
+ "STANDINGS",
33
39
  "AsyncDatabaseApi",
34
40
  "AsyncInternetData",
35
41
  "Database",
@@ -7,6 +7,7 @@ import asyncio
7
7
  import contextlib
8
8
  import contextvars
9
9
  import json
10
+ import math
10
11
  import os
11
12
  import threading
12
13
  from collections.abc import Awaitable, Callable, Iterator
@@ -105,6 +106,30 @@ def build_async_transfer_client(
105
106
  )
106
107
 
107
108
 
109
+ def check_timeout(timeout: float | None) -> float | None:
110
+ """`timeout`, once it is a bound an attempt can meet.
111
+
112
+ Refused where it is SET, because nothing downstream refuses it: zero, a negative
113
+ number, NaN or a string reached the first call and failed it, and every call after,
114
+ as a retried `network` error after three seconds of backoff, or for a string as a
115
+ `server_error` blaming the API. Infinity is refused too, since the sync client's wait
116
+ cannot hold it (`OverflowError`); None is the spelling for no bound.
117
+ """
118
+ if timeout is None:
119
+ return None
120
+ if (
121
+ isinstance(timeout, bool)
122
+ or not isinstance(timeout, int | float)
123
+ or not math.isfinite(timeout)
124
+ or timeout <= 0
125
+ ):
126
+ raise ValueError(
127
+ f"timeout must be a number of seconds greater than 0, or None for no bound, "
128
+ f"not {timeout!r}"
129
+ )
130
+ return timeout
131
+
132
+
108
133
  def storage_refusal(res: httpx.Response) -> InternetDataError:
109
134
  """What object storage refusing a download link becomes.
110
135
 
@@ -26,6 +26,7 @@ from ._core import (
26
26
  assert_whole_transfer,
27
27
  build_async_transfer_client,
28
28
  build_client,
29
+ check_timeout,
29
30
  checksums_of,
30
31
  databases_of,
31
32
  downloads_of,
@@ -73,6 +74,7 @@ class AsyncInternetData:
73
74
  timeout: float | None = DEFAULT_TIMEOUT,
74
75
  transport: httpx.AsyncBaseTransport | None = None,
75
76
  ) -> None:
77
+ timeout = check_timeout(timeout)
76
78
  self._client = build_client(api_key, base_url, timeout, transport)
77
79
  self._transfer = build_async_transfer_client(timeout, transport)
78
80
  self._retries = retries
@@ -94,6 +96,10 @@ class AsyncInternetData:
94
96
  ) -> None:
95
97
  await self.aclose()
96
98
 
99
+ # A per-call timeout, checked, or the client's own when the call gave none.
100
+ def _bound(self, timeout: float | None) -> float | None:
101
+ return self._timeout if timeout is None else check_timeout(timeout)
102
+
97
103
  async def _retrying(self, call: Callable[[], Awaitable[T]], retries: int) -> T:
98
104
  attempt = 0
99
105
  while True:
@@ -113,12 +119,17 @@ class AsyncDatabaseApi:
113
119
 
114
120
  `list` is a method here, which shadows the builtin for everything else in the class
115
121
  body, so the return annotations name `builtins.list` explicitly.
122
+
123
+ Every call here that asks the API a question takes `timeout`, in seconds, bounding
124
+ each ATTEMPT of that call alone and overriding the client's. The two transfers take
125
+ none and refuse one rather than ignoring it: a database runs to gigabytes and minutes,
126
+ so any bound that suits a JSON call would abandon a healthy download.
116
127
  """
117
128
 
118
129
  def __init__(self, owner: AsyncInternetData) -> None:
119
130
  self._owner = owner
120
131
 
121
- async def list(self) -> builtins.list[Database]:
132
+ async def list(self, *, timeout: float | None = None) -> builtins.list[Database]:
122
133
  """The published catalog as YOUR organization may see it.
123
134
 
124
135
  Every family carries a `standing`, so one you have never bought is listed as
@@ -126,33 +137,43 @@ class AsyncDatabaseApi:
126
137
  single customer, which is absent entirely for everyone else. Nothing is cached
127
138
  and nothing is reconstructed here - what you get is what the server sent for the
128
139
  key you are holding.
140
+
141
+ `timeout` bounds each attempt at this call alone, in place of the client's.
129
142
  """
130
143
 
131
144
  async def call() -> builtins.list[Database]:
132
- res = await request_async(list_databases, self._client, self._owner._timeout)
145
+ res = await request_async(list_databases, self._client, self._bound(timeout))
133
146
  return parse_body(unwrap(res), databases_of)
134
147
 
135
148
  return await self._retrying(call)
136
149
 
137
- async def metadata(self, database_id: str) -> DatabaseMetadata:
138
- """What is inside one database: freshness, row count, columns, samples and sizes."""
150
+ async def metadata(self, database_id: str, *, timeout: float | None = None) -> DatabaseMetadata:
151
+ """What is inside one database: freshness, row count, columns, samples and sizes.
152
+
153
+ `timeout` bounds each attempt at this call alone, in place of the client's.
154
+ """
139
155
 
140
156
  async def call() -> DatabaseMetadata:
141
157
  res = await request_async(
142
- database_metadata_v2, self._client, self._owner._timeout, id=database_id
158
+ database_metadata_v2, self._client, self._bound(timeout), id=database_id
143
159
  )
144
160
  return parse_body(unwrap(res), to_metadata)
145
161
 
146
162
  return await self._retrying(call)
147
163
 
148
- async def checksums(self, database_id: str, format: Format) -> dict[str, str]:
149
- """Every checksum published for one database file, keyed by algorithm."""
164
+ async def checksums(
165
+ self, database_id: str, format: Format, *, timeout: float | None = None
166
+ ) -> dict[str, str]:
167
+ """Every checksum published for one database file, keyed by algorithm.
168
+
169
+ `timeout` bounds each attempt at this call alone, in place of the client's.
170
+ """
150
171
 
151
172
  async def call() -> dict[str, str]:
152
173
  res = await request_async(
153
174
  database_checksum_v2,
154
175
  self._client,
155
- self._owner._timeout,
176
+ self._bound(timeout),
156
177
  id=database_id,
157
178
  format_=DatabaseFormat(format),
158
179
  )
@@ -160,18 +181,25 @@ class AsyncDatabaseApi:
160
181
 
161
182
  return await self._retrying(call)
162
183
 
163
- async def downloads(self, limit: int = DEFAULT_DOWNLOADS_LIMIT) -> builtins.list[Download]:
164
- """Your organization's recent download attempts, newest first."""
184
+ async def downloads(
185
+ self, limit: int = DEFAULT_DOWNLOADS_LIMIT, *, timeout: float | None = None
186
+ ) -> builtins.list[Download]:
187
+ """Your organization's recent download attempts, newest first.
188
+
189
+ `timeout` bounds each attempt at this call alone, in place of the client's.
190
+ """
165
191
 
166
192
  async def call() -> builtins.list[Download]:
167
193
  res = await request_async(
168
- list_downloads, self._client, self._owner._timeout, limit=limit
194
+ list_downloads, self._client, self._bound(timeout), limit=limit
169
195
  )
170
196
  return parse_body(unwrap(res), downloads_of)
171
197
 
172
198
  return await self._retrying(call)
173
199
 
174
- async def download_url(self, database_id: str, format: Format) -> str:
200
+ async def download_url(
201
+ self, database_id: str, format: Format, *, timeout: float | None = None
202
+ ) -> str:
175
203
  """The time-limited URL for one database file.
176
204
 
177
205
  The API answers `302` to object storage, and the link carries its own signature,
@@ -179,13 +207,16 @@ class AsyncDatabaseApi:
179
207
  rather than followed so the caller decides how to move a file that reaches
180
208
  gigabytes; the link authorizes the START of a transfer, so one already running is
181
209
  not interrupted when it lapses.
210
+
211
+ `timeout` bounds each attempt at MINTING the link, which is an ordinary API
212
+ request, and says nothing about the transfer you then run with it.
182
213
  """
183
214
 
184
215
  async def call() -> str:
185
216
  res = await request_async(
186
217
  download_database_v2,
187
218
  self._client,
188
- self._owner._timeout,
219
+ self._bound(timeout),
189
220
  id=database_id,
190
221
  format_=DatabaseFormat(format),
191
222
  )
@@ -264,5 +295,8 @@ class AsyncDatabaseApi:
264
295
  def _client(self) -> AuthenticatedClient:
265
296
  return self._owner._client
266
297
 
298
+ def _bound(self, timeout: float | None) -> float | None:
299
+ return self._owner._bound(timeout)
300
+
267
301
  async def _retrying(self, call: Callable[[], Awaitable[T]]) -> T:
268
302
  return await self._owner._retrying(call, self._owner._retries)
@@ -21,6 +21,7 @@ from ._core import (
21
21
  assert_whole_transfer,
22
22
  build_client,
23
23
  build_transfer_client,
24
+ check_timeout,
24
25
  checksums_of,
25
26
  databases_of,
26
27
  downloads_of,
@@ -55,12 +56,15 @@ class InternetData:
55
56
  Every database published today is licensed, so create a key carrying the
56
57
  `db.download` scope in the console and pass it in. The argument is optional
57
58
  nonetheless, and an absent or empty one sends no `Authorization` header at all
58
- rather than an empty one: what this API serves without a licence is a product
59
+ rather than an empty one: what this API serves without a license is a product
59
60
  decision, not the client's to refuse.
60
61
 
61
62
  `timeout` is how long one attempt at a request may take, in seconds, body included, so a
62
63
  call that is retried can take longer in total; None means no bound, and a database
63
- transfer is exempt.
64
+ transfer is exempt. Every `database` call but the two transfers also takes `timeout`,
65
+ which overrides the client's for that call alone. Anything else that is not a finite
66
+ number greater than 0 is a `ValueError` where it is set, rather than a failure of every
67
+ call.
64
68
 
65
69
  Holds an HTTP connection pool, so use it as a context manager or call `close()` when
66
70
  you are done with it.
@@ -78,6 +82,7 @@ class InternetData:
78
82
  timeout: float | None = DEFAULT_TIMEOUT,
79
83
  transport: httpx.BaseTransport | None = None,
80
84
  ) -> None:
85
+ timeout = check_timeout(timeout)
81
86
  self._client = build_client(api_key, base_url, timeout, transport)
82
87
  self._transfer = build_transfer_client(timeout, transport)
83
88
  self._retries = retries
@@ -99,6 +104,10 @@ class InternetData:
99
104
  ) -> None:
100
105
  self.close()
101
106
 
107
+ # A per-call timeout, checked, or the client's own when the call gave none.
108
+ def _bound(self, timeout: float | None) -> float | None:
109
+ return self._timeout if timeout is None else check_timeout(timeout)
110
+
102
111
  def _retrying(self, call: Callable[[], T], retries: int) -> T:
103
112
  attempt = 0
104
113
  while True:
@@ -118,12 +127,17 @@ class DatabaseApi:
118
127
 
119
128
  `list` is a method here, which shadows the builtin for everything else in the class
120
129
  body, so the return annotations name `builtins.list` explicitly.
130
+
131
+ Every call here that asks the API a question takes `timeout`, in seconds, bounding
132
+ each ATTEMPT of that call alone and overriding the client's. The two transfers take
133
+ none and refuse one rather than ignoring it: a database runs to gigabytes and minutes,
134
+ so any bound that suits a JSON call would abandon a healthy download.
121
135
  """
122
136
 
123
137
  def __init__(self, owner: InternetData) -> None:
124
138
  self._owner = owner
125
139
 
126
- def list(self) -> builtins.list[Database]:
140
+ def list(self, *, timeout: float | None = None) -> builtins.list[Database]:
127
141
  """The published catalog as YOUR organization may see it.
128
142
 
129
143
  Every family carries a `standing`, so one you have never bought is listed as
@@ -133,41 +147,49 @@ class DatabaseApi:
133
147
  customer. Nothing is cached and nothing is reconstructed here - what you get is
134
148
  what the server sent for the key you are holding.
135
149
 
136
- A licence covers a FAMILY, while a download names a version, so the ids for the
150
+ A license covers a FAMILY, while a download names a version, so the ids for the
137
151
  other calls come from each entry's `versions`.
152
+
153
+ `timeout` bounds each attempt at this call alone, in place of the client's.
138
154
  """
139
155
 
140
156
  def call() -> builtins.list[Database]:
141
- res = request(list_databases, self._client, self._owner._timeout)
157
+ res = request(list_databases, self._client, self._bound(timeout))
142
158
  return parse_body(unwrap(res), databases_of)
143
159
 
144
160
  return self._retrying(call)
145
161
 
146
- def metadata(self, database_id: str) -> DatabaseMetadata:
162
+ def metadata(self, database_id: str, *, timeout: float | None = None) -> DatabaseMetadata:
147
163
  """What is inside one database: freshness, row count, columns, samples and sizes.
148
164
 
149
165
  Cheap enough to poll: it answers `updated` and `entries` without moving the
150
166
  build. `size` is the number to budget a transfer against before starting one.
167
+
168
+ `timeout` bounds each attempt at this call alone, in place of the client's.
151
169
  """
152
170
 
153
171
  def call() -> DatabaseMetadata:
154
- res = request(database_metadata_v2, self._client, self._owner._timeout, id=database_id)
172
+ res = request(database_metadata_v2, self._client, self._bound(timeout), id=database_id)
155
173
  return parse_body(unwrap(res), to_metadata)
156
174
 
157
175
  return self._retrying(call)
158
176
 
159
- def checksums(self, database_id: str, format: Format) -> dict[str, str]:
177
+ def checksums(
178
+ self, database_id: str, format: Format, *, timeout: float | None = None
179
+ ) -> dict[str, str]:
160
180
  """Every checksum published for one database file, keyed by algorithm.
161
181
 
162
182
  Keyed rather than one digest because the API publishes md5, sha1, sha256 and
163
183
  sha512 side by side and which of them you want is your verifier's business.
184
+
185
+ `timeout` bounds each attempt at this call alone, in place of the client's.
164
186
  """
165
187
 
166
188
  def call() -> dict[str, str]:
167
189
  res = request(
168
190
  database_checksum_v2,
169
191
  self._client,
170
- self._owner._timeout,
192
+ self._bound(timeout),
171
193
  id=database_id,
172
194
  format_=DatabaseFormat(format),
173
195
  )
@@ -175,20 +197,26 @@ class DatabaseApi:
175
197
 
176
198
  return self._retrying(call)
177
199
 
178
- def downloads(self, limit: int = DEFAULT_DOWNLOADS_LIMIT) -> builtins.list[Download]:
200
+ def downloads(
201
+ self, limit: int = DEFAULT_DOWNLOADS_LIMIT, *, timeout: float | None = None
202
+ ) -> builtins.list[Download]:
179
203
  """Your organization's recent download attempts, newest first.
180
204
 
181
205
  Refusals are listed too: a denial is what answers "it stopped working", and its
182
206
  absence answers nothing.
207
+
208
+ `timeout` bounds each attempt at this call alone, in place of the client's.
183
209
  """
184
210
 
185
211
  def call() -> builtins.list[Download]:
186
- res = request(list_downloads, self._client, self._owner._timeout, limit=limit)
212
+ res = request(list_downloads, self._client, self._bound(timeout), limit=limit)
187
213
  return parse_body(unwrap(res), downloads_of)
188
214
 
189
215
  return self._retrying(call)
190
216
 
191
- def download_url(self, database_id: str, format: Format) -> str:
217
+ def download_url(
218
+ self, database_id: str, format: Format, *, timeout: float | None = None
219
+ ) -> str:
192
220
  """The time-limited URL for one database file.
193
221
 
194
222
  The API answers `302` to object storage, and the link carries its own signature,
@@ -196,13 +224,16 @@ class DatabaseApi:
196
224
  rather than followed so the caller decides how to move a file that reaches
197
225
  gigabytes; the link authorizes the START of a transfer, so one already running is
198
226
  not interrupted when it lapses.
227
+
228
+ `timeout` bounds each attempt at MINTING the link, which is an ordinary API
229
+ request, and says nothing about the transfer you then run with it.
199
230
  """
200
231
 
201
232
  def call() -> str:
202
233
  res = request(
203
234
  download_database_v2,
204
235
  self._client,
205
- self._owner._timeout,
236
+ self._bound(timeout),
206
237
  id=database_id,
207
238
  format_=DatabaseFormat(format),
208
239
  )
@@ -276,5 +307,8 @@ class DatabaseApi:
276
307
  def _client(self) -> AuthenticatedClient:
277
308
  return self._owner._client
278
309
 
310
+ def _bound(self, timeout: float | None) -> float | None:
311
+ return self._owner._bound(timeout)
312
+
279
313
  def _retrying(self, call: Callable[[], T]) -> T:
280
314
  return self._owner._retrying(call, self._owner._retries)
@@ -11,9 +11,12 @@ from __future__ import annotations
11
11
 
12
12
  import datetime
13
13
  from dataclasses import dataclass, field
14
- from typing import Any, Literal
14
+ from typing import Any, Literal, get_args
15
15
 
16
16
  __all__ = [
17
+ "DATABASE_FORMATS",
18
+ "LICENSE_TYPES",
19
+ "STANDINGS",
17
20
  "Database",
18
21
  "DatabaseMetadata",
19
22
  "DatabaseVersion",
@@ -37,7 +40,20 @@ Standing = Literal["licensed", "expired", "unlicensed"]
37
40
  """Where your organization stands with one database family."""
38
41
 
39
42
  LicenseType = Literal["evaluation", "standard", "redistribute"]
40
- """What a licence permits you to do with the data. `None` when there is no licence."""
43
+ """What a license permits you to do with the data. `None` when there is no license."""
44
+
45
+ DATABASE_FORMATS: tuple[Format, ...] = get_args(Format)
46
+ """Every `Format`, at runtime.
47
+
48
+ A `Literal` is erased to nothing a program can check against, so a format read from a
49
+ flag, a form or a config file has these to be tested against before a call.
50
+ """
51
+
52
+ STANDINGS: tuple[Standing, ...] = get_args(Standing)
53
+ """Every `Standing`, at runtime."""
54
+
55
+ LICENSE_TYPES: tuple[LicenseType, ...] = get_args(LicenseType)
56
+ """Every `LicenseType`, at runtime. `None`, for no license, is not one of them."""
41
57
 
42
58
  Outcome = Literal["ok", "unauthorized", "denied", "expired", "unknown", "unavailable"]
43
59
  """How one download attempt ended, refusals included."""
@@ -49,7 +65,7 @@ class DatabaseVersion:
49
65
 
50
66
  Old versions are frozen rather than migrated, so several stay downloadable at once.
51
67
  `id` is what `download`, `checksums` and `metadata` take; the family `base` is what a
52
- licence is held against.
68
+ license is held against.
53
69
  """
54
70
 
55
71
  id: str
@@ -60,13 +76,18 @@ class DatabaseVersion:
60
76
 
61
77
  @dataclass(frozen=True, slots=True)
62
78
  class Database:
63
- """One database FAMILY, with your organization's licence beside it.
79
+ """One database FAMILY, with your organization's license beside it.
64
80
 
65
81
  A family your organization has never licensed is still listed, with `standing` set to
66
82
  `unlicensed`, so you can see what else exists. A family commissioned for a single
67
83
  customer is a different matter: it is absent from this listing entirely for everyone
68
84
  who does not license it. Absence here means "not yours to see", never "does not
69
85
  exist", so the catalog is not the same document for every key.
86
+
87
+ A rolling license carries `renews_at`, when it next renews, and `notice_due_at`, the
88
+ last day notice of non-renewal can be given for the term ending then. Both are None
89
+ when there is no license, when it has no defined term, or when `expires` sets a hard
90
+ stop instead; `notice_due_at` is None too when the agreement records no notice period.
70
91
  """
71
92
 
72
93
  base: str
@@ -76,6 +97,10 @@ class Database:
76
97
  license_type: LicenseType | None
77
98
  starts: datetime.datetime | None
78
99
  expires: datetime.datetime | None
100
+ # Keyword-only with a default, so a Database built by hand before 2.2.0 still builds;
101
+ # the parser always sets both.
102
+ renews_at: datetime.datetime | None = field(default=None, kw_only=True)
103
+ notice_due_at: datetime.datetime | None = field(default=None, kw_only=True)
79
104
  versions: tuple[DatabaseVersion, ...]
80
105
  raw: dict[str, Any] = field(default_factory=dict)
81
106
 
@@ -137,6 +162,8 @@ def to_database(body: dict[str, Any]) -> Database:
137
162
  license_type=body["license_type"],
138
163
  starts=_datetime(body["starts"]),
139
164
  expires=_datetime(body["expires"]),
165
+ renews_at=_datetime(body["renews_at"]),
166
+ notice_due_at=_datetime(body["notice_due_at"]),
140
167
  versions=tuple(to_version(v) for v in body["versions"]),
141
168
  raw=body,
142
169
  )
@@ -185,7 +212,7 @@ def to_download(body: dict[str, Any]) -> Download:
185
212
 
186
213
 
187
214
  def _datetime(value: Any) -> datetime.datetime | None:
188
- """A NULLABLE wire timestamp. A licence with no end date carries `expires: null`.
215
+ """A NULLABLE wire timestamp. A license with no end date carries `expires: null`.
189
216
 
190
217
  `fromisoformat` only learned to read a trailing `Z` in 3.11, which is one of the
191
218
  three reasons this package floors there. A value that is neither null nor readable
@@ -27,7 +27,7 @@
27
27
  }
28
28
  },
29
29
  {
30
- "name": "licence-lapsed",
30
+ "name": "license-lapsed",
31
31
  "status": 403,
32
32
  "headers": {},
33
33
  "body": {
@@ -78,6 +78,20 @@
78
78
  "message": "INVALID_FORMAT"
79
79
  }
80
80
  },
81
+ {
82
+ "name": "unlisted-4xx-is-not-retryable",
83
+ "why": "The RANGE is the rule, not a list of statuses. An SDK that enumerated 400 and 404 passed every other case here while a 422 fell through to the retryable server_error default.",
84
+ "status": 422,
85
+ "headers": {},
86
+ "body": {
87
+ "rc": "INVALID_REQUEST"
88
+ },
89
+ "expect": {
90
+ "kind": "bad_request",
91
+ "retryable": false,
92
+ "message": "INVALID_REQUEST"
93
+ }
94
+ },
81
95
  {
82
96
  "name": "rate-limited-transient",
83
97
  "status": 429,
@@ -83,32 +83,34 @@ class DatabaseAdapter:
83
83
  def __init__(self, client: InternetData | AsyncInternetData) -> None:
84
84
  self._client = client
85
85
 
86
- def list(self) -> Any:
87
- return self._call("list")
86
+ def list(self, **kwargs: Any) -> Any:
87
+ return self._call("list", **kwargs)
88
88
 
89
- def metadata(self, database_id: str) -> Any:
90
- return self._call("metadata", database_id)
89
+ def metadata(self, database_id: str, **kwargs: Any) -> Any:
90
+ return self._call("metadata", database_id, **kwargs)
91
91
 
92
- def checksums(self, database_id: str, format: str) -> Any:
93
- return self._call("checksums", database_id, format)
92
+ def checksums(self, database_id: str, format: str, **kwargs: Any) -> Any:
93
+ return self._call("checksums", database_id, format, **kwargs)
94
94
 
95
- def downloads(self, *args: Any) -> Any:
96
- return self._call("downloads", *args)
95
+ def downloads(self, *args: Any, **kwargs: Any) -> Any:
96
+ return self._call("downloads", *args, **kwargs)
97
97
 
98
- def download_url(self, database_id: str, format: str) -> Any:
99
- return self._call("download_url", database_id, format)
98
+ def download_url(self, database_id: str, format: str, **kwargs: Any) -> Any:
99
+ return self._call("download_url", database_id, format, **kwargs)
100
100
 
101
- def download(self, database_id: str, format: str, path: Any) -> Any:
102
- return self._call("download", database_id, format, path)
101
+ def download(self, database_id: str, format: str, path: Any, **kwargs: Any) -> Any:
102
+ return self._call("download", database_id, format, path, **kwargs)
103
103
 
104
- def download_bytes(self, database_id: str, format: str) -> Any:
105
- return self._call("download_bytes", database_id, format)
104
+ def download_bytes(self, database_id: str, format: str, **kwargs: Any) -> Any:
105
+ return self._call("download_bytes", database_id, format, **kwargs)
106
106
 
107
- def _call(self, name: str, *args: Any) -> Any:
107
+ # Keyword arguments are forwarded untouched and none is named here: a `timeout`
108
+ # in this signature would swallow the TypeError a transfer must raise for one.
109
+ def _call(self, name: str, *args: Any, **kwargs: Any) -> Any:
108
110
  method = getattr(self._client.database, name)
109
111
  if isinstance(self._client, InternetData):
110
- return method(*args)
111
- return asyncio.run(method(*args))
112
+ return method(*args, **kwargs)
113
+ return asyncio.run(method(*args, **kwargs))
112
114
 
113
115
 
114
116
  class Stub:
@@ -2,13 +2,16 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import dataclasses
6
7
  import datetime
7
8
  import inspect
8
9
  import time
9
10
  from collections.abc import Callable
10
- from typing import Any
11
+ from pathlib import Path
12
+ from typing import Any, get_args
11
13
 
14
+ import attrs
12
15
  import httpx
13
16
  import pytest
14
17
  from helpers import (
@@ -18,7 +21,6 @@ from helpers import (
18
21
  DOWNLOADS_PATH,
19
22
  LIST_PATH,
20
23
  METADATA_PATH,
21
- ClientAdapter,
22
24
  ClientFactory,
23
25
  SlowBody,
24
26
  Stub,
@@ -26,7 +28,28 @@ from helpers import (
26
28
  settle,
27
29
  )
28
30
 
29
- from internetdata import AsyncInternetData, InternetData, InternetDataError, _core
31
+ from internetdata import (
32
+ AsyncInternetData,
33
+ Database,
34
+ DatabaseMetadata,
35
+ DatabaseVersion,
36
+ Download,
37
+ InternetData,
38
+ InternetDataError,
39
+ MetadataColumn,
40
+ Outcome,
41
+ _core,
42
+ )
43
+ from internetdata._generated.models.database import Database as WireDatabase
44
+ from internetdata._generated.models.database_metadata import (
45
+ DatabaseMetadata as WireDatabaseMetadata,
46
+ )
47
+ from internetdata._generated.models.database_metadata_column import (
48
+ DatabaseMetadataColumn as WireDatabaseMetadataColumn,
49
+ )
50
+ from internetdata._generated.models.database_version import DatabaseVersion as WireDatabaseVersion
51
+ from internetdata._generated.models.download import Download as WireDownload
52
+ from internetdata._generated.models.download_outcome import DownloadOutcome as WireDownloadOutcome
30
53
 
31
54
  METADATA = {
32
55
  "id": "bogon_ip_v1",
@@ -77,13 +100,28 @@ TIMEOUT = 0.3
77
100
  TRICKLE = 0.02
78
101
  # What starting a thread or an event loop may add to a deadline.
79
102
  SLACK = 0.25
103
+ # The client's bound sits far above the call's, so an override accepted and ignored fails
104
+ # on how long the call took rather than passing on the timeout it hit anyway.
105
+ CLIENT_TIMEOUT = 0.6
106
+ CALL_TIMEOUT = 0.1
107
+
108
+ # Every call that asks the API a question, called with the keyword arguments given.
109
+ JSON_CALLS: dict[str, Callable[..., Any]] = {
110
+ "list": lambda client, **kw: client.database.list(**kw),
111
+ "metadata": lambda client, **kw: client.database.metadata("bogon_ip_v1", **kw),
112
+ "checksums": lambda client, **kw: client.database.checksums("bogon_ip_v1", "csvgz", **kw),
113
+ "downloads": lambda client, **kw: client.database.downloads(**kw),
114
+ "download_url": lambda client, **kw: client.database.download_url("bogon_ip_v1", "csvgz", **kw),
115
+ }
80
116
 
81
- JSON_CALLS: dict[str, Callable[[ClientAdapter], Any]] = {
82
- "list": lambda client: client.database.list(),
83
- "metadata": lambda client: client.database.metadata("bogon_ip_v1"),
84
- "checksums": lambda client: client.database.checksums("bogon_ip_v1", "csvgz"),
85
- "downloads": lambda client: client.database.downloads(),
86
- "download_url": lambda client: client.database.download_url("bogon_ip_v1", "csvgz"),
117
+ # The two transfers, which must REFUSE a per-call timeout rather than ignore one.
118
+ TRANSFERS: dict[str, Callable[..., Any]] = {
119
+ "download": lambda client, path, **kw: client.database.download(
120
+ "bogon_ip_v1", "csvgz", path, **kw
121
+ ),
122
+ "download_bytes": lambda client, _path, **kw: client.database.download_bytes(
123
+ "bogon_ip_v1", "csvgz", **kw
124
+ ),
87
125
  }
88
126
 
89
127
 
@@ -141,6 +179,54 @@ def test_a_licence_with_no_end_date_reads_as_none(make_client: ClientFactory) ->
141
179
 
142
180
  assert family.starts == datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
143
181
  assert family.expires is None
182
+ assert family.renews_at is None
183
+ assert family.notice_due_at is None
184
+
185
+
186
+ def test_a_rolling_license_carries_its_renewal_dates(make_client: ClientFactory) -> None:
187
+ served = database(
188
+ "bogon_ip",
189
+ renews_at="2027-01-01T00:00:00.000Z",
190
+ notice_due_at="2026-10-02T00:00:00.000Z",
191
+ )
192
+ stub = Stub({LIST_PATH: {"body": {"databases": [served]}}})
193
+ client = make_client(transport=stub.transport)
194
+
195
+ family = client.database.list()[0]
196
+
197
+ assert family.renews_at == datetime.datetime(2027, 1, 1, tzinfo=datetime.UTC)
198
+ assert family.notice_due_at == datetime.datetime(2026, 10, 2, tzinfo=datetime.UTC)
199
+
200
+
201
+ @pytest.mark.parametrize(
202
+ ("wire", "ours"),
203
+ [
204
+ (WireDatabase, Database),
205
+ (WireDatabaseVersion, DatabaseVersion),
206
+ (WireDatabaseMetadata, DatabaseMetadata),
207
+ (WireDatabaseMetadataColumn, MetadataColumn),
208
+ (WireDownload, Download),
209
+ ],
210
+ )
211
+ def test_every_field_the_pinned_spec_serves_is_on_the_model(wire: Any, ours: Any) -> None:
212
+ """The staleness pin on the hand-written models.
213
+
214
+ They parse the keys they name and nothing else, so a field a re-pin adds reaches the
215
+ generated code and `raw` while the typed model never hears of it: `renews_at` and
216
+ `notice_due_at` sat that way from the 2026-09-09 re-pin until 2.2.0. The generated
217
+ classes come from the same pinned spec, which makes them the list to check against.
218
+ The generator suffixes a name that shadows a builtin with `_`.
219
+ """
220
+ served = {f.name.rstrip("_") for f in attrs.fields(wire)} - {"additional_properties"}
221
+ modeled = {f.name for f in dataclasses.fields(ours)} - {"raw"}
222
+ assert served - modeled == set(), f"{ours.__name__} lacks what the spec serves"
223
+
224
+
225
+ def test_the_outcome_vocabulary_is_the_pinned_specs() -> None:
226
+ """The staleness pin on `Outcome`, the one hand-written Literal the corpus does not
227
+ carry: the generated enum comes from the same pinned spec, so a re-pin that adds an
228
+ outcome turns this red instead of leaving the type a member short."""
229
+ assert sorted(get_args(Outcome)) == sorted(member.value for member in WireDownloadOutcome)
144
230
 
145
231
 
146
232
  def test_a_database_cannot_be_mutated(make_client: ClientFactory) -> None:
@@ -287,11 +373,87 @@ def test_a_timed_out_attempt_is_retried_under_a_bound_of_its_own(
287
373
  )
288
374
 
289
375
 
376
+ @pytest.mark.parametrize("call", JSON_CALLS)
377
+ def test_a_per_call_timeout_bounds_a_trickling_body_and_leaves_the_clients_own_alone(
378
+ make_client: ClientFactory, call: str
379
+ ) -> None:
380
+ with SlowBody(trickle=TRICKLE) as server:
381
+ client = make_client(base_url=server.url, timeout=CLIENT_TIMEOUT, retries=0)
382
+ overridden, first = _timed(lambda: JSON_CALLS[call](client, timeout=CALL_TIMEOUT))
383
+ default, second = _timed(lambda: JSON_CALLS[call](client))
384
+
385
+ _assert_timed_out(first)
386
+ _assert_bounded_by(overridden, CALL_TIMEOUT)
387
+ # A per-call value written onto the one httpx client every call shares passes the
388
+ # call above and fails this one, which is what the second call is here for.
389
+ _assert_timed_out(second)
390
+ _assert_bounded_by(default, CLIENT_TIMEOUT)
391
+
392
+
393
+ @pytest.mark.parametrize("timeout", [0, -1, float("nan"), float("inf"), "30", True])
394
+ @pytest.mark.parametrize("call", JSON_CALLS)
395
+ def test_a_per_call_timeout_no_attempt_can_meet_is_refused_before_any_request(
396
+ make_client: ClientFactory, call: str, timeout: Any
397
+ ) -> None:
398
+ """Accepted, each of these failed the call instead, after the retries' backoff."""
399
+ stub = Stub({})
400
+ client = make_client(transport=stub.transport, retries=0)
401
+
402
+ with pytest.raises(ValueError, match="timeout"):
403
+ JSON_CALLS[call](client, timeout=timeout)
404
+
405
+ assert stub.requests == []
406
+
407
+
408
+ @pytest.mark.parametrize("call", TRANSFERS)
409
+ def test_a_transfer_refuses_a_per_call_timeout(
410
+ make_client: ClientFactory, call: str, tmp_path: Path
411
+ ) -> None:
412
+ """A transfer is exempt from the deadline, so the option is not in its signature and
413
+ Python refuses it for us. Accepted and quietly ignored, a caller would be told
414
+ nothing; honored, a bound that suits a JSON call would abandon a healthy download."""
415
+ stub = Stub({})
416
+ client = make_client(transport=stub.transport, retries=0)
417
+ path = tmp_path / "bogon_ip_v1.csv.gz"
418
+
419
+ with pytest.raises(TypeError, match="timeout"):
420
+ TRANSFERS[call](client, path, timeout=CALL_TIMEOUT)
421
+
422
+ assert stub.requests == []
423
+ assert not path.exists()
424
+
425
+
426
+ @pytest.mark.parametrize("name", JSON_CALLS)
427
+ def test_every_json_call_takes_a_per_call_timeout(make_client: ClientFactory, name: str) -> None:
428
+ """The other half of the refusal above, which without this would pass just as well on
429
+ a surface that had never been given a per-call timeout at all."""
430
+ timeout = inspect.signature(getattr(make_client().client.database, name)).parameters["timeout"]
431
+
432
+ assert timeout.kind is inspect.Parameter.KEYWORD_ONLY
433
+ assert timeout.default is None
434
+
435
+
290
436
  def test_the_default_timeout_is_thirty_seconds() -> None:
291
437
  for client in (InternetData, AsyncInternetData):
292
438
  assert inspect.signature(client).parameters["timeout"].default == 30
293
439
 
294
440
 
441
+ @pytest.mark.parametrize("client", [InternetData, AsyncInternetData])
442
+ @pytest.mark.parametrize("timeout", [0, -1, 0.0, float("nan"), float("inf"), "30", True])
443
+ def test_a_timeout_no_attempt_can_meet_is_refused_when_the_client_is_built(
444
+ client: type[InternetData | AsyncInternetData], timeout: Any
445
+ ) -> None:
446
+ """Accepted, each of these failed every call instead, after the retries' backoff."""
447
+ with pytest.raises(ValueError, match="timeout"):
448
+ client(API_KEY, timeout=timeout)
449
+
450
+
451
+ @pytest.mark.parametrize("timeout", [None, 0.25, 1, 30])
452
+ def test_a_usable_timeout_builds_a_client(timeout: float | None) -> None:
453
+ InternetData(API_KEY, timeout=timeout).close()
454
+ asyncio.run(AsyncInternetData(API_KEY, timeout=timeout).aclose())
455
+
456
+
295
457
  # Refused before the network, and never retried: a typo is the caller's, not a failure of
296
458
  # the server's to wait out.
297
459
  def test_an_unpublished_format_is_refused_before_any_request(make_client: ClientFactory) -> None:
@@ -346,6 +508,13 @@ def _assert_timed_out(outcome: Any) -> None:
346
508
 
347
509
 
348
510
  def _assert_one_bound(elapsed: float) -> None:
349
- assert TIMEOUT - 0.05 <= elapsed < TIMEOUT + SLACK, (
350
- f"gave up after {elapsed:.2f}s against a {TIMEOUT}s bound"
511
+ _assert_bounded_by(elapsed, TIMEOUT)
512
+
513
+
514
+ def _assert_bounded_by(elapsed: float, bound: float) -> None:
515
+ """One bound's worth of waiting, with an UPPER and a LOWER limit: without the upper a
516
+ call that gave up at some other bound passes, and without the lower one abandoned at
517
+ its first chunk does."""
518
+ assert bound - 0.05 <= elapsed < bound + SLACK, (
519
+ f"gave up after {elapsed:.2f}s against a {bound}s bound"
351
520
  )
@@ -19,7 +19,7 @@ from helpers import (
19
19
  database,
20
20
  )
21
21
 
22
- from internetdata import InternetDataError
22
+ from internetdata import DATABASE_FORMATS, LICENSE_TYPES, STANDINGS, InternetDataError
23
23
 
24
24
  # One live grant, one that has run out, and one never bought. Between them these cover
25
25
  # every value the corpus pins for `standing` and `license_type`.
@@ -65,21 +65,32 @@ def test_every_error_shape_maps_to_the_same_kind_in_every_language(
65
65
  assert err.retry_after_seconds == expect["retryAfterSeconds"], name
66
66
 
67
67
 
68
- # The one that has caught three of four bindings: 404 is a CLIENT error. Mapping
69
- # 400/401/403/429 by name and letting the rest fall through to a retryable server_error
70
- # means an unknown database id is asked for three times before failing.
71
- def test_a_404_is_never_retried(make_client: ClientFactory) -> None:
72
- for case in TESTDATA["errors"]:
73
- if case["status"] != 404:
74
- continue
75
- stub = Stub({METADATA_PATH: {"status": 404, "body": case["body"]}})
68
+ # The one that has caught three of four bindings: every 4xx but a 429 carrying Retry-After
69
+ # is the CALLER's, and classifying by an enumerated list lets the rest (the corpus's 422)
70
+ # fall through to a retryable server_error. Asserted with retries ON and by counting
71
+ # requests: with retries off, one request is guaranteed and a wrong classifier passes.
72
+ def test_no_client_error_is_ever_retried(make_client: ClientFactory) -> None:
73
+ cases = [case for case in TESTDATA["errors"] if not case["expect"]["retryable"]]
74
+ assert any(case["name"] == "unlisted-4xx-is-not-retryable" for case in cases)
75
+
76
+ for case in cases:
77
+ stub = Stub(
78
+ {
79
+ METADATA_PATH: {
80
+ "status": case["status"],
81
+ "body": case["body"],
82
+ "headers": case["headers"],
83
+ }
84
+ }
85
+ )
76
86
  client = make_client(transport=stub.transport, retries=3)
77
87
 
78
88
  with pytest.raises(InternetDataError) as caught:
79
89
  client.database.metadata("nope_v1")
80
90
 
81
- assert caught.value.retryable is False, case["name"]
91
+ # The count first: a retried case also ends on an error, just a later one.
82
92
  assert len(stub.requests) == 1, f"{case['name']}: issued {len(stub.requests)} requests"
93
+ assert caught.value.kind == case["expect"]["kind"], case["name"]
83
94
 
84
95
 
85
96
  # Both arrive as 429 and the header is the only thing separating them, so a client that
@@ -118,7 +129,7 @@ def test_the_catalog_carries_every_standing_and_license_type_the_corpus_pins(
118
129
  assert served_rights <= set(TESTDATA["license_type"]), (
119
130
  f"undocumented license_type right in {served_rights}"
120
131
  )
121
- # Null when there is no licence, which is a different answer from any of the three.
132
+ # Null when there is no license, which is a different answer from any of the three.
122
133
  assert [f.license_type for f in families][-1] is None
123
134
  for family in families:
124
135
  assert family.versions, f"{family.base} carries no versions"
@@ -128,6 +139,17 @@ def test_the_catalog_carries_every_standing_and_license_type_the_corpus_pins(
128
139
  )
129
140
 
130
141
 
142
+ def test_the_runtime_vocabularies_are_the_pinned_specs() -> None:
143
+ """The staleness pin on three hand-written Literals.
144
+
145
+ `emit.mjs` reads the corpus's lists out of the pinned spec, so a re-pin that widens a
146
+ vocabulary turns this red instead of leaving a published value unnamed here.
147
+ """
148
+ assert sorted(DATABASE_FORMATS) == sorted(TESTDATA["formats"])
149
+ assert sorted(STANDINGS) == sorted(TESTDATA["standings"])
150
+ assert sorted(LICENSE_TYPES) == sorted(TESTDATA["license_type"])
151
+
152
+
131
153
  # The visibility contract. A private family is one commissioned for a single customer: the
132
154
  # server leaves it out of the listing entirely for anyone else, rather than including it
133
155
  # with standing 'unlicensed'. All this library has to do is not undo that, which is a real
@@ -161,7 +183,7 @@ def test_a_listing_is_never_reused_across_clients(make_client: ClientFactory) ->
161
183
  """Two keys are two organizations, and they do not see the same catalog.
162
184
 
163
185
  So there is no listing cache at all here, per instance or otherwise: a cached one
164
- would be wrong the moment a licence is granted, and a shared one would show an
186
+ would be wrong the moment a license is granted, and a shared one would show an
165
187
  organization a family it is not allowed to know exists.
166
188
  """
167
189
  assert "a-listing-is-never-reused-across-clients" in TESTDATA["visibility"]["clientRules"]
@@ -172,12 +194,29 @@ def test_a_listing_is_never_reused_across_clients(make_client: ClientFactory) ->
172
194
  assert len(first.database.list()) == len(CATALOG)
173
195
  assert second.database.list() == [], "the second key answered from the first one's listing"
174
196
  assert len(stub.requests) == 2
175
- # A repeat on the SAME client asks again too: a licence granted between two calls has
197
+ # A repeat on the SAME client asks again too: a license granted between two calls has
176
198
  # to show up.
177
199
  first.database.list()
178
200
  assert len(stub.requests) == 3
179
201
 
180
202
 
203
+ # Each visibility rule the corpus names, and the test above that holds it. The three tests
204
+ # each check that their own rule is still in the corpus; this checks the other direction,
205
+ # so a rule added to the corpus fails here until something in this module holds it.
206
+ VISIBILITY_RULES = {
207
+ "listing-is-returned-as-served": test_the_listing_is_returned_exactly_as_served,
208
+ "no-catalog-is-compiled-into-the-client": test_no_catalog_is_compiled_into_the_client,
209
+ "a-listing-is-never-reused-across-clients": test_a_listing_is_never_reused_across_clients,
210
+ }
211
+
212
+
213
+ def test_every_visibility_rule_in_the_corpus_is_held_here() -> None:
214
+ rules = TESTDATA["visibility"]["clientRules"]
215
+ assert rules, "the corpus pins no visibility rules"
216
+ unheld = sorted(set(rules) - set(VISIBILITY_RULES))
217
+ assert unheld == [], f"the corpus adds {unheld} and this suite does not check it"
218
+
219
+
181
220
  def test_checksums_unwrap_past_the_envelope(make_client: ClientFactory) -> None:
182
221
  """`checksums` nests under a key, beside `id` and `format`.
183
222
 
@@ -210,7 +249,7 @@ def test_the_api_key_reaches_the_wire_under_the_bearer_scheme(
210
249
  def test_a_keyless_client_sends_no_authorization_header(
211
250
  make_client: ClientFactory, api_key: str | None
212
251
  ) -> None:
213
- """The key is optional because what this API serves without a licence is a product
252
+ """The key is optional because what this API serves without a license is a product
214
253
  decision, and a client that could not be built without one would have to change
215
254
  shape to follow it. What must never go out is `Bearer ` with nothing after it, which
216
255
  reads as a wrong key rather than as none.
File without changes
File without changes