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.
@@ -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)