internetdata 2.1.0__tar.gz → 2.2.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 (55) hide show
  1. {internetdata-2.1.0 → internetdata-2.2.0}/PKG-INFO +8 -4
  2. {internetdata-2.1.0 → internetdata-2.2.0}/README.md +7 -3
  3. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/__init__.py +7 -1
  4. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_core.py +25 -0
  5. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/aio.py +2 -0
  6. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/client.py +6 -3
  7. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/models.py +32 -5
  8. {internetdata-2.1.0 → internetdata-2.2.0}/testdata/testdata.json +15 -1
  9. {internetdata-2.1.0 → internetdata-2.2.0}/tests/test_client.py +79 -1
  10. {internetdata-2.1.0 → internetdata-2.2.0}/tests/test_conformance.py +36 -14
  11. {internetdata-2.1.0 → internetdata-2.2.0}/.gitignore +0 -0
  12. {internetdata-2.1.0 → internetdata-2.2.0}/LICENSE +0 -0
  13. {internetdata-2.1.0 → internetdata-2.2.0}/pyproject.toml +0 -0
  14. {internetdata-2.1.0 → internetdata-2.2.0}/scripts/download-spec.sh +0 -0
  15. {internetdata-2.1.0 → internetdata-2.2.0}/scripts/generate.sh +0 -0
  16. {internetdata-2.1.0 → internetdata-2.2.0}/scripts/publish.sh +0 -0
  17. {internetdata-2.1.0 → internetdata-2.2.0}/scripts/v2_subset.py +0 -0
  18. {internetdata-2.1.0 → internetdata-2.2.0}/spec/openapi.yaml +0 -0
  19. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/__init__.py +0 -0
  20. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/api/__init__.py +0 -0
  21. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
  22. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
  23. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
  24. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
  25. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
  26. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
  27. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/client.py +0 -0
  28. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/errors.py +0 -0
  29. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/__init__.py +0 -0
  30. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/database.py +0 -0
  31. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
  32. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/database_format.py +0 -0
  33. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
  34. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
  35. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
  36. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/database_metadata.py +0 -0
  37. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
  38. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
  39. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
  40. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
  41. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
  42. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/database_version.py +0 -0
  43. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/db_checksums.py +0 -0
  44. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/download.py +0 -0
  45. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/download_outcome.py +0 -0
  46. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/error.py +0 -0
  47. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
  48. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
  49. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/models/standing.py +0 -0
  50. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/_generated/types.py +0 -0
  51. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/errors.py +0 -0
  52. {internetdata-2.1.0 → internetdata-2.2.0}/src/internetdata/py.typed +0 -0
  53. {internetdata-2.1.0 → internetdata-2.2.0}/tests/conftest.py +0 -0
  54. {internetdata-2.1.0 → internetdata-2.2.0}/tests/helpers.py +0 -0
  55. {internetdata-2.1.0 → internetdata-2.2.0}/tests/test_download.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: internetdata
3
- Version: 2.1.0
3
+ Version: 2.2.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
@@ -72,7 +72,7 @@ with InternetData(api_key) as client:
72
72
 
73
73
  ### The catalog
74
74
 
75
- `list` answers database FAMILIES. A licence is held against a family, while a download names a specific version, so the ids the other calls take come from each family's `versions`:
75
+ `list` answers database FAMILIES. A license is held against a family, while a download names a specific version, so the ids the other calls take come from each family's `versions`:
76
76
 
77
77
  ```python
78
78
  for family in client.database.list():
@@ -82,7 +82,9 @@ for family in client.database.list():
82
82
  print(version.id, version.formats) # 'bogon_ip_v1' ('csvgz', 'mmdb')
83
83
  ```
84
84
 
