internetdata 2.8.0__tar.gz → 2.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. {internetdata-2.8.0 → internetdata-2.9.0}/PKG-INFO +15 -1
  2. {internetdata-2.8.0 → internetdata-2.9.0}/README.md +14 -0
  3. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/__init__.py +3 -1
  4. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_core.py +36 -5
  5. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/aio.py +29 -4
  6. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/client.py +39 -4
  7. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/models.py +35 -0
  8. {internetdata-2.8.0 → internetdata-2.9.0}/testdata/testdata.json +491 -0
  9. {internetdata-2.8.0 → internetdata-2.9.0}/tests/helpers.py +3 -0
  10. {internetdata-2.8.0 → internetdata-2.9.0}/tests/test_oauth.py +123 -1
  11. {internetdata-2.8.0 → internetdata-2.9.0}/.gitignore +0 -0
  12. {internetdata-2.8.0 → internetdata-2.9.0}/LICENSE +0 -0
  13. {internetdata-2.8.0 → internetdata-2.9.0}/pyproject.toml +0 -0
  14. {internetdata-2.8.0 → internetdata-2.9.0}/scripts/download-spec.sh +0 -0
  15. {internetdata-2.8.0 → internetdata-2.9.0}/scripts/generate.sh +0 -0
  16. {internetdata-2.8.0 → internetdata-2.9.0}/scripts/publish.sh +0 -0
  17. {internetdata-2.8.0 → internetdata-2.9.0}/scripts/v2_subset.py +0 -0
  18. {internetdata-2.8.0 → internetdata-2.9.0}/spec/openapi.yaml +0 -0
  19. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/__init__.py +0 -0
  20. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/__init__.py +0 -0
  21. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/account/__init__.py +0 -0
  22. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/account/account_identity.py +0 -0
  23. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/api_keys/__init__.py +0 -0
  24. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/api_keys/account_create_apikey.py +0 -0
  25. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/api_keys/account_list_apikeys.py +0 -0
  26. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/api_keys/account_reveal_apikey.py +0 -0
  27. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/api_keys/account_revoke_apikey.py +0 -0
  28. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/api_keys/account_rotate_apikey.py +0 -0
  29. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/authorization/__init__.py +0 -0
  30. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/authorization/oauth_authorize.py +0 -0
  31. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/authorization/oauth_device_authorization.py +0 -0
  32. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/authorization/oauth_metadata.py +0 -0
  33. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/authorization/oauth_register.py +0 -0
  34. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/authorization/oauth_revoke.py +0 -0
  35. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/authorization/oauth_token.py +0 -0
  36. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
  37. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
  38. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
  39. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
  40. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
  41. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
  42. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/organization/__init__.py +0 -0
  43. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/organization/account_org.py +0 -0
  44. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/api/organization/account_org_members.py +0 -0
  45. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/client.py +0 -0
  46. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/errors.py +0 -0
  47. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/__init__.py +0 -0
  48. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/account_create_apikey_request.py +0 -0
  49. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/account_created_apikey.py +0 -0
  50. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/account_org.py +0 -0
  51. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/account_org_ref.py +0 -0
  52. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/account_org_wrap.py +0 -0
  53. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/account_rc.py +0 -0
  54. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/account_revealed_apikey.py +0 -0
  55. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/account_user.py +0 -0
  56. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/apikey_detail.py +0 -0
  57. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/apikey_list.py +0 -0
  58. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/client_registration.py +0 -0
  59. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/client_registration_request.py +0 -0
  60. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database.py +0 -0
  61. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
  62. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database_format.py +0 -0
  63. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
  64. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
  65. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
  66. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database_metadata.py +0 -0
  67. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
  68. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
  69. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
  70. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database_metadata_sample_size.py +0 -0
  71. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
  72. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
  73. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/database_version.py +0 -0
  74. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/db_checksums.py +0 -0
  75. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/device_authorization.py +0 -0
  76. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/device_authorization_request.py +0 -0
  77. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/download.py +0 -0
  78. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/download_outcome.py +0 -0
  79. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/error.py +0 -0
  80. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/identity.py +0 -0
  81. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
  82. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
  83. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/oauth_authorize_code_challenge_method.py +0 -0
  84. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/oauth_authorize_response_type.py +0 -0
  85. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/oauth_error.py +0 -0
  86. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/oauth_metadata.py +0 -0
  87. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/oauth_revoke_response_200.py +0 -0
  88. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/revoke_request.py +0 -0
  89. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/standing.py +0 -0
  90. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/token_request.py +0 -0
  91. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/models/token_response.py +0 -0
  92. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/_generated/types.py +0 -0
  93. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/errors.py +0 -0
  94. {internetdata-2.8.0 → internetdata-2.9.0}/src/internetdata/py.typed +0 -0
  95. {internetdata-2.8.0 → internetdata-2.9.0}/tests/conftest.py +0 -0
  96. {internetdata-2.8.0 → internetdata-2.9.0}/tests/test_client.py +0 -0
  97. {internetdata-2.8.0 → internetdata-2.9.0}/tests/test_conformance.py +0 -0
  98. {internetdata-2.8.0 → internetdata-2.9.0}/tests/test_download.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: internetdata
