internetdata 2.7.3__tar.gz → 2.7.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {internetdata-2.7.3 → internetdata-2.7.5}/PKG-INFO +1 -1
  2. {internetdata-2.7.3 → internetdata-2.7.5}/spec/openapi.yaml +4 -4
  3. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/__init__.py +1 -1
  4. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_core.py +13 -7
  5. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/organization/account_org_members.py +4 -4
  6. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/account_create_apikey_request.py +1 -1
  7. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/apikey_detail.py +1 -1
  8. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database_metadata.py +1 -3
  9. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/models.py +52 -28
  10. {internetdata-2.7.3 → internetdata-2.7.5}/tests/test_client.py +44 -0
  11. {internetdata-2.7.3 → internetdata-2.7.5}/tests/test_conformance.py +36 -4
  12. {internetdata-2.7.3 → internetdata-2.7.5}/.gitignore +0 -0
  13. {internetdata-2.7.3 → internetdata-2.7.5}/LICENSE +0 -0
  14. {internetdata-2.7.3 → internetdata-2.7.5}/README.md +0 -0
  15. {internetdata-2.7.3 → internetdata-2.7.5}/pyproject.toml +0 -0
  16. {internetdata-2.7.3 → internetdata-2.7.5}/scripts/download-spec.sh +0 -0
  17. {internetdata-2.7.3 → internetdata-2.7.5}/scripts/generate.sh +0 -0
  18. {internetdata-2.7.3 → internetdata-2.7.5}/scripts/publish.sh +0 -0
  19. {internetdata-2.7.3 → internetdata-2.7.5}/scripts/v2_subset.py +0 -0
  20. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/__init__.py +0 -0
  21. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/__init__.py +0 -0
  22. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/account/__init__.py +0 -0
  23. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/account/account_identity.py +0 -0
  24. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/api_keys/__init__.py +0 -0
  25. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/api_keys/account_create_apikey.py +0 -0
  26. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/api_keys/account_list_apikeys.py +0 -0
  27. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/api_keys/account_reveal_apikey.py +0 -0
  28. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/api_keys/account_revoke_apikey.py +0 -0
  29. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/api_keys/account_rotate_apikey.py +0 -0
  30. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/authorization/__init__.py +0 -0
  31. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/authorization/oauth_authorize.py +0 -0
  32. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/authorization/oauth_device_authorization.py +0 -0
  33. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/authorization/oauth_metadata.py +0 -0
  34. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/authorization/oauth_revoke.py +0 -0
  35. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/authorization/oauth_token.py +0 -0
  36. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
  37. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
  38. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
  39. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
  40. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
  41. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
  42. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/organization/__init__.py +0 -0
  43. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/api/organization/account_org.py +0 -0
  44. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/client.py +0 -0
  45. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/errors.py +0 -0
  46. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/__init__.py +0 -0
  47. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/account_created_apikey.py +0 -0
  48. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/account_org.py +0 -0
  49. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/account_org_ref.py +0 -0
  50. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/account_org_wrap.py +0 -0
  51. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/account_rc.py +0 -0
  52. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/account_revealed_apikey.py +0 -0
  53. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/account_user.py +0 -0
  54. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/apikey_list.py +0 -0
  55. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database.py +0 -0
  56. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
  57. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database_format.py +0 -0
  58. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
  59. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
  60. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
  61. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
  62. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
  63. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
  64. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database_metadata_sample_size.py +0 -0
  65. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
  66. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
  67. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/database_version.py +0 -0
  68. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/db_checksums.py +0 -0
  69. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/device_authorization.py +0 -0
  70. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/device_authorization_request.py +0 -0
  71. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/download.py +0 -0
  72. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/download_outcome.py +0 -0
  73. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/error.py +0 -0
  74. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/identity.py +0 -0
  75. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
  76. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
  77. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/oauth_authorize_code_challenge_method.py +0 -0
  78. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/oauth_authorize_response_type.py +0 -0
  79. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/oauth_error.py +0 -0
  80. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/oauth_metadata.py +0 -0
  81. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/oauth_revoke_response_200.py +0 -0
  82. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/revoke_request.py +0 -0
  83. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/standing.py +0 -0
  84. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/token_request.py +0 -0
  85. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/models/token_response.py +0 -0
  86. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/_generated/types.py +0 -0
  87. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/aio.py +0 -0
  88. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/client.py +0 -0
  89. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/errors.py +0 -0
  90. {internetdata-2.7.3 → internetdata-2.7.5}/src/internetdata/py.typed +0 -0
  91. {internetdata-2.7.3 → internetdata-2.7.5}/testdata/testdata.json +0 -0
  92. {internetdata-2.7.3 → internetdata-2.7.5}/tests/conftest.py +0 -0
  93. {internetdata-2.7.3 → internetdata-2.7.5}/tests/helpers.py +0 -0
  94. {internetdata-2.7.3 → internetdata-2.7.5}/tests/test_download.py +0 -0
  95. {internetdata-2.7.3 → internetdata-2.7.5}/tests/test_oauth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: internetdata
