internetdata 2.9.1__tar.gz → 2.9.3__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.9.1 → internetdata-2.9.3}/PKG-INFO +2 -2
  2. {internetdata-2.9.1 → internetdata-2.9.3}/README.md +1 -1
  3. {internetdata-2.9.1 → internetdata-2.9.3}/spec/openapi.yaml +28 -9
  4. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/__init__.py +1 -1
  5. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_core.py +46 -6
  6. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +5 -0
  7. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/database_v_2/list_databases.py +5 -0
  8. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/database_v_2/list_downloads.py +5 -0
  9. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/aio.py +3 -2
  10. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/client.py +7 -2
  11. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/models.py +90 -29
  12. {internetdata-2.9.1 → internetdata-2.9.3}/tests/test_client.py +153 -2
  13. {internetdata-2.9.1 → internetdata-2.9.3}/.gitignore +0 -0
  14. {internetdata-2.9.1 → internetdata-2.9.3}/LICENSE +0 -0
  15. {internetdata-2.9.1 → internetdata-2.9.3}/pyproject.toml +0 -0
  16. {internetdata-2.9.1 → internetdata-2.9.3}/scripts/download-spec.sh +0 -0
  17. {internetdata-2.9.1 → internetdata-2.9.3}/scripts/generate.sh +0 -0
  18. {internetdata-2.9.1 → internetdata-2.9.3}/scripts/publish.sh +0 -0
  19. {internetdata-2.9.1 → internetdata-2.9.3}/scripts/v2_subset.py +0 -0
  20. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/__init__.py +0 -0
  21. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/__init__.py +0 -0
  22. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/account/__init__.py +0 -0
  23. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/account/account_identity.py +0 -0
  24. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/api_keys/__init__.py +0 -0
  25. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/api_keys/account_create_apikey.py +0 -0
  26. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/api_keys/account_list_apikeys.py +0 -0
  27. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/api_keys/account_reveal_apikey.py +0 -0
  28. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/api_keys/account_revoke_apikey.py +0 -0
  29. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/api_keys/account_rotate_apikey.py +0 -0
  30. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/authorization/__init__.py +0 -0
  31. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/authorization/oauth_authorize.py +0 -0
  32. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/authorization/oauth_device_authorization.py +0 -0
  33. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/authorization/oauth_metadata.py +0 -0
  34. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/authorization/oauth_register.py +0 -0
  35. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/authorization/oauth_revoke.py +0 -0
  36. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/authorization/oauth_token.py +0 -0
  37. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
  38. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
  39. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
  40. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/organization/__init__.py +0 -0
  41. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/organization/account_org.py +0 -0
  42. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/api/organization/account_org_members.py +0 -0
  43. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/client.py +0 -0
  44. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/errors.py +0 -0
  45. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/__init__.py +0 -0
  46. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/account_create_apikey_request.py +0 -0
  47. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/account_created_apikey.py +0 -0
  48. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/account_org.py +0 -0
  49. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/account_org_ref.py +0 -0
  50. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/account_org_wrap.py +0 -0
  51. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/account_rc.py +0 -0
  52. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/account_revealed_apikey.py +0 -0
  53. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/account_user.py +0 -0
  54. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/apikey_detail.py +0 -0
  55. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/apikey_list.py +0 -0
  56. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/client_registration.py +0 -0
  57. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/client_registration_request.py +0 -0
  58. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database.py +0 -0
  59. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
  60. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database_format.py +0 -0
  61. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
  62. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
  63. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
  64. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database_metadata.py +0 -0
  65. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
  66. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
  67. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
  68. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database_metadata_sample_size.py +0 -0
  69. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
  70. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
  71. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/database_version.py +0 -0
  72. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/db_checksums.py +0 -0
  73. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/device_authorization.py +0 -0
  74. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/device_authorization_request.py +0 -0
  75. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/download.py +0 -0
  76. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/download_outcome.py +0 -0
  77. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/error.py +0 -0
  78. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/identity.py +0 -0
  79. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
  80. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
  81. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/oauth_authorize_code_challenge_method.py +0 -0
  82. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/oauth_authorize_response_type.py +0 -0
  83. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/oauth_error.py +0 -0
  84. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/oauth_metadata.py +0 -0
  85. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/oauth_revoke_response_200.py +0 -0
  86. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/revoke_request.py +0 -0
  87. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/standing.py +0 -0
  88. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/token_request.py +0 -0
  89. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/models/token_response.py +0 -0
  90. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/_generated/types.py +0 -0
  91. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/errors.py +0 -0
  92. {internetdata-2.9.1 → internetdata-2.9.3}/src/internetdata/py.typed +0 -0
  93. {internetdata-2.9.1 → internetdata-2.9.3}/testdata/testdata.json +0 -0
  94. {internetdata-2.9.1 → internetdata-2.9.3}/tests/conftest.py +0 -0
  95. {internetdata-2.9.1 → internetdata-2.9.3}/tests/helpers.py +0 -0
  96. {internetdata-2.9.1 → internetdata-2.9.3}/tests/test_conformance.py +0 -0
  97. {internetdata-2.9.1 → internetdata-2.9.3}/tests/test_download.py +0 -0
  98. {internetdata-2.9.1 → internetdata-2.9.3}/tests/test_oauth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: internetdata
