internetdata 2.5.0__tar.gz → 2.6.1__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.5.0 → internetdata-2.6.1}/PKG-INFO +21 -1
- {internetdata-2.5.0 → internetdata-2.6.1}/README.md +20 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/__init__.py +3 -1
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_core.py +86 -8
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/aio.py +61 -2
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/client.py +67 -3
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/models.py +11 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/testdata/testdata.json +66 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/tests/helpers.py +17 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/tests/test_client.py +33 -3
- {internetdata-2.5.0 → internetdata-2.6.1}/tests/test_download.py +26 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/tests/test_oauth.py +160 -4
- {internetdata-2.5.0 → internetdata-2.6.1}/.gitignore +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/LICENSE +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/pyproject.toml +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/scripts/download-spec.sh +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/scripts/generate.sh +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/scripts/publish.sh +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/scripts/v2_subset.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/spec/openapi.yaml +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/account/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/account/account_identity.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_create_apikey.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_list_apikeys.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_reveal_apikey.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_revoke_apikey.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_rotate_apikey.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_authorize.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_device_authorization.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_metadata.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_revoke.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_token.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/organization/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/organization/account_org.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/organization/account_org_members.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/client.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/errors.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/__init__.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_create_apikey_request.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_created_apikey.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_org.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_org_ref.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_org_wrap.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_rc.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_revealed_apikey.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_user.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/apikey_detail.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/apikey_list.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_format.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_sample_size.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_version.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/db_checksums.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/device_authorization.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/device_authorization_request.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/download.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/download_outcome.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/error.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/identity.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_authorize_code_challenge_method.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_authorize_response_type.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_error.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_metadata.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_revoke_response_200.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/revoke_request.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/standing.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/token_request.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/token_response.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/types.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/errors.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/py.typed +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/tests/conftest.py +0 -0
- {internetdata-2.5.0 → internetdata-2.6.1}/tests/test_conformance.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: internetdata
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.1
|
|
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
|
|
@@ -203,6 +203,26 @@ client = InternetData(tokens.apikey)
|
|
|
203
203
|
|
|
204
204
|
A refusal raises `OauthAccessDeniedError` and a code that ran out raises `OauthExpiredTokenError`, and client IDs are issued on request from support@internetdata.io. `client.oauth.revoke("your-client-id", tokens.refresh_token)` signs the machine out.
|
|
205
205
|
|
|
206
|
+
### Sign in with OAuth (authorization code)
|
|
207
|
+
|
|
208
|
+
An app that can take a browser redirect signs the person in there instead, with a PKCE pair made for that one sign-in:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from internetdata import InternetData
|
|
212
|
+
|
|
213
|
+
signin = InternetData()
|
|
214
|
+
redirect_uri = "http://127.0.0.1:8765/callback"
|
|
215
|
+
pkce = signin.oauth.create_pkce()
|
|
216
|
+
|
|
217
|
+
url = signin.oauth.authorization_url(
|
|
218
|
+
"your-client-id", redirect_uri, pkce.challenge, scope="apikeys.use", state="your-state"
|
|
219
|
+
)
|
|
220
|
+
# Open url in the browser. Its redirect to redirect_uri carries code and state.
|
|
221
|
+
tokens = signin.oauth.exchange_authorization_code("your-client-id", code, pkce.verifier, redirect_uri)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Check that `state` came back as you sent it before you exchange `code`, which works once. The client ID can also be the https URL of a client metadata document your app serves, and such an app is never handed a key, so `tokens.apikey` stays `None`.
|
|
225
|
+
|
|
206
226
|
## Other Libraries
|
|
207
227
|
|
|
208
228
|
There are official InternetData client libraries available for many languages including PHP, Python, Go, Java, Ruby, and many popular frameworks such as Django, Rails, and Laravel. See our GitHub at https://github.com/internetdata for more.
|
|
@@ -169,6 +169,26 @@ client = InternetData(tokens.apikey)
|
|
|
169
169
|
|
|
170
170
|
A refusal raises `OauthAccessDeniedError` and a code that ran out raises `OauthExpiredTokenError`, and client IDs are issued on request from support@internetdata.io. `client.oauth.revoke("your-client-id", tokens.refresh_token)` signs the machine out.
|
|
171
171
|
|
|
172
|
+
### Sign in with OAuth (authorization code)
|
|
173
|
+
|
|
174
|
+
An app that can take a browser redirect signs the person in there instead, with a PKCE pair made for that one sign-in:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from internetdata import InternetData
|
|
178
|
+
|
|
179
|
+
signin = InternetData()
|
|
180
|
+
redirect_uri = "http://127.0.0.1:8765/callback"
|
|
181
|
+
pkce = signin.oauth.create_pkce()
|
|
182
|
+
|
|
183
|
+
url = signin.oauth.authorization_url(
|
|
184
|
+
"your-client-id", redirect_uri, pkce.challenge, scope="apikeys.use", state="your-state"
|
|
185
|
+
)
|
|
186
|
+
# Open url in the browser. Its redirect to redirect_uri carries code and state.
|
|
187
|
+
tokens = signin.oauth.exchange_authorization_code("your-client-id", code, pkce.verifier, redirect_uri)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Check that `state` came back as you sent it before you exchange `code`, which works once. The client ID can also be the https URL of a client metadata document your app serves, and such an app is never handed a key, so `tokens.apikey` stays `None`.
|
|
191
|
+
|
|
172
192
|
## Other Libraries
|
|
173
193
|
|
|
174
194
|
There are official InternetData client libraries available for many languages including PHP, Python, Go, Java, Ruby, and many popular frameworks such as Django, Rails, and Laravel. See our GitHub at https://github.com/internetdata for more.
|
|
@@ -34,11 +34,12 @@ from .models import (
|
|
|
34
34
|
MetadataColumn,
|
|
35
35
|
OauthMetadata,
|
|
36
36
|
Outcome,
|
|
37
|
+
Pkce,
|
|
37
38
|
Standing,
|
|
38
39
|
TokenResponse,
|
|
39
40
|
)
|
|
40
41
|
|
|
41
|
-
__version__ = "2.
|
|
42
|
+
__version__ = "2.6.1"
|
|
42
43
|
|
|
43
44
|
__all__ = [
|
|
44
45
|
"DATABASE_FORMATS",
|
|
@@ -66,6 +67,7 @@ __all__ = [
|
|
|
66
67
|
"OauthExpiredTokenError",
|
|
67
68
|
"OauthMetadata",
|
|
68
69
|
"Outcome",
|
|
70
|
+
"Pkce",
|
|
69
71
|
"Standing",
|
|
70
72
|
"TokenResponse",
|
|
71
73
|
"__version__",
|
|
@@ -4,24 +4,28 @@ each attempt, response unwrapping, and the retry policy."""
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import asyncio
|
|
7
|
+
import base64
|
|
7
8
|
import contextlib
|
|
8
9
|
import contextvars
|
|
10
|
+
import hashlib
|
|
9
11
|
import json
|
|
10
12
|
import math
|
|
11
13
|
import os
|
|
14
|
+
import secrets
|
|
12
15
|
import threading
|
|
13
16
|
import time
|
|
14
17
|
from collections.abc import Awaitable, Callable, Iterator
|
|
15
18
|
from pathlib import Path
|
|
16
19
|
from types import ModuleType
|
|
17
20
|
from typing import IO, Any, TypeVar, cast
|
|
21
|
+
from urllib.parse import quote
|
|
18
22
|
|
|
19
23
|
import httpx
|
|
20
24
|
|
|
21
25
|
from ._generated.client import AuthenticatedClient, Client
|
|
22
26
|
from ._generated.types import Response
|
|
23
27
|
from .errors import InternetDataError, error_from_response, oauth_error_from
|
|
24
|
-
from .models import Database, Download, to_database, to_download
|
|
28
|
+
from .models import Database, Download, Pkce, to_database, to_download
|
|
25
29
|
|
|
26
30
|
DEFAULT_BASE_URL = "https://internetdata.io"
|
|
27
31
|
DEFAULT_RETRIES = 2
|
|
@@ -32,6 +36,7 @@ OAUTH_METADATA_PATH = "/.well-known/oauth-authorization-server"
|
|
|
32
36
|
OAUTH_DEVICE_AUTHORIZATION_PATH = "/oauth/device_authorization"
|
|
33
37
|
OAUTH_TOKEN_PATH = "/oauth/token"
|
|
34
38
|
OAUTH_REVOKE_PATH = "/oauth/revoke"
|
|
39
|
+
OAUTH_AUTHORIZE_PATH = "/oauth/authorize"
|
|
35
40
|
DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
36
41
|
|
|
37
42
|
# One chunk of a transfer, and therefore the ceiling on what a download of any size
|
|
@@ -40,6 +45,20 @@ TRANSFER_CHUNK_BYTES = 1 << 20
|
|
|
40
45
|
|
|
41
46
|
_BACKOFF_BASE = 1.0
|
|
42
47
|
|
|
48
|
+
# The longest wait the sync client's clock can count, about 292 years: past it a lock's
|
|
49
|
+
# wait, `socket.settimeout` and `time.sleep` all raise OverflowError instead.
|
|
50
|
+
WAIT_CEILING = threading.TIMEOUT_MAX
|
|
51
|
+
|
|
52
|
+
# The longest `Retry-After` waited as given, 2^31 - 1 ms, the bound every SDK takes. One
|
|
53
|
+
# past it is still a throttle, waited out on the backoff: as given, 2147484 held a call
|
|
54
|
+
# for 24.8 days, and 9223372036854775807, 1e400 or a year-9999 date raised a raw
|
|
55
|
+
# OverflowError from the sync client's sleep (measured on 2.6.0, 2026-09-30).
|
|
56
|
+
RETRY_AFTER_CEILING = 2147483.647
|
|
57
|
+
|
|
58
|
+
# An `expires_in` past this, about 146 billion years, is a deadline nobody reaches. The
|
|
59
|
+
# cap keeps the poll's deadline a float: past 1.8e308 adding it to the clock raises.
|
|
60
|
+
POLL_DEADLINE_CAP = 2**62
|
|
61
|
+
|
|
43
62
|
T = TypeVar("T")
|
|
44
63
|
|
|
45
64
|
|
|
@@ -119,8 +138,9 @@ def check_timeout(timeout: float | None) -> float | None:
|
|
|
119
138
|
Refused where it is SET, because nothing downstream refuses it: zero, a negative
|
|
120
139
|
number, NaN or a string reached the first call and failed it, and every call after,
|
|
121
140
|
as a retried `network` error after three seconds of backoff, or for a string as a
|
|
122
|
-
`server_error` blaming the API.
|
|
123
|
-
cannot hold it
|
|
141
|
+
`server_error` blaming the API. So is anything past `WAIT_CEILING`, infinity
|
|
142
|
+
included, since the sync client's wait cannot hold it: 1e300 failed every call with
|
|
143
|
+
a raw `OverflowError` (2.6.0, measured 2026-09-30). None is the spelling for no bound.
|
|
124
144
|
"""
|
|
125
145
|
if timeout is None:
|
|
126
146
|
return None
|
|
@@ -129,10 +149,11 @@ def check_timeout(timeout: float | None) -> float | None:
|
|
|
129
149
|
or not isinstance(timeout, int | float)
|
|
130
150
|
or not math.isfinite(timeout)
|
|
131
151
|
or timeout <= 0
|
|
152
|
+
or timeout > WAIT_CEILING
|
|
132
153
|
):
|
|
133
154
|
raise ValueError(
|
|
134
|
-
f"timeout must be a number of seconds greater than 0
|
|
135
|
-
f"not {timeout!r}"
|
|
155
|
+
f"timeout must be a number of seconds greater than 0 and at most "
|
|
156
|
+
f"{WAIT_CEILING:.0f}, or None for no bound, not {timeout!r}"
|
|
136
157
|
)
|
|
137
158
|
return timeout
|
|
138
159
|
|
|
@@ -337,6 +358,58 @@ async def oauth_request_async(
|
|
|
337
358
|
return oauth_checked(await exchange_async(http, req, bound))
|
|
338
359
|
|
|
339
360
|
|
|
361
|
+
def authorization_url(
|
|
362
|
+
base_url: str,
|
|
363
|
+
client_id: str,
|
|
364
|
+
redirect_uri: str,
|
|
365
|
+
code_challenge: str,
|
|
366
|
+
optional: dict[str, str | None],
|
|
367
|
+
) -> str:
|
|
368
|
+
"""The authorization code flow's URL, built with no request: the five parameters every
|
|
369
|
+
one carries, then `scope`, `state` and `resource` when given and not empty.
|
|
370
|
+
|
|
371
|
+
Each value is percent-encoded over UTF-8 with only A-Z a-z 0-9 - . _ ~ left literal, so
|
|
372
|
+
a space is %20 and never +. A required value that is empty, or has no UTF-8 (a lone
|
|
373
|
+
surrogate), is refused.
|
|
374
|
+
"""
|
|
375
|
+
required = {
|
|
376
|
+
"client_id": client_id,
|
|
377
|
+
"redirect_uri": redirect_uri,
|
|
378
|
+
"code_challenge": code_challenge,
|
|
379
|
+
}
|
|
380
|
+
for name, value in required.items():
|
|
381
|
+
if not isinstance(value, str) or not value:
|
|
382
|
+
raise ValueError(f"{name} must be a non-empty string, not {value!r}")
|
|
383
|
+
params = [
|
|
384
|
+
("response_type", "code"),
|
|
385
|
+
*required.items(),
|
|
386
|
+
("code_challenge_method", "S256"),
|
|
387
|
+
*((name, value) for name, value in optional.items() if value),
|
|
388
|
+
]
|
|
389
|
+
encoded = []
|
|
390
|
+
for name, value in params:
|
|
391
|
+
try:
|
|
392
|
+
encoded.append(f"{name}={quote(value, safe='')}")
|
|
393
|
+
except UnicodeEncodeError as exc:
|
|
394
|
+
raise ValueError(f"{name} has no UTF-8 to send: {value!r}") from exc
|
|
395
|
+
return f"{base_url.rstrip('/')}{OAUTH_AUTHORIZE_PATH}?{'&'.join(encoded)}"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def create_pkce() -> Pkce:
|
|
399
|
+
"""A fresh PKCE pair, from 32 bytes of the system's secure random source."""
|
|
400
|
+
verifier = _base64url(secrets.token_bytes(32))
|
|
401
|
+
return Pkce(verifier=verifier, challenge=pkce_challenge(verifier))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def pkce_challenge(verifier: str) -> str:
|
|
405
|
+
"""The `S256` challenge for a PKCE verifier: its SHA-256, as unpadded base64url."""
|
|
406
|
+
return _base64url(hashlib.sha256(verifier.encode("utf-8")).digest())
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _base64url(raw: bytes) -> str:
|
|
410
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
411
|
+
|
|
412
|
+
|
|
340
413
|
def oauth_checked(res: httpx.Response) -> httpx.Response:
|
|
341
414
|
"""A 2xx as it came, or the failure it describes.
|
|
342
415
|
|
|
@@ -368,7 +441,11 @@ class Clock:
|
|
|
368
441
|
return time.monotonic()
|
|
369
442
|
|
|
370
443
|
def sleep(self, seconds: float) -> None:
|
|
371
|
-
time.sleep
|
|
444
|
+
# In parts, since `time.sleep` raises past WAIT_CEILING rather than waiting.
|
|
445
|
+
while seconds > 0:
|
|
446
|
+
part = min(seconds, WAIT_CEILING)
|
|
447
|
+
time.sleep(part)
|
|
448
|
+
seconds -= part
|
|
372
449
|
|
|
373
450
|
|
|
374
451
|
class AsyncClock:
|
|
@@ -456,11 +533,12 @@ def retry_delay(err: InternetDataError, attempt: int, retries: int) -> float | N
|
|
|
456
533
|
|
|
457
534
|
A server-supplied `Retry-After` wins over the backoff schedule outright: it is the
|
|
458
535
|
only thing that makes a 429 worth retrying at all, so second-guessing it with a
|
|
459
|
-
shorter wait would just spend the next attempt on the same rejection.
|
|
536
|
+
shorter wait would just spend the next attempt on the same rejection. One past
|
|
537
|
+
`RETRY_AFTER_CEILING` is waited out on the backoff instead.
|
|
460
538
|
"""
|
|
461
539
|
if attempt >= retries or not err.retryable:
|
|
462
540
|
return None
|
|
463
|
-
if err.retry_after_seconds is not None:
|
|
541
|
+
if err.retry_after_seconds is not None and err.retry_after_seconds <= RETRY_AFTER_CEILING:
|
|
464
542
|
return err.retry_after_seconds
|
|
465
543
|
return _BACKOFF_BASE * (2.0**attempt)
|
|
466
544
|
|
|
@@ -26,20 +26,24 @@ from ._core import (
|
|
|
26
26
|
OAUTH_METADATA_PATH,
|
|
27
27
|
OAUTH_REVOKE_PATH,
|
|
28
28
|
OAUTH_TOKEN_PATH,
|
|
29
|
+
POLL_DEADLINE_CAP,
|
|
29
30
|
TRANSFER_CHUNK_BYTES,
|
|
30
31
|
AsyncClock,
|
|
31
32
|
as_error,
|
|
32
33
|
assert_whole_transfer,
|
|
34
|
+
authorization_url,
|
|
33
35
|
build_async_transfer_client,
|
|
34
36
|
build_client,
|
|
35
37
|
check_timeout,
|
|
36
38
|
checksums_of,
|
|
39
|
+
create_pkce,
|
|
37
40
|
databases_of,
|
|
38
41
|
downloads_of,
|
|
39
42
|
oauth_body,
|
|
40
43
|
oauth_request_async,
|
|
41
44
|
parse_body,
|
|
42
45
|
part_file,
|
|
46
|
+
pkce_challenge,
|
|
43
47
|
redirect_location,
|
|
44
48
|
request_async,
|
|
45
49
|
retry_delay,
|
|
@@ -63,6 +67,7 @@ from .models import (
|
|
|
63
67
|
Download,
|
|
64
68
|
Format,
|
|
65
69
|
OauthMetadata,
|
|
70
|
+
Pkce,
|
|
66
71
|
TokenResponse,
|
|
67
72
|
to_device_authorization,
|
|
68
73
|
to_metadata,
|
|
@@ -99,6 +104,7 @@ class AsyncInternetData:
|
|
|
99
104
|
) -> None:
|
|
100
105
|
timeout = check_timeout(timeout)
|
|
101
106
|
self._client = build_client(api_key, base_url, timeout, transport)
|
|
107
|
+
self._base_url = base_url
|
|
102
108
|
self._transfer = build_async_transfer_client(timeout, transport)
|
|
103
109
|
self._retries = retries
|
|
104
110
|
self._timeout = timeout
|
|
@@ -381,6 +387,54 @@ class AsyncOauthApi:
|
|
|
381
387
|
}
|
|
382
388
|
return await self._exchange(form, timeout)
|
|
383
389
|
|
|
390
|
+
async def exchange_authorization_code(
|
|
391
|
+
self,
|
|
392
|
+
client_id: str,
|
|
393
|
+
code: str,
|
|
394
|
+
code_verifier: str,
|
|
395
|
+
redirect_uri: str,
|
|
396
|
+
*,
|
|
397
|
+
timeout: float | None = None,
|
|
398
|
+
) -> TokenResponse:
|
|
399
|
+
"""Trade a redirect's code for tokens, once; see `OauthApi.exchange_authorization_code`."""
|
|
400
|
+
form = {
|
|
401
|
+
"grant_type": "authorization_code",
|
|
402
|
+
"code": code,
|
|
403
|
+
"redirect_uri": redirect_uri,
|
|
404
|
+
"client_id": client_id,
|
|
405
|
+
"code_verifier": code_verifier,
|
|
406
|
+
}
|
|
407
|
+
return await self._exchange(form, timeout)
|
|
408
|
+
|
|
409
|
+
def authorization_url(
|
|
410
|
+
self,
|
|
411
|
+
client_id: str,
|
|
412
|
+
redirect_uri: str,
|
|
413
|
+
code_challenge: str,
|
|
414
|
+
*,
|
|
415
|
+
scope: str | None = None,
|
|
416
|
+
state: str | None = None,
|
|
417
|
+
resource: str | None = None,
|
|
418
|
+
) -> str:
|
|
419
|
+
"""The authorization code flow's URL, made with no request; see
|
|
420
|
+
`OauthApi.authorization_url`.
|
|
421
|
+
"""
|
|
422
|
+
return authorization_url(
|
|
423
|
+
self._owner._base_url,
|
|
424
|
+
client_id,
|
|
425
|
+
redirect_uri,
|
|
426
|
+
code_challenge,
|
|
427
|
+
{"scope": scope, "state": state, "resource": resource},
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
def create_pkce(self) -> Pkce:
|
|
431
|
+
"""A fresh PKCE pair for one sign-in, from the system's secure random source."""
|
|
432
|
+
return create_pkce()
|
|
433
|
+
|
|
434
|
+
def pkce_challenge(self, verifier: str) -> str:
|
|
435
|
+
"""The `S256` challenge for a PKCE verifier: its SHA-256, as unpadded base64url."""
|
|
436
|
+
return pkce_challenge(verifier)
|
|
437
|
+
|
|
384
438
|
async def revoke(self, client_id: str, token: str, *, timeout: float | None = None) -> None:
|
|
385
439
|
"""End a token; revoking the refresh token signs the machine out."""
|
|
386
440
|
|
|
@@ -398,9 +452,14 @@ class AsyncOauthApi:
|
|
|
398
452
|
# Refused before the first wait, not after it.
|
|
399
453
|
check_timeout(timeout)
|
|
400
454
|
interval = device.interval if device.interval >= 1 else 5
|
|
401
|
-
deadline
|
|
455
|
+
# Capped where a deadline stops meaning anything, since the clock is a float and
|
|
456
|
+
# `expires_in` is the server's integer, of any size.
|
|
457
|
+
deadline = self._clock.now() + min(device.expires_in, POLL_DEADLINE_CAP)
|
|
402
458
|
while True:
|
|
403
|
-
|
|
459
|
+
# No wait runs past the deadline: an interval ending after it waits only the
|
|
460
|
+
# time left, and the expiry follows with nothing sent. In full, an interval of
|
|
461
|
+
# 2147483647 held a poll with 2 s left for 68 years (2.6.0).
|
|
462
|
+
await self._clock.sleep(min(interval, max(deadline - self._clock.now(), 0)))
|
|
404
463
|
if self._clock.now() >= deadline:
|
|
405
464
|
raise OauthExpiredTokenError()
|
|
406
465
|
try:
|
|
@@ -21,20 +21,24 @@ from ._core import (
|
|
|
21
21
|
OAUTH_METADATA_PATH,
|
|
22
22
|
OAUTH_REVOKE_PATH,
|
|
23
23
|
OAUTH_TOKEN_PATH,
|
|
24
|
+
POLL_DEADLINE_CAP,
|
|
24
25
|
TRANSFER_CHUNK_BYTES,
|
|
25
26
|
Clock,
|
|
26
27
|
as_error,
|
|
27
28
|
assert_whole_transfer,
|
|
29
|
+
authorization_url,
|
|
28
30
|
build_client,
|
|
29
31
|
build_transfer_client,
|
|
30
32
|
check_timeout,
|
|
31
33
|
checksums_of,
|
|
34
|
+
create_pkce,
|
|
32
35
|
databases_of,
|
|
33
36
|
downloads_of,
|
|
34
37
|
oauth_body,
|
|
35
38
|
oauth_request,
|
|
36
39
|
parse_body,
|
|
37
40
|
part_file,
|
|
41
|
+
pkce_challenge,
|
|
38
42
|
redirect_location,
|
|
39
43
|
request,
|
|
40
44
|
retry_delay,
|
|
@@ -58,6 +62,7 @@ from .models import (
|
|
|
58
62
|
Download,
|
|
59
63
|
Format,
|
|
60
64
|
OauthMetadata,
|
|
65
|
+
Pkce,
|
|
61
66
|
TokenResponse,
|
|
62
67
|
to_device_authorization,
|
|
63
68
|
to_metadata,
|
|
@@ -107,6 +112,7 @@ class InternetData:
|
|
|
107
112
|
) -> None:
|
|
108
113
|
timeout = check_timeout(timeout)
|
|
109
114
|
self._client = build_client(api_key, base_url, timeout, transport)
|
|
115
|
+
self._base_url = base_url
|
|
110
116
|
self._transfer = build_transfer_client(timeout, transport)
|
|
111
117
|
self._retries = retries
|
|
112
118
|
self._timeout = timeout
|
|
@@ -339,7 +345,8 @@ class DatabaseApi:
|
|
|
339
345
|
|
|
340
346
|
class OauthApi:
|
|
341
347
|
"""Signing a person in with the OAuth device flow, so a program running on their own
|
|
342
|
-
machine can be handed one of their API keys instead of asking them to paste it
|
|
348
|
+
machine can be handed one of their API keys instead of asking them to paste it, or
|
|
349
|
+
through a browser redirect with the authorization code flow.
|
|
343
350
|
|
|
344
351
|
No request here carries this client's API key, and none needs one: build the client
|
|
345
352
|
with no key to sign in, then a second one with the key the sign-in hands over. The
|
|
@@ -412,6 +419,58 @@ class OauthApi:
|
|
|
412
419
|
}
|
|
413
420
|
return self._exchange(form, timeout)
|
|
414
421
|
|
|
422
|
+
def exchange_authorization_code(
|
|
423
|
+
self,
|
|
424
|
+
client_id: str,
|
|
425
|
+
code: str,
|
|
426
|
+
code_verifier: str,
|
|
427
|
+
redirect_uri: str,
|
|
428
|
+
*,
|
|
429
|
+
timeout: float | None = None,
|
|
430
|
+
) -> TokenResponse:
|
|
431
|
+
"""Trade the `code` a sign-in's redirect brought back for tokens, once: the server
|
|
432
|
+
spends the code on first read, before it checks `code_verifier`, the PKCE verifier
|
|
433
|
+
whose challenge went into the authorization URL. `redirect_uri` is that URL's, exactly.
|
|
434
|
+
"""
|
|
435
|
+
form = {
|
|
436
|
+
"grant_type": "authorization_code",
|
|
437
|
+
"code": code,
|
|
438
|
+
"redirect_uri": redirect_uri,
|
|
439
|
+
"client_id": client_id,
|
|
440
|
+
"code_verifier": code_verifier,
|
|
441
|
+
}
|
|
442
|
+
return self._exchange(form, timeout)
|
|
443
|
+
|
|
444
|
+
def authorization_url(
|
|
445
|
+
self,
|
|
446
|
+
client_id: str,
|
|
447
|
+
redirect_uri: str,
|
|
448
|
+
code_challenge: str,
|
|
449
|
+
*,
|
|
450
|
+
scope: str | None = None,
|
|
451
|
+
state: str | None = None,
|
|
452
|
+
resource: str | None = None,
|
|
453
|
+
) -> str:
|
|
454
|
+
"""The URL to open in the person's browser for the authorization code flow. Makes no
|
|
455
|
+
request. Once they decide, the server redirects to `redirect_uri` with a `code` for
|
|
456
|
+
`exchange_authorization_code` (and `state`, when one was given), or with an `error`.
|
|
457
|
+
"""
|
|
458
|
+
return authorization_url(
|
|
459
|
+
self._owner._base_url,
|
|
460
|
+
client_id,
|
|
461
|
+
redirect_uri,
|
|
462
|
+
code_challenge,
|
|
463
|
+
{"scope": scope, "state": state, "resource": resource},
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def create_pkce(self) -> Pkce:
|
|
467
|
+
"""A fresh PKCE pair for one sign-in, from the system's secure random source."""
|
|
468
|
+
return create_pkce()
|
|
469
|
+
|
|
470
|
+
def pkce_challenge(self, verifier: str) -> str:
|
|
471
|
+
"""The `S256` challenge for a PKCE verifier: its SHA-256, as unpadded base64url."""
|
|
472
|
+
return pkce_challenge(verifier)
|
|
473
|
+
|
|
415
474
|
def revoke(self, client_id: str, token: str, *, timeout: float | None = None) -> None:
|
|
416
475
|
"""End a token. A refresh token ends the whole sign-in and every token it issued, so
|
|
417
476
|
revoking it is how a program signs the machine out."""
|
|
@@ -439,9 +498,14 @@ class OauthApi:
|
|
|
439
498
|
# Refused before the first wait, not after it.
|
|
440
499
|
check_timeout(timeout)
|
|
441
500
|
interval = device.interval if device.interval >= 1 else 5
|
|
442
|
-
deadline
|
|
501
|
+
# Capped where a deadline stops meaning anything, since the clock is a float and
|
|
502
|
+
# `expires_in` is the server's integer, of any size.
|
|
503
|
+
deadline = self._clock.now() + min(device.expires_in, POLL_DEADLINE_CAP)
|
|
443
504
|
while True:
|
|
444
|
-
|
|
505
|
+
# No wait runs past the deadline: an interval ending after it waits only the
|
|
506
|
+
# time left, and the expiry follows with nothing sent. In full, an interval of
|
|
507
|
+
# 2147483647 held a poll with 2 s left for 68 years (2.6.0).
|
|
508
|
+
self._clock.sleep(min(interval, max(deadline - self._clock.now(), 0)))
|
|
445
509
|
if self._clock.now() >= deadline:
|
|
446
510
|
raise OauthExpiredTokenError()
|
|
447
511
|
try:
|
|
@@ -278,6 +278,17 @@ class DeviceAuthorization:
|
|
|
278
278
|
verification_uri_complete: str | None = None
|
|
279
279
|
|
|
280
280
|
|
|
281
|
+
@dataclass(frozen=True, slots=True)
|
|
282
|
+
class Pkce:
|
|
283
|
+
"""One sign-in's PKCE pair: `challenge` goes in the authorization URL, `verifier` to the
|
|
284
|
+
code exchange. The verifier is left out of the repr, so logging the pair does not leak it.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
verifier: str = field(repr=False)
|
|
288
|
+
challenge: str
|
|
289
|
+
method: Literal["S256"] = "S256"
|
|
290
|
+
|
|
291
|
+
|
|
281
292
|
@dataclass(frozen=True, slots=True)
|
|
282
293
|
class TokenResponse:
|
|
283
294
|
"""What a token exchange answers.
|
|
@@ -1215,6 +1215,72 @@
|
|
|
1215
1215
|
5
|
|
1216
1216
|
]
|
|
1217
1217
|
}
|
|
1218
|
+
},
|
|
1219
|
+
{
|
|
1220
|
+
"name": "interval-past-the-deadline-sleeps-only-to-it",
|
|
1221
|
+
"clientId": "internetdata-cli",
|
|
1222
|
+
"device": {
|
|
1223
|
+
"device_code": "mo_dc_poll",
|
|
1224
|
+
"user_code": "BCDF-GHJK",
|
|
1225
|
+
"verification_uri": "https://app.internetdata.io/device",
|
|
1226
|
+
"verification_uri_complete": "https://app.internetdata.io/device?user_code=BCDF-GHJK",
|
|
1227
|
+
"expires_in": 2,
|
|
1228
|
+
"interval": 2147483647
|
|
1229
|
+
},
|
|
1230
|
+
"why": "A sleep that would end past the local deadline ends at it, and the local expiry follows with no request sent: the outcome the full sleep reaches anyway, without asking the runtime for a sleep it may not hold (.NET's Task.Delay throws past ~49.7 days). 2147483647 is the largest interval every SDK's type holds.",
|
|
1231
|
+
"responses": [
|
|
1232
|
+
{
|
|
1233
|
+
"status": 400,
|
|
1234
|
+
"body": {
|
|
1235
|
+
"error": "authorization_pending"
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
],
|
|
1239
|
+
"expect": {
|
|
1240
|
+
"outcome": "expiredToken",
|
|
1241
|
+
"requests": 0,
|
|
1242
|
+
"waits": [
|
|
1243
|
+
2
|
|
1244
|
+
],
|
|
1245
|
+
"status": null
|
|
1246
|
+
}
|
|
1247
|
+
},
|
|
1248
|
+
{
|
|
1249
|
+
"name": "slow-down-past-the-deadline-sleeps-only-to-it",
|
|
1250
|
+
"clientId": "internetdata-cli",
|
|
1251
|
+
"device": {
|
|
1252
|
+
"device_code": "mo_dc_poll",
|
|
1253
|
+
"user_code": "BCDF-GHJK",
|
|
1254
|
+
"verification_uri": "https://app.internetdata.io/device",
|
|
1255
|
+
"verification_uri_complete": "https://app.internetdata.io/device?user_code=BCDF-GHJK",
|
|
1256
|
+
"expires_in": 8,
|
|
1257
|
+
"interval": 1
|
|
1258
|
+
},
|
|
1259
|
+
"why": "The cut applies to every sleep, a widened one included: 1, then 6 after the first slow_down, then only the 1 s left of the 8 rather than 11.",
|
|
1260
|
+
"responses": [
|
|
1261
|
+
{
|
|
1262
|
+
"status": 400,
|
|
1263
|
+
"body": {
|
|
1264
|
+
"error": "slow_down"
|
|
1265
|
+
}
|
|
1266
|
+
},
|
|
1267
|
+
{
|
|
1268
|
+
"status": 400,
|
|
1269
|
+
"body": {
|
|
1270
|
+
"error": "slow_down"
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
],
|
|
1274
|
+
"expect": {
|
|
1275
|
+
"outcome": "expiredToken",
|
|
1276
|
+
"requests": 2,
|
|
1277
|
+
"waits": [
|
|
1278
|
+
1,
|
|
1279
|
+
6,
|
|
1280
|
+
1
|
|
1281
|
+
],
|
|
1282
|
+
"status": null
|
|
1283
|
+
}
|
|
1218
1284
|
}
|
|
1219
1285
|
]
|
|
1220
1286
|
},
|
|
@@ -139,9 +139,26 @@ class OauthAdapter:
|
|
|
139
139
|
def exchange_refresh_token(self, client_id: str, refresh_token: str, **kwargs: Any) -> Any:
|
|
140
140
|
return self._call("exchange_refresh_token", client_id, refresh_token, **kwargs)
|
|
141
141
|
|
|
142
|
+
def exchange_authorization_code(
|
|
143
|
+
self, client_id: str, code: str, code_verifier: str, redirect_uri: str, **kwargs: Any
|
|
144
|
+
) -> Any:
|
|
145
|
+
return self._call(
|
|
146
|
+
"exchange_authorization_code", client_id, code, code_verifier, redirect_uri, **kwargs
|
|
147
|
+
)
|
|
148
|
+
|
|
142
149
|
def revoke(self, client_id: str, token: str, **kwargs: Any) -> Any:
|
|
143
150
|
return self._call("revoke", client_id, token, **kwargs)
|
|
144
151
|
|
|
152
|
+
# These three make no request, so they are plain methods on both clients.
|
|
153
|
+
def authorization_url(self, *args: Any, **kwargs: Any) -> Any:
|
|
154
|
+
return self._client.oauth.authorization_url(*args, **kwargs)
|
|
155
|
+
|
|
156
|
+
def create_pkce(self) -> Any:
|
|
157
|
+
return self._client.oauth.create_pkce()
|
|
158
|
+
|
|
159
|
+
def pkce_challenge(self, verifier: str) -> Any:
|
|
160
|
+
return self._client.oauth.pkce_challenge(verifier)
|
|
161
|
+
|
|
145
162
|
def poll_device_token(self, client_id: str, device: Any, **kwargs: Any) -> Any:
|
|
146
163
|
return self._call("poll_device_token", client_id, device, **kwargs)
|
|
147
164
|
|
|
@@ -357,6 +357,34 @@ def test_a_rate_limit_is_retried_and_honors_retry_after(make_client: ClientFacto
|
|
|
357
357
|
assert len(stub.requests) == 3
|
|
358
358
|
|
|
359
359
|
|
|
360
|
+
@pytest.mark.parametrize(
|
|
361
|
+
"retry_after", ["2147484", "9223372036854775807", "1e400", "Fri, 31 Dec 9999 23:59:59 GMT"]
|
|
362
|
+
)
|
|
363
|
+
def test_a_retry_after_past_the_ceiling_waits_the_backoff_and_stays_a_throttle(
|
|
364
|
+
make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, retry_after: str
|
|
365
|
+
) -> None:
|
|
366
|
+
"""As given, 2147484 held the call for 24.8 days, and the rest raised a raw
|
|
367
|
+
OverflowError from the sync client's sleep."""
|
|
368
|
+
monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
|
|
369
|
+
headers = {"Retry-After": retry_after}
|
|
370
|
+
stub = Stub({LIST_PATH: {"status": 429, "body": {"rc": "RATE_LIMITED"}, "headers": headers}})
|
|
371
|
+
client = make_client(transport=stub.transport, retries=1)
|
|
372
|
+
|
|
373
|
+
outcome = settle(client.database.list, within=5.0)
|
|
374
|
+
|
|
375
|
+
assert isinstance(outcome, InternetDataError), f"settled with {outcome!r}"
|
|
376
|
+
assert outcome.kind == "rate_limited"
|
|
377
|
+
assert len(stub.requests) == 2
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_a_retry_after_is_waited_as_given_up_to_the_ceiling() -> None:
|
|
381
|
+
def throttled(seconds: float) -> InternetDataError:
|
|
382
|
+
return InternetDataError("rate_limited", "RATE_LIMITED", 429, seconds)
|
|
383
|
+
|
|
384
|
+
assert _core.retry_delay(throttled(2147483.647), 0, 1) == 2147483.647
|
|
385
|
+
assert _core.retry_delay(throttled(2147483.648), 0, 1) == _core._BACKOFF_BASE
|
|
386
|
+
|
|
387
|
+
|
|
360
388
|
def test_a_transport_failure_surfaces_as_a_network_error() -> None:
|
|
361
389
|
def refuse(request: httpx.Request) -> httpx.Response:
|
|
362
390
|
raise httpx.ConnectError("connection refused", request=request)
|
|
@@ -441,7 +469,7 @@ def test_a_per_call_timeout_bounds_a_trickling_body_and_leaves_the_clients_own_a
|
|
|
441
469
|
_assert_bounded_by(default, CLIENT_TIMEOUT)
|
|
442
470
|
|
|
443
471
|
|
|
444
|
-
@pytest.mark.parametrize("timeout", [0, -1, float("nan"), float("inf"), "30", True])
|
|
472
|
+
@pytest.mark.parametrize("timeout", [0, -1, float("nan"), float("inf"), 1e300, "30", True])
|
|
445
473
|
@pytest.mark.parametrize("call", PER_CALL)
|
|
446
474
|
def test_a_per_call_timeout_no_attempt_can_meet_is_refused_before_any_request(
|
|
447
475
|
make_client: ClientFactory, call: str, timeout: Any
|
|
@@ -494,7 +522,9 @@ def test_the_default_timeout_is_thirty_seconds() -> None:
|
|
|
494
522
|
|
|
495
523
|
|
|
496
524
|
@pytest.mark.parametrize("client", [InternetData, AsyncInternetData])
|
|
497
|
-
@pytest.mark.parametrize(
|
|
525
|
+
@pytest.mark.parametrize(
|
|
526
|
+
"timeout", [0, -1, 0.0, float("nan"), float("inf"), 9223372037, 1e300, "30", True]
|
|
527
|
+
)
|
|
498
528
|
def test_a_timeout_no_attempt_can_meet_is_refused_when_the_client_is_built(
|
|
499
529
|
client: type[InternetData | AsyncInternetData], timeout: Any
|
|
500
530
|
) -> None:
|
|
@@ -503,7 +533,7 @@ def test_a_timeout_no_attempt_can_meet_is_refused_when_the_client_is_built(
|
|
|
503
533
|
client(API_KEY, timeout=timeout)
|
|
504
534
|
|
|
505
535
|
|
|
506
|
-
@pytest.mark.parametrize("timeout", [None, 0.25, 1, 30])
|
|
536
|
+
@pytest.mark.parametrize("timeout", [None, 0.25, 1, 30, 9223372036])
|
|
507
537
|
def test_a_usable_timeout_builds_a_client(timeout: float | None) -> None:
|
|
508
538
|
InternetData(API_KEY, timeout=timeout).close()
|
|
509
539
|
asyncio.run(AsyncInternetData(API_KEY, timeout=timeout).aclose())
|
|
@@ -326,6 +326,32 @@ def test_a_storage_5xx_before_the_body_is_retried(
|
|
|
326
326
|
assert outcome == SMALL
|
|
327
327
|
|
|
328
328
|
|
|
329
|
+
@pytest.mark.parametrize("retry_after", ["2147484", "9223372036854775807"])
|
|
330
|
+
def test_a_storage_retry_after_past_the_ceiling_waits_the_backoff(
|
|
331
|
+
make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, retry_after: str
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Object storage's 429 is bounded like the API's: as given, one held the transfer for
|
|
334
|
+
24.8 days, and the other raised a raw OverflowError from the sync client's sleep."""
|
|
335
|
+
monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
|
|
336
|
+
requests: list[httpx.Request] = []
|
|
337
|
+
|
|
338
|
+
def handle(request: httpx.Request) -> httpx.Response:
|
|
339
|
+
requests.append(request)
|
|
340
|
+
if request.url.path == DOWNLOAD_PATH:
|
|
341
|
+
return httpx.Response(302, headers={"Location": BLOB})
|
|
342
|
+
return httpx.Response(429, headers={"Retry-After": retry_after})
|
|
343
|
+
|
|
344
|
+
client = make_client(
|
|
345
|
+
api_key=API_KEY, base_url=API, transport=httpx.MockTransport(handle), retries=1
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
outcome = settle(lambda: client.database.download_bytes("bogon_ip_v1", "csvgz"), within=5.0)
|
|
349
|
+
|
|
350
|
+
assert [r.url.path for r in requests].count("/blob") == 2, "storage's 429 and its retry"
|
|
351
|
+
assert isinstance(outcome, InternetDataError), f"settled with {outcome!r}"
|
|
352
|
+
assert outcome.kind == "rate_limited"
|
|
353
|
+
|
|
354
|
+
|
|
329
355
|
@pytest.mark.parametrize("method", ["download", "download_bytes"])
|
|
330
356
|
def test_a_transfer_that_dies_part_way_is_not_fetched_again(
|
|
331
357
|
make_client: ClientFactory, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, method: str
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""The `oauth` accessor against the shared corpus's oauth section, on both clients.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The authorization code flow's vectors, under `oauth.deferred`, are read here too. Every call
|
|
4
4
|
under test runs through `settle`, so a loop that never ends fails its test instead of
|
|
5
5
|
hanging the suite.
|
|
6
6
|
"""
|
|
@@ -8,6 +8,7 @@ hanging the suite.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
|
+
import re
|
|
11
12
|
import time
|
|
12
13
|
import urllib.parse
|
|
13
14
|
from collections.abc import Iterator
|
|
@@ -82,7 +83,9 @@ def test_no_oauth_request_carries_the_api_key(make_client: ClientFactory) -> Non
|
|
|
82
83
|
outcome = settle(lambda: _every_operation(client.oauth), bound)
|
|
83
84
|
|
|
84
85
|
assert outcome is None, f"settled with {outcome!r}"
|
|
85
|
-
assert len(stub.requests) ==
|
|
86
|
+
assert len(stub.requests) == 7
|
|
87
|
+
url = client.oauth.authorization_url("internetdata-cli", "http://127.0.0.1/cb", "c" * 43)
|
|
88
|
+
assert key not in url, "the authorization URL carried the API key"
|
|
86
89
|
for req in stub.requests:
|
|
87
90
|
label = f"{req.method} {req.url.path}"
|
|
88
91
|
for name in case["forbiddenHeaders"]:
|
|
@@ -98,7 +101,9 @@ def test_no_oauth_request_carries_the_api_key(make_client: ClientFactory) -> Non
|
|
|
98
101
|
assert stub.requests[-1].headers["authorization"] == f"Bearer {key}"
|
|
99
102
|
|
|
100
103
|
|
|
101
|
-
@pytest.mark.parametrize(
|
|
104
|
+
@pytest.mark.parametrize(
|
|
105
|
+
"case", CORPUS["forms"]["cases"] + CORPUS["deferred"]["forms"], ids=lambda case: case["name"]
|
|
106
|
+
)
|
|
102
107
|
def test_each_operation_requests_its_endpoint_with_exactly_its_form_fields(
|
|
103
108
|
make_client: ClientFactory, case: dict[str, Any]
|
|
104
109
|
) -> None:
|
|
@@ -244,7 +249,11 @@ def test_a_failed_answer_is_an_oauth_refusal_only_when_it_is_one(
|
|
|
244
249
|
_assert_outcome(outcome, case["expect"], case["name"])
|
|
245
250
|
|
|
246
251
|
|
|
247
|
-
@pytest.mark.parametrize(
|
|
252
|
+
@pytest.mark.parametrize(
|
|
253
|
+
"case",
|
|
254
|
+
CORPUS["retries"]["cases"] + CORPUS["deferred"]["retries"],
|
|
255
|
+
ids=lambda case: case["name"],
|
|
256
|
+
)
|
|
248
257
|
def test_only_what_consumes_nothing_is_retried(
|
|
249
258
|
make_client: ClientFactory, case: dict[str, Any]
|
|
250
259
|
) -> None:
|
|
@@ -296,6 +305,92 @@ def test_poll_device_token_waits_widens_and_ends_as_the_corpus_says(
|
|
|
296
305
|
_assert_outcome(outcome, {**expect, "type": expect["outcome"]}, case["name"])
|
|
297
306
|
|
|
298
307
|
|
|
308
|
+
def test_the_poll_sleeps_the_fraction_left_before_its_deadline(make_client: ClientFactory) -> None:
|
|
309
|
+
"""On the real clock, which the corpus's seam replaces. The second wait is the ~0.99 s
|
|
310
|
+
left, so the deadline passes with one request sent; a sleep that dropped the fraction
|
|
311
|
+
would poll again at once, and this stub approves that second poll."""
|
|
312
|
+
bound = LoopBound()
|
|
313
|
+
replies = [
|
|
314
|
+
{"status": 400, "body": {"error": "authorization_pending"}},
|
|
315
|
+
{"status": 200, "body": EVERY_REQUIRED_MEMBER},
|
|
316
|
+
]
|
|
317
|
+
stub = OauthStub(replies, bound, limit=2)
|
|
318
|
+
client = make_client(base_url=BASE_URL, transport=stub.transport)
|
|
319
|
+
device = DeviceAuthorization(
|
|
320
|
+
device_code="mo_dc_x",
|
|
321
|
+
user_code="BCDF-GHJK",
|
|
322
|
+
verification_uri="https://app.example.test/device",
|
|
323
|
+
expires_in=2,
|
|
324
|
+
interval=1,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
outcome = settle(lambda: client.oauth.poll_device_token("internetdata-cli", device), bound)
|
|
328
|
+
|
|
329
|
+
assert isinstance(outcome, OauthExpiredTokenError), f"settled with {outcome!r}"
|
|
330
|
+
assert outcome.status is None, "the local deadline, not the server's"
|
|
331
|
+
assert len(stub.requests) == 1
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_the_real_clock_sleeps_past_what_time_sleep_can_count_in_parts(
|
|
335
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
336
|
+
) -> None:
|
|
337
|
+
"""`time.sleep` raises OverflowError past WAIT_CEILING, and a poll's wait is the server's
|
|
338
|
+
`interval` or `expires_in`, of any size."""
|
|
339
|
+
waits: list[float] = []
|
|
340
|
+
monkeypatch.setattr(time, "sleep", waits.append)
|
|
341
|
+
|
|
342
|
+
_core.Clock().sleep(_core.WAIT_CEILING * 2.5)
|
|
343
|
+
|
|
344
|
+
assert waits == [_core.WAIT_CEILING, _core.WAIT_CEILING, _core.WAIT_CEILING / 2]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_a_poll_whose_request_outlives_the_deadline_waits_nothing_more(
|
|
348
|
+
make_client: ClientFactory,
|
|
349
|
+
) -> None:
|
|
350
|
+
"""The request itself takes the clock 10 s past the deadline, so the time left is
|
|
351
|
+
negative, and `time.sleep` raises ValueError for a negative wait."""
|
|
352
|
+
bound = LoopBound()
|
|
353
|
+
clock = FakeClock(bound)
|
|
354
|
+
stub = OauthStub([{"status": 400, "body": {"error": "authorization_pending"}}], bound, limit=1)
|
|
355
|
+
|
|
356
|
+
def slow(request: httpx.Request) -> httpx.Response:
|
|
357
|
+
clock._elapsed += 10
|
|
358
|
+
return stub._handle(request)
|
|
359
|
+
|
|
360
|
+
client = make_client(base_url=BASE_URL, transport=httpx.MockTransport(slow))
|
|
361
|
+
client.oauth.use_clock(clock)
|
|
362
|
+
device = DeviceAuthorization(**{**_device_fields(), "expires_in": 5, "interval": 1})
|
|
363
|
+
|
|
364
|
+
outcome = settle(lambda: client.oauth.poll_device_token("internetdata-cli", device), bound)
|
|
365
|
+
|
|
366
|
+
assert isinstance(outcome, OauthExpiredTokenError), f"settled with {outcome!r}"
|
|
367
|
+
assert clock.waits == [1, 0]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_an_expires_in_past_any_float_still_polls(make_client: ClientFactory) -> None:
|
|
371
|
+
"""The server's integer, of any size: added to the clock whole, 10**400 raised a raw
|
|
372
|
+
OverflowError before the first wait."""
|
|
373
|
+
bound = LoopBound()
|
|
374
|
+
stub = OauthStub([{"status": 200, "body": EVERY_REQUIRED_MEMBER}], bound, limit=1)
|
|
375
|
+
client = make_client(base_url=BASE_URL, transport=stub.transport)
|
|
376
|
+
clock = FakeClock(bound)
|
|
377
|
+
client.oauth.use_clock(clock)
|
|
378
|
+
device = DeviceAuthorization(**{**_device_fields(), "expires_in": 10**400, "interval": 1})
|
|
379
|
+
|
|
380
|
+
outcome = settle(lambda: client.oauth.poll_device_token("internetdata-cli", device), bound)
|
|
381
|
+
|
|
382
|
+
assert isinstance(outcome, TokenResponse), f"settled with {outcome!r}"
|
|
383
|
+
assert clock.waits == [1]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _device_fields() -> dict[str, Any]:
|
|
387
|
+
return {
|
|
388
|
+
"device_code": "mo_dc_x",
|
|
389
|
+
"user_code": "BCDF-GHJK",
|
|
390
|
+
"verification_uri": "https://app.example.test/device",
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
299
394
|
def test_cancelling_a_poll_during_its_first_wait_ends_it_at_once() -> None:
|
|
300
395
|
bound = LoopBound()
|
|
301
396
|
stub = OauthStub([{"status": 400, "body": {"error": "authorization_pending"}}], bound, limit=1)
|
|
@@ -319,11 +414,68 @@ def test_cancelling_a_poll_during_its_first_wait_ends_it_at_once() -> None:
|
|
|
319
414
|
assert stub.requests == [], "no request after the cancel"
|
|
320
415
|
|
|
321
416
|
|
|
417
|
+
@pytest.mark.parametrize(
|
|
418
|
+
"case", CORPUS["deferred"]["authorizationUrl"], ids=lambda case: case["name"]
|
|
419
|
+
)
|
|
420
|
+
def test_an_authorization_url_is_built_exactly_as_the_corpus_spells_it(
|
|
421
|
+
make_client: ClientFactory, case: dict[str, Any]
|
|
422
|
+
) -> None:
|
|
423
|
+
stub = OauthStub([{"status": 200, "body": EVERY_REQUIRED_MEMBER}], LoopBound())
|
|
424
|
+
client = make_client(base_url=case["baseUrl"], transport=stub.transport)
|
|
425
|
+
|
|
426
|
+
url = client.oauth.authorization_url(
|
|
427
|
+
case["clientId"],
|
|
428
|
+
case["redirectUri"],
|
|
429
|
+
case["codeChallenge"],
|
|
430
|
+
scope=case.get("scope"),
|
|
431
|
+
state=case.get("state"),
|
|
432
|
+
resource=case.get("resource"),
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
assert url == case["expect"]
|
|
436
|
+
assert stub.requests == [], "requests sent"
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_an_authorization_url_leaves_out_an_empty_option_and_refuses_an_empty_value(
|
|
440
|
+
make_client: ClientFactory,
|
|
441
|
+
) -> None:
|
|
442
|
+
case = CORPUS["deferred"]["authorizationUrl"][0]
|
|
443
|
+
oauth = make_client(base_url=BASE_URL).oauth
|
|
444
|
+
args = (case["clientId"], case["redirectUri"], case["codeChallenge"])
|
|
445
|
+
|
|
446
|
+
url = oauth.authorization_url(*args, scope="", state="", resource="")
|
|
447
|
+
|
|
448
|
+
assert url == case["expect"].replace(case["baseUrl"], BASE_URL)
|
|
449
|
+
with pytest.raises(ValueError, match="client_id"):
|
|
450
|
+
oauth.authorization_url("", case["redirectUri"], case["codeChallenge"])
|
|
451
|
+
with pytest.raises(ValueError, match="code_challenge"):
|
|
452
|
+
oauth.authorization_url(case["clientId"], case["redirectUri"], "\ud800")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def test_a_pkce_pair_is_fresh_and_its_challenge_is_the_s256_one(
|
|
456
|
+
make_client: ClientFactory,
|
|
457
|
+
) -> None:
|
|
458
|
+
pkce = CORPUS["deferred"]["pkce"]
|
|
459
|
+
oauth = make_client().oauth
|
|
460
|
+
|
|
461
|
+
first = oauth.create_pkce()
|
|
462
|
+
|
|
463
|
+
assert oauth.pkce_challenge(pkce["verifier"]) == pkce["challenge"]
|
|
464
|
+
assert re.fullmatch(pkce["generatedVerifierPattern"], first.verifier)
|
|
465
|
+
assert first.challenge == oauth.pkce_challenge(first.verifier)
|
|
466
|
+
assert first.method == pkce["method"]
|
|
467
|
+
assert oauth.create_pkce().verifier != first.verifier, "two pairs share a verifier"
|
|
468
|
+
assert first.verifier not in repr(first)
|
|
469
|
+
|
|
470
|
+
|
|
322
471
|
def _every_operation(oauth: OauthAdapter) -> None:
|
|
323
472
|
oauth.metadata()
|
|
324
473
|
oauth.device_authorization("internetdata-cli", scope="account.read")
|
|
325
474
|
oauth.exchange_device_code("internetdata-cli", "mo_dc_x")
|
|
326
475
|
oauth.exchange_refresh_token("internetdata-cli", "mo_rt_x")
|
|
476
|
+
oauth.exchange_authorization_code(
|
|
477
|
+
"internetdata-cli", "mo_ac_x", "v" * 43, "http://127.0.0.1/cb"
|
|
478
|
+
)
|
|
327
479
|
oauth.revoke("internetdata-cli", "mo_rt_x")
|
|
328
480
|
oauth.poll_device_token("internetdata-cli", DEVICE)
|
|
329
481
|
|
|
@@ -348,6 +500,10 @@ def _call(oauth: OauthAdapter, operation: str, args: dict[str, str]) -> Any:
|
|
|
348
500
|
return oauth.exchange_device_code(args["clientId"], args["deviceCode"])
|
|
349
501
|
if operation == "exchangeRefreshToken":
|
|
350
502
|
return oauth.exchange_refresh_token(args["clientId"], args["refreshToken"])
|
|
503
|
+
if operation == "exchangeAuthorizationCode":
|
|
504
|
+
return oauth.exchange_authorization_code(
|
|
505
|
+
args["clientId"], args["code"], args["codeVerifier"], args["redirectUri"]
|
|
506
|
+
)
|
|
351
507
|
if operation == "revoke":
|
|
352
508
|
return oauth.revoke(args["clientId"], args["token"])
|
|
353
509
|
raise AssertionError(f"the corpus names an operation this suite does not know: {operation}")
|
|
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.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/account/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/organization/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_org_ref.py
RENAMED
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_org_wrap.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_user.py
RENAMED
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/apikey_detail.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_format.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_version.py
RENAMED
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/db_checksums.py
RENAMED
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/device_authorization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/download_outcome.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_metadata.py
RENAMED
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/revoke_request.py
RENAMED
|
File without changes
|
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/token_request.py
RENAMED
|
File without changes
|
{internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/token_response.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|