ideal-postcodes 0.1.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.
- ideal_postcodes-0.1.0/LICENSE +1 -0
- ideal_postcodes-0.1.0/PKG-INFO +121 -0
- ideal_postcodes-0.1.0/README.md +98 -0
- ideal_postcodes-0.1.0/ideal_postcodes/__init__.py +27 -0
- ideal_postcodes-0.1.0/ideal_postcodes/_transport.py +194 -0
- ideal_postcodes-0.1.0/ideal_postcodes/client.py +1321 -0
- ideal_postcodes-0.1.0/ideal_postcodes/errors.py +81 -0
- ideal_postcodes-0.1.0/ideal_postcodes/py.typed +0 -0
- ideal_postcodes-0.1.0/ideal_postcodes/requests.py +70 -0
- ideal_postcodes-0.1.0/ideal_postcodes/types.py +6939 -0
- ideal_postcodes-0.1.0/pyproject.toml +57 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Copyright (C) IDDQD Limited - All Rights Reserved
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ideal-postcodes
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Explicit Python client for Ideal Postcodes
|
|
5
|
+
Keywords: address,postcode,address-lookup,address-autocomplete,uk
|
|
6
|
+
Author: Ideal Postcodes
|
|
7
|
+
Author-email: Ideal Postcodes <support@ideal-postcodes.co.uk>
|
|
8
|
+
License-Expression: LicenseRef-Proprietary
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Dist: httpx==0.28.1
|
|
16
|
+
Requires-Dist: pydantic==2.13.5
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Project-URL: Documentation, https://docs.ideal-postcodes.co.uk/docs/sdks/python
|
|
19
|
+
Project-URL: Homepage, https://ideal-postcodes.co.uk
|
|
20
|
+
Project-URL: Issues, https://github.com/ideal-postcodes/feedback/issues
|
|
21
|
+
Project-URL: Repository, https://github.com/ideal-postcodes/feedback
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Ideal Postcodes Python SDK
|
|
25
|
+
|
|
26
|
+
Client for every current API operation: address search and lookup, place search, email and phone validation, and key, licensee and configuration management. Requires Python 3.12 or later; tested on 3.12, 3.13 and 3.14.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install ideal-postcodes
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from ideal_postcodes import IdealPostcodes
|
|
34
|
+
|
|
35
|
+
with IdealPostcodes("ak_test") as client:
|
|
36
|
+
for address in client.postcodes("BR8 7RE"):
|
|
37
|
+
print(address.line_1, address.post_town)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`ak_test` is the public test key. The client returns Pydantic models from the API's `result` field. Read fields as attributes and use `model_dump()` for a dictionary. Reuse one client and close it with a context manager. Production keys belong in environment variables.
|
|
41
|
+
|
|
42
|
+
## Search and resolve
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from ideal_postcodes import IdealPostcodes
|
|
46
|
+
|
|
47
|
+
with IdealPostcodes("ak_test") as client:
|
|
48
|
+
hits = client.find_address(query="9 Malyons Road", limit=5).hits
|
|
49
|
+
if hits:
|
|
50
|
+
address = client.resolve_address(hits[0].id)
|
|
51
|
+
print(address.postcode)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Resolve the suggestion the user chooses. An empty suggestion list is a normal result.
|
|
55
|
+
|
|
56
|
+
## Methods
|
|
57
|
+
|
|
58
|
+
| Method | Purpose |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `postcodes(postcode, ...)` | Addresses at a postcode |
|
|
61
|
+
| `addresses(query=..., ...)` | Paginated address search; filters can be used without query text |
|
|
62
|
+
| `address_cleanse(query=..., ...)` | Cleanse a freeform address, with optional postcode and post town |
|
|
63
|
+
| `find_address(query=..., ...)` | Autocomplete suggestions |
|
|
64
|
+
| `resolve_address(address, ...)` | Resolve a suggestion using the spec's GBR endpoint |
|
|
65
|
+
| `find_place(query=..., ...)` | Place search |
|
|
66
|
+
| `resolve_place(place, ...)` | Resolve a place suggestion |
|
|
67
|
+
| `email_validation(query=..., ...)` | Validate an email address |
|
|
68
|
+
| `phone_number_validation(query=..., ...)` | Validate a phone number |
|
|
69
|
+
| `key_availability(key)` | Public availability and licensed contexts |
|
|
70
|
+
| `key_details(key, user_token=...)` | Private settings and remaining lookups |
|
|
71
|
+
| `update_key_details(key, user_token=..., ...)` | Update selected key settings |
|
|
72
|
+
| `key_usage(key, user_token=..., ...)` | Usage totals and daily breakdown |
|
|
73
|
+
| `key_logs(key, user_token=..., ...)` | Paid lookup logs as CSV text |
|
|
74
|
+
| `list_licensees`, `create_licensee`, `retrieve_licensee`, `update_licensee`, `delete_licensee`, `regenerate_licensee_key` | Licensee management |
|
|
75
|
+
| `list_configs`, `create_config`, `retrieve_config`, `update_config`, `delete_config` | Saved configurations |
|
|
76
|
+
Methods expose every supported input in their signatures. Required keyword arguments must be supplied. Optional `None` values are omitted, leaving defaults to the server. Dataset lists are comma-joined. String flags use `"true"` and `"false"`, as declared by the API. Parameter constraints appear in each method's docstring; scalar ranges, lengths and enums are checked before sending.
|
|
77
|
+
|
|
78
|
+
Use `help(IdealPostcodes.find_address)` or inspect `ideal_postcodes/client.py` for parameters. Response definitions live in `ideal_postcodes/types.py`. `examples/` has a runnable script for each method.
|
|
79
|
+
|
|
80
|
+
## Keys
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from ideal_postcodes import IdealPostcodes
|
|
84
|
+
|
|
85
|
+
with IdealPostcodes("ak_test") as client:
|
|
86
|
+
availability = client.key_availability("ak_test")
|
|
87
|
+
print(availability.available)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The `key` argument identifies the key to inspect or update. Availability and `retrieve_config` need no Management Key. Other key, licensee and configuration methods require your Management Key as the keyword `user_token`; the client sends it in the `Authorization` header, never the URL. Load it from your application's secret storage. Usage and logs accept `start` and `end` as UNIX timestamps in milliseconds. `key_logs` returns CSV text rather than a response model.
|
|
91
|
+
|
|
92
|
+
Update only the settings you want to change. Nested settings accept typed dictionaries from `ideal_postcodes.requests`. For example, `daily_limit={"limit": None}` disables the daily limit, `notifications={"enabled": False}` disables notifications and `allowed_urls=[]` clears the URL restriction. Omitted top-level settings remain unchanged. Nested `None` values are sent as JSON `null`.
|
|
93
|
+
|
|
94
|
+
## Errors and retries
|
|
95
|
+
|
|
96
|
+
All SDK request failures derive from `ClientError`:
|
|
97
|
+
|
|
98
|
+
- `APIError`: unsuccessful HTTP response, with `status`, `code`, `message`, `body` and raw `retry_after`
|
|
99
|
+
- `AuthenticationError`, `PaymentRequiredError`, `NotFoundError`, `RateLimitError` and `ServerError`: API error subclasses
|
|
100
|
+
- `TransportError`: connection or timeout failure before a response
|
|
101
|
+
- `ProtocolError`: unexpected success status, malformed envelope or invalid response fields. When response fields fail validation, `result` holds the unvalidated result, so `except ProtocolError as error: raw = error.result` can fall back to it
|
|
102
|
+
|
|
103
|
+
`NotFoundError.suggestions` contains postcode suggestions when supplied by the API. HTTP 429 and HTTP 503 with API code 5030 raise `RateLimitError`. Other 5xx responses raise `ServerError`. Exception strings include the API's `message`, for example `API request failed (HTTP 404, code 4040): Postcode not found`, and omit credentials and request URLs. `body` may contain caller input.
|
|
104
|
+
|
|
105
|
+
There are no automatic retries. A timeout may occur after a lookup was processed. Choose retry behaviour in the application and bound attempts; `Retry-After` can be seconds or an HTTP date. Inputs are checked against the spec before the network call: a missing argument, wrong type or unknown settings key raises `TypeError`, and an out-of-range number, string length or pattern raises `ValueError`. Option values the SDK does not list yet pass through to the API.
|
|
106
|
+
|
|
107
|
+
## Testing and limits
|
|
108
|
+
|
|
109
|
+
Pass `transport=httpx.MockTransport(handler)` to test without network access, or override `base_url`. The default timeout is 10 seconds.
|
|
110
|
+
|
|
111
|
+
Response models validate required fields, types, lists and unions from the checked-in OpenAPI contract, and accept normal API growth: extra fields survive in `model_dump()`, option fields are plain strings so new values come through, and a `native` record from a dataset the SDK does not know yet arrives as a dictionary. An explicit `filter` returns address dictionaries containing the requested fields; paginated address search returns a model whose `hits` are dictionaries. The client is synchronous; run it in a worker thread when calling from an async application.
|
|
112
|
+
|
|
113
|
+
Signup is not included.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
Run `uv sync --frozen`, then `make ci` and `make build`. `make generate` rebuilds the client, transport and response types from the shared generator and this brand's spec. `make check-generated` detects drift without rewriting committed files. `make format` applies Ruff fixes and formatting; `make ci` checks both. Run commands from this package directory.
|
|
118
|
+
|
|
119
|
+
## Licence
|
|
120
|
+
|
|
121
|
+
SEE LICENSE IN LICENSE
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Ideal Postcodes Python SDK
|
|
2
|
+
|
|
3
|
+
Client for every current API operation: address search and lookup, place search, email and phone validation, and key, licensee and configuration management. Requires Python 3.12 or later; tested on 3.12, 3.13 and 3.14.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install ideal-postcodes
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from ideal_postcodes import IdealPostcodes
|
|
11
|
+
|
|
12
|
+
with IdealPostcodes("ak_test") as client:
|
|
13
|
+
for address in client.postcodes("BR8 7RE"):
|
|
14
|
+
print(address.line_1, address.post_town)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`ak_test` is the public test key. The client returns Pydantic models from the API's `result` field. Read fields as attributes and use `model_dump()` for a dictionary. Reuse one client and close it with a context manager. Production keys belong in environment variables.
|
|
18
|
+
|
|
19
|
+
## Search and resolve
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from ideal_postcodes import IdealPostcodes
|
|
23
|
+
|
|
24
|
+
with IdealPostcodes("ak_test") as client:
|
|
25
|
+
hits = client.find_address(query="9 Malyons Road", limit=5).hits
|
|
26
|
+
if hits:
|
|
27
|
+
address = client.resolve_address(hits[0].id)
|
|
28
|
+
print(address.postcode)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Resolve the suggestion the user chooses. An empty suggestion list is a normal result.
|
|
32
|
+
|
|
33
|
+
## Methods
|
|
34
|
+
|
|
35
|
+
| Method | Purpose |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `postcodes(postcode, ...)` | Addresses at a postcode |
|
|
38
|
+
| `addresses(query=..., ...)` | Paginated address search; filters can be used without query text |
|
|
39
|
+
| `address_cleanse(query=..., ...)` | Cleanse a freeform address, with optional postcode and post town |
|
|
40
|
+
| `find_address(query=..., ...)` | Autocomplete suggestions |
|
|
41
|
+
| `resolve_address(address, ...)` | Resolve a suggestion using the spec's GBR endpoint |
|
|
42
|
+
| `find_place(query=..., ...)` | Place search |
|
|
43
|
+
| `resolve_place(place, ...)` | Resolve a place suggestion |
|
|
44
|
+
| `email_validation(query=..., ...)` | Validate an email address |
|
|
45
|
+
| `phone_number_validation(query=..., ...)` | Validate a phone number |
|
|
46
|
+
| `key_availability(key)` | Public availability and licensed contexts |
|
|
47
|
+
| `key_details(key, user_token=...)` | Private settings and remaining lookups |
|
|
48
|
+
| `update_key_details(key, user_token=..., ...)` | Update selected key settings |
|
|
49
|
+
| `key_usage(key, user_token=..., ...)` | Usage totals and daily breakdown |
|
|
50
|
+
| `key_logs(key, user_token=..., ...)` | Paid lookup logs as CSV text |
|
|
51
|
+
| `list_licensees`, `create_licensee`, `retrieve_licensee`, `update_licensee`, `delete_licensee`, `regenerate_licensee_key` | Licensee management |
|
|
52
|
+
| `list_configs`, `create_config`, `retrieve_config`, `update_config`, `delete_config` | Saved configurations |
|
|
53
|
+
Methods expose every supported input in their signatures. Required keyword arguments must be supplied. Optional `None` values are omitted, leaving defaults to the server. Dataset lists are comma-joined. String flags use `"true"` and `"false"`, as declared by the API. Parameter constraints appear in each method's docstring; scalar ranges, lengths and enums are checked before sending.
|
|
54
|
+
|
|
55
|
+
Use `help(IdealPostcodes.find_address)` or inspect `ideal_postcodes/client.py` for parameters. Response definitions live in `ideal_postcodes/types.py`. `examples/` has a runnable script for each method.
|
|
56
|
+
|
|
57
|
+
## Keys
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from ideal_postcodes import IdealPostcodes
|
|
61
|
+
|
|
62
|
+
with IdealPostcodes("ak_test") as client:
|
|
63
|
+
availability = client.key_availability("ak_test")
|
|
64
|
+
print(availability.available)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The `key` argument identifies the key to inspect or update. Availability and `retrieve_config` need no Management Key. Other key, licensee and configuration methods require your Management Key as the keyword `user_token`; the client sends it in the `Authorization` header, never the URL. Load it from your application's secret storage. Usage and logs accept `start` and `end` as UNIX timestamps in milliseconds. `key_logs` returns CSV text rather than a response model.
|
|
68
|
+
|
|
69
|
+
Update only the settings you want to change. Nested settings accept typed dictionaries from `ideal_postcodes.requests`. For example, `daily_limit={"limit": None}` disables the daily limit, `notifications={"enabled": False}` disables notifications and `allowed_urls=[]` clears the URL restriction. Omitted top-level settings remain unchanged. Nested `None` values are sent as JSON `null`.
|
|
70
|
+
|
|
71
|
+
## Errors and retries
|
|
72
|
+
|
|
73
|
+
All SDK request failures derive from `ClientError`:
|
|
74
|
+
|
|
75
|
+
- `APIError`: unsuccessful HTTP response, with `status`, `code`, `message`, `body` and raw `retry_after`
|
|
76
|
+
- `AuthenticationError`, `PaymentRequiredError`, `NotFoundError`, `RateLimitError` and `ServerError`: API error subclasses
|
|
77
|
+
- `TransportError`: connection or timeout failure before a response
|
|
78
|
+
- `ProtocolError`: unexpected success status, malformed envelope or invalid response fields. When response fields fail validation, `result` holds the unvalidated result, so `except ProtocolError as error: raw = error.result` can fall back to it
|
|
79
|
+
|
|
80
|
+
`NotFoundError.suggestions` contains postcode suggestions when supplied by the API. HTTP 429 and HTTP 503 with API code 5030 raise `RateLimitError`. Other 5xx responses raise `ServerError`. Exception strings include the API's `message`, for example `API request failed (HTTP 404, code 4040): Postcode not found`, and omit credentials and request URLs. `body` may contain caller input.
|
|
81
|
+
|
|
82
|
+
There are no automatic retries. A timeout may occur after a lookup was processed. Choose retry behaviour in the application and bound attempts; `Retry-After` can be seconds or an HTTP date. Inputs are checked against the spec before the network call: a missing argument, wrong type or unknown settings key raises `TypeError`, and an out-of-range number, string length or pattern raises `ValueError`. Option values the SDK does not list yet pass through to the API.
|
|
83
|
+
|
|
84
|
+
## Testing and limits
|
|
85
|
+
|
|
86
|
+
Pass `transport=httpx.MockTransport(handler)` to test without network access, or override `base_url`. The default timeout is 10 seconds.
|
|
87
|
+
|
|
88
|
+
Response models validate required fields, types, lists and unions from the checked-in OpenAPI contract, and accept normal API growth: extra fields survive in `model_dump()`, option fields are plain strings so new values come through, and a `native` record from a dataset the SDK does not know yet arrives as a dictionary. An explicit `filter` returns address dictionaries containing the requested fields; paginated address search returns a model whose `hits` are dictionaries. The client is synchronous; run it in a worker thread when calling from an async application.
|
|
89
|
+
|
|
90
|
+
Signup is not included.
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
Run `uv sync --frozen`, then `make ci` and `make build`. `make generate` rebuilds the client, transport and response types from the shared generator and this brand's spec. `make check-generated` detects drift without rewriting committed files. `make format` applies Ruff fixes and formatting; `make ci` checks both. Run commands from this package directory.
|
|
95
|
+
|
|
96
|
+
## Licence
|
|
97
|
+
|
|
98
|
+
SEE LICENSE IN LICENSE
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""IdealPostcodes address and key client."""
|
|
2
|
+
|
|
3
|
+
from .client import IdealPostcodes
|
|
4
|
+
from .errors import (
|
|
5
|
+
APIError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
ClientError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PaymentRequiredError,
|
|
10
|
+
ProtocolError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
ServerError,
|
|
13
|
+
TransportError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"IdealPostcodes",
|
|
18
|
+
"APIError",
|
|
19
|
+
"AuthenticationError",
|
|
20
|
+
"ClientError",
|
|
21
|
+
"NotFoundError",
|
|
22
|
+
"PaymentRequiredError",
|
|
23
|
+
"ProtocolError",
|
|
24
|
+
"RateLimitError",
|
|
25
|
+
"ServerError",
|
|
26
|
+
"TransportError",
|
|
27
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Connection lifecycle and envelope handling shared by generated methods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from functools import cache
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
from typing import Any, Literal, Self, cast
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import TypeAdapter, ValidationError
|
|
14
|
+
|
|
15
|
+
from .errors import ProtocolError, TransportError, error_for
|
|
16
|
+
|
|
17
|
+
BASE_URL = "https://api.ideal-postcodes.co.uk/v1"
|
|
18
|
+
USER_AGENT = f"ideal-postcodes-python-sdk/{version('ideal-postcodes')}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def segment(value: str | int) -> str:
|
|
22
|
+
"""Encode a single non-empty path segment without treating it as a URL."""
|
|
23
|
+
text = str(value)
|
|
24
|
+
if not text or text in {".", ".."}:
|
|
25
|
+
raise ValueError("Path identifiers must be non-empty and cannot be dot segments")
|
|
26
|
+
return quote(text, safe="")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fields(values: Mapping[str, object]) -> dict[str, object]:
|
|
30
|
+
"""Omit optional inputs; preserve false, zero and empty strings."""
|
|
31
|
+
return {key: value for key, value in values.items() if value is not None}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
TYPES: dict[str, tuple[type, ...]] = {
|
|
35
|
+
"string": (str,),
|
|
36
|
+
"integer": (int,),
|
|
37
|
+
"number": (int, float),
|
|
38
|
+
"boolean": (bool,),
|
|
39
|
+
"array": (list, tuple),
|
|
40
|
+
"object": (Mapping,),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check(name: str, value: object, schema: Mapping[str, Any]) -> None:
|
|
45
|
+
"""Raise TypeError or ValueError when an input breaks its spec schema. Messages omit the value."""
|
|
46
|
+
if value is None:
|
|
47
|
+
if not schema.get("nullable"):
|
|
48
|
+
raise TypeError(f"{name} cannot be None")
|
|
49
|
+
return
|
|
50
|
+
kind = schema.get("type")
|
|
51
|
+
if kind and (not isinstance(value, TYPES[kind]) or (isinstance(value, bool) and kind != "boolean")):
|
|
52
|
+
raise TypeError(f"{name} must be a{'n' if kind[0] in 'aeiou' else ''} {kind}")
|
|
53
|
+
broken: str | None = None
|
|
54
|
+
if isinstance(value, int | float) and not isinstance(value, bool):
|
|
55
|
+
if value < schema.get("minimum", value):
|
|
56
|
+
broken = "minimum"
|
|
57
|
+
elif value > schema.get("maximum", value):
|
|
58
|
+
broken = "maximum"
|
|
59
|
+
elif isinstance(value, str):
|
|
60
|
+
if len(value) < schema.get("minLength", 0):
|
|
61
|
+
broken = "minLength"
|
|
62
|
+
elif len(value) > schema.get("maxLength", len(value)):
|
|
63
|
+
broken = "maxLength"
|
|
64
|
+
elif "pattern" in schema and not re.search(schema["pattern"], value):
|
|
65
|
+
broken = "pattern"
|
|
66
|
+
elif isinstance(value, list | tuple):
|
|
67
|
+
size = len(cast("list[object]", value))
|
|
68
|
+
if size < schema.get("minItems", 0):
|
|
69
|
+
broken = "minItems"
|
|
70
|
+
elif size > schema.get("maxItems", size):
|
|
71
|
+
broken = "maxItems"
|
|
72
|
+
if broken:
|
|
73
|
+
raise ValueError(f"{name} must satisfy {broken}={schema[broken]!r}")
|
|
74
|
+
if "items" in schema:
|
|
75
|
+
for index, item in enumerate(cast("list[object]", value)):
|
|
76
|
+
check(f"{name}[{index}]", item, schema["items"])
|
|
77
|
+
if "properties" in schema:
|
|
78
|
+
entries = cast("Mapping[str, object]", value)
|
|
79
|
+
properties: Mapping[str, Any] = schema["properties"]
|
|
80
|
+
unknown = sorted(set(entries) - set(properties))
|
|
81
|
+
if unknown:
|
|
82
|
+
raise TypeError(f"{name} has unknown keys: {', '.join(map(str, unknown))}")
|
|
83
|
+
missing = [key for key in schema.get("required", []) if key not in entries]
|
|
84
|
+
if missing:
|
|
85
|
+
raise TypeError(f"{name} is missing keys: {', '.join(missing)}")
|
|
86
|
+
for key, item in entries.items():
|
|
87
|
+
check(f"{name}.{key}", item, properties[key])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def check_inputs(schemas: Mapping[str, Mapping[str, Any]], **values: object) -> None:
|
|
91
|
+
"""Check every supplied argument; None means omitted."""
|
|
92
|
+
for name, value in values.items():
|
|
93
|
+
if value is not None:
|
|
94
|
+
check(name, value, schemas[name])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@cache
|
|
98
|
+
def adapter(schema: Any) -> TypeAdapter[Any]:
|
|
99
|
+
return TypeAdapter(schema)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def validate(schema: Any, value: object) -> Any:
|
|
103
|
+
"""Validate results without including payload values in error messages."""
|
|
104
|
+
try:
|
|
105
|
+
return adapter(schema).validate_python(value)
|
|
106
|
+
except ValidationError as error:
|
|
107
|
+
details = error.errors(include_input=False)
|
|
108
|
+
locations = ", ".join(".".join(map(str, item["loc"])) for item in details[:8])
|
|
109
|
+
if len(details) > 8:
|
|
110
|
+
locations += f" (+{len(details) - 8} more)"
|
|
111
|
+
raise ProtocolError(f"Invalid response fields: {locations}", value) from None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Transport:
|
|
115
|
+
"""Synchronous connection pool. Use a context manager or call close().
|
|
116
|
+
|
|
117
|
+
api_key: API key sent in the Authorization header, with any Management Key the operation takes.
|
|
118
|
+
timeout: Per-request HTTPX timeout in seconds, default 10.
|
|
119
|
+
base_url: API root including /v1, or a test server URL.
|
|
120
|
+
transport: Optional HTTPX transport for offline tests.
|
|
121
|
+
|
|
122
|
+
Requests are never retried automatically, including billable lookups.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
api_key: str,
|
|
128
|
+
*,
|
|
129
|
+
timeout: float = 10.0,
|
|
130
|
+
base_url: str = BASE_URL,
|
|
131
|
+
transport: httpx.BaseTransport | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
self._http = httpx.Client(
|
|
134
|
+
base_url=base_url.rstrip("/") + "/",
|
|
135
|
+
timeout=timeout,
|
|
136
|
+
transport=transport,
|
|
137
|
+
headers={"User-Agent": USER_AGENT},
|
|
138
|
+
)
|
|
139
|
+
self._api_key = api_key
|
|
140
|
+
|
|
141
|
+
def __enter__(self) -> Self:
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def __exit__(self, *_: object) -> None:
|
|
145
|
+
self.close()
|
|
146
|
+
|
|
147
|
+
def close(self) -> None:
|
|
148
|
+
"""Close the connection pool."""
|
|
149
|
+
self._http.close()
|
|
150
|
+
|
|
151
|
+
def _request(
|
|
152
|
+
self,
|
|
153
|
+
method: str,
|
|
154
|
+
path: str,
|
|
155
|
+
*,
|
|
156
|
+
query: Mapping[str, object] | None = None,
|
|
157
|
+
body: Mapping[str, object] | None = None,
|
|
158
|
+
result_shape: Literal["object", "array", "csv"] = "object",
|
|
159
|
+
user_token: str | None = None,
|
|
160
|
+
) -> object:
|
|
161
|
+
params: dict[str, Any] = {}
|
|
162
|
+
for key, value in fields(query or {}).items():
|
|
163
|
+
params[key] = (
|
|
164
|
+
",".join(str(item) for item in cast("list[object]", value)) if isinstance(value, list) else value
|
|
165
|
+
)
|
|
166
|
+
# v1 credentials: api_key="ak_..." user_token="uk_..." in one Authorization header.
|
|
167
|
+
credentials = [
|
|
168
|
+
f'{name}="{value}"' for name, value in (("api_key", self._api_key), ("user_token", user_token)) if value
|
|
169
|
+
]
|
|
170
|
+
headers = {"Authorization": " ".join(credentials)} if credentials else None
|
|
171
|
+
try:
|
|
172
|
+
response = self._http.request(method, path.lstrip("/"), params=params, json=body, headers=headers)
|
|
173
|
+
except httpx.RequestError:
|
|
174
|
+
raise TransportError("Request failed before a response was received; no retry was made") from None
|
|
175
|
+
try:
|
|
176
|
+
payload: object = response.json()
|
|
177
|
+
except ValueError:
|
|
178
|
+
payload = None
|
|
179
|
+
envelope = cast("dict[str, Any]", payload) if isinstance(payload, dict) else {}
|
|
180
|
+
if not 200 <= response.status_code < 300:
|
|
181
|
+
raise error_for(response.status_code, envelope, response.headers.get("Retry-After"))
|
|
182
|
+
if response.status_code != 200:
|
|
183
|
+
raise ProtocolError(f"Unexpected success status {response.status_code}")
|
|
184
|
+
if result_shape == "csv":
|
|
185
|
+
if response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() != "text/csv":
|
|
186
|
+
raise ProtocolError("Expected a CSV response")
|
|
187
|
+
return response.text
|
|
188
|
+
if envelope.get("code") != 2000 or "result" not in envelope or envelope["result"] is None:
|
|
189
|
+
raise ProtocolError("Expected a JSON success envelope with code 2000 and a result")
|
|
190
|
+
result = envelope["result"]
|
|
191
|
+
expected = list if result_shape == "array" else dict
|
|
192
|
+
if not isinstance(result, expected):
|
|
193
|
+
raise ProtocolError(f"Expected result to be a JSON {result_shape}")
|
|
194
|
+
return cast("list[object] | dict[str, object]", result)
|