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.
Files changed (95) hide show
  1. {internetdata-2.5.0 → internetdata-2.6.1}/PKG-INFO +21 -1
  2. {internetdata-2.5.0 → internetdata-2.6.1}/README.md +20 -0
  3. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/__init__.py +3 -1
  4. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_core.py +86 -8
  5. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/aio.py +61 -2
  6. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/client.py +67 -3
  7. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/models.py +11 -0
  8. {internetdata-2.5.0 → internetdata-2.6.1}/testdata/testdata.json +66 -0
  9. {internetdata-2.5.0 → internetdata-2.6.1}/tests/helpers.py +17 -0
  10. {internetdata-2.5.0 → internetdata-2.6.1}/tests/test_client.py +33 -3
  11. {internetdata-2.5.0 → internetdata-2.6.1}/tests/test_download.py +26 -0
  12. {internetdata-2.5.0 → internetdata-2.6.1}/tests/test_oauth.py +160 -4
  13. {internetdata-2.5.0 → internetdata-2.6.1}/.gitignore +0 -0
  14. {internetdata-2.5.0 → internetdata-2.6.1}/LICENSE +0 -0
  15. {internetdata-2.5.0 → internetdata-2.6.1}/pyproject.toml +0 -0
  16. {internetdata-2.5.0 → internetdata-2.6.1}/scripts/download-spec.sh +0 -0
  17. {internetdata-2.5.0 → internetdata-2.6.1}/scripts/generate.sh +0 -0
  18. {internetdata-2.5.0 → internetdata-2.6.1}/scripts/publish.sh +0 -0
  19. {internetdata-2.5.0 → internetdata-2.6.1}/scripts/v2_subset.py +0 -0
  20. {internetdata-2.5.0 → internetdata-2.6.1}/spec/openapi.yaml +0 -0
  21. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/__init__.py +0 -0
  22. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/__init__.py +0 -0
  23. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/account/__init__.py +0 -0
  24. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/account/account_identity.py +0 -0
  25. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/__init__.py +0 -0
  26. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_create_apikey.py +0 -0
  27. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_list_apikeys.py +0 -0
  28. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_reveal_apikey.py +0 -0
  29. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_revoke_apikey.py +0 -0
  30. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_rotate_apikey.py +0 -0
  31. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/__init__.py +0 -0
  32. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_authorize.py +0 -0
  33. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_device_authorization.py +0 -0
  34. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_metadata.py +0 -0
  35. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_revoke.py +0 -0
  36. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_token.py +0 -0
  37. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
  38. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
  39. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
  40. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
  41. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
  42. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
  43. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/organization/__init__.py +0 -0
  44. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/organization/account_org.py +0 -0
  45. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/api/organization/account_org_members.py +0 -0
  46. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/client.py +0 -0
  47. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/errors.py +0 -0
  48. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/__init__.py +0 -0
  49. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_create_apikey_request.py +0 -0
  50. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_created_apikey.py +0 -0
  51. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_org.py +0 -0
  52. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_org_ref.py +0 -0
  53. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_org_wrap.py +0 -0
  54. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_rc.py +0 -0
  55. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_revealed_apikey.py +0 -0
  56. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_user.py +0 -0
  57. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/apikey_detail.py +0 -0
  58. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/apikey_list.py +0 -0
  59. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database.py +0 -0
  60. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
  61. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_format.py +0 -0
  62. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
  63. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
  64. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
  65. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata.py +0 -0
  66. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
  67. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
  68. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
  69. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_sample_size.py +0 -0
  70. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
  71. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
  72. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_version.py +0 -0
  73. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/db_checksums.py +0 -0
  74. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/device_authorization.py +0 -0
  75. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/device_authorization_request.py +0 -0
  76. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/download.py +0 -0
  77. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/download_outcome.py +0 -0
  78. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/error.py +0 -0
  79. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/identity.py +0 -0
  80. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
  81. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
  82. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_authorize_code_challenge_method.py +0 -0
  83. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_authorize_response_type.py +0 -0
  84. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_error.py +0 -0
  85. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_metadata.py +0 -0
  86. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_revoke_response_200.py +0 -0
  87. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/revoke_request.py +0 -0
  88. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/standing.py +0 -0
  89. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/token_request.py +0 -0
  90. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/models/token_response.py +0 -0
  91. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/_generated/types.py +0 -0
  92. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/errors.py +0 -0
  93. {internetdata-2.5.0 → internetdata-2.6.1}/src/internetdata/py.typed +0 -0
  94. {internetdata-2.5.0 → internetdata-2.6.1}/tests/conftest.py +0 -0
  95. {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.5.0
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.5.0"
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. Infinity is refused too, since the sync client's wait
123
- cannot hold it (`OverflowError`); None is the spelling for no bound.
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, or None for no bound, "
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(seconds)
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 = self._clock.now() + device.expires_in
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
- await self._clock.sleep(interval)
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 = self._clock.now() + device.expires_in
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
- self._clock.sleep(interval)
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("timeout", [0, -1, 0.0, float("nan"), float("inf"), "30", True])
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
- Nothing here reads `oauth.deferred`: those operations are not in this release. Every call
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) == 6
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("case", CORPUS["forms"]["cases"], ids=lambda case: case["name"])
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("case", CORPUS["retries"]["cases"], ids=lambda case: case["name"])
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