ideal-postcodes 0.1.0__py3-none-any.whl
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/__init__.py +27 -0
- ideal_postcodes/_transport.py +194 -0
- ideal_postcodes/client.py +1321 -0
- ideal_postcodes/errors.py +81 -0
- ideal_postcodes/py.typed +0 -0
- ideal_postcodes/requests.py +70 -0
- ideal_postcodes/types.py +6939 -0
- ideal_postcodes-0.1.0.dist-info/METADATA +121 -0
- ideal_postcodes-0.1.0.dist-info/RECORD +11 -0
- ideal_postcodes-0.1.0.dist-info/WHEEL +4 -0
- ideal_postcodes-0.1.0.dist-info/licenses/LICENSE +1 -0
|
@@ -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)
|