85
- `standing` is `licensed`, `expired` or `unlicensed`, and `license_type` is what your licence lets you do with the data (`evaluation`, `standard`, `redistribute`, or `None` when there is no licence). A family you have never bought is still listed, as `unlicensed`, so you can see what else exists.
85
+ `standing` is `licensed`, `expired` or `unlicensed`, and `license_type` is what your license lets you do with the data (`evaluation`, `standard`, `redistribute`, or `None` when there is no license). A family you have never bought is still listed, as `unlicensed`, so you can see what else exists.
86
+
87
+ A rolling license also carries `renews_at`, when it next renews, and `notice_due_at`, the last day you can give notice of non-renewal for that term. Both are `None` when there is no license, when it has no defined term, or when `expires` sets a hard stop instead. `DATABASE_FORMATS`, `STANDINGS` and `LICENSE_TYPES` hold the published values at runtime, for checking one that came from a flag or a form before you make a call.
86
88
 
87
89
  ### What is inside a build
88
90
 
@@ -165,7 +167,7 @@ except InternetDataError as err:
165
167
 
166
168
  `kind` is one of `bad_request`, `unauthorized`, `forbidden`, `rate_limited`, `quota_exceeded`, `server_error` or `network`. `message` is the API's own result code, passed through as it was sent, so you can switch on `NOT_LICENSED` against `LICENSE_EXPIRED` without reading the status.
167
169
 
168
- A request that runs past its `timeout` fails with `network`, and is retried like any other network failure. The default is 30 seconds per attempt, body included, so a retried call can take longer in total. A database transfer is exempt, so `download` and `download_bytes` are never cut off part way through a large file. Set it on the client, in seconds, or pass `None` for no bound:
170
+ A request that runs past its `timeout` fails with `network`, and is retried like any other network failure. The default is 30 seconds per attempt, body included, so a retried call can take longer in total. A database transfer is exempt, so `download` and `download_bytes` are never cut off part way through a large file. Set it on the client, in seconds, or pass `None` for no bound. Anything else that is not a number greater than 0 raises `ValueError` when the client is built:
169
171
 
170
172
  ```python
171
173
  client = InternetData(api_key, timeout=10)
@@ -173,6 +175,8 @@ client = InternetData(api_key, timeout=10)
173
175
 
174
176
  **Changed in 2.1.0:** the default was 10 seconds, and it bounded each read of a response rather than the whole attempt, so a response trickling in slowly could run past it for as long as the server kept sending.
175
177
 
178
+ **Changed in 2.2.0:** a timeout of 0 or less, NaN or a string used to be accepted, and failed every call.
179
+
176
180
  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.
177
181
 
178
182
  ## Other Libraries
@@ -38,7 +38,7 @@ with InternetData(api_key) as client:
38
38
 
39
39
  ### The catalog
40
40
 
41
- `list` answers database FAMILIES. A licence is held against a family, while a download names a specific version, so the ids the other calls take come from each family's `versions`:
41
+ `list` answers database FAMILIES. A license is held against a family, while a download names a specific version, so the ids the other calls take come from each family's `versions`:
42
42
 
43
43
  ```python
44
44
  for family in client.database.list():
@@ -48,7 +48,9 @@ for family in client.database.list():
48
48
  print(version.id, version.formats) # 'bogon_ip_v1' ('csvgz', 'mmdb')
49
49
  ```
50
50
 
51
- `standing` is `licensed`, `expired` or `unlicensed`, and `license_type` is what your licence lets you do with the data (`evaluation`, `standard`, `redistribute`, or `None` when there is no licence). A family you have never bought is still listed, as `unlicensed`, so you can see what else exists.
51
+ `standing` is `licensed`, `expired` or `unlicensed`, and `license_type` is what your license lets you do with the data (`evaluation`, `standard`, `redistribute`, or `None` when there is no license). A family you have never bought is still listed, as `unlicensed`, so you can see what else exists.
52
+
53
+ A rolling license also carries `renews_at`, when it next renews, and `notice_due_at`, the last day you can give notice of non-renewal for that term. Both are `None` when there is no license, when it has no defined term, or when `expires` sets a hard stop instead. `DATABASE_FORMATS`, `STANDINGS` and `LICENSE_TYPES` hold the published values at runtime, for checking one that came from a flag or a form before you make a call.
52
54
 
53
55
  ### What is inside a build
54
56
 
@@ -131,7 +133,7 @@ except InternetDataError as err:
131
133
 
132
134
  `kind` is one of `bad_request`, `unauthorized`, `forbidden`, `rate_limited`, `quota_exceeded`, `server_error` or `network`. `message` is the API's own result code, passed through as it was sent, so you can switch on `NOT_LICENSED` against `LICENSE_EXPIRED` without reading the status.
133
135
 
134
- A request that runs past its `timeout` fails with `network`, and is retried like any other network failure. The default is 30 seconds per attempt, body included, so a retried call can take longer in total. A database transfer is exempt, so `download` and `download_bytes` are never cut off part way through a large file. Set it on the client, in seconds, or pass `None` for no bound:
136
+ A request that runs past its `timeout` fails with `network`, and is retried like any other network failure. The default is 30 seconds per attempt, body included, so a retried call can take longer in total. A database transfer is exempt, so `download` and `download_bytes` are never cut off part way through a large file. Set it on the client, in seconds, or pass `None` for no bound. Anything else that is not a number greater than 0 raises `ValueError` when the client is built:
135
137
 
136
138
  ```python