3
- Version: 2.7.3
3
+ Version: 2.7.5
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
@@ -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.13
15
+ version: 2026.10.15
16
16
  servers:
17
17
  - url: https://internetdata.io
18
18
  tags:
@@ -416,7 +416,7 @@ paths:
416
416
  summary: Members
417
417
  description: |
418
418
  Read-only. Adding or removing a member is an invitation flow with email
419
- in the middle rather than a POST, and modelling it as one here would
419
+ in the middle rather than a POST, and modeling it as one here would
420
420
  promise something this API does not do.
421
421
  security:
422
422
  - BearerAuth:
@@ -1365,7 +1365,7 @@ components:
1365
1365
  type: string
1366
1366
  key_prefix:
1367
1367
  type: string
1368
- description: The leading, non-secret part, so a key is recognisable without storing it.
1368
+ description: The leading, non-secret part, so a key is recognizable without storing it.
1369
1369
  created:
1370
1370
  type: string
1371
1371
  format: date-time
@@ -1405,7 +1405,7 @@ components:
1405
1405
  properties:
1406
1406
  name:
1407
1407
  type: string
1408
- description: A label you will recognise later. Shown wherever the key is listed.
1408
+ description: A label you will recognize later. Shown wherever the key is listed.
1409
1409
  allowed_scopes:
1410
1410
  type: array
1411
1411
  items:
@@ -39,7 +39,7 @@ from .models import (
39
39
  TokenResponse,
40
40
  )
41
41
 
42
- __version__ = "2.7.3"
42
+ __version__ = "2.7.5"
43
43
 