3
- Version: 2.9.1
3
+ Version: 2.9.3
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
@@ -180,7 +180,7 @@ From 2.3.0, `list`, `metadata`, `checksums`, `downloads` and `download_url` each
180
180
 
181
181
  **Changed in 2.2.0:** a timeout of 0 or less, NaN or a string used to be accepted, and failed every call.
182
182
 
183
- Note that `rate_limited` and `quota_exceeded` both arrive as HTTP 429 and are not the same thing. A rate limit is the API facing a traffic burst, and retrying later works; a spent quota needs your allowance raised or the window to roll over. The library retries rate limits for you, and server and network failures, but never a spent quota or anything else you sent.
183
+ `rate_limited` is a 429 that carries `Retry-After`, and the library retries it for you, as it does server and network failures. `quota_exceeded` is a 429 without one, which it never retries, and it retries nothing else you sent either. InternetData's database API has no quota, so you won't see `quota_exceeded` from it.
184
184
 
185
185
  ### Sign in with OAuth (device flow)
186
186
 
@@ -146,7 +146,7 @@ From 2.3.0, `list`, `metadata`, `checksums`, `downloads` and `download_url` each
146
146
 
147
147
  **Changed in 2.2.0:** a timeout of 0 or less, NaN or a string used to be accepted, and failed every call.
148
148
 
149
- Note that `rate_limited` and `quota_exceeded` both arrive as HTTP 429 and are not the same thing. A rate limit is the API facing a traffic burst, and retrying later works; a spent quota needs your allowance raised or the window to roll over. The library retries rate limits for you, and server and network failures, but never a spent quota or anything else you sent.
149
+ `rate_limited` is a 429 that carries `Retry-After`, and the library retries it for you, as it does server and network failures. `quota_exceeded` is a 429 without one, which it never retries, and it retries nothing else you sent either. InternetData's database API has no quota, so you won't see `quota_exceeded` from it.
150
150
 
151
151
  ### Sign in with OAuth (device flow)
152
152
 
@@ -12,7 +12,7 @@ info:
12
12
  email: support@internetdata.io
13
13
  url: https://internetdata.io
14
14
  termsOfService: https://internetdata.io/terms
15
- version: 2026.10.18
15
+ version: 2026.10.21
16
16
  servers:
17
17
  - url: https://internetdata.io
18
18
  tags:
@@ -81,6 +81,8 @@ paths:
81
81
  $ref: '#/components/schemas/Database'
82
82
  '401':
83
83
  $ref: '#/components/responses/V2Unauthorized'
84
+ '403':
85
+ $ref: '#/components/responses/V2IpNotAllowed'
84
86
  /api/v2/database/download:
85
87
  get:
86
88
  tags:
@@ -113,7 +115,7 @@ paths:
113
115
  '401':
114
116
  $ref: '#/components/responses/V2Unauthorized'
115
117
  '403':
116
- $ref: '#/components/responses/V2NotLicensed'
118
+ $ref: '#/components/responses/V2Forbidden'
117
119
  '404':
118
120
  $ref: '#/components/responses/V2UnknownDatabase'
119
121
  '503':
@@ -146,6 +148,8 @@ paths:
146
148
  $ref: '#/components/schemas/DatabaseMetadata'
147
149
  '401':
148
150
  $ref: '#/components/responses/V2Unauthorized'
151
+ '403':
152
+ $ref: '#/components/responses/V2IpNotAllowed'
149
153
  '404':
150
154
  $ref: '#/components/responses/V2UnknownDatabase'
151
155
  '503':
@@ -192,7 +196,7 @@ paths:
192
196
  '401':
193
197
  $ref: '#/components/responses/V2Unauthorized'
194
198
  '403':
195
- $ref: '#/components/responses/V2NotLicensed'
199
+ $ref: '#/components/responses/V2Forbidden'
196
200
  '404':
197
201
  $ref: '#/components/responses/V2UnknownDatabase'
198
202
  '503':
@@ -236,6 +240,8 @@ paths:
236
240
  $ref: '#/components/schemas/Download'
237
241
  '401':
238
242
  $ref: '#/components/responses/V2Unauthorized'
243
+ '403':
244
+ $ref: '#/components/responses/V2IpNotAllowed'
239
245
  /api/v1/database/download:
240
246
  get:
241
247
  tags:
@@ -1758,18 +1764,31 @@ components:
1758
1764
  responses:
1759
1765
  V2Unauthorized:
1760
1766
  description: |
1761
- Missing or invalid bearer key, the key lacks the `db.download` scope, or
1762
- it belongs to no organization.
1767
+ Missing, invalid, revoked or expired key; the key lacks the
1768
+ `db.download` scope; or the key belongs to no active organization.
1769
+ Each answers the same.
1763
1770
  `rc`: `UNAUTHORIZED`.
1764
1771
  content:
1765
1772
  application/json:
1766
1773
  schema:
1767
1774
  $ref: '#/components/schemas/Error'
