internetdata 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- internetdata/__init__.py +49 -0
- internetdata/_core.py +287 -0
- internetdata/_generated/__init__.py +8 -0
- internetdata/_generated/api/__init__.py +1 -0
- internetdata/_generated/api/database_v_2/__init__.py +1 -0
- internetdata/_generated/api/database_v_2/database_checksum_v2.py +203 -0
- internetdata/_generated/api/database_v_2/database_metadata_v2.py +205 -0
- internetdata/_generated/api/database_v_2/download_database_v2.py +213 -0
- internetdata/_generated/api/database_v_2/list_databases.py +162 -0
- internetdata/_generated/api/database_v_2/list_downloads.py +178 -0
- internetdata/_generated/client.py +272 -0
- internetdata/_generated/errors.py +16 -0
- internetdata/_generated/models/__init__.py +53 -0
- internetdata/_generated/models/database.py +233 -0
- internetdata/_generated/models/database_checksum_v2_format.py +9 -0
- internetdata/_generated/models/database_checksum_v2_response_200.py +85 -0
- internetdata/_generated/models/database_checksum_v2_response_200_format.py +9 -0
- internetdata/_generated/models/database_metadata.py +132 -0
- internetdata/_generated/models/database_metadata_column.py +80 -0
- internetdata/_generated/models/database_metadata_sample.py +78 -0
- internetdata/_generated/models/database_metadata_sample_additional_property_item.py +45 -0
- internetdata/_generated/models/database_metadata_schema.py +72 -0
- internetdata/_generated/models/database_metadata_size.py +47 -0
- internetdata/_generated/models/database_redistribution_type_1.py +10 -0
- internetdata/_generated/models/database_redistribution_type_2_type_1.py +10 -0
- internetdata/_generated/models/database_redistribution_type_3_type_1.py +10 -0
- internetdata/_generated/models/database_standing.py +10 -0
- internetdata/_generated/models/database_version.py +97 -0
- internetdata/_generated/models/database_version_formats_item.py +9 -0
- internetdata/_generated/models/db_checksums.py +85 -0
- internetdata/_generated/models/download.py +161 -0
- internetdata/_generated/models/download_database_v2_format.py +9 -0
- internetdata/_generated/models/download_outcome.py +13 -0
- internetdata/_generated/models/error.py +62 -0
- internetdata/_generated/models/list_databases_response_200.py +75 -0
- internetdata/_generated/models/list_downloads_response_200.py +75 -0
- internetdata/_generated/types.py +54 -0
- internetdata/aio.py +268 -0
- internetdata/client.py +278 -0
- internetdata/errors.py +128 -0
- internetdata/models.py +197 -0
- internetdata/py.typed +0 -0
- internetdata-1.0.0.dist-info/METADATA +184 -0
- internetdata-1.0.0.dist-info/RECORD +46 -0
- internetdata-1.0.0.dist-info/WHEEL +4 -0
- internetdata-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Self, TypeVar
|
|
5
|
+
|
|
6
|
+
from attrs import define as _attrs_define
|
|
7
|
+
from attrs import field as _attrs_field
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ..models.download import Download
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T", bound="ListDownloadsResponse200")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@_attrs_define
|
|
17
|
+
class ListDownloadsResponse200:
|
|
18
|
+
"""
|
|
19
|
+
Attributes:
|
|
20
|
+
downloads (list[Download]):
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
downloads: list[Download]
|
|
24
|
+
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
downloads = []
|
|
28
|
+
for downloads_item_data in self.downloads:
|
|
29
|
+
downloads_item = downloads_item_data.to_dict()
|
|
30
|
+
downloads.append(downloads_item)
|
|
31
|
+
|
|
32
|
+
field_dict: dict[str, Any] = {}
|
|
33
|
+
field_dict.update(self.additional_properties)
|
|
34
|
+
field_dict.update(
|
|
35
|
+
{
|
|
36
|
+
"downloads": downloads,
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return field_dict
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_dict(cls, src_dict: Mapping[str, Any]) -> Self:
|
|
44
|
+
from ..models.download import Download
|
|
45
|
+
|
|
46
|
+
d = dict(src_dict)
|
|
47
|
+
downloads = []
|
|
48
|
+
_downloads = d.pop("downloads")
|
|
49
|
+
for downloads_item_data in _downloads:
|
|
50
|
+
downloads_item = Download.from_dict(downloads_item_data)
|
|
51
|
+
|
|
52
|
+
downloads.append(downloads_item)
|
|
53
|
+
|
|
54
|
+
list_downloads_response_200 = cls(
|
|
55
|
+
downloads=downloads,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
list_downloads_response_200.additional_properties = d
|
|
59
|
+
return list_downloads_response_200
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def additional_keys(self) -> list[str]:
|
|
63
|
+
return list(self.additional_properties.keys())
|
|
64
|
+
|
|
65
|
+
def __getitem__(self, key: str) -> Any:
|
|
66
|
+
return self.additional_properties[key]
|
|
67
|
+
|
|
68
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
69
|
+
self.additional_properties[key] = value
|
|
70
|
+
|
|
71
|
+
def __delitem__(self, key: str) -> None:
|
|
72
|
+
del self.additional_properties[key]
|
|
73
|
+
|
|
74
|
+
def __contains__(self, key: str) -> bool:
|
|
75
|
+
return key in self.additional_properties
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Contains some shared types for properties"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, MutableMapping
|
|
4
|
+
from http import HTTPStatus
|
|
5
|
+
from typing import IO, BinaryIO, Generic, Literal, TypeVar
|
|
6
|
+
|
|
7
|
+
from attrs import define
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Unset:
|
|
11
|
+
def __bool__(self) -> Literal[False]:
|
|
12
|
+
return False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
UNSET: Unset = Unset()
|
|
16
|
+
|
|
17
|
+
# The types that `httpx.Client(files=)` can accept, copied from that library.
|
|
18
|
+
FileContent = IO[bytes] | bytes | str
|
|
19
|
+
FileTypes = (
|
|
20
|
+
# (filename, file (or bytes), content_type)
|
|
21
|
+
tuple[str | None, FileContent, str | None]
|
|
22
|
+
# (filename, file (or bytes), content_type, headers)
|
|
23
|
+
| tuple[str | None, FileContent, str | None, Mapping[str, str]]
|
|
24
|
+
)
|
|
25
|
+
RequestFiles = list[tuple[str, FileTypes]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@define
|
|
29
|
+
class File:
|
|
30
|
+
"""Contains information for file uploads"""
|
|
31
|
+
|
|
32
|
+
payload: BinaryIO
|
|
33
|
+
file_name: str | None = None
|
|
34
|
+
mime_type: str | None = None
|
|
35
|
+
|
|
36
|
+
def to_tuple(self) -> FileTypes:
|
|
37
|
+
"""Return a tuple representation that httpx will accept for multipart/form-data"""
|
|
38
|
+
return self.file_name, self.payload, self.mime_type
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
T = TypeVar("T")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@define
|
|
45
|
+
class Response(Generic[T]):
|
|
46
|
+
"""A response from an endpoint"""
|
|
47
|
+
|
|
48
|
+
status_code: HTTPStatus
|
|
49
|
+
content: bytes
|
|
50
|
+
headers: MutableMapping[str, str]
|
|
51
|
+
parsed: T | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"]
|
internetdata/aio.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""The asyncio client.
|
|
2
|
+
|
|
3
|
+
Mirrors `client.py` method for method; only the waiting differs. The two are written out
|
|
4
|
+
rather than shared because every difference between them is an `await`, and the wrappers
|
|
5
|
+
that hide that are harder to read than the duplication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import builtins
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from types import TracebackType
|
|
15
|
+
from typing import Self, TypeVar
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ._core import (
|
|
20
|
+
DEFAULT_BASE_URL,
|
|
21
|
+
DEFAULT_DOWNLOADS_LIMIT,
|
|
22
|
+
DEFAULT_RETRIES,
|
|
23
|
+
DEFAULT_TIMEOUT,
|
|
24
|
+
TRANSFER_CHUNK_BYTES,
|
|
25
|
+
as_error,
|
|
26
|
+
assert_whole_transfer,
|
|
27
|
+
build_async_transfer_client,
|
|
28
|
+
build_client,
|
|
29
|
+
checksums_of,
|
|
30
|
+
databases_of,
|
|
31
|
+
downloads_of,
|
|
32
|
+
parse_body,
|
|
33
|
+
part_file,
|
|
34
|
+
redirect_location,
|
|
35
|
+
retry_delay,
|
|
36
|
+
send_async,
|
|
37
|
+
storage_refusal,
|
|
38
|
+
unwrap,
|
|
39
|
+
)
|
|
40
|
+
from ._generated.api.database_v_2 import (
|
|
41
|
+
database_checksum_v2,
|
|
42
|
+
database_metadata_v2,
|
|
43
|
+
download_database_v2,
|
|
44
|
+
list_databases,
|
|
45
|
+
list_downloads,
|
|
46
|
+
)
|
|
47
|
+
from ._generated.client import AuthenticatedClient
|
|
48
|
+
from ._generated.models.database_checksum_v2_format import DatabaseChecksumV2Format
|
|
49
|
+
from ._generated.models.download_database_v2_format import DownloadDatabaseV2Format
|
|
50
|
+
from .errors import InternetDataError
|
|
51
|
+
from .models import Database, DatabaseMetadata, Download, Format, to_metadata
|
|
52
|
+
|
|
53
|
+
__all__ = ["AsyncDatabaseApi", "AsyncInternetData"]
|
|
54
|
+
|
|
55
|
+
T = TypeVar("T")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AsyncInternetData:
|
|
59
|
+
"""A client for the InternetData API, for asyncio.
|
|
60
|
+
|
|
61
|
+
Identical in behavior to `InternetData`. Close it with `await client.aclose()`, or
|
|
62
|
+
use it as an async context manager.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
database: AsyncDatabaseApi
|
|
66
|
+
"""The licensed database catalog and downloads, which is the whole API."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
api_key: str | None = None,
|
|
71
|
+
*,
|
|
72
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
73
|
+
retries: int = DEFAULT_RETRIES,
|
|
74
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
75
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
self._client = build_client(api_key, base_url, timeout, transport)
|
|
78
|
+
self._transfer = build_async_transfer_client(timeout, transport)
|
|
79
|
+
self._retries = retries
|
|
80
|
+
self.database = AsyncDatabaseApi(self)
|
|
81
|
+
|
|
82
|
+
async def aclose(self) -> None:
|
|
83
|
+
await self._client.get_async_httpx_client().aclose()
|
|
84
|
+
await self._transfer.aclose()
|
|
85
|
+
|
|
86
|
+
async def __aenter__(self) -> Self:
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
async def __aexit__(
|
|
90
|
+
self,
|
|
91
|
+
exc_type: type[BaseException] | None,
|
|
92
|
+
exc: BaseException | None,
|
|
93
|
+
tb: TracebackType | None,
|
|
94
|
+
) -> None:
|
|
95
|
+
await self.aclose()
|
|
96
|
+
|
|
97
|
+
async def _retrying(self, call: Callable[[], Awaitable[T]], retries: int) -> T:
|
|
98
|
+
attempt = 0
|
|
99
|
+
while True:
|
|
100
|
+
try:
|
|
101
|
+
return await call()
|
|
102
|
+
except (InternetDataError, httpx.HTTPError) as exc:
|
|
103
|
+
err = as_error(exc)
|
|
104
|
+
delay = retry_delay(err, attempt, retries)
|
|
105
|
+
if delay is None:
|
|
106
|
+
raise err
|
|
107
|
+
await asyncio.sleep(delay)
|
|
108
|
+
attempt += 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AsyncDatabaseApi:
|
|
112
|
+
"""The database catalog and downloads. Access is granted by contract, not self-serve.
|
|
113
|
+
|
|
114
|
+
`list` is a method here, which shadows the builtin for everything else in the class
|
|
115
|
+
body, so the return annotations name `builtins.list` explicitly.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, owner: AsyncInternetData) -> None:
|
|
119
|
+
self._owner = owner
|
|
120
|
+
|
|
121
|
+
async def list(self) -> builtins.list[Database]:
|
|
122
|
+
"""The published catalog as YOUR organization may see it.
|
|
123
|
+
|
|
124
|
+
Every family carries a `standing`, so one you have never bought is listed as
|
|
125
|
+
`unlicensed` rather than hidden. The exception is a family commissioned for a
|
|
126
|
+
single customer, which is absent entirely for everyone else. Nothing is cached
|
|
127
|
+
and nothing is reconstructed here - what you get is what the server sent for the
|
|
128
|
+
key you are holding.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
async def call() -> builtins.list[Database]:
|
|
132
|
+
res = await send_async(lambda: list_databases.asyncio_detailed(client=self._client))
|
|
133
|
+
return parse_body(unwrap(res), databases_of)
|
|
134
|
+
|
|
135
|
+
return await self._retrying(call)
|
|
136
|
+
|
|
137
|
+
async def metadata(self, database_id: str) -> DatabaseMetadata:
|
|
138
|
+
"""What is inside one database: freshness, row count, columns, samples and sizes."""
|
|
139
|
+
|
|
140
|
+
async def call() -> DatabaseMetadata:
|
|
141
|
+
res = await send_async(
|
|
142
|
+
lambda: database_metadata_v2.asyncio_detailed(client=self._client, id=database_id)
|
|
143
|
+
)
|
|
144
|
+
return parse_body(unwrap(res), to_metadata)
|
|
145
|
+
|
|
146
|
+
return await self._retrying(call)
|
|
147
|
+
|
|
148
|
+
async def checksums(self, database_id: str, format: Format) -> dict[str, str]:
|
|
149
|
+
"""Every checksum published for one database file, keyed by algorithm."""
|
|
150
|
+
|
|
151
|
+
async def call() -> dict[str, str]:
|
|
152
|
+
res = await send_async(
|
|
153
|
+
lambda: database_checksum_v2.asyncio_detailed(
|
|
154
|
+
client=self._client,
|
|
155
|
+
id=database_id,
|
|
156
|
+
format_=DatabaseChecksumV2Format(format),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return parse_body(unwrap(res), checksums_of)
|
|
160
|
+
|
|
161
|
+
return await self._retrying(call)
|
|
162
|
+
|
|
163
|
+
async def downloads(self, limit: int = DEFAULT_DOWNLOADS_LIMIT) -> builtins.list[Download]:
|
|
164
|
+
"""Your organization's recent download attempts, newest first."""
|
|
165
|
+
|
|
166
|
+
async def call() -> builtins.list[Download]:
|
|
167
|
+
res = await send_async(
|
|
168
|
+
lambda: list_downloads.asyncio_detailed(client=self._client, limit=limit)
|
|
169
|
+
)
|
|
170
|
+
return parse_body(unwrap(res), downloads_of)
|
|
171
|
+
|
|
172
|
+
return await self._retrying(call)
|
|
173
|
+
|
|
174
|
+
async def download_url(self, database_id: str, format: Format) -> str:
|
|
175
|
+
"""The time-limited URL for one database file.
|
|
176
|
+
|
|
177
|
+
The API answers `302` to object storage, and the link carries its own signature,
|
|
178
|
+
so it holds no API key and can be handed to anything that speaks HTTP. Returned
|
|
179
|
+
rather than followed so the caller decides how to move a file that reaches
|
|
180
|
+
gigabytes; the link authorizes the START of a transfer, so one already running is
|
|
181
|
+
not interrupted when it lapses.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
async def call() -> str:
|
|
185
|
+
res = await send_async(
|
|
186
|
+
lambda: download_database_v2.asyncio_detailed(
|
|
187
|
+
client=self._client,
|
|
188
|
+
id=database_id,
|
|
189
|
+
format_=DownloadDatabaseV2Format(format),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
return redirect_location(res)
|
|
193
|
+
|
|
194
|
+
return await self._retrying(call)
|
|
195
|
+
|
|
196
|
+
async def download(self, database_id: str, format: Format, path: str | os.PathLike[str]) -> int:
|
|
197
|
+
"""Download one database file to `path`, and return the bytes written.
|
|
198
|
+
|
|
199
|
+
The bytes are streamed straight to disk, so nothing larger than a chunk is ever
|
|
200
|
+
held in memory whatever the database weighs. They land in a neighboring `.part`
|
|
201
|
+
file that is moved into place only once the whole transfer has arrived, so a
|
|
202
|
+
failure leaves neither a truncated file at `path` nor the `.part` behind, and an
|
|
203
|
+
existing copy at `path` survives a refresh that fails.
|
|
204
|
+
|
|
205
|
+
A failure DURING the transfer surfaces as it happened, an `httpx` error or an
|
|
206
|
+
`OSError`, rather than as this library's error type: a reset socket and a full
|
|
207
|
+
disk are different problems, and only one of them is ours.
|
|
208
|
+
|
|
209
|
+
Each chunk is written from a worker thread. A gigabyte of blocking writes on the
|
|
210
|
+
event loop would stall every other task in the process for the length of the
|
|
211
|
+
transfer, which is the one thing an async caller cannot afford.
|
|
212
|
+
"""
|
|
213
|
+
res = await self._open_transfer(database_id, format)
|
|
214
|
+
try:
|
|
215
|
+
loop = asyncio.get_running_loop()
|
|
216
|
+
written = 0
|
|
217
|
+
with part_file(path) as sink:
|
|
218
|
+
async for chunk in res.aiter_bytes(TRANSFER_CHUNK_BYTES):
|
|
219
|
+
await loop.run_in_executor(None, sink.write, chunk)
|
|
220
|
+
written += len(chunk)
|
|
221
|
+
# Inside, so a short transfer fails before anything is moved into place.
|
|
222
|
+
assert_whole_transfer(res, written)
|
|
223
|
+
return written
|
|
224
|
+
finally:
|
|
225
|
+
await res.aclose()
|
|
226
|
+
|
|
227
|
+
async def download_bytes(self, database_id: str, format: Format) -> bytes:
|
|
228
|
+
"""Download one database file and hand back its bytes.
|
|
229
|
+
|
|
230
|
+
**This holds the entire file in memory**, and the catalog spans seven orders of
|
|
231
|
+
magnitude, from a few hundred bytes to several gigabytes. Reach for it at the
|
|
232
|
+
small end, where the bytes go straight into a parser, and use `download` for
|
|
233
|
+
anything you have not checked `metadata` for.
|
|
234
|
+
"""
|
|
235
|
+
res = await self._open_transfer(database_id, format)
|
|
236
|
+
try:
|
|
237
|
+
body = await res.aread()
|
|
238
|
+
assert_whole_transfer(res, len(body))
|
|
239
|
+
return body
|
|
240
|
+
finally:
|
|
241
|
+
await res.aclose()
|
|
242
|
+
|
|
243
|
+
# Follows the 302 as a SECOND, unauthenticated request rather than by loosening the
|
|
244
|
+
# redirect guard: the presigned URL authorizes itself, so forwarding the API key
|
|
245
|
+
# would hand a credential to a host with no business holding it - and object storage
|
|
246
|
+
# rejects a request carrying both signatures outright.
|
|
247
|
+
#
|
|
248
|
+
# Returns the response with its body still unread, so the caller decides whether a
|
|
249
|
+
# database is going to disk or into memory.
|
|
250
|
+
async def _open_transfer(self, database_id: str, format: Format) -> httpx.Response:
|
|
251
|
+
url = await self.download_url(database_id, format)
|
|
252
|
+
transfer = self._owner._transfer
|
|
253
|
+
|
|
254
|
+
async def call() -> httpx.Response:
|
|
255
|
+
res = await transfer.send(transfer.build_request("GET", url), stream=True)
|
|
256
|
+
if res.status_code != httpx.codes.OK:
|
|
257
|
+
await res.aclose()
|
|
258
|
+
raise storage_refusal(res)
|
|
259
|
+
return res
|
|
260
|
+
|
|
261
|
+
return await self._retrying(call)
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def _client(self) -> AuthenticatedClient:
|
|
265
|
+
return self._owner._client
|
|
266
|
+
|
|
267
|
+
async def _retrying(self, call: Callable[[], Awaitable[T]]) -> T:
|
|
268
|
+
return await self._owner._retrying(call, self._owner._retries)
|
internetdata/client.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""The synchronous client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Self, TypeVar
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ._core import (
|
|
15
|
+
DEFAULT_BASE_URL,
|
|
16
|
+
DEFAULT_DOWNLOADS_LIMIT,
|
|
17
|
+
DEFAULT_RETRIES,
|
|
18
|
+
DEFAULT_TIMEOUT,
|
|
19
|
+
TRANSFER_CHUNK_BYTES,
|
|
20
|
+
as_error,
|
|
21
|
+
assert_whole_transfer,
|
|
22
|
+
build_client,
|
|
23
|
+
build_transfer_client,
|
|
24
|
+
checksums_of,
|
|
25
|
+
databases_of,
|
|
26
|
+
downloads_of,
|
|
27
|
+
parse_body,
|
|
28
|
+
part_file,
|
|
29
|
+
redirect_location,
|
|
30
|
+
retry_delay,
|
|
31
|
+
send,
|
|
32
|
+
storage_refusal,
|
|
33
|
+
unwrap,
|
|
34
|
+
)
|
|
35
|
+
from ._generated.api.database_v_2 import (
|
|
36
|
+
database_checksum_v2,
|
|
37
|
+
database_metadata_v2,
|
|
38
|
+
download_database_v2,
|
|
39
|
+
list_databases,
|
|
40
|
+
list_downloads,
|
|
41
|
+
)
|
|
42
|
+
from ._generated.client import AuthenticatedClient
|
|
43
|
+
from ._generated.models.database_checksum_v2_format import DatabaseChecksumV2Format
|
|
44
|
+
from ._generated.models.download_database_v2_format import DownloadDatabaseV2Format
|
|
45
|
+
from .errors import InternetDataError
|
|
46
|
+
from .models import Database, DatabaseMetadata, Download, Format, to_metadata
|
|
47
|
+
|
|
48
|
+
__all__ = ["DatabaseApi", "InternetData"]
|
|
49
|
+
|
|
50
|
+
T = TypeVar("T")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InternetData:
|
|
54
|
+
"""A client for the InternetData API.
|
|
55
|
+
|
|
56
|
+
Every database published today is licensed, so create a key carrying the
|
|
57
|
+
`db.download` scope in the console and pass it in. The argument is optional
|
|
58
|
+
nonetheless, and an absent or empty one sends no `Authorization` header at all
|
|
59
|
+
rather than an empty one: what this API serves without a licence is a product
|
|
60
|
+
decision, not the client's to refuse.
|
|
61
|
+
|
|
62
|
+
Holds an HTTP connection pool, so use it as a context manager or call `close()` when
|
|
63
|
+
you are done with it.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
database: DatabaseApi
|
|
67
|
+
"""The licensed database catalog and downloads, which is the whole API."""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
api_key: str | None = None,
|
|
72
|
+
*,
|
|
73
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
74
|
+
retries: int = DEFAULT_RETRIES,
|
|
75
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
76
|
+
transport: httpx.BaseTransport | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self._client = build_client(api_key, base_url, timeout, transport)
|
|
79
|
+
self._transfer = build_transfer_client(timeout, transport)
|
|
80
|
+
self._retries = retries
|
|
81
|
+
self.database = DatabaseApi(self)
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
self._client.get_httpx_client().close()
|
|
85
|
+
self._transfer.close()
|
|
86
|
+
|
|
87
|
+
def __enter__(self) -> Self:
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def __exit__(
|
|
91
|
+
self,
|
|
92
|
+
exc_type: type[BaseException] | None,
|
|
93
|
+
exc: BaseException | None,
|
|
94
|
+
tb: TracebackType | None,
|
|
95
|
+
) -> None:
|
|
96
|
+
self.close()
|
|
97
|
+
|
|
98
|
+
def _retrying(self, call: Callable[[], T], retries: int) -> T:
|
|
99
|
+
attempt = 0
|
|
100
|
+
while True:
|
|
101
|
+
try:
|
|
102
|
+
return call()
|
|
103
|
+
except (InternetDataError, httpx.HTTPError) as exc:
|
|
104
|
+
err = as_error(exc)
|
|
105
|
+
delay = retry_delay(err, attempt, retries)
|
|
106
|
+
if delay is None:
|
|
107
|
+
raise err
|
|
108
|
+
time.sleep(delay)
|
|
109
|
+
attempt += 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class DatabaseApi:
|
|
113
|
+
"""The database catalog and downloads. Access is granted by contract, not self-serve.
|
|
114
|
+
|
|
115
|
+
`list` is a method here, which shadows the builtin for everything else in the class
|
|
116
|
+
body, so the return annotations name `builtins.list` explicitly.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, owner: InternetData) -> None:
|
|
120
|
+
self._owner = owner
|
|
121
|
+
|
|
122
|
+
def list(self) -> builtins.list[Database]:
|
|
123
|
+
"""The published catalog as YOUR organization may see it.
|
|
124
|
+
|
|
125
|
+
Every family carries a `standing`, so one you have never bought is listed as
|
|
126
|
+
`unlicensed` rather than hidden: the catalog is a shop window as much as an
|
|
127
|
+
inventory. The exception is a family commissioned for a single customer, which is
|
|
128
|
+
absent entirely for everyone else, because listing it would advertise that
|
|
129
|
+
customer. Nothing is cached and nothing is reconstructed here - what you get is
|
|
130
|
+
what the server sent for the key you are holding.
|
|
131
|
+
|
|
132
|
+
A licence covers a FAMILY, while a download names a version, so the ids for the
|
|
133
|
+
other calls come from each entry's `versions`.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def call() -> builtins.list[Database]:
|
|
137
|
+
res = send(lambda: list_databases.sync_detailed(client=self._client))
|
|
138
|
+
return parse_body(unwrap(res), databases_of)
|
|
139
|
+
|
|
140
|
+
return self._retrying(call)
|
|
141
|
+
|
|
142
|
+
def metadata(self, database_id: str) -> DatabaseMetadata:
|
|
143
|
+
"""What is inside one database: freshness, row count, columns, samples and sizes.
|
|
144
|
+
|
|
145
|
+
Cheap enough to poll: it answers `updated` and `entries` without moving the
|
|
146
|
+
build. `size` is the number to budget a transfer against before starting one.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def call() -> DatabaseMetadata:
|
|
150
|
+
res = send(
|
|
151
|
+
lambda: database_metadata_v2.sync_detailed(client=self._client, id=database_id)
|
|
152
|
+
)
|
|
153
|
+
return parse_body(unwrap(res), to_metadata)
|
|
154
|
+
|
|
155
|
+
return self._retrying(call)
|
|
156
|
+
|
|
157
|
+
def checksums(self, database_id: str, format: Format) -> dict[str, str]:
|
|
158
|
+
"""Every checksum published for one database file, keyed by algorithm.
|
|
159
|
+
|
|
160
|
+
Keyed rather than one digest because the API publishes md5, sha1, sha256 and
|
|
161
|
+
sha512 side by side and which of them you want is your verifier's business.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def call() -> dict[str, str]:
|
|
165
|
+
res = send(
|
|
166
|
+
lambda: database_checksum_v2.sync_detailed(
|
|
167
|
+
client=self._client,
|
|
168
|
+
id=database_id,
|
|
169
|
+
format_=DatabaseChecksumV2Format(format),
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return parse_body(unwrap(res), checksums_of)
|
|
173
|
+
|
|
174
|
+
return self._retrying(call)
|
|
175
|
+
|
|
176
|
+
def downloads(self, limit: int = DEFAULT_DOWNLOADS_LIMIT) -> builtins.list[Download]:
|
|
177
|
+
"""Your organization's recent download attempts, newest first.
|
|
178
|
+
|
|
179
|
+
Refusals are listed too: a denial is what answers "it stopped working", and its
|
|
180
|
+
absence answers nothing.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def call() -> builtins.list[Download]:
|
|
184
|
+
res = send(lambda: list_downloads.sync_detailed(client=self._client, limit=limit))
|
|
185
|
+
return parse_body(unwrap(res), downloads_of)
|
|
186
|
+
|
|
187
|
+
return self._retrying(call)
|
|
188
|
+
|
|
189
|
+
def download_url(self, database_id: str, format: Format) -> str:
|
|
190
|
+
"""The time-limited URL for one database file.
|
|
191
|
+
|
|
192
|
+
The API answers `302` to object storage, and the link carries its own signature,
|
|
193
|
+
so it holds no API key and can be handed to anything that speaks HTTP. Returned
|
|
194
|
+
rather than followed so the caller decides how to move a file that reaches
|
|
195
|
+
gigabytes; the link authorizes the START of a transfer, so one already running is
|
|
196
|
+
not interrupted when it lapses.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def call() -> str:
|
|
200
|
+
res = send(
|
|
201
|
+
lambda: download_database_v2.sync_detailed(
|
|
202
|
+
client=self._client,
|
|
203
|
+
id=database_id,
|
|
204
|
+
format_=DownloadDatabaseV2Format(format),
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
return redirect_location(res)
|
|
208
|
+
|
|
209
|
+
return self._retrying(call)
|
|
210
|
+
|
|
211
|
+
def download(self, database_id: str, format: Format, path: str | os.PathLike[str]) -> int:
|
|
212
|
+
"""Download one database file to `path`, and return the bytes written.
|
|
213
|
+
|
|
214
|
+
The bytes are streamed straight to disk, so nothing larger than a chunk is ever
|
|
215
|
+
held in memory whatever the database weighs. They land in a neighboring `.part`
|
|
216
|
+
file that is moved into place only once the whole transfer has arrived, so a
|
|
217
|
+
failure leaves neither a truncated file at `path` nor the `.part` behind, and an
|
|
218
|
+
existing copy at `path` survives a refresh that fails.
|
|
219
|
+
|
|
220
|
+
A failure DURING the transfer surfaces as it happened, an `httpx` error or an
|
|
221
|
+
`OSError`, rather than as this library's error type: a reset socket and a full
|
|
222
|
+
disk are different problems, and only one of them is ours.
|
|
223
|
+
"""
|
|
224
|
+
res = self._open_transfer(database_id, format)
|
|
225
|
+
try:
|
|
226
|
+
written = 0
|
|
227
|
+
with part_file(path) as sink:
|
|
228
|
+
for chunk in res.iter_bytes(TRANSFER_CHUNK_BYTES):
|
|
229
|
+
sink.write(chunk)
|
|
230
|
+
written += len(chunk)
|
|
231
|
+
# Inside, so a short transfer fails before anything is moved into place.
|
|
232
|
+
assert_whole_transfer(res, written)
|
|
233
|
+
return written
|
|
234
|
+
finally:
|
|
235
|
+
res.close()
|
|
236
|
+
|
|
237
|
+
def download_bytes(self, database_id: str, format: Format) -> bytes:
|
|
238
|
+
"""Download one database file and hand back its bytes.
|
|
239
|
+
|
|
240
|
+
**This holds the entire file in memory**, and the catalog spans seven orders of
|
|
241
|
+
magnitude, from a few hundred bytes to several gigabytes. Reach for it at the
|
|
242
|
+
small end, where the bytes go straight into a parser, and use `download` for
|
|
243
|
+
anything you have not checked `metadata` for.
|
|
244
|
+
"""
|
|
245
|
+
res = self._open_transfer(database_id, format)
|
|
246
|
+
try:
|
|
247
|
+
body = res.read()
|
|
248
|
+
assert_whole_transfer(res, len(body))
|
|
249
|
+
return body
|
|
250
|
+
finally:
|
|
251
|
+
res.close()
|
|
252
|
+
|
|
253
|
+
# Follows the 302 as a SECOND, unauthenticated request rather than by loosening the
|
|
254
|
+
# redirect guard: the presigned URL authorizes itself, so forwarding the API key
|
|
255
|
+
# would hand a credential to a host with no business holding it - and object storage
|
|
256
|
+
# rejects a request carrying both signatures outright.
|
|
257
|
+
#
|
|
258
|
+
# Returns the response with its body still unread, so the caller decides whether a
|
|
259
|
+
# database is going to disk or into memory.
|
|
260
|
+
def _open_transfer(self, database_id: str, format: Format) -> httpx.Response:
|
|
261
|
+
url = self.download_url(database_id, format)
|
|
262
|
+
transfer = self._owner._transfer
|
|
263
|
+
|
|
264
|
+
def call() -> httpx.Response:
|
|
265
|
+
res = transfer.send(transfer.build_request("GET", url), stream=True)
|
|
266
|
+
if res.status_code != httpx.codes.OK:
|
|
267
|
+
res.close()
|
|
268
|
+
raise storage_refusal(res)
|
|
269
|
+
return res
|
|
270
|
+
|
|
271
|
+
return self._retrying(call)
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def _client(self) -> AuthenticatedClient:
|
|
275
|
+
return self._owner._client
|
|
276
|
+
|
|
277
|
+
def _retrying(self, call: Callable[[], T]) -> T:
|
|
278
|
+
return self._owner._retrying(call, self._owner._retries)
|