htag-sdk 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.
- htag/__init__.py +108 -0
- htag/_async_client.py +85 -0
- htag/_base.py +356 -0
- htag/_client.py +88 -0
- htag/_exceptions.py +107 -0
- htag/_types.py +20 -0
- htag/address/__init__.py +25 -0
- htag/address/async_client.py +109 -0
- htag/address/client.py +119 -0
- htag/address/models.py +112 -0
- htag/markets/__init__.py +39 -0
- htag/markets/async_client.py +410 -0
- htag/markets/client.py +558 -0
- htag/markets/models.py +340 -0
- htag/property/__init__.py +15 -0
- htag/property/async_client.py +101 -0
- htag/property/client.py +101 -0
- htag/property/models.py +37 -0
- htag/py.typed +0 -0
- htag_sdk-0.1.0.dist-info/METADATA +429 -0
- htag_sdk-0.1.0.dist-info/RECORD +22 -0
- htag_sdk-0.1.0.dist-info/WHEEL +4 -0
htag/__init__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""HtAG AI Python SDK.
|
|
2
|
+
|
|
3
|
+
The official Python client for the HtAG AI API, providing access to
|
|
4
|
+
Australian address data, property sales records, and market analytics.
|
|
5
|
+
|
|
6
|
+
Quick start::
|
|
7
|
+
|
|
8
|
+
from htag import HtAgApi
|
|
9
|
+
|
|
10
|
+
client = HtAgApi(api_key="sk-...", environment="prod")
|
|
11
|
+
results = client.address.search("100 George St Sydney")
|
|
12
|
+
|
|
13
|
+
For async usage::
|
|
14
|
+
|
|
15
|
+
from htag import AsyncHtAgApi
|
|
16
|
+
|
|
17
|
+
async_client = AsyncHtAgApi(api_key="sk-...", environment="prod")
|
|
18
|
+
results = await async_client.address.search("100 George St Sydney")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from htag._async_client import AsyncHtAgApi
|
|
22
|
+
from htag._client import HtAgApi
|
|
23
|
+
from htag._exceptions import (
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
ConnectionError,
|
|
26
|
+
HtAgError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
ServerError,
|
|
30
|
+
ValidationError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Domain models -- address
|
|
34
|
+
from htag.address.models import (
|
|
35
|
+
AddressInsightsResponse,
|
|
36
|
+
AddressRecord,
|
|
37
|
+
AddressSearchResponse,
|
|
38
|
+
AddressSearchResult,
|
|
39
|
+
AustralianAddressComponents,
|
|
40
|
+
BatchStandardiseResponse,
|
|
41
|
+
StandardiseResult,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Domain models -- property
|
|
45
|
+
from htag.property.models import (
|
|
46
|
+
SoldPropertiesResponse,
|
|
47
|
+
SoldPropertyRecord,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Domain models -- markets
|
|
51
|
+
from htag.markets.models import (
|
|
52
|
+
AdvancedSearchBody,
|
|
53
|
+
BaseResponse,
|
|
54
|
+
DemandProfileOut,
|
|
55
|
+
EssentialsOut,
|
|
56
|
+
FSDMonthlyOut,
|
|
57
|
+
FSDQuarterlyOut,
|
|
58
|
+
FSDYearlyOut,
|
|
59
|
+
GRCOut,
|
|
60
|
+
LevelEnum,
|
|
61
|
+
MarketSnapshot,
|
|
62
|
+
PriceHistoryOut,
|
|
63
|
+
PropertyTypeEnum,
|
|
64
|
+
RentHistoryOut,
|
|
65
|
+
YieldHistoryOut,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
__version__ = "0.1.0"
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
# Clients
|
|
72
|
+
"HtAgApi",
|
|
73
|
+
"AsyncHtAgApi",
|
|
74
|
+
# Exceptions
|
|
75
|
+
"HtAgError",
|
|
76
|
+
"AuthenticationError",
|
|
77
|
+
"ConnectionError",
|
|
78
|
+
"NotFoundError",
|
|
79
|
+
"RateLimitError",
|
|
80
|
+
"ServerError",
|
|
81
|
+
"ValidationError",
|
|
82
|
+
# Address models
|
|
83
|
+
"AddressInsightsResponse",
|
|
84
|
+
"AddressRecord",
|
|
85
|
+
"AddressSearchResponse",
|
|
86
|
+
"AddressSearchResult",
|
|
87
|
+
"AustralianAddressComponents",
|
|
88
|
+
"BatchStandardiseResponse",
|
|
89
|
+
"StandardiseResult",
|
|
90
|
+
# Property models
|
|
91
|
+
"SoldPropertiesResponse",
|
|
92
|
+
"SoldPropertyRecord",
|
|
93
|
+
# Markets models
|
|
94
|
+
"AdvancedSearchBody",
|
|
95
|
+
"BaseResponse",
|
|
96
|
+
"DemandProfileOut",
|
|
97
|
+
"EssentialsOut",
|
|
98
|
+
"FSDMonthlyOut",
|
|
99
|
+
"FSDQuarterlyOut",
|
|
100
|
+
"FSDYearlyOut",
|
|
101
|
+
"GRCOut",
|
|
102
|
+
"LevelEnum",
|
|
103
|
+
"MarketSnapshot",
|
|
104
|
+
"PriceHistoryOut",
|
|
105
|
+
"PropertyTypeEnum",
|
|
106
|
+
"RentHistoryOut",
|
|
107
|
+
"YieldHistoryOut",
|
|
108
|
+
]
|
htag/_async_client.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Asynchronous HtAG API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from htag._base import AsyncRequestMixin
|
|
10
|
+
from htag.address.async_client import AsyncAddressClient
|
|
11
|
+
from htag.markets.async_client import AsyncMarketsClient
|
|
12
|
+
from htag.property.async_client import AsyncPropertyClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncHtAgApi(AsyncRequestMixin):
|
|
16
|
+
"""Asynchronous client for the HtAG AI API.
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
from htag import AsyncHtAgApi
|
|
22
|
+
|
|
23
|
+
async def main():
|
|
24
|
+
client = AsyncHtAgApi(api_key="sk-...", environment="prod")
|
|
25
|
+
|
|
26
|
+
results = await client.address.search("100 George St Sydney")
|
|
27
|
+
sold = await client.property.sold_search(address="100 George St Sydney")
|
|
28
|
+
snapshots = await client.markets.snapshots(
|
|
29
|
+
level="suburb", property_type=["house"]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
await client.close()
|
|
33
|
+
|
|
34
|
+
asyncio.run(main())
|
|
35
|
+
|
|
36
|
+
As an async context manager::
|
|
37
|
+
|
|
38
|
+
async with AsyncHtAgApi(api_key="sk-...", environment="prod") as client:
|
|
39
|
+
results = await client.address.search("100 George St Sydney")
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
api_key: Your HtAG API key (required).
|
|
43
|
+
environment: Target environment -- ``"prod"`` or ``"dev"``.
|
|
44
|
+
base_url: Custom base URL (overrides ``environment``).
|
|
45
|
+
timeout: Request timeout in seconds (default 60).
|
|
46
|
+
max_retries: Maximum number of retry attempts for transient errors (default 3).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
address: AsyncAddressClient
|
|
50
|
+
property: AsyncPropertyClient
|
|
51
|
+
markets: AsyncMarketsClient
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
api_key: str,
|
|
57
|
+
environment: Optional[str] = None,
|
|
58
|
+
base_url: Optional[str] = None,
|
|
59
|
+
timeout: float = 60.0,
|
|
60
|
+
max_retries: int = 3,
|
|
61
|
+
) -> None:
|
|
62
|
+
super().__init__(
|
|
63
|
+
api_key=api_key,
|
|
64
|
+
environment=environment,
|
|
65
|
+
base_url=base_url,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
max_retries=max_retries,
|
|
68
|
+
)
|
|
69
|
+
self._http = httpx.AsyncClient()
|
|
70
|
+
self.address = AsyncAddressClient(self)
|
|
71
|
+
self.property = AsyncPropertyClient(self)
|
|
72
|
+
self.markets = AsyncMarketsClient(self)
|
|
73
|
+
|
|
74
|
+
async def close(self) -> None:
|
|
75
|
+
"""Release underlying HTTP connection pool resources."""
|
|
76
|
+
await self._http.aclose()
|
|
77
|
+
|
|
78
|
+
async def __aenter__(self) -> "AsyncHtAgApi":
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
async def __aexit__(self, *args: object) -> None:
|
|
82
|
+
await self.close()
|
|
83
|
+
|
|
84
|
+
def __repr__(self) -> str:
|
|
85
|
+
return f"AsyncHtAgApi(base_url={self._base_url!r})"
|
htag/_base.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Shared base functionality for sync and async HTTP clients.
|
|
2
|
+
|
|
3
|
+
Handles authentication, base URL resolution, retry logic with exponential
|
|
4
|
+
backoff, and consistent error mapping.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
import random
|
|
11
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, Union
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from htag._exceptions import (
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
ConnectionError as HtAgConnectionError,
|
|
18
|
+
HtAgError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
ServerError,
|
|
22
|
+
ValidationError,
|
|
23
|
+
)
|
|
24
|
+
from htag._types import Headers, JSONDict, QueryParams
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Constants
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
_ENV_BASE_URLS: Dict[str, str] = {
|
|
31
|
+
"prod": "https://api.prod.htagai.com",
|
|
32
|
+
"dev": "https://api.dev.htagai.com",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
DEFAULT_TIMEOUT: float = 60.0
|
|
36
|
+
DEFAULT_MAX_RETRIES: int = 3
|
|
37
|
+
RETRYABLE_STATUS_CODES: Tuple[int, ...] = (429, 500, 502, 503, 504)
|
|
38
|
+
INITIAL_RETRY_DELAY: float = 0.5 # seconds
|
|
39
|
+
MAX_RETRY_DELAY: float = 30.0 # seconds
|
|
40
|
+
JITTER_FACTOR: float = 0.25
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Error mapping
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
_STATUS_TO_EXCEPTION: List[Tuple[Sequence[int], Type[HtAgError]]] = [
|
|
47
|
+
((401, 403), AuthenticationError),
|
|
48
|
+
((429,), RateLimitError),
|
|
49
|
+
((400, 422), ValidationError),
|
|
50
|
+
((404,), NotFoundError),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _exception_for_status(status_code: int) -> Type[HtAgError]:
|
|
55
|
+
"""Return the most specific exception class for an HTTP status code."""
|
|
56
|
+
for codes, exc_cls in _STATUS_TO_EXCEPTION:
|
|
57
|
+
if status_code in codes:
|
|
58
|
+
return exc_cls
|
|
59
|
+
if 500 <= status_code < 600:
|
|
60
|
+
return ServerError
|
|
61
|
+
return HtAgError
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_error(response: httpx.Response) -> HtAgError:
|
|
65
|
+
"""Build a typed exception from an HTTP error response."""
|
|
66
|
+
status = response.status_code
|
|
67
|
+
request_id = response.headers.get("x-request-id")
|
|
68
|
+
|
|
69
|
+
# Try to extract a useful message from JSON body
|
|
70
|
+
body: Any = None
|
|
71
|
+
message = f"HTTP {status}"
|
|
72
|
+
try:
|
|
73
|
+
body = response.json()
|
|
74
|
+
if isinstance(body, dict):
|
|
75
|
+
message = body.get("detail") or body.get("message") or body.get("error") or message
|
|
76
|
+
if isinstance(message, list):
|
|
77
|
+
# FastAPI validation errors return a list of dicts
|
|
78
|
+
message = "; ".join(
|
|
79
|
+
f"{e.get('loc', '?')}: {e.get('msg', '?')}" if isinstance(e, dict) else str(e)
|
|
80
|
+
for e in message
|
|
81
|
+
)
|
|
82
|
+
except Exception:
|
|
83
|
+
body = response.text or None
|
|
84
|
+
|
|
85
|
+
exc_cls = _exception_for_status(status)
|
|
86
|
+
|
|
87
|
+
kwargs: Dict[str, Any] = {
|
|
88
|
+
"status_code": status,
|
|
89
|
+
"body": body,
|
|
90
|
+
"request_id": request_id,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if exc_cls is RateLimitError:
|
|
94
|
+
retry_after_raw = response.headers.get("retry-after")
|
|
95
|
+
retry_after: Optional[float] = None
|
|
96
|
+
if retry_after_raw is not None:
|
|
97
|
+
try:
|
|
98
|
+
retry_after = float(retry_after_raw)
|
|
99
|
+
except (ValueError, TypeError):
|
|
100
|
+
pass
|
|
101
|
+
kwargs["retry_after"] = retry_after
|
|
102
|
+
|
|
103
|
+
return exc_cls(str(message), **kwargs)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Retry delay calculation
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _retry_delay(attempt: int, retry_after: Optional[float] = None) -> float:
|
|
112
|
+
"""Calculate the delay before the next retry using exponential backoff with jitter."""
|
|
113
|
+
if retry_after is not None and retry_after > 0:
|
|
114
|
+
return retry_after
|
|
115
|
+
|
|
116
|
+
base_delay = INITIAL_RETRY_DELAY * (2 ** attempt)
|
|
117
|
+
delay = min(base_delay, MAX_RETRY_DELAY)
|
|
118
|
+
jitter = delay * JITTER_FACTOR * random.random()
|
|
119
|
+
return delay + jitter
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Query-parameter serialisation
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _serialise_params(params: QueryParams) -> Dict[str, Any]:
|
|
128
|
+
"""Flatten query parameters into a form suitable for httpx.
|
|
129
|
+
|
|
130
|
+
- ``None`` values are dropped.
|
|
131
|
+
- Lists are passed through (httpx repeats keys for lists).
|
|
132
|
+
- Booleans are lowered to ``"true"`` / ``"false"``.
|
|
133
|
+
"""
|
|
134
|
+
out: Dict[str, Any] = {}
|
|
135
|
+
for key, value in params.items():
|
|
136
|
+
if value is None:
|
|
137
|
+
continue
|
|
138
|
+
if isinstance(value, list):
|
|
139
|
+
filtered = [_scalar(v) for v in value if v is not None]
|
|
140
|
+
if filtered:
|
|
141
|
+
out[key] = filtered
|
|
142
|
+
else:
|
|
143
|
+
out[key] = _scalar(value)
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _scalar(v: Any) -> Any:
|
|
148
|
+
if isinstance(v, bool):
|
|
149
|
+
return str(v).lower()
|
|
150
|
+
return v
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Base client configuration
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class BaseClient:
|
|
159
|
+
"""Configuration and helpers shared by sync and async clients.
|
|
160
|
+
|
|
161
|
+
This class is not intended to be instantiated directly. Use
|
|
162
|
+
:class:`htag.HtAgApi` or :class:`htag.AsyncHtAgApi` instead.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
_api_key: str
|
|
166
|
+
_base_url: str
|
|
167
|
+
_internal_base_url: str
|
|
168
|
+
_timeout: float
|
|
169
|
+
_max_retries: int
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
*,
|
|
174
|
+
api_key: str,
|
|
175
|
+
environment: Optional[str] = None,
|
|
176
|
+
base_url: Optional[str] = None,
|
|
177
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
178
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
179
|
+
) -> None:
|
|
180
|
+
if not api_key:
|
|
181
|
+
raise ValueError("api_key must be a non-empty string")
|
|
182
|
+
|
|
183
|
+
self._api_key = api_key
|
|
184
|
+
self._timeout = timeout
|
|
185
|
+
self._max_retries = max_retries
|
|
186
|
+
|
|
187
|
+
if base_url is not None:
|
|
188
|
+
self._base_url = base_url.rstrip("/")
|
|
189
|
+
self._internal_base_url = base_url.rstrip("/")
|
|
190
|
+
elif environment is not None:
|
|
191
|
+
env_lower = environment.lower()
|
|
192
|
+
if env_lower not in _ENV_BASE_URLS:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"Unknown environment {environment!r}. "
|
|
195
|
+
f"Expected one of: {', '.join(_ENV_BASE_URLS)}"
|
|
196
|
+
)
|
|
197
|
+
base = _ENV_BASE_URLS[env_lower]
|
|
198
|
+
self._base_url = f"{base}/v1"
|
|
199
|
+
self._internal_base_url = f"{base}/internal-api/v1"
|
|
200
|
+
else:
|
|
201
|
+
raise ValueError("Either 'environment' or 'base_url' must be provided")
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def _default_headers(self) -> Headers:
|
|
205
|
+
return {
|
|
206
|
+
"x-api-key": self._api_key,
|
|
207
|
+
"Accept": "application/json",
|
|
208
|
+
"User-Agent": "htag-sdk-python/0.1.0",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# Sync request helpers
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class SyncRequestMixin(BaseClient):
|
|
218
|
+
"""Mixin providing synchronous HTTP request methods with retry logic."""
|
|
219
|
+
|
|
220
|
+
_http: httpx.Client
|
|
221
|
+
|
|
222
|
+
def _request(
|
|
223
|
+
self,
|
|
224
|
+
method: str,
|
|
225
|
+
path: str,
|
|
226
|
+
*,
|
|
227
|
+
params: Optional[QueryParams] = None,
|
|
228
|
+
json: Optional[JSONDict] = None,
|
|
229
|
+
base_url: Optional[str] = None,
|
|
230
|
+
) -> Any:
|
|
231
|
+
url = f"{base_url or self._base_url}{path}"
|
|
232
|
+
serialised = _serialise_params(params) if params else None
|
|
233
|
+
|
|
234
|
+
last_exc: Optional[Exception] = None
|
|
235
|
+
for attempt in range(self._max_retries + 1):
|
|
236
|
+
try:
|
|
237
|
+
response = self._http.request(
|
|
238
|
+
method,
|
|
239
|
+
url,
|
|
240
|
+
params=serialised,
|
|
241
|
+
json=json,
|
|
242
|
+
headers=self._default_headers,
|
|
243
|
+
timeout=self._timeout,
|
|
244
|
+
)
|
|
245
|
+
except httpx.ConnectError as exc:
|
|
246
|
+
last_exc = HtAgConnectionError(
|
|
247
|
+
f"Connection failed: {exc}",
|
|
248
|
+
status_code=None,
|
|
249
|
+
)
|
|
250
|
+
if attempt < self._max_retries:
|
|
251
|
+
time.sleep(_retry_delay(attempt))
|
|
252
|
+
continue
|
|
253
|
+
raise last_exc from exc
|
|
254
|
+
except httpx.TimeoutException as exc:
|
|
255
|
+
last_exc = HtAgConnectionError(
|
|
256
|
+
f"Request timed out after {self._timeout}s",
|
|
257
|
+
status_code=None,
|
|
258
|
+
)
|
|
259
|
+
if attempt < self._max_retries:
|
|
260
|
+
time.sleep(_retry_delay(attempt))
|
|
261
|
+
continue
|
|
262
|
+
raise last_exc from exc
|
|
263
|
+
|
|
264
|
+
if response.status_code < 400:
|
|
265
|
+
return response.json()
|
|
266
|
+
|
|
267
|
+
error = _build_error(response)
|
|
268
|
+
|
|
269
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < self._max_retries:
|
|
270
|
+
retry_after = (
|
|
271
|
+
error.retry_after
|
|
272
|
+
if isinstance(error, RateLimitError)
|
|
273
|
+
else None
|
|
274
|
+
)
|
|
275
|
+
time.sleep(_retry_delay(attempt, retry_after))
|
|
276
|
+
last_exc = error
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
raise error
|
|
280
|
+
|
|
281
|
+
# Should never reach here, but satisfy type-checker
|
|
282
|
+
raise last_exc or HtAgError("Request failed after retries") # pragma: no cover
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
# Async request helpers
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class AsyncRequestMixin(BaseClient):
|
|
291
|
+
"""Mixin providing asynchronous HTTP request methods with retry logic."""
|
|
292
|
+
|
|
293
|
+
_http: httpx.AsyncClient
|
|
294
|
+
|
|
295
|
+
async def _request(
|
|
296
|
+
self,
|
|
297
|
+
method: str,
|
|
298
|
+
path: str,
|
|
299
|
+
*,
|
|
300
|
+
params: Optional[QueryParams] = None,
|
|
301
|
+
json: Optional[JSONDict] = None,
|
|
302
|
+
base_url: Optional[str] = None,
|
|
303
|
+
) -> Any:
|
|
304
|
+
import asyncio
|
|
305
|
+
|
|
306
|
+
url = f"{base_url or self._base_url}{path}"
|
|
307
|
+
serialised = _serialise_params(params) if params else None
|
|
308
|
+
|
|
309
|
+
last_exc: Optional[Exception] = None
|
|
310
|
+
for attempt in range(self._max_retries + 1):
|
|
311
|
+
try:
|
|
312
|
+
response = await self._http.request(
|
|
313
|
+
method,
|
|
314
|
+
url,
|
|
315
|
+
params=serialised,
|
|
316
|
+
json=json,
|
|
317
|
+
headers=self._default_headers,
|
|
318
|
+
timeout=self._timeout,
|
|
319
|
+
)
|
|
320
|
+
except httpx.ConnectError as exc:
|
|
321
|
+
last_exc = HtAgConnectionError(
|
|
322
|
+
f"Connection failed: {exc}",
|
|
323
|
+
status_code=None,
|
|
324
|
+
)
|
|
325
|
+
if attempt < self._max_retries:
|
|
326
|
+
await asyncio.sleep(_retry_delay(attempt))
|
|
327
|
+
continue
|
|
328
|
+
raise last_exc from exc
|
|
329
|
+
except httpx.TimeoutException as exc:
|
|
330
|
+
last_exc = HtAgConnectionError(
|
|
331
|
+
f"Request timed out after {self._timeout}s",
|
|
332
|
+
status_code=None,
|
|
333
|
+
)
|
|
334
|
+
if attempt < self._max_retries:
|
|
335
|
+
await asyncio.sleep(_retry_delay(attempt))
|
|
336
|
+
continue
|
|
337
|
+
raise last_exc from exc
|
|
338
|
+
|
|
339
|
+
if response.status_code < 400:
|
|
340
|
+
return response.json()
|
|
341
|
+
|
|
342
|
+
error = _build_error(response)
|
|
343
|
+
|
|
344
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < self._max_retries:
|
|
345
|
+
retry_after = (
|
|
346
|
+
error.retry_after
|
|
347
|
+
if isinstance(error, RateLimitError)
|
|
348
|
+
else None
|
|
349
|
+
)
|
|
350
|
+
await asyncio.sleep(_retry_delay(attempt, retry_after))
|
|
351
|
+
last_exc = error
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
raise error
|
|
355
|
+
|
|
356
|
+
raise last_exc or HtAgError("Request failed after retries") # pragma: no cover
|
htag/_client.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Synchronous HtAG API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from htag._base import SyncRequestMixin
|
|
10
|
+
from htag.address.client import AddressClient
|
|
11
|
+
from htag.markets.client import MarketsClient
|
|
12
|
+
from htag.property.client import PropertyClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HtAgApi(SyncRequestMixin):
|
|
16
|
+
"""Synchronous client for the HtAG AI API.
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
from htag import HtAgApi
|
|
21
|
+
|
|
22
|
+
client = HtAgApi(api_key="sk-...", environment="prod")
|
|
23
|
+
|
|
24
|
+
# Address search
|
|
25
|
+
results = client.address.search("100 George St Sydney")
|
|
26
|
+
|
|
27
|
+
# Property sold search
|
|
28
|
+
sold = client.property.sold_search(address="100 George St Sydney")
|
|
29
|
+
|
|
30
|
+
# Market snapshots
|
|
31
|
+
snapshots = client.markets.snapshots(level="suburb", property_type=["house"])
|
|
32
|
+
|
|
33
|
+
# Market trends
|
|
34
|
+
prices = client.markets.trends.price(level="suburb", area_id=["SAL10001"])
|
|
35
|
+
|
|
36
|
+
# Always close when done (or use as a context manager)
|
|
37
|
+
client.close()
|
|
38
|
+
|
|
39
|
+
As a context manager::
|
|
40
|
+
|
|
41
|
+
with HtAgApi(api_key="sk-...", environment="prod") as client:
|
|
42
|
+
results = client.address.search("100 George St Sydney")
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
api_key: Your HtAG API key (required).
|
|
46
|
+
environment: Target environment -- ``"prod"`` or ``"dev"``.
|
|
47
|
+
base_url: Custom base URL (overrides ``environment``).
|
|
48
|
+
timeout: Request timeout in seconds (default 60).
|
|
49
|
+
max_retries: Maximum number of retry attempts for transient errors (default 3).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
address: AddressClient
|
|
53
|
+
property: PropertyClient
|
|
54
|
+
markets: MarketsClient
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
api_key: str,
|
|
60
|
+
environment: Optional[str] = None,
|
|
61
|
+
base_url: Optional[str] = None,
|
|
62
|
+
timeout: float = 60.0,
|
|
63
|
+
max_retries: int = 3,
|
|
64
|
+
) -> None:
|
|
65
|
+
super().__init__(
|
|
66
|
+
api_key=api_key,
|
|
67
|
+
environment=environment,
|
|
68
|
+
base_url=base_url,
|
|
69
|
+
timeout=timeout,
|
|
70
|
+
max_retries=max_retries,
|
|
71
|
+
)
|
|
72
|
+
self._http = httpx.Client()
|
|
73
|
+
self.address = AddressClient(self)
|
|
74
|
+
self.property = PropertyClient(self)
|
|
75
|
+
self.markets = MarketsClient(self)
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
"""Release underlying HTTP connection pool resources."""
|
|
79
|
+
self._http.close()
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> "HtAgApi":
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *args: object) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return f"HtAgApi(base_url={self._base_url!r})"
|