1768
- V2NotLicensed:
1775
+ V2IpNotAllowed:
1776
+ description: |
1777
+ The request comes from an address outside the key's or its
1778
+ organization's allowed addresses. Change the allowlist or call from an
1779
+ allowed address: the key itself is fine.
1780
+ `rc`: `IP_NOT_ALLOWED`.
1781
+ content:
1782
+ application/json:
1783
+ schema:
1784
+ $ref: '#/components/schemas/Error'
1785
+ V2Forbidden:
1769
1786
  description: |
1770
- Your organization holds no license for this database, or its term has
1771
- ended.
1772
- `rc`: `NOT_LICENSED`, `LICENSE_EXPIRED`.
1787
+ Your organization holds no license for this database (`NOT_LICENSED`),
1788
+ or its term has ended (`LICENSE_EXPIRED`); or the request comes from an
1789
+ address outside the key's or its organization's allowed addresses
1790
+ (`IP_NOT_ALLOWED`).
1791
+ `rc`: `NOT_LICENSED`, `LICENSE_EXPIRED`, `IP_NOT_ALLOWED`.
1773
1792
  content:
1774
1793
  application/json:
1775
1794
  schema:
@@ -40,7 +40,7 @@ from .models import (
40
40
  TokenResponse,
41
41
  )
42
42
 
43
- __version__ = "2.9.1"
43
+ __version__ = "2.9.3"
44
44
 