44
44
  __all__ = [
45
45
  "DATABASE_FORMATS",
@@ -81,6 +81,11 @@ def build_client(
81
81
  the `Authorization` header when it CONSTRUCTS the httpx client itself.
82
82
  """
83
83
  api_key = check_api_key(api_key)
84
+ # httpx drops one trailing slash when it joins a path onto the base URL and keeps the
85
+ # rest, so a second or third reached the API as `//api/v2/database/list`, which prod
86
+ # answers with a redirect this client does not follow: every call failed (2.7.3,
87
+ # measured 2026-10-16).
88
+ base_url = base_url.rstrip("/")
84
89
  httpx_args: dict[str, Any] = {}
85
90
  if transport is not None:
86
91
  httpx_args["transport"] = transport
@@ -135,19 +140,20 @@ def build_async_transfer_client(
135
140
  def check_api_key(api_key: str | None) -> str | None:
136
141
  """`api_key` without the blanks around it, or None when nothing else is left.
137
142
 
138
- Refused where it is SET when a control character remains: httpx refuses a header
139
- holding a CR, LF or NUL, so every call failed as a retried `network` error with nothing
140
- sent, while a tab or DEL inside the key went out on the wire. Untrimmed, a key of blanks
141
- was sent as one rather than as no key, and failed every call the same way (2.7.1,
142
- measured 2026-10-12).
143
+ Refused where it is SET when a control character remains, or a character outside
144
+ ASCII, which httpx cannot put in a header: a CR, LF or NUL failed every call as a
145
+ retried `network` error with nothing sent, a C1 control or any other non-ASCII
146
+ character as a raw `UnicodeEncodeError` (2.7.3, measured 2026-10-16), while a tab or
147
+ DEL went out on the wire. Untrimmed, a key of blanks was sent as one rather than as no
148
+ key, and failed every call the same way (2.7.1, measured 2026-10-12).
143
149
  """
144
150
  if api_key is None:
145
151
  return None
146
152
  if not isinstance(api_key, str):
147
153
  raise TypeError(f"api_key must be a string or None, not {type(api_key).__name__}")
148
154
  key = api_key.strip()
149
- if any(ord(char) < 0x20 or ord(char) == 0x7F for char in key):
150
- raise ValueError("api_key holds a control character")
155
+ if any(ord(char) < 0x20 or ord(char) >= 0x7F for char in key):
156
+ raise ValueError("api_key holds a control character or one outside ASCII")
151
157
  return key or None
152
158
 
153
159
 
@@ -61,7 +61,7 @@ def sync_detailed(
61
61
  """Members
62
62
 
63
63
  Read-only. Adding or removing a member is an invitation flow with email
64
- in the middle rather than a POST, and modelling it as one here would
64
+ in the middle rather than a POST, and modeling it as one here would
65
65
  promise something this API does not do.
66
66
 
67
67
  Raises:
@@ -88,7 +88,7 @@ def sync(
88
88
  """Members
89
89
 
90
90
  Read-only. Adding or removing a member is an invitation flow with email
91
- in the middle rather than a POST, and modelling it as one here would
91
+ in the middle rather than a POST, and modeling it as one here would
92
92
  promise something this API does not do.
93
93
 
94
94
  Raises:
@@ -111,7 +111,7 @@ async def asyncio_detailed(
111
111
  """Members
112
112
 
113
113
  Read-only. Adding or removing a member is an invitation flow with email
114
- in the middle rather than a POST, and modelling it as one here would
114
+ in the middle rather than a POST, and modeling it as one here would
115
115
  promise something this API does not do.
116
116
 
117
117
  Raises:
@@ -136,7 +136,7 @@ async def asyncio(
136
136
  """Members
137
137
 
138
138
  Read-only. Adding or removing a member is an invitation flow with email
139
- in the middle rather than a POST, and modelling it as one here would
139
+ in the middle rather than a POST, and modeling it as one here would
140
140
  promise something this API does not do.
141
141
 
142
142
  Raises:
@@ -15,7 +15,7 @@ T = TypeVar("T", bound="AccountCreateApikeyRequest")
15
15
  class AccountCreateApikeyRequest:
16
16
  """
17
17
  Attributes:
18
- name (str): A label you will recognise later. Shown wherever the key is listed.
18
+ name (str): A label you will recognize later. Shown wherever the key is listed.
19
19
  allowed_scopes (list[str] | Unset): What the new key may do. Omit for a key that carries no named scope, which
20
20
  is the safe default.
21
21
  """
@@ -20,7 +20,7 @@ class ApikeyDetail:
20
20
  Attributes:
21
21
  id (UUID):
22
22
  name (str):
23
- key_prefix (str): The leading, non-secret part, so a key is recognisable without storing it.
23
+ key_prefix (str): The leading, non-secret part, so a key is recognizable without storing it.
24
24
  created (datetime.datetime):
25
25
  expires (datetime.datetime | None | Unset):
26
26
  last_used_at (datetime.datetime | None | Unset):
@@ -95,9 +95,7 @@ class DatabaseMetadata:
95
95
  @classmethod
96
96
  def from_dict(cls, src_dict: Mapping[str, Any]) -> Self:
97
97
  from ..models.database_metadata_sample import DatabaseMetadataSample
98
- from ..models.database_metadata_sample_size import (
99
- DatabaseMetadataSampleSize,
100
- )
98
+ from ..models.database_metadata_sample_size import DatabaseMetadataSampleSize
101
99
  from ..models.database_metadata_schema import DatabaseMetadataSchema
102
100
  from ..models.database_metadata_size import DatabaseMetadataSize
103
101
 
@@ -174,85 +174,109 @@ class Download:
174
174
 
175
175
  def to_database(body: dict[str, Any]) -> Database:
176
176
  return Database(
177
- base=_present(body, "base"),
178
- name=_present(body, "name"),
179
- summary=_present(body, "summary"),
180
- standing=_present(body, "standing"),
181
- license_type=body["license_type"],
177
+ base=_present(body, "base", str),
178
+ name=_present(body, "name", str),
179
+ summary=_present(body, "summary", str),
180
+ standing=_present(body, "standing", str),
181
+ license_type=_nullable(body, "license_type", str),
182
182
  starts=_datetime(body["starts"]),
183
183
  expires=_datetime(body["expires"]),
184
184
  renews_at=_datetime(body["renews_at"]),
185
185
  notice_due_at=_datetime(body["notice_due_at"]),
186
- versions=tuple(to_version(v) for v in _present(body, "versions")),
186
+ versions=tuple(to_version(v) for v in _present(body, "versions", list)),
187
187
  raw=body,
188
- open=_present(body, "open"),
188
+ open=_present(body, "open", bool),
189
189
  )
190
190
 
191
191
 
192
192
  def to_version(body: dict[str, Any]) -> DatabaseVersion:
193
193
  return DatabaseVersion(
194
- id=_present(body, "id"),
195
- version=_present(body, "version"),
196
- summary=_present(body, "summary"),
197
- formats=tuple(_present(body, "formats")),
194
+ id=_present(body, "id", str),
195
+ version=_present(body, "version", int),
196
+ summary=_present(body, "summary", str),
197
+ formats=_strings(_present(body, "formats", list), "formats"),
198
198
  sample_formats=_optional_tuple(body.get("sample_formats")),
199
199
  )
200
200
 
201
201
 
202
202
  def to_metadata(body: dict[str, Any]) -> DatabaseMetadata:
203
203
  return DatabaseMetadata(
204
- id=_present(body, "id"),
204
+ id=_present(body, "id", str),
205
205
  updated=datetime.date.fromisoformat(body["updated"]),
206
- entries=_present(body, "entries"),
206
+ entries=_present(body, "entries", int),
207
207
  schema={
208
208
  fmt: tuple(to_column(c) for c in columns) for fmt, columns in body["schema"].items()
209
209
  },
210
- size=dict(body["size"]),
210
+ size=_sizes(_present(body, "size", dict), "size"),
211
211
  update_freq=body.get("update_freq"),
212
212
  sample={fmt: tuple(rows) for fmt, rows in body.get("sample", {}).items()},
213
213
  raw=body,
214
- sample_size=dict(body["sample_size"]) if body.get("sample_size") is not None else None,
215
- sample_entries=body.get("sample_entries"),
214
+ sample_size=_sizes(body["sample_size"], "sample_size")
215
+ if body.get("sample_size") is not None
216
+ else None,
217
+ sample_entries=_nullable(body, "sample_entries", int, absent=True),
216
218
  )
217
219
 
218
220
 
219
221
  def to_column(body: dict[str, Any]) -> MetadataColumn:
220
222
  return MetadataColumn(
221
- name=_present(body, "name"),
222
- type=_present(body, "type"),
223
+ name=_present(body, "name", str),
224
+ type=_present(body, "type", str),
223
225
  description=body.get("description"),
224
226
  )
225
227
 
226
228
 
227
229
  def to_download(body: dict[str, Any]) -> Download:
228
230
  return Download(
229
- dataset_id=_present(body, "dataset_id"),
230
- format=_present(body, "format"),
231
- outcome=_present(body, "outcome"),
231
+ dataset_id=_present(body, "dataset_id", str),
232
+ format=_present(body, "format", str),
233
+ outcome=_present(body, "outcome", str),
232
234
  created=datetime.datetime.fromisoformat(body["created"]),
233
- bytes=body["bytes"],
234
- http_status=body["http_status"],
235
+ bytes=_nullable(body, "bytes", int),
236
+ http_status=_nullable(body, "http_status", int),
235
237
  apikey_id=body["apikey_id"],
236
238
  client_ip=body["client_ip"],
237
239
  user_agent=body["user_agent"],
238
- sample=_present(body, "sample"),
239
- open=_present(body, "open"),
240
+ sample=_present(body, "sample", bool),
241
+ open=_present(body, "open", bool),
240
242
  )
241
243
 
242
244
 
243
- def _present(body: dict[str, Any], name: str) -> Any:
244
- """A member the spec requires and never sends as null.
245
+ def _present(body: dict[str, Any], name: str, kind: type) -> Any:
246
+ """A member the spec requires and never sends as null, of the type it gives.
245
247
 
246
248
  A null one is the server's failure, raised for the caller to report as one, rather
247
249
  than handed back in the answer: 2.7.1 listed a family whose `base` was null as a
248
- family with no base (measured 2026-10-12).
250
+ family with no base (measured 2026-10-12). So is one of another type: 2.7.4 handed
251
+ back a version served as `"3"` as that string (measured 2026-10-16).
249
252
  """
250
253
  value = body[name]
251
254
  if value is None:
252
255
  raise ValueError(f"{name} is null")
256
+ return _of_type(value, name, kind)
257
+
258
+
259
+ def _nullable(body: dict[str, Any], name: str, kind: type, absent: bool = False) -> Any:
260
+ """A member that may be null, or with `absent` left out, and is otherwise of `kind`."""
261
+ value = body.get(name) if absent else body[name]
262
+ return None if value is None else _of_type(value, name, kind)
263
+
264
+
265
+ def _of_type(value: Any, name: str, kind: type) -> Any:
266
+ # A bool is an int to isinstance, and neither may stand for the other.
267
+ if isinstance(value, bool) != (kind is bool) or not isinstance(value, kind):
268
+ raise ValueError(f"{name} is not a {kind.__name__}: {value!r}")
253
269
  return value
254
270
 
255
271
 
272
+ def _strings(values: list[Any], name: str) -> tuple[Any, ...]:
273
+ return tuple(_of_type(v, name, str) for v in values)
274
+
275
+
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()}
278
+
279
+
256
280
  def _optional_tuple(value: Any) -> tuple[Any, ...] | None:
257
281
  return tuple(value) if value is not None else None
258
282
 
@@ -410,6 +410,50 @@ UNREADABLE_MEMBERS: dict[str, tuple[str, dict[str, Any]]] = {
410
410
  ),
411
411
  }
412
412
 
413
+
414
+ def _with(member: str, value: Any, entry: dict[str, Any]) -> dict[str, Any]:
415
+ return {**entry, member: value}
416
+
417
+
418
+ def _version_with(member: str, value: Any) -> dict[str, Any]:
419
+ family = database("bogon_ip")
420
+ return {**family, "versions": [_with(member, value, family["versions"][0])]}
421
+
422
+
423
+ _DOWNLOAD = cast(list[dict[str, Any]], DOWNLOADS["downloads"])[0]
424
+
425
+ # A member of another type than the spec gives it, which 2.7.4 handed back in the answer:
426
+ # a version served as "3" came back as that string (measured 2026-10-16).
427
+ UNREADABLE_MEMBERS.update(
428
+ {
429
+ "list version as a numeric string": (
430
+ LIST_PATH,
431
+ {"databases": [_version_with("version", "3")]},
432
+ ),
433
+ "list version as true": (LIST_PATH, {"databases": [_version_with("version", True)]}),
434
+ "list format as a number": (LIST_PATH, {"databases": [_version_with("formats", [1])]}),
435
+ "list base as a number": (
436
+ LIST_PATH,
437
+ {"databases": [_with("base", 7, database("bogon_ip"))]},
438
+ ),
439
+ "list open as a string": (
440
+ LIST_PATH,
441
+ {"databases": [_with("open", "false", database("bogon_ip"))]},
442
+ ),
443
+ "list license_type as a number": (
444
+ LIST_PATH,
445
+ {"databases": [_with("license_type", 1, database("bogon_ip"))]},
446
+ ),
447
+ "metadata entries as a string": (METADATA_PATH, _with("entries", "42", METADATA)),
448
+ "metadata size as a string": (METADATA_PATH, _with("size", {"csvgz": "760"}, METADATA)),
449
+ "downloads bytes as a string": (
450
+ DOWNLOADS_PATH,
451
+ {"downloads": [_with("bytes", "760", _DOWNLOAD)]},
452
+ ),
453
+ "downloads sample as 1": (DOWNLOADS_PATH, {"downloads": [_with("sample", 1, _DOWNLOAD)]}),
454
+ }
455
+ )
456
+
413
457
  MEMBER_CALLS = {LIST_PATH: "list", METADATA_PATH: "metadata", DOWNLOADS_PATH: "downloads"}
414
458
 
415
459
 
@@ -274,12 +274,44 @@ def test_the_api_key_is_trimmed(make_client: ClientFactory) -> None:
274
274
 
275
275
 
276
276
  @pytest.mark.parametrize(
277
- "api_key", ["key\r\nX-Injected: 1", "ke\ty", "key\x00", "key\x7f", "k\ney"]
277
+ "api_key", ["key\r\nX-Injected: 1", "ke\ty", "key\x00", "key\x7f", "k\ney", "k\x85ey", "kéy"]
278
278
  )
279
- def test_a_control_character_in_the_api_key_is_refused_where_it_is_set(
279
+ def test_a_control_character_or_one_outside_ascii_is_refused_where_the_key_is_set(
280
280
  make_client: ClientFactory, api_key: str
281
281
  ) -> None:
282
282
  """Accepted, a CR, LF or NUL failed every call as a retried network error with nothing
283
- sent, and a tab or DEL went out on the wire inside the header (2.7.1)."""
284
- with pytest.raises(ValueError, match="control character"):
283
+ sent, and a tab or DEL went out on the wire inside the header (2.7.1). A C1 control or
284
+ any other character outside ASCII failed every call with a raw UnicodeEncodeError and
285
+ nothing sent, since httpx encodes a header value as ASCII (2.7.3)."""
286
+ with pytest.raises(ValueError, match="control character or one outside ASCII"):
285
287
  make_client(api_key=api_key)
288
+
289
+
290
+ @pytest.mark.parametrize("slashes", [1, 2, 3])
291
+ def test_every_trailing_slash_on_the_base_url_is_dropped(
292
+ make_client: ClientFactory, slashes: int
293
+ ) -> None:
294
+ """httpx drops one when it joins a path on and keeps the rest, so through 2.7.3 a
295
+ second reached the API as `//api/v2/database/list`, which prod answers with a
296
+ redirect this client does not follow."""
297
+ digests = {"md5": "m", "sha1": "s1", "sha256": "s256", "sha512": "s512"}
298
+ stub = Stub(
299
+ {
300
+ LIST_PATH: {"body": {"databases": []}},
301
+ CHECKSUM_PATH: {"body": {"id": "bogon_ip_v1", "format": "csvgz", "checksums": digests}},
302
+ "/.well-known/oauth-authorization-server": {
303
+ "body": {
304
+ "issuer": "https://api.example",
305
+ "authorization_endpoint": "https://api.example/oauth/authorize",
306
+ "token_endpoint": "https://api.example/oauth/token",
307
+ }
308
+ },
309
+ }
310
+ )
311
+ client = make_client(transport=stub.transport, base_url="https://api.example" + "/" * slashes)
312
+
313
+ client.database.list()
314
+ client.database.checksums("bogon_ip_v1", "csvgz")
315
+ client.oauth.metadata()
316
+
317
+ assert stub.paths() == [LIST_PATH, CHECKSUM_PATH, "/.well-known/oauth-authorization-server"]
File without changes
File without changes
File without changes