3
- Version: 2.8.0
3
+ Version: 2.9.0
4
4
  Summary: Official Python client library for the InternetData API. Download and verify licensed IP datasets.
5
5
  Project-URL: Homepage, https://internetdata.io
6
6
  Project-URL: Documentation, https://docs.internetdata.io
@@ -223,6 +223,20 @@ tokens = signin.oauth.exchange_authorization_code("your-client-id", code, pkce.v
223
223
 
224
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
225
 
226
+ ### Register a client
227
+
228
+ An app with no client ID, and no client metadata document to use as one, registers itself and signs in with the `client_id` it gets back:
229
+
230
+ ```python
231
+ client = InternetData()
232
+ registration = client.oauth.register(
233
+ ["http://127.0.0.1:8765/callback"], client_name="Your App"
234
+ )
235
+ # registration.client_id is the client ID for authorization_url and exchange_authorization_code.
236
+ ```
237
+
238
+ Each redirect URI must be https on a public host name, or http on a loopback address, which then matches any port. Nothing is kept until a person approves the client, so an app with no client ID saved can simply register again.
239
+
226
240
  ## Other Libraries
227
241
 
228
242
  There are official InternetData client libraries available for many languages including PHP, Python, Go, Java, and Ruby. See our GitHub at https://github.com/internetdata for more.
@@ -189,6 +189,20 @@ tokens = signin.oauth.exchange_authorization_code("your-client-id", code, pkce.v
189
189
 
190
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
191
 
192
+ ### Register a client
193
+
194
+ An app with no client ID, and no client metadata document to use as one, registers itself and signs in with the `client_id` it gets back:
195
+
196
+ ```python
197
+ client = InternetData()
198
+ registration = client.oauth.register(
199
+ ["http://127.0.0.1:8765/callback"], client_name="Your App"
200
+ )
201
+ # registration.client_id is the client ID for authorization_url and exchange_authorization_code.
202
+ ```
203
+
204
+ Each redirect URI must be https on a public host name, or http on a loopback address, which then matches any port. Nothing is kept until a person approves the client, so an app with no client ID saved can simply register again.
205
+
192
206
  ## Other Libraries
193
207
 
194
208
  There are official InternetData client libraries available for many languages including PHP, Python, Go, Java, and Ruby. See our GitHub at https://github.com/internetdata for more.
@@ -24,6 +24,7 @@ from .models import (
24
24
  DATABASE_FORMATS,
25
25
  LICENSE_TYPES,
26
26
  STANDINGS,
27
+ ClientRegistration,
27
28
  Database,
28
29
  DatabaseMetadata,
29
30
  DatabaseVersion,
@@ -39,7 +40,7 @@ from .models import (
39
40
  TokenResponse,
40
41
  )
41
42
 
42
- __version__ = "2.8.0"
43
+ __version__ = "2.9.0"
43
44
 
44
45
  __all__ = [
45
46
  "DATABASE_FORMATS",
@@ -49,6 +50,7 @@ __all__ = [
49
50
  "AsyncDatabaseApi",
50
51
  "AsyncInternetData",
51
52
  "AsyncOauthApi",
53
+ "ClientRegistration",
52
54
  "Database",
53
55
  "DatabaseApi",
54
56
  "DatabaseMetadata",
@@ -14,7 +14,7 @@ import os
14
14
  import secrets
15
15
  import threading
16
16
  import time
17
- from collections.abc import Callable, Iterator
17
+ from collections.abc import Callable, Iterator, Sequence
18
18
  from pathlib import Path
19
19
  from types import ModuleType
20
20
  from typing import IO, Any, TypeVar, cast
@@ -36,6 +36,7 @@ OAUTH_DEVICE_AUTHORIZATION_PATH = "/oauth/device_authorization"
36
36
  OAUTH_TOKEN_PATH = "/oauth/token"
37
37
  OAUTH_REVOKE_PATH = "/oauth/revoke"
38
38
  OAUTH_AUTHORIZE_PATH = "/oauth/authorize"
39
+ OAUTH_REGISTER_PATH = "/oauth/register"
39
40
  DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
40
41
 
41
42
  # One chunk of a transfer, and therefore the ceiling on what a download of any size
@@ -348,10 +349,13 @@ def oauth_request(
348
349
  path: str,
349
350
  form: dict[str, str] | None,
350
351
  bound: float | None,
352
+ body: dict[str, Any] | None = None,
351
353
  ) -> httpx.Response:
352
- """One attempt at an OAuth endpoint, carrying no credential, its failure raised."""
354
+ """One attempt at an OAuth endpoint, carrying no credential, its failure raised. `body`
355
+ goes as JSON, for `register`; every other one sends a form or nothing."""
353
356
  http = client.get_httpx_client()
354
- return oauth_checked(exchange(http, _oauth_build(http, method, path, form, bound), bound))
357
+ req = _oauth_build(http, method, path, form, bound, body)
358
+ return oauth_checked(exchange(http, req, bound))
355
359
 
356
360
 
357
361
  async def oauth_request_async(
@@ -360,13 +364,39 @@ async def oauth_request_async(
360
364
  path: str,
361
365
  form: dict[str, str] | None,
362
366
  bound: float | None,
367
+ body: dict[str, Any] | None = None,
363
368
  ) -> httpx.Response:
364
369
  """`oauth_request`, awaited."""
365
370
  http = client.get_async_httpx_client()
366
- req = _oauth_build(http, method, path, form, bound)
371
+ req = _oauth_build(http, method, path, form, bound, body)
367
372
  return oauth_checked(await exchange_async(http, req, bound))
368
373
 
369
374
 
375
+ def registration_body(redirect_uris: Sequence[str], client_name: str | None) -> dict[str, Any]:
376
+ """`register`'s JSON body: the redirect URIs in the order given, then `client_name`
377
+ unless it is None or empty. No URI at all, an empty one, or one with no UTF-8 (a lone
378
+ surrogate) is refused before anything is sent."""
379
+ if isinstance(redirect_uris, str) or not redirect_uris:
380
+ raise ValueError(f"redirect_uris must list at least one URI, not {redirect_uris!r}")
381
+ uris = list(redirect_uris)
382
+ for i, uri in enumerate(uris):
383
+ _check_text(f"redirect_uris[{i}]", uri)
384
+ body: dict[str, Any] = {"redirect_uris": uris}
385
+ if client_name:
386
+ _check_text("client_name", client_name)
387
+ body["client_name"] = client_name
388
+ return body
389
+
390
+
391
+ def _check_text(name: str, value: Any) -> None:
392
+ if not isinstance(value, str) or value == "":
393
+ raise ValueError(f"{name} must be a non-empty string, not {value!r}")
394
+ try:
395
+ value.encode("utf-8")
396
+ except UnicodeEncodeError as exc:
397
+ raise ValueError(f"{name} has no UTF-8 to send: {value!r}") from exc
398
+
399
+
370
400
  def authorization_url(
371
401
  base_url: str,
372
402
  client_id: str,
@@ -571,8 +601,9 @@ def _oauth_build(
571
601
  path: str,
572
602
  form: dict[str, str] | None,
573
603
  bound: float | None,
604
+ body: dict[str, Any] | None = None,
574
605
  ) -> httpx.Request:
575
- req = http.build_request(method, path, data=form, timeout=httpx.Timeout(bound))
606
+ req = http.build_request(method, path, data=form, json=body, timeout=httpx.Timeout(bound))
576
607
  req.headers.pop("authorization", None)
577
608
  return req
578
609
 
@@ -10,9 +10,9 @@ from __future__ import annotations
10
10
  import asyncio
11
11
  import builtins
12
12
  import os
13
- from collections.abc import Awaitable, Callable
13
+ from collections.abc import Awaitable, Callable, Sequence
14
14
  from types import TracebackType
15
- from typing import Self, TypeVar
15
+ from typing import Any, Self, TypeVar
16
16
 
17
17
  import httpx
18
18
 
@@ -24,6 +24,7 @@ from ._core import (
24
24
  DEVICE_CODE_GRANT,
25
25
  OAUTH_DEVICE_AUTHORIZATION_PATH,
26
26
  OAUTH_METADATA_PATH,
27
+ OAUTH_REGISTER_PATH,
27
28
  OAUTH_REVOKE_PATH,
28
29
  OAUTH_TOKEN_PATH,
29
30
  POLL_DEADLINE_CAP,
@@ -45,6 +46,7 @@ from ._core import (
45
46
  part_file,
46
47
  pkce_challenge,
47
48
  redirect_location,
49
+ registration_body,
48
50
  request_async,
49
51
  retry_delay,
50
52
  storage_refusal,
@@ -60,6 +62,7 @@ from ._generated.client import AuthenticatedClient
60
62
  from ._generated.models.database_format import DatabaseFormat
61
63
  from .errors import InternetDataError, OauthError, OauthExpiredTokenError
62
64
  from .models import (
65
+ ClientRegistration,
63
66
  Database,
64
67
  DatabaseMetadata,
65
68
  DeviceAuthorization,
@@ -68,6 +71,7 @@ from .models import (
68
71
  OauthMetadata,
69
72
  Pkce,
70
73
  TokenResponse,
74
+ to_client_registration,
71
75
  to_device_authorization,
72
76
  to_metadata,
73
77
  to_oauth_metadata,
@@ -434,6 +438,22 @@ class AsyncOauthApi:
434
438
  """The `S256` challenge for a PKCE verifier: its SHA-256, as unpadded base64url."""
435
439
  return pkce_challenge(verifier)
436
440
 
441
+ async def register(
442
+ self,
443
+ redirect_uris: Sequence[str],
444
+ *,
445
+ client_name: str | None = None,
446
+ timeout: float | None = None,
447
+ ) -> ClientRegistration:
448
+ """Register a client for the authorization code flow; see `OauthApi.register`."""
449
+ body = registration_body(redirect_uris, client_name)
450
+
451
+ async def call() -> ClientRegistration:
452
+ res = await self._send("POST", OAUTH_REGISTER_PATH, None, timeout, body)
453
+ return to_client_registration(oauth_body(res), res.status_code)
454
+
455
+ return await self._owner._retrying(call, self._owner._retries)
456
+
437
457
  async def revoke(self, client_id: str, token: str, *, timeout: float | None = None) -> None:
438
458
  """End a token; revoking the refresh token signs the machine out."""
439
459
 
@@ -480,7 +500,12 @@ class AsyncOauthApi:
480
500
  return await self._owner._retrying(call, 0)
481
501
 
482
502
  async def _send(
483
- self, method: str, path: str, form: dict[str, str] | None, timeout: float | None
503
+ self,
504
+ method: str,
505
+ path: str,
506
+ form: dict[str, str] | None,
507
+ timeout: float | None,
508
+ body: dict[str, Any] | None = None,
484
509
  ) -> httpx.Response:
485
510
  bound = self._owner._bound(timeout)
486
- return await oauth_request_async(self._owner._client, method, path, form, bound)
511
+ return await oauth_request_async(self._owner._client, method, path, form, bound, body)
@@ -5,9 +5,9 @@ from __future__ import annotations
5
5
  import builtins
6
6
  import os
7
7
  import time
8
- from collections.abc import Callable
8
+ from collections.abc import Callable, Sequence
9
9
  from types import TracebackType
10
- from typing import Self, TypeVar
10
+ from typing import Any, Self, TypeVar
11
11
 
12
12
  import httpx
13
13
 
@@ -19,6 +19,7 @@ from ._core import (
19
19
  DEVICE_CODE_GRANT,
20
20
  OAUTH_DEVICE_AUTHORIZATION_PATH,
21
21
  OAUTH_METADATA_PATH,
22
+ OAUTH_REGISTER_PATH,
22
23
  OAUTH_REVOKE_PATH,
23
24
  OAUTH_TOKEN_PATH,
24
25
  POLL_DEADLINE_CAP,
@@ -40,6 +41,7 @@ from ._core import (
40
41
  part_file,
41
42
  pkce_challenge,
42
43
  redirect_location,
44
+ registration_body,
43
45
  request,
44
46
  retry_delay,
45
47
  storage_refusal,
@@ -55,6 +57,7 @@ from ._generated.client import AuthenticatedClient
55
57
  from ._generated.models.database_format import DatabaseFormat
56
58
  from .errors import InternetDataError, OauthError, OauthExpiredTokenError
57
59
  from .models import (
60
+ ClientRegistration,
58
61
  Database,
59
62
  DatabaseMetadata,
60
63
  DeviceAuthorization,
@@ -63,6 +66,7 @@ from .models import (
63
66
  OauthMetadata,
64
67
  Pkce,
65
68
  TokenResponse,
69
+ to_client_registration,
66
70
  to_device_authorization,
67
71
  to_metadata,
68
72
  to_oauth_metadata,
@@ -470,6 +474,31 @@ class OauthApi:
470
474
  """The `S256` challenge for a PKCE verifier: its SHA-256, as unpadded base64url."""
471
475
  return pkce_challenge(verifier)
472
476
 
477
+ def register(
478
+ self,
479
+ redirect_uris: Sequence[str],
480
+ *,
481
+ client_name: str | None = None,
482
+ timeout: float | None = None,
483
+ ) -> ClientRegistration:
484
+ """Register a client for the authorization code flow, for a program that cannot
485
+ serve a Client ID Metadata Document, and get its `client_id`.
486
+
487
+ Each redirect URI must be https on a public host name, or http on a loopback
488
+ address, which then matches on any port; the server refuses any other with the
489
+ `OauthError` `invalid_redirect_uri`. Nothing is kept until a person approves the
490
+ client, so registering again costs nothing, and this is retried like a lookup. No
491
+ URI at all, an empty one, or one with no UTF-8 raises `ValueError` before anything
492
+ is sent.
493
+ """
494
+ body = registration_body(redirect_uris, client_name)
495
+
496
+ def call() -> ClientRegistration:
497
+ res = self._send("POST", OAUTH_REGISTER_PATH, None, timeout, body)
498
+ return to_client_registration(oauth_body(res), res.status_code)
499
+
500
+ return self._owner._retrying(call, self._owner._retries)
501
+
473
502
  def revoke(self, client_id: str, token: str, *, timeout: float | None = None) -> None:
474
503
  """End a token. A refresh token ends the whole sign-in and every token it issued, so
475
504
  revoking it is how a program signs the machine out."""
@@ -524,6 +553,12 @@ class OauthApi:
524
553
  return self._owner._retrying(call, 0)
525
554
 
526
555
  def _send(
527
- self, method: str, path: str, form: dict[str, str] | None, timeout: float | None
556
+ self,
557
+ method: str,
558
+ path: str,
559
+ form: dict[str, str] | None,
560
+ timeout: float | None,
561
+ body: dict[str, Any] | None = None,
528
562
  ) -> httpx.Response:
529
- return oauth_request(self._owner._client, method, path, form, self._owner._bound(timeout))
563
+ bound = self._owner._bound(timeout)
564
+ return oauth_request(self._owner._client, method, path, form, bound, body)
@@ -361,6 +361,26 @@ class TokenResponse:
361
361
  apikey: str | None = field(default=None, repr=False)
362
362
 
363
363
 
364
+ @dataclass(frozen=True, slots=True)
365
+ class ClientRegistration:
366
+ """A registered client, public and with no secret: it proves each sign-in with PKCE.
367
+
368
+ `client_id` goes to `authorization_url` and `exchange_authorization_code`.
369
+ `client_id_issued_at` is Unix seconds, and `client_name` the name the person will see,
370
+ a placeholder when none was given or none can be shown. `token_endpoint_auth_method` is
371
+ always `none`, and `scope` the most this client may be granted.
372
+ """
373
+
374
+ client_id: str
375
+ client_id_issued_at: int
376
+ client_name: str
377
+ redirect_uris: tuple[str, ...]
378
+ token_endpoint_auth_method: str
379
+ grant_types: tuple[str, ...] | None = None
380
+ response_types: tuple[str, ...] | None = None
381
+ scope: str | None = None
382
+
383
+
364
384
  def to_oauth_metadata(body: Any, status: int) -> OauthMetadata:
365
385
  return OauthMetadata(**_members(body, _OAUTH_METADATA, status))
366
386
 
@@ -373,6 +393,10 @@ def to_token_response(body: Any, status: int) -> TokenResponse:
373
393
  return TokenResponse(**_members(body, _TOKEN_RESPONSE, status))
374
394
 
375
395
 
396
+ def to_client_registration(body: Any, status: int) -> ClientRegistration:
397
+ return ClientRegistration(**_members(body, _CLIENT_REGISTRATION, status))
398
+
399
+
376
400
  # A member's type on the wire, and whether a 2xx without it is malformed.
377
401
  _Member = tuple[Literal["str", "int", "bool", "strs"], bool]
378
402
 
@@ -412,6 +436,17 @@ _TOKEN_RESPONSE: dict[str, _Member] = {
412
436
  "apikey": ("str", False),
413
437
  }
414
438
 
439
+ _CLIENT_REGISTRATION: dict[str, _Member] = {
440
+ "client_id": ("str", True),
441
+ "client_id_issued_at": ("int", True),
442
+ "client_name": ("str", True),
443
+ "redirect_uris": ("strs", True),
444
+ "token_endpoint_auth_method": ("str", True),
445
+ "grant_types": ("strs", False),
446
+ "response_types": ("strs", False),
447
+ "scope": ("str", False),
448
+ }
449
+
415
450
  _WIRE_NAMES = {"apikey_id": "mslm:apikey_id", "apikey": "mslm:apikey"}
416
451
 
417
452