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.
- {internetdata-2.1.0 → internetdata-2.3.0}/PKG-INFO +11 -4
- {internetdata-2.1.0 → internetdata-2.3.0}/README.md +10 -3
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/__init__.py +7 -1
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_core.py +25 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/aio.py +47 -13
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/client.py +47 -13
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/models.py +32 -5
- {internetdata-2.1.0 → internetdata-2.3.0}/testdata/testdata.json +15 -1
- {internetdata-2.1.0 → internetdata-2.3.0}/tests/helpers.py +19 -17
- {internetdata-2.1.0 → internetdata-2.3.0}/tests/test_client.py +180 -11
- {internetdata-2.1.0 → internetdata-2.3.0}/tests/test_conformance.py +53 -14
- {internetdata-2.1.0 → internetdata-2.3.0}/.gitignore +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/LICENSE +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/pyproject.toml +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/scripts/download-spec.sh +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/scripts/generate.sh +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/scripts/publish.sh +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/scripts/v2_subset.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/spec/openapi.yaml +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/__init__.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/__init__.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/client.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/errors.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/__init__.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_format.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_metadata.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_version.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/db_checksums.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/download.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/download_outcome.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/error.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/standing.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/types.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/errors.py +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/py.typed +0 -0
- {internetdata-2.1.0 → internetdata-2.3.0}/tests/conftest.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
149
|
-
|
|
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.
|
|
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(
|
|
164
|
-
|
|
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.
|
|
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(
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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(
|
|
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.
|
|
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(
|
|
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.
|
|
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(
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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": "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"
|
|
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
|
-
|
|
350
|
-
|
|
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:
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/api/database_v_2/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_format.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_metadata.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/database_version.py
RENAMED
|
File without changes
|
{internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/db_checksums.py
RENAMED
|
File without changes
|
|
File without changes
|
{internetdata-2.1.0 → internetdata-2.3.0}/src/internetdata/_generated/models/download_outcome.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|