137
139
  client = InternetData(api_key, timeout=10)
@@ -139,6 +141,8 @@ client = InternetData(api_key, timeout=10)
139
141
 
140
142
  **Changed in 2.1.0:** the default was 10 seconds, and it bounded each read of a response rather than the whole attempt, so a response trickling in slowly could run past it for as long as the server kept sending.
141
143
 
144
+ **Changed in 2.2.0:** a timeout of 0 or less, NaN or a string used to be accepted, and failed every call.
145
+
142
146
  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.
143
147
 
144
148
  ## Other Libraries
@@ -15,6 +15,9 @@ from .aio import AsyncDatabaseApi, AsyncInternetData
15
15
  from .client import DatabaseApi, InternetData
16
16
  from .errors import ErrorKind, InternetDataError
17
17
  from .models import (
18
+ DATABASE_FORMATS,
19
+ LICENSE_TYPES,
20
+ STANDINGS,
18
21
  Database,
19
22
  DatabaseMetadata,
20
23
  DatabaseVersion,
@@ -26,10 +29,13 @@ from .models import (
26
29
  Standing,
27
30
  )
28
31
 
29
- __version__ = "2.1.0"
32
+ __version__ = "2.2.0"
30
33
 
31
34
  __all__ = [
35
+ "DATABASE_FORMATS",
32
36
  "DEFAULT_BASE_URL",
37
+ "LICENSE_TYPES",
38
+ "STANDINGS",
33
39
  "AsyncDatabaseApi",
34
40
  "AsyncInternetData",
35
41
  "Database",
@@ -7,6 +7,7 @@ import asyncio
7
7
  import contextlib
8
8
  import contextvars
9
9
  import json
10
+ import math
10
11
  import os
11
12
  import threading
12
13
  from collections.abc import Awaitable, Callable, Iterator
@@ -105,6 +106,30 @@ def build_async_transfer_client(
105
106
  )
106
107
 
107
108
 
109
+ def check_timeout(timeout: float | None) -> float | None:
110
+ """`timeout`, once it is a bound an attempt can meet.
111
+
112
+ Refused where it is SET, because nothing downstream refuses it: zero, a negative
113
+ number, NaN or a string reached the first call and failed it, and every call after,
114
+ as a retried `network` error after three seconds of backoff, or for a string as a
115
+ `server_error` blaming the API. Infinity is refused too, since the sync client's wait
116
+ cannot hold it (`OverflowError`); None is the spelling for no bound.
117
+ """
118
+ if timeout is None:
119
+ return None
120
+ if (
121
+ isinstance(timeout, bool)
122
+ or not isinstance(timeout, int | float)
123
+ or not math.isfinite(timeout)
124
+ or timeout <= 0
125
+ ):
126
+ raise ValueError(
127
+ f"timeout must be a number of seconds greater than 0, or None for no bound, "
128
+ f"not {timeout!r}"
129
+ )
130
+ return timeout
131
+
132
+
108
133
  def storage_refusal(res: httpx.Response) -> InternetDataError:
109
134
  """What object storage refusing a download link becomes.
110
135
 
@@ -26,6 +26,7 @@ from ._core import (
26
26
  assert_whole_transfer,
27
27
  build_async_transfer_client,
28
28
  build_client,
29
+ check_timeout,
29
30
  checksums_of,
30
31
  databases_of,
31
32
  downloads_of,
@@ -73,6 +74,7 @@ class AsyncInternetData:
73
74
  timeout: float | None = DEFAULT_TIMEOUT,
74
75
  transport: httpx.AsyncBaseTransport | None = None,
75
76
  ) -> None:
77
+ timeout = check_timeout(timeout)
76
78
  self._client = build_client(api_key, base_url, timeout, transport)
77
79
  self._transfer = build_async_transfer_client(timeout, transport)
78
80
  self._retries = retries
@@ -21,6 +21,7 @@ from ._core import (
21
21
  assert_whole_transfer,
22
22
  build_client,
23
23
  build_transfer_client,
24
+ check_timeout,
24
25
  checksums_of,
25
26
  databases_of,
26
27
  downloads_of,
@@ -55,12 +56,13 @@ class InternetData:
55
56
  Every database published today is licensed, so create a key carrying the
56
57
  `db.download` scope in the console and pass it in. The argument is optional
57
58
  nonetheless, and an absent or empty one sends no `Authorization` header at all
58
- rather than an empty one: what this API serves without a licence is a product
59
+ rather than an empty one: what this API serves without a license is a product
59
60
  decision, not the client's to refuse.
60
61
 
61
62
  `timeout` is how long one attempt at a request may take, in seconds, body included, so a
62
63
  call that is retried can take longer in total; None means no bound, and a database
63
- transfer is exempt.
64
+ transfer is exempt. Anything else that is not a finite number greater than 0 is a
65
+ `ValueError` here, rather than a failure of every call.
64
66
 
65
67
  Holds an HTTP connection pool, so use it as a context manager or call `close()` when
66
68
  you are done with it.
@@ -78,6 +80,7 @@ class InternetData:
78
80
  timeout: float | None = DEFAULT_TIMEOUT,
79
81
  transport: httpx.BaseTransport | None = None,
80
82
  ) -> None:
83
+ timeout = check_timeout(timeout)
81
84
  self._client = build_client(api_key, base_url, timeout, transport)
82
85
  self._transfer = build_transfer_client(timeout, transport)
83
86
  self._retries = retries
@@ -133,7 +136,7 @@ class DatabaseApi:
133
136
  customer. Nothing is cached and nothing is reconstructed here - what you get is
134
137
  what the server sent for the key you are holding.
135
138
 
136
- A licence covers a FAMILY, while a download names a version, so the ids for the
139
+ A license covers a FAMILY, while a download names a version, so the ids for the
137
140
  other calls come from each entry's `versions`.
138
141
  """
139
142
 
@@ -11,9 +11,12 @@ from __future__ import annotations
11
11
 
12
12
  import datetime
13
13
  from dataclasses import dataclass, field
14
- from typing import Any, Literal
14
+ from typing import Any, Literal, get_args
15
15
 
16
16
  __all__ = [
17
+ "DATABASE_FORMATS",
18
+ "LICENSE_TYPES",
19
+ "STANDINGS",
17
20
  "Database",
18
21
  "DatabaseMetadata",
19
22
  "DatabaseVersion",
@@ -37,7 +40,20 @@ Standing = Literal["licensed", "expired", "unlicensed"]
37
40
  """Where your organization stands with one database family."""
38
41
 
39
42
  LicenseType = Literal["evaluation", "standard", "redistribute"]
40
- """What a licence permits you to do with the data. `None` when there is no licence."""
43
+ """What a license permits you to do with the data. `None` when there is no license."""
44
+
45
+ DATABASE_FORMATS: tuple[Format, ...] = get_args(Format)
46
+ """Every `Format`, at runtime.
47
+
48
+ A `Literal` is erased to nothing a program can check against, so a format read from a
49
+ flag, a form or a config file has these to be tested against before a call.
50
+ """
51
+
52
+ STANDINGS: tuple[Standing, ...] = get_args(Standing)
53
+ """Every `Standing`, at runtime."""
54
+
55
+ LICENSE_TYPES: tuple[LicenseType, ...] = get_args(LicenseType)
56
+ """Every `LicenseType`, at runtime. `None`, for no license, is not one of them."""
41
57
 
42
58
  Outcome = Literal["ok", "unauthorized", "denied", "expired", "unknown", "unavailable"]
43
59
  """How one download attempt ended, refusals included."""
@@ -49,7 +65,7 @@ class DatabaseVersion:
49
65
 
50
66
  Old versions are frozen rather than migrated, so several stay downloadable at once.
51
67
  `id` is what `download`, `checksums` and `metadata` take; the family `base` is what a
52
- licence is held against.
68
+ license is held against.
53
69
  """
54
70
 
55
71
  id: str
@@ -60,13 +76,18 @@ class DatabaseVersion:
60
76
 
61
77
  @dataclass(frozen=True, slots=True)
62
78
  class Database:
63
- """One database FAMILY, with your organization's licence beside it.
79
+ """One database FAMILY, with your organization's license beside it.
64
80
 
65
81
  A family your organization has never licensed is still listed, with `standing` set to
66
82
  `unlicensed`, so you can see what else exists. A family commissioned for a single
67
83
  customer is a different matter: it is absent from this listing entirely for everyone
68
84
  who does not license it. Absence here means "not yours to see", never "does not
69
85
  exist", so the catalog is not the same document for every key.
86
+
87
+ A rolling license carries `renews_at`, when it next renews, and `notice_due_at`, the
88
+ last day notice of non-renewal can be given for the term ending then. Both are None
89
+ when there is no license, when it has no defined term, or when `expires` sets a hard
90
+ stop instead; `notice_due_at` is None too when the agreement records no notice period.
70
91
  """
71
92
 
72
93
  base: str
@@ -76,6 +97,10 @@ class Database:
76
97
  license_type: LicenseType | None
77
98
  starts: datetime.datetime | None
78
99
  expires: datetime.datetime | None
100
+ # Keyword-only with a default, so a Database built by hand before 2.2.0 still builds;
101
+ # the parser always sets both.
102
+ renews_at: datetime.datetime | None = field(default=None, kw_only=True)
103
+ notice_due_at: datetime.datetime | None = field(default=None, kw_only=True)
79
104
  versions: tuple[DatabaseVersion, ...]
80
105
  raw: dict[str, Any] = field(default_factory=dict)
81
106
 
@@ -137,6 +162,8 @@ def to_database(body: dict[str, Any]) -> Database:
137
162
  license_type=body["license_type"],
138
163
  starts=_datetime(body["starts"]),
139
164
  expires=_datetime(body["expires"]),
165
+ renews_at=_datetime(body["renews_at"]),
166
+ notice_due_at=_datetime(body["notice_due_at"]),
140
167
  versions=tuple(to_version(v) for v in body["versions"]),
141
168
  raw=body,
142
169
  )
@@ -185,7 +212,7 @@ def to_download(body: dict[str, Any]) -> Download:
185
212
 
186
213
 
187
214
  def _datetime(value: Any) -> datetime.datetime | None:
188
- """A NULLABLE wire timestamp. A licence with no end date carries `expires: null`.
215
+ """A NULLABLE wire timestamp. A license with no end date carries `expires: null`.
189
216
 
190
217
  `fromisoformat` only learned to read a trailing `Z` in 3.11, which is one of the
191
218
  three reasons this package floors there. A value that is neither null nor readable
@@ -27,7 +27,7 @@
27
27
  }
28
28
  },
