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 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})"