45
45
  __all__ = [
46
46
  "DATABASE_FORMATS",
@@ -167,15 +167,17 @@ def check_timeout(timeout: float | None) -> float | None:
167
167
  `server_error` blaming the API. So is anything past `WAIT_CEILING`, infinity
168
168
  included, since the sync client's wait cannot hold it: 1e300 failed every call with
169
169
  a raw `OverflowError` (2.6.0, measured 2026-09-30). None is the spelling for no bound.
170
+
171
+ Compared rather than handed to `math.isfinite`, which raised a raw `OverflowError` for
172
+ an integer too large for a float, such as 10**400 (2.9.2, measured 2026-10-25). NaN
173
+ fails both comparisons.
170
174
  """
171
175
  if timeout is None:
172
176
  return None
173
177
  if (
174
178
  isinstance(timeout, bool)
175
179
  or not isinstance(timeout, int | float)
176
- or not math.isfinite(timeout)
177
- or timeout <= 0
178
- or timeout > WAIT_CEILING
180
+ or not 0 < timeout <= WAIT_CEILING
179
181
  ):
180
182
  raise ValueError(
181
183
  f"timeout must be a number of seconds greater than 0 and at most "
@@ -184,6 +186,25 @@ def check_timeout(timeout: float | None) -> float | None:
184
186
  return timeout
185
187
 
186
188
 
189
+ def check_retries(retries: float) -> float:
190
+ """`retries`, once it is a count of retries: a whole number from 0, or `math.inf` to
191
+ retry a failing call until it succeeds.
192
+
193
+ Refused where it is SET, because nothing downstream refuses it: NaN compares false with
194
+ every attempt number, so a failing call was retried until it succeeded, a negative read
195
+ as 0, 1.5 as two retries, and a string or None raised a raw `TypeError` out of every
196
+ call that failed, a 404 included (2.9.2, measured 2026-10-25).
197
+ """
198
+ if (
199
+ isinstance(retries, bool)
200
+ or not isinstance(retries, int | float)
201
+ or not retries >= 0
202
+ or (isinstance(retries, float) and not (retries.is_integer() or retries == math.inf))
203
+ ):
204
+ raise ValueError(f"retries must be a whole number from 0, or math.inf, not {retries!r}")
205
+ return retries
206
+
207
+
187
208
  def storage_refusal(res: httpx.Response) -> InternetDataError:
188
209
  """What object storage refusing a download link becomes.
189
210
 
@@ -553,12 +574,22 @@ def databases_of(body: dict[str, Any]) -> list[Database]:
553
574
  Nothing is added here, and nothing may be: a family built for a single customer is
554
575
  absent from this array for every organization that does not license it, so filling a
555
576
  gap from any other source would publish the fact that the family exists.
577
+
578
+ An object where the list is due is refused rather than read as one with nothing in it:
579
+ `{"databases": {}}` answered an empty catalog (2.9.2, measured 2026-10-25).
556
580
  """
557
- return [to_database(d) for d in body["databases"]]
581
+ return [to_database(d) for d in _listed(body, "databases")]
558
582
 
559
583
 
560
584
  def downloads_of(body: dict[str, Any]) -> list[Download]:
561
- return [to_download(d) for d in body["downloads"]]
585
+ return [to_download(d) for d in _listed(body, "downloads")]
586
+
587
+
588
+ def _listed(body: dict[str, Any], name: str) -> list[Any]:
589
+ value = body[name]
590
+ if not isinstance(value, list):
591
+ raise TypeError(f"{name} is not a list: {value!r}")
592
+ return value
562
593
 
563
594
 
564
595
  def checksums_of(body: dict[str, Any]) -> dict[str, str]:
@@ -568,7 +599,16 @@ def checksums_of(body: dict[str, Any]) -> dict[str, str]:
568
599
  The response carries `id` and `format` beside them, so reading a top-level `sha256`
569
600
  finds nothing. That exact mistake shipped in another binding's 1.0.x, which is why
570
601
  the shared corpus pins the depth.
602
+
603
+ `id` and `format` are read too, though nothing returns them: a null or numeric `id`
604
+ came back as a whole answer, and so did `checksums` sent as a list of pairs (2.9.2,
605
+ measured 2026-10-25).
571
606
  """
607
+ for member in ("id", "format"):
608
+ if not isinstance(body[member], str):
609
+ raise TypeError(f"the checksum answer's {member} is not a string: {body[member]!r}")
610
+ if not isinstance(body["checksums"], dict):
611
+ raise TypeError(f"checksums is not an object: {body['checksums']!r}")
572
612
  digests = dict(body["checksums"])
573
613
  for algorithm in ("md5", "sha1", "sha256", "sha512"):
574
614
  if not isinstance(digests[algorithm], str):
@@ -576,7 +616,7 @@ def checksums_of(body: dict[str, Any]) -> dict[str, str]:
576
616
  return digests
577
617
 
578
618
 
579
- def retry_delay(err: InternetDataError, attempt: int, retries: int) -> float | None:
619
+ def retry_delay(err: InternetDataError, attempt: int, retries: float) -> float | None:
580
620
  """How long to wait before attempt `attempt + 1`, or None when there must not be one.
581
621
 
582
622
  A server-supplied `Retry-After` wins over the backoff schedule outright: it is the
@@ -43,6 +43,11 @@ def _parse_response(
43
43
 
44
44
  return response_401
45
45
 
46
+ if response.status_code == 403:
47
+ response_403 = Error.from_dict(response.json())
48
+
49
+ return response_403
50
+
46
51
  if response.status_code == 404:
47
52
  response_404 = Error.from_dict(response.json())
48
53
 
@@ -33,6 +33,11 @@ def _parse_response(
33
33
 
34
34
  return response_401
35
35
 
36
+ if response.status_code == 403:
37
+ response_403 = Error.from_dict(response.json())
38
+
39
+ return response_403
40
+
36
41
  if client.raise_on_unexpected_status:
37
42
  raise errors.UnexpectedStatus(response.status_code, response.content)
38
43
  else:
@@ -43,6 +43,11 @@ def _parse_response(
43
43
 
44
44
  return response_401
45
45
 
46
+ if response.status_code == 403:
47
+ response_403 = Error.from_dict(response.json())
48
+
49
+ return response_403
50
+
46
51
  if client.raise_on_unexpected_status:
47
52
  raise errors.UnexpectedStatus(response.status_code, response.content)
48
53
  else:
@@ -35,6 +35,7 @@ from ._core import (
35
35
  authorization_url,
36
36
  build_async_transfer_client,
37
37
  build_client,
38
+ check_retries,
38
39
  check_timeout,
39
40
  checksums_of,
40
41
  create_pkce,
@@ -109,7 +110,7 @@ class AsyncInternetData:
109
110
  self._client = build_client(api_key, base_url, timeout, transport)
110
111
  self._base_url = base_url
111
112
  self._transfer = build_async_transfer_client(timeout, transport)
112
- self._retries = retries
113
+ self._retries = check_retries(retries)
113
114
  self._timeout = timeout
114
115
  self.database = AsyncDatabaseApi(self)
115
116
  self.oauth = AsyncOauthApi(self)
@@ -133,7 +134,7 @@ class AsyncInternetData:
133
134
  def _bound(self, timeout: float | None) -> float | None:
134
135
  return self._timeout if timeout is None else check_timeout(timeout)
135
136
 
136
- async def _retrying(self, call: Callable[[], Awaitable[T]], retries: int) -> T:
137
+ async def _retrying(self, call: Callable[[], Awaitable[T]], retries: float) -> T:
137
138
  attempt = 0
138
139
  while True:
139
140
  try:
@@ -30,6 +30,7 @@ from ._core import (
30
30
  authorization_url,
31
31
  build_client,
32
32
  build_transfer_client,
33
+ check_retries,
33
34
  check_timeout,
34
35
  checksums_of,
35
36
  create_pkce,
@@ -94,6 +95,10 @@ class InternetData:
94
95
  Anything else that is not a finite number greater than 0 is a `ValueError` where it is
95
96
  set, rather than a failure of every call.
96
97
 
98
+ `retries` is how many times a failed attempt is retried, 2 unless given: a whole number
99
+ from 0, or `math.inf` to retry until the call succeeds. Anything else is a `ValueError`
100
+ here too.
101
+
97
102
  Holds an HTTP connection pool, so use it as a context manager or call `close()` when
98
103
  you are done with it.
99
104
  """
@@ -117,7 +122,7 @@ class InternetData:
117
122
  self._client = build_client(api_key, base_url, timeout, transport)
118
123
  self._base_url = base_url
119
124
  self._transfer = build_transfer_client(timeout, transport)
120
- self._retries = retries
125
+ self._retries = check_retries(retries)
121
126
  self._timeout = timeout
122
127
  self.database = DatabaseApi(self)
123
128
  self.oauth = OauthApi(self)
@@ -141,7 +146,7 @@ class InternetData:
141
146
  def _bound(self, timeout: float | None) -> float | None:
142
147
  return self._timeout if timeout is None else check_timeout(timeout)
143
148
 
144
- def _retrying(self, call: Callable[[], T], retries: int) -> T:
149
+ def _retrying(self, call: Callable[[], T], retries: float) -> T:
145
150
  attempt = 0
146
151
  while True:
147
152
  try:
@@ -10,6 +10,7 @@ them so a field this pinned spec predates is still reachable.
10
10
  from __future__ import annotations
11
11
 
12
12
  import datetime
13
+ import re
13
14
  from dataclasses import dataclass, field
14
15
  from typing import Any, Literal, get_args
15
16
 
@@ -177,12 +178,12 @@ def to_database(body: dict[str, Any]) -> Database:
177
178
  base=_present(body, "base", str),
178
179
  name=_present(body, "name", str),
179
180
  summary=_present(body, "summary", str),
180
- standing=_present(body, "standing", str),
181
- license_type=_nullable(body, "license_type", str),
182
- starts=_datetime(body["starts"]),
183
- expires=_datetime(body["expires"]),
184
- renews_at=_datetime(body["renews_at"]),
185
- notice_due_at=_datetime(body["notice_due_at"]),
181
+ standing=_one_of(_present(body, "standing", str), STANDINGS, "standing"),
182
+ license_type=_one_of(_nullable(body, "license_type", str), LICENSE_TYPES, "license_type"),
183
+ starts=_datetime(body["starts"], "starts"),
184
+ expires=_datetime(body["expires"], "expires"),
185
+ renews_at=_datetime(body["renews_at"], "renews_at"),
186
+ notice_due_at=_datetime(body["notice_due_at"], "notice_due_at"),
186
187
  versions=tuple(to_version(v) for v in _present(body, "versions", list)),
187
188
  raw=body,
188
189
  open=_present(body, "open", bool),
@@ -190,26 +191,37 @@ def to_database(body: dict[str, Any]) -> Database:
190
191
 
191
192
 
192
193
  def to_version(body: dict[str, Any]) -> DatabaseVersion:
194
+ sample_formats = _nullable(body, "sample_formats", list, absent=True)
193
195
  return DatabaseVersion(
194
196
  id=_present(body, "id", str),
195
197
  version=_present(body, "version", int),
196
198
  summary=_present(body, "summary", str),
197
199
  formats=_strings(_present(body, "formats", list), "formats"),
198
- sample_formats=_optional_tuple(body.get("sample_formats")),
200
+ sample_formats=None
201
+ if sample_formats is None
202
+ else _strings(sample_formats, "sample_formats"),
199
203
  )
200
204
 
201
205
 
202
206
  def to_metadata(body: dict[str, Any]) -> DatabaseMetadata:
207
+ schema = _present(body, "schema", dict)
208
+ sample = _of_type(body.get("sample", {}), "sample", dict)
203
209
  return DatabaseMetadata(
204
210
  id=_present(body, "id", str),
205
- updated=datetime.date.fromisoformat(body["updated"]),
211
+ updated=_date(_present(body, "updated", str), "updated"),
206
212
  entries=_present(body, "entries", int),
207
213
  schema={
208
- fmt: tuple(to_column(c) for c in columns) for fmt, columns in body["schema"].items()
214
+ fmt: tuple(
215
+ to_column(_of_type(c, "schema", dict)) for c in _of_type(cols, "schema", list)
216
+ )
217
+ for fmt, cols in schema.items()
209
218
  },
210
219
  size=_sizes(_present(body, "size", dict), "size"),
211
- update_freq=body.get("update_freq"),
212
- sample={fmt: tuple(rows) for fmt, rows in body.get("sample", {}).items()},
220
+ update_freq=_nullable(body, "update_freq", str, absent=True),
221
+ sample={
222
+ fmt: tuple(_of_type(row, "sample", dict) for row in _of_type(rows, "sample", list))
223
+ for fmt, rows in sample.items()
224
+ },
213
225
  raw=body,
214
226
  sample_size=_sizes(body["sample_size"], "sample_size")
215
227
  if body.get("sample_size") is not None
@@ -222,7 +234,7 @@ def to_column(body: dict[str, Any]) -> MetadataColumn:
222
234
  return MetadataColumn(
223
235
  name=_present(body, "name", str),
224
236
  type=_present(body, "type", str),
225
- description=body.get("description"),
237
+ description=_nullable(body, "description", str, absent=True),
226
238
  )
227
239
 
228
240
 
@@ -230,13 +242,13 @@ def to_download(body: dict[str, Any]) -> Download:
230
242
  return Download(
231
243
  dataset_id=_present(body, "dataset_id", str),
232
244
  format=_present(body, "format", str),
233
- outcome=_present(body, "outcome", str),
234
- created=datetime.datetime.fromisoformat(body["created"]),
245
+ outcome=_one_of(_present(body, "outcome", str), get_args(Outcome), "outcome"),
246
+ created=_date_time(_present(body, "created", str), "created"),
235
247
  bytes=_nullable(body, "bytes", int),
236
248
  http_status=_nullable(body, "http_status", int),
237
- apikey_id=body["apikey_id"],
238
- client_ip=body["client_ip"],
239
- user_agent=body["user_agent"],
249
+ apikey_id=_nullable(body, "apikey_id", str),
250
+ client_ip=_nullable(body, "client_ip", str),
251
+ user_agent=_nullable(body, "user_agent", str),
240
252
  sample=_present(body, "sample", bool),
241
253
  open=_present(body, "open", bool),
242
254
  )
@@ -273,25 +285,74 @@ def _strings(values: list[Any], name: str) -> tuple[Any, ...]:
273
285
  return tuple(_of_type(v, name, str) for v in values)
274
286
 
275
287
 
276
- def _sizes(sizes: Any, name: str) -> dict[str, int]:
277
- return {fmt: _of_type(n, name, int) for fmt, n in _of_type(sizes, name, dict).items()}
288
+ def _one_of(value: Any, allowed: tuple[str, ...], name: str) -> Any:
289
+ """`value`, a member of `allowed`, or None. One outside it is a value this client was not
290
+ built for: `license_type` came back as served while its type promised one of three, and
291
+ `standing` beside it was refused (2.9.2, measured 2026-10-25)."""
292
+ if value is not None and value not in allowed:
293
+ raise ValueError(f"{name} is not one of {', '.join(allowed)}: {value!r}")
294
+ return value
278
295
 
279
296
 
280
- def _optional_tuple(value: Any) -> tuple[Any, ...] | None:
281
- return tuple(value) if value is not None else None
297
+ def _sizes(sizes: Any, name: str) -> dict[str, int]:
298
+ return {fmt: _of_type(n, name, int) for fmt, n in _of_type(sizes, name, dict).items()}
282
299
 
283
300
 
284
- def _datetime(value: Any) -> datetime.datetime | None:
301
+ def _datetime(value: Any, name: str) -> datetime.datetime | None:
285
302
  """A NULLABLE wire timestamp. A license with no end date carries `expires: null`.
286
303
 
287
- `fromisoformat` only learned to read a trailing `Z` in 3.11, which is one of the
288
- three reasons this package floors there. A value that is neither null nor readable
289
- raises, and the caller reports it as the server's failure rather than as None, which
290
- would quietly read as "no end date".
304
+ A value that is neither null nor readable raises, and the caller reports it as the
305
+ server's failure rather than as None, which would quietly read as "no end date".
291
306
  """
292
- if value is None:
293
- return None
294
- return datetime.datetime.fromisoformat(value)
307
+ return None if value is None else _date_time(value, name)
308
+
309
+
310
+ # The spec's `date-time` and `date` are RFC 3339's, and only those are read: `fromisoformat`
311
+ # took a date-time with no offset as a naive datetime, which no aware one can be compared
312
+ # with, a date alone as midnight, `+0500`, `+05`, `+05:00:30`, `+05:60` as an hour more,
313
+ # a space for the `T`, no seconds, a comma for the point, week dates and `20261005`
314
+ # (2.9.2, measured 2026-10-25). RFC 3339 allows a lowercase `t` and `z`, which it refused.
315
+ _DIGITS2 = "([0-9]{2})"
316
+ _FULL_DATE = re.compile(rf"([0-9]{{4}})-{_DIGITS2}-{_DIGITS2}")
317
+ _DATE_TIME = re.compile(
318
+ rf"([0-9]{{4}})-{_DIGITS2}-{_DIGITS2}[Tt]{_DIGITS2}:{_DIGITS2}:{_DIGITS2}(?:\.([0-9]+))?"
319
+ rf"(?:[Zz]|([+-]){_DIGITS2}:{_DIGITS2})"
320
+ )
321
+
322
+
323
+ def _date(value: str, name: str) -> datetime.date:
324
+ match = _FULL_DATE.fullmatch(value)
325
+ if match is None:
326
+ raise ValueError(f"{name} is not an RFC 3339 date: {value!r}")
327
+ # A day its month lacks, 30 February, raises here.
328
+ return datetime.date(*(int(part) for part in match.groups()))
329
+
330
+
331
+ def _date_time(value: Any, name: str) -> datetime.datetime:
332
+ match = _DATE_TIME.fullmatch(_of_type(value, name, str))
333
+ if match is None:
334
+ raise ValueError(f"{name} is not an RFC 3339 date-time: {value!r}")
335
+ year, month, day, hour, minute, second, fraction, sign, offset_hour, offset_minute = (
336
+ match.groups()
337
+ )
338
+ zone = datetime.UTC
339
+ if sign is not None:
340
+ if int(offset_hour) > 23 or int(offset_minute) > 59:
341
+ raise ValueError(f"{name} has no such offset: {value!r}")
342
+ offset = datetime.timedelta(hours=int(offset_hour), minutes=int(offset_minute))
343
+ zone = datetime.timezone(-offset if sign == "-" else offset)
344
+ # A day its month lacks, an hour of 24 and a leap second's 60 raise here; past six
345
+ # digits a fraction is cut, as `fromisoformat` cuts it.
346
+ return datetime.datetime(
347
+ int(year),
348
+ int(month),
349
+ int(day),
350
+ int(hour),
351
+ int(minute),
352
+ int(second),
353
+ int((fraction or "")[:6].ljust(6, "0")),
354
+ tzinfo=zone,
355
+ )
295
356
 
296
357
 
297
358
  @dataclass(frozen=True, slots=True)
@@ -454,6 +454,86 @@ UNREADABLE_MEMBERS.update(
454
454
  }
455
455
  )
456
456
 
457
+ _CHECKSUM = {"id": "bogon_ip_v1", "format": "csvgz", "checksums": DIGESTS}
458
+
459
+ # Each of these came back from 2.9.2 as an answer (measured 2026-10-25): an object where a
460
+ # list is due as an empty one, a member typed nowhere but the spec as served, a
461
+ # `license_type` outside its three, and a checksum answer's unread `id` whatever it held.
462
+ UNREADABLE_MEMBERS.update(
463
+ {
464
+ "list databases as an object": (LIST_PATH, {"databases": {}}),
465
+ "downloads as an object": (DOWNLOADS_PATH, {"downloads": {}}),
466
+ "list license_type outside its enum": (
467
+ LIST_PATH,
468
+ {"databases": [_with("license_type", "trial", database("bogon_ip"))]},
469
+ ),
470
+ "list sample_formats as an object": (
471
+ LIST_PATH,
472
+ {"databases": [_version_with("sample_formats", {})]},
473
+ ),
474
+ "metadata update_freq as a number": (METADATA_PATH, _with("update_freq", 1, METADATA)),
475
+ "metadata column description as a number": (
476
+ METADATA_PATH,
477
+ {**METADATA, "schema": {"csvgz": [{"name": "ip", "type": "string", "description": 1}]}},
478
+ ),
479
+ "metadata columns as an object": (METADATA_PATH, {**METADATA, "schema": {"csvgz": {}}}),
480
+ "metadata sample rows as an object": (METADATA_PATH, {**METADATA, "sample": {"csvgz": {}}}),
481
+ "metadata sample row as pairs": (
482
+ METADATA_PATH,
483
+ {**METADATA, "sample": {"csvgz": [[["first_ip", "10.0.0.0"]]]}},
484
+ ),
485
+ **{
486
+ f"downloads {m} as a number": (DOWNLOADS_PATH, {"downloads": [_with(m, 7, _DOWNLOAD)]})
487
+ for m in ("apikey_id", "client_ip", "user_agent")
488
+ },
489
+ "checksums id as null": (CHECKSUM_PATH, _with("id", None, _CHECKSUM)),
490
+ "checksums id as a number": (CHECKSUM_PATH, _with("id", 7, _CHECKSUM)),
491
+ "checksums as pairs": (CHECKSUM_PATH, _with("checksums", list(DIGESTS.items()), _CHECKSUM)),
492
+ }
493
+ )
494
+
495
+ # What `fromisoformat` read as a date-time and RFC 3339 does not (2.9.2, measured
496
+ # 2026-10-25): no offset, which made a naive datetime, a date alone, a space for the `T`,
497
+ # offsets without their colon, with seconds or past 23:59, no seconds, a comma for the
498
+ # point, the basic format and a week date. Then what neither reads.
499
+ NOT_DATE_TIMES = [
500
+ "2026-10-05T10:00:00",
501
+ "2026-10-05",
502
+ "2026-10-05 10:00:00Z",
503
+ "2026-10-05T10:00:00+0500",
504
+ "2026-10-05T10:00:00+05",
505
+ "2026-10-05T10:00:00+05:00:30",
506
+ "2026-10-05T10:00:00+05:60",
507
+ "2026-10-05T10:00:00+24:00",
508
+ "2026-10-05T10:00Z",
509
+ "2026-10-05T10:00:00,5Z",
510
+ "20261005T100000Z",
511
+ "2026-W41-1T10:00:00Z",
512
+ "2026-10-5T10:00:00Z",
513
+ " 2026-10-05T10:00:00Z",
514
+ "2026-02-30T10:00:00Z",
515
+ "2026-10-05T23:59:60Z",
516
+ "tomorrow",
517
+ ]
518
+
519
+ # The same for `updated`, a `date`: the basic format and week dates were read.
520
+ NOT_DATES = ["20261005", "2026-W41-1", "2026-W41", "2026-10-5", "2026-02-30", "2026-10-05T00:00Z"]
521
+
522
+ for _value in NOT_DATE_TIMES:
523
+ UNREADABLE_MEMBERS[f"list starts {_value!r}"] = (
524
+ LIST_PATH,
525
+ {"databases": [database("bogon_ip", starts=_value)]},
526
+ )
527
+ UNREADABLE_MEMBERS[f"downloads created {_value!r}"] = (
528
+ DOWNLOADS_PATH,
529
+ {"downloads": [_with("created", _value, _DOWNLOAD)]},
530
+ )
531
+ for _value in NOT_DATES:
532
+ UNREADABLE_MEMBERS[f"metadata updated {_value!r}"] = (
533
+ METADATA_PATH,
534
+ _with("updated", _value, METADATA),
535
+ )
536
+
457
537
  MEMBER_CALLS = {LIST_PATH: "list", METADATA_PATH: "metadata", DOWNLOADS_PATH: "downloads"}
458
538
 
459
539
 
@@ -475,6 +555,32 @@ def test_an_answer_missing_what_the_spec_requires_is_a_retried_server_error(
475
555
  assert len(stub.requests) == 3
476
556
 
477
557
 
558
+ @pytest.mark.parametrize(
559
+ "value",
560
+ [
561
+ "2026-10-05T10:00:00Z",
562
+ "2026-10-05t10:00:00z",
563
+ "2026-10-05T10:00:00-00:00",
564
+ "2026-10-05T15:30:00+05:30",
565
+ "2026-10-05T05:00:00.123456789-05:00",
566
+ ],
567
+ )
568
+ def test_an_rfc_3339_date_time_is_read_as_the_instant_it_names(
569
+ make_client: ClientFactory, value: str
570
+ ) -> None:
571
+ """In each of RFC 3339's spellings, the lowercase ones `fromisoformat` refused included;
572
+ past six digits a fraction is cut."""
573
+ stub = Stub({LIST_PATH: {"body": {"databases": [database("bogon_ip", starts=value)]}}})
574
+ client = make_client(transport=stub.transport)
575
+
576
+ starts = client.database.list()[0].starts
577
+
578
+ assert starts is not None and starts.utcoffset() is not None
579
+ expected = datetime.datetime(2026, 10, 5, 10, tzinfo=datetime.UTC)
580
+ assert starts.replace(microsecond=0) == expected
581
+ assert starts.microsecond == (123456 if "." in value else 0)
582
+
583
+
478
584
  @pytest.mark.parametrize(
479
585
  ("status", "body", "kind"),
480
586
  [
@@ -538,6 +644,51 @@ def test_retries_are_configurable_on_the_client(make_client: ClientFactory) -> N
538
644
  assert len(stub.requests) == 1
539
645
 
540
646
 
647
+ @pytest.mark.parametrize("client", [InternetData, AsyncInternetData])
648
+ @pytest.mark.parametrize(
649
+ "retries", [-1, float("nan"), 1.5, float("-inf"), "2", None, True, 10**400 * -1]
650
+ )
651
+ def test_a_retry_count_that_is_not_a_whole_number_from_0_is_refused_when_the_client_is_built(
652
+ client: type[InternetData | AsyncInternetData], retries: Any
653
+ ) -> None:
654
+ """Accepted by 2.9.2: NaN retried a failing call until it succeeded, -1 read as 0, 1.5 as
655
+ two retries, and a string or None raised a raw TypeError out of every failing call."""
656
+ with pytest.raises(ValueError, match="retries"):
657
+ client(API_KEY, retries=retries)
658
+
659
+
660
+ @pytest.mark.parametrize("retries", [0, 2, 2.0])
661
+ def test_a_whole_retry_count_is_what_a_failing_call_spends(
662
+ make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, retries: float
663
+ ) -> None:
664
+ monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
665
+ stub = Stub({LIST_PATH: {"status": 503, "body": {"rc": "UNAVAILABLE"}}})
666
+ client = make_client(transport=stub.transport, retries=retries)
667
+
668
+ with pytest.raises(InternetDataError):
669
+ client.database.list()
670
+
671
+ assert len(stub.requests) == retries + 1
672
+
673
+
674
+ def test_an_infinite_retry_count_retries_until_the_call_succeeds(
675
+ make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch
676
+ ) -> None:
677
+ monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
678
+ sent: list[httpx.Request] = []
679
+
680
+ def handler(request: httpx.Request) -> httpx.Response:
681
+ sent.append(request)
682
+ if len(sent) <= 6:
683
+ return httpx.Response(503, json={"rc": "UNAVAILABLE"})
684
+ return httpx.Response(200, json={"databases": []})
685
+
686
+ client = make_client(transport=httpx.MockTransport(handler), retries=float("inf"))
687
+
688
+ assert client.database.list() == []
689
+ assert len(sent) == 7
690
+
691
+
541
692
  def test_a_spent_quota_is_never_retried(make_client: ClientFactory) -> None:
542
693
  stub = Stub({LIST_PATH: {"status": 429, "body": {"rc": "QUOTA_EXCEEDED"}}})
543
694
  client = make_client(transport=stub.transport, retries=3)
@@ -745,7 +896,7 @@ def test_a_per_call_timeout_bounds_a_trickling_body_and_leaves_the_clients_own_a
745
896
  _assert_bounded_by(default, CLIENT_TIMEOUT)
746
897
 
747
898
 
748
- @pytest.mark.parametrize("timeout", [0, -1, float("nan"), float("inf"), 1e300, "30", True])
899
+ @pytest.mark.parametrize("timeout", [0, -1, float("nan"), float("inf"), 1e300, 10**400, "30", True])
749
900
  @pytest.mark.parametrize("call", PER_CALL)
750
901
  def test_a_per_call_timeout_no_attempt_can_meet_is_refused_before_any_request(
751
902
  make_client: ClientFactory, call: str, timeout: Any
@@ -799,7 +950,7 @@ def test_the_default_timeout_is_thirty_seconds() -> None:
799
950
 
800
951
  @pytest.mark.parametrize("client", [InternetData, AsyncInternetData])
801
952
  @pytest.mark.parametrize(
802
- "timeout", [0, -1, 0.0, float("nan"), float("inf"), 9223372037, 1e300, "30", True]
953
+ "timeout", [0, -1, 0.0, float("nan"), float("inf"), 9223372037, 1e300, 10**400, "30", True]
803
954
  )
804
955
  def test_a_timeout_no_attempt_can_meet_is_refused_when_the_client_is_built(
805
956
  client: type[InternetData | AsyncInternetData], timeout: Any
File without changes
File without changes