29
29
  {
30
- "name": "licence-lapsed",
30
+ "name": "license-lapsed",
31
31
  "status": 403,
32
32
  "headers": {},
33
33
  "body": {
@@ -78,6 +78,20 @@
78
78
  "message": "INVALID_FORMAT"
79
79
  }
80
80
  },
81
+ {
82
+ "name": "unlisted-4xx-is-not-retryable",
83
+ "why": "The RANGE is the rule, not a list of statuses. An SDK that enumerated 400 and 404 passed every other case here while a 422 fell through to the retryable server_error default.",
84
+ "status": 422,
85
+ "headers": {},
86
+ "body": {
87
+ "rc": "INVALID_REQUEST"
88
+ },
89
+ "expect": {
90
+ "kind": "bad_request",
91
+ "retryable": false,
92
+ "message": "INVALID_REQUEST"
93
+ }
94
+ },
81
95
  {
82
96
  "name": "rate-limited-transient",
83
97
  "status": 429,
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import dataclasses
6
7
  import datetime
7
8
  import inspect
@@ -9,6 +10,7 @@ import time
9
10
  from collections.abc import Callable
10
11
  from typing import Any
11
12
 
13
+ import attrs
12
14
  import httpx
13
15
  import pytest
14
16
  from helpers import (
@@ -26,7 +28,26 @@ from helpers import (
26
28
  settle,
27
29
  )
28
30
 
29
- from internetdata import AsyncInternetData, InternetData, InternetDataError, _core
31
+ from internetdata import (
32
+ AsyncInternetData,
33
+ Database,
34
+ DatabaseMetadata,
35
+ DatabaseVersion,
36
+ Download,
37
+ InternetData,
38
+ InternetDataError,
39
+ MetadataColumn,
40
+ _core,
41
+ )
42
+ from internetdata._generated.models.database import Database as WireDatabase
43
+ from internetdata._generated.models.database_metadata import (
44
+ DatabaseMetadata as WireDatabaseMetadata,
45
+ )
46
+ from internetdata._generated.models.database_metadata_column import (
47
+ DatabaseMetadataColumn as WireDatabaseMetadataColumn,
48
+ )
49
+ from internetdata._generated.models.database_version import DatabaseVersion as WireDatabaseVersion
50
+ from internetdata._generated.models.download import Download as WireDownload
30
51
 
31
52
  METADATA = {
32
53
  "id": "bogon_ip_v1",
@@ -141,6 +162,47 @@ def test_a_licence_with_no_end_date_reads_as_none(make_client: ClientFactory) ->
141
162
 
142
163
  assert family.starts == datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
143
164
  assert family.expires is None
165
+ assert family.renews_at is None
166
+ assert family.notice_due_at is None
167
+
168
+
169
+ def test_a_rolling_license_carries_its_renewal_dates(make_client: ClientFactory) -> None:
170
+ served = database(
171
+ "bogon_ip",
172
+ renews_at="2027-01-01T00:00:00.000Z",
173
+ notice_due_at="2026-10-02T00:00:00.000Z",
174
+ )
175
+ stub = Stub({LIST_PATH: {"body": {"databases": [served]}}})
176
+ client = make_client(transport=stub.transport)
177
+
178
+ family = client.database.list()[0]
179
+
180
+ assert family.renews_at == datetime.datetime(2027, 1, 1, tzinfo=datetime.UTC)
181
+ assert family.notice_due_at == datetime.datetime(2026, 10, 2, tzinfo=datetime.UTC)
182
+
183
+
184
+ @pytest.mark.parametrize(
185
+ ("wire", "ours"),
186
+ [
187
+ (WireDatabase, Database),
188
+ (WireDatabaseVersion, DatabaseVersion),
189
+ (WireDatabaseMetadata, DatabaseMetadata),
190
+ (WireDatabaseMetadataColumn, MetadataColumn),
191
+ (WireDownload, Download),
192
+ ],
193
+ )
194
+ def test_every_field_the_pinned_spec_serves_is_on_the_model(wire: Any, ours: Any) -> None:
195
+ """The staleness pin on the hand-written models.
196
+
197
+ They parse the keys they name and nothing else, so a field a re-pin adds reaches the
198
+ generated code and `raw` while the typed model never hears of it: `renews_at` and
199
+ `notice_due_at` sat that way from the 2026-09-09 re-pin until 2.2.0. The generated
200
+ classes come from the same pinned spec, which makes them the list to check against.
201
+ The generator suffixes a name that shadows a builtin with `_`.
202
+ """
203
+ served = {f.name.rstrip("_") for f in attrs.fields(wire)} - {"additional_properties"}
204
+ modeled = {f.name for f in dataclasses.fields(ours)} - {"raw"}
205
+ assert served - modeled == set(), f"{ours.__name__} lacks what the spec serves"
144
206
 
145
207
 
146
208
  def test_a_database_cannot_be_mutated(make_client: ClientFactory) -> None:
@@ -292,6 +354,22 @@ def test_the_default_timeout_is_thirty_seconds() -> None:
292
354
  assert inspect.signature(client).parameters["timeout"].default == 30
293
355
 
294
356
 
357
+ @pytest.mark.parametrize("client", [InternetData, AsyncInternetData])
358
+ @pytest.mark.parametrize("timeout", [0, -1, 0.0, float("nan"), float("inf"), "30", True])
359
+ def test_a_timeout_no_attempt_can_meet_is_refused_when_the_client_is_built(
360
+ client: type[InternetData | AsyncInternetData], timeout: Any
361
+ ) -> None:
362
+ """Accepted, each of these failed every call instead, after the retries' backoff."""
363
+ with pytest.raises(ValueError, match="timeout"):
364
+ client(API_KEY, timeout=timeout)
365
+
366
+
367
+ @pytest.mark.parametrize("timeout", [None, 0.25, 1, 30])
368
+ def test_a_usable_timeout_builds_a_client(timeout: float | None) -> None:
369
+ InternetData(API_KEY, timeout=timeout).close()
370
+ asyncio.run(AsyncInternetData(API_KEY, timeout=timeout).aclose())
371
+
372
+
295
373
  # Refused before the network, and never retried: a typo is the caller's, not a failure of
296
374
  # the server's to wait out.
297
375
  def test_an_unpublished_format_is_refused_before_any_request(make_client: ClientFactory) -> None:
@@ -19,7 +19,7 @@ from helpers import (
19
19
  database,
20
20
  )
21
21
 
22
- from internetdata import InternetDataError
22
+ from internetdata import DATABASE_FORMATS, LICENSE_TYPES, STANDINGS, InternetDataError
23
23
 
24
24
  # One live grant, one that has run out, and one never bought. Between them these cover
25
25
  # every value the corpus pins for `standing` and `license_type`.
@@ -65,21 +65,32 @@ def test_every_error_shape_maps_to_the_same_kind_in_every_language(
65
65
  assert err.retry_after_seconds == expect["retryAfterSeconds"], name
66
66
 
67
67
 
68
- # The one that has caught three of four bindings: 404 is a CLIENT error. Mapping
69
- # 400/401/403/429 by name and letting the rest fall through to a retryable server_error
70
- # means an unknown database id is asked for three times before failing.
71
- def test_a_404_is_never_retried(make_client: ClientFactory) -> None:
72
- for case in TESTDATA["errors"]:
73
- if case["status"] != 404:
74
- continue
75
- stub = Stub({METADATA_PATH: {"status": 404, "body": case["body"]}})
68
+ # The one that has caught three of four bindings: every 4xx but a 429 carrying Retry-After
69
+ # is the CALLER's, and classifying by an enumerated list lets the rest (the corpus's 422)
70
+ # fall through to a retryable server_error. Asserted with retries ON and by counting
71
+ # requests: with retries off, one request is guaranteed and a wrong classifier passes.
72
+ def test_no_client_error_is_ever_retried(make_client: ClientFactory) -> None:
73
+ cases = [case for case in TESTDATA["errors"] if not case["expect"]["retryable"]]
74
+ assert any(case["name"] == "unlisted-4xx-is-not-retryable" for case in cases)
75
+
76
+ for case in cases:
77
+ stub = Stub(
78
+ {
79
+ METADATA_PATH: {
80
+ "status": case["status"],
81
+ "body": case["body"],
82
+ "headers": case["headers"],
83
+ }
84
+ }
85
+ )
76
86
  client = make_client(transport=stub.transport, retries=3)
77
87
 
78
88
  with pytest.raises(InternetDataError) as caught:
79
89
  client.database.metadata("nope_v1")
80
90
 
81
- assert caught.value.retryable is False, case["name"]
91
+ # The count first: a retried case also ends on an error, just a later one.
82
92
  assert len(stub.requests) == 1, f"{case['name']}: issued {len(stub.requests)} requests"
93
+ assert caught.value.kind == case["expect"]["kind"], case["name"]
83
94
 
84
95
 
85
96
  # Both arrive as 429 and the header is the only thing separating them, so a client that
@@ -118,7 +129,7 @@ def test_the_catalog_carries_every_standing_and_license_type_the_corpus_pins(
118
129
  assert served_rights <= set(TESTDATA["license_type"]), (
119
130
  f"undocumented license_type right in {served_rights}"
120
131
  )
121
- # Null when there is no licence, which is a different answer from any of the three.
132
+ # Null when there is no license, which is a different answer from any of the three.
122
133
  assert [f.license_type for f in families][-1] is None
123
134
  for family in families:
124
135
  assert family.versions, f"{family.base} carries no versions"
@@ -128,6 +139,17 @@ def test_the_catalog_carries_every_standing_and_license_type_the_corpus_pins(
128
139
  )
129
140
 
130
141
 
142
+ def test_the_runtime_vocabularies_are_the_pinned_specs() -> None:
143
+ """The staleness pin on three hand-written Literals.
144
+
145
+ `emit.mjs` reads the corpus's lists out of the pinned spec, so a re-pin that widens a
146
+ vocabulary turns this red instead of leaving a published value unnamed here.
147
+ """
148
+ assert sorted(DATABASE_FORMATS) == sorted(TESTDATA["formats"])
149
+ assert sorted(STANDINGS) == sorted(TESTDATA["standings"])
150
+ assert sorted(LICENSE_TYPES) == sorted(TESTDATA["license_type"])
151
+
152
+
131
153
  # The visibility contract. A private family is one commissioned for a single customer: the
132
154
  # server leaves it out of the listing entirely for anyone else, rather than including it
133
155
  # with standing 'unlicensed'. All this library has to do is not undo that, which is a real
@@ -161,7 +183,7 @@ def test_a_listing_is_never_reused_across_clients(make_client: ClientFactory) ->
161
183
  """Two keys are two organizations, and they do not see the same catalog.
162
184
 
163
185
  So there is no listing cache at all here, per instance or otherwise: a cached one
164
- would be wrong the moment a licence is granted, and a shared one would show an
186
+ would be wrong the moment a license is granted, and a shared one would show an
165
187
  organization a family it is not allowed to know exists.
166
188
  """
167
189
  assert "a-listing-is-never-reused-across-clients" in TESTDATA["visibility"]["clientRules"]
@@ -172,7 +194,7 @@ def test_a_listing_is_never_reused_across_clients(make_client: ClientFactory) ->
172
194
  assert len(first.database.list()) == len(CATALOG)
173
195
  assert second.database.list() == [], "the second key answered from the first one's listing"
174
196
  assert len(stub.requests) == 2
175
- # A repeat on the SAME client asks again too: a licence granted between two calls has
197
+ # A repeat on the SAME client asks again too: a license granted between two calls has
176
198
  # to show up.
177
199
  first.database.list()
178
200
  assert len(stub.requests) == 3
@@ -210,7 +232,7 @@ def test_the_api_key_reaches_the_wire_under_the_bearer_scheme(
210
232
  def test_a_keyless_client_sends_no_authorization_header(
211
233
  make_client: ClientFactory, api_key: str | None
212
234
  ) -> None:
213
- """The key is optional because what this API serves without a licence is a product
235
+ """The key is optional because what this API serves without a license is a product
214
236
  decision, and a client that could not be built without one would have to change
215
237
  shape to follow it. What must never go out is `Bearer ` with nothing after it, which
216
238
  reads as a wrong key rather than as none.
File without changes
File without changes