ausdata-sdk 0.2.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.
ausdata/client.py ADDED
@@ -0,0 +1,349 @@
1
+ """Synchronous ``Client`` for the ausdata REST API.
2
+
3
+ Built on ``httpx.Client``. Mirrors :class:`ausdata.AsyncClient` one-to-one
4
+ in surface area so callers can swap freely. Method docstrings here are the
5
+ canonical place to learn the public surface — the async client just delegates.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Any, Literal, Self
12
+
13
+ import httpx
14
+
15
+ from ._internal.http import (
16
+ DEFAULT_TIMEOUT,
17
+ build_default_headers,
18
+ normalise_base_url,
19
+ raise_for_response,
20
+ resolve_api_key,
21
+ )
22
+ from ._internal.retry import RetryPolicy
23
+ from .exceptions import AusdataError
24
+ from .models import (
25
+ AccountResponse,
26
+ ApiResponse,
27
+ DatasetSummary,
28
+ EconomicDashboard,
29
+ HealthResponse,
30
+ RealWageRow,
31
+ WhoamiResponse,
32
+ parse_api_response,
33
+ )
34
+
35
+
36
+ class _AccountNamespace:
37
+ """Sub-namespace for ``client.account.*`` JWT-only methods.
38
+
39
+ Implemented as an attribute proxy so callers get a clean ``client.account.api_key()``
40
+ surface without flooding the top-level client with dozens of methods.
41
+ """
42
+
43
+ def __init__(self, client: "Client") -> None:
44
+ self._client = client
45
+
46
+ def api_key(self) -> AccountResponse:
47
+ """Fetch the current account's API key (creates one if first call).
48
+
49
+ Note: the plaintext key is only returned on the very first call;
50
+ subsequent calls return a masked display value. Rotate the key with
51
+ :meth:`rotate_key` to force a new plaintext.
52
+ """
53
+ body = self._client._request("GET", "/v1/account/api-key")
54
+ return AccountResponse.model_validate(body)
55
+
56
+ def rotate_key(self) -> AccountResponse:
57
+ """Revoke the current key and issue a new one. Plaintext returned once.
58
+
59
+ Save the returned ``key`` immediately — you can't fetch the
60
+ plaintext again without rotating again.
61
+ """
62
+ body = self._client._request(
63
+ "POST", "/v1/account/api-key", params={"rotate": "true"}
64
+ )
65
+ return AccountResponse.model_validate(body)
66
+
67
+
68
+ class Client:
69
+ """Synchronous client for the ausdata REST API.
70
+
71
+ Args:
72
+ api_key: Bearer token. If omitted, falls back to the
73
+ ``AUSDATA_API_KEY`` env var. Raises ``AuthenticationError``
74
+ when neither source is set.
75
+ base_url: Override the default ``https://api.ausdata.io``. Useful
76
+ for local development against a staging instance.
77
+ timeout: Per-request timeout in seconds. Defaults to 30s.
78
+ max_retries: Maximum retry attempts on 5xx / 429 / network errors.
79
+ Defaults to 3.
80
+ retry_backoff_factor: Multiplier for exponential backoff between
81
+ retries. Defaults to 2.0 (so delays go 1s, 2s, 4s).
82
+ transport: Optional ``httpx.BaseTransport`` for advanced use
83
+ (mocking in tests, custom DNS, etc.).
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ api_key: str | None = None,
89
+ *,
90
+ base_url: str | None = None,
91
+ timeout: float = DEFAULT_TIMEOUT,
92
+ max_retries: int = 3,
93
+ retry_backoff_factor: float = 2.0,
94
+ transport: httpx.BaseTransport | None = None,
95
+ ) -> None:
96
+ resolved_key = resolve_api_key(api_key)
97
+ self._base_url = normalise_base_url(base_url)
98
+ self._retry_policy = RetryPolicy(
99
+ max_retries=max_retries,
100
+ backoff_factor=retry_backoff_factor,
101
+ )
102
+ self._httpx = httpx.Client(
103
+ base_url=self._base_url,
104
+ headers=build_default_headers(resolved_key),
105
+ timeout=timeout,
106
+ transport=transport,
107
+ )
108
+ self.account = _AccountNamespace(self)
109
+
110
+ # ------------------------------------------------------------------
111
+ # Context manager — encourage explicit cleanup of the connection pool.
112
+ # ------------------------------------------------------------------
113
+
114
+ def __enter__(self) -> Self:
115
+ return self
116
+
117
+ def __exit__(self, *exc: Any) -> None:
118
+ self.close()
119
+
120
+ def close(self) -> None:
121
+ """Release the underlying httpx connection pool."""
122
+ self._httpx.close()
123
+
124
+ # ------------------------------------------------------------------
125
+ # Endpoint methods
126
+ # ------------------------------------------------------------------
127
+
128
+ def health(self) -> HealthResponse:
129
+ """``GET /v1/health`` — service status; doesn't consume your quota."""
130
+ body = self._request("GET", "/v1/health")
131
+ return HealthResponse.model_validate(body)
132
+
133
+ def whoami(self) -> WhoamiResponse:
134
+ """``GET /v1/whoami`` — verify credentials and see your tier/usage.
135
+
136
+ Works for both API-key and JWT callers. Never includes the plaintext
137
+ key — safe to print or log.
138
+ """
139
+ body = self._request("GET", "/v1/whoami")
140
+ return WhoamiResponse.model_validate(body)
141
+
142
+ def search_datasets(
143
+ self,
144
+ q: str,
145
+ *,
146
+ source: str | list[str] | None = None,
147
+ limit: int = 10,
148
+ ) -> ApiResponse[list[DatasetSummary]]:
149
+ """``GET /v1/search-datasets`` — fan-out search across all 9 sources.
150
+
151
+ Args:
152
+ q: Plain-English query, 2-200 chars.
153
+ source: Optional restriction to specific sources. Accepts a
154
+ comma-separated string or a list (e.g. ``["abs", "rba"]``).
155
+ limit: Max results (1-50, default 10).
156
+ """
157
+ params: dict[str, Any] = {"q": q, "limit": limit}
158
+ if source is not None:
159
+ params["source"] = (
160
+ source if isinstance(source, str) else ",".join(source)
161
+ )
162
+ body = self._request("GET", "/v1/search-datasets", params=params)
163
+ return parse_api_response(body, data_list_model=DatasetSummary)
164
+
165
+ def real_wages(
166
+ self,
167
+ *,
168
+ start: str = "2019-Q1",
169
+ end: str | None = None,
170
+ seasonal_adjustment: Literal[
171
+ "original", "trend", "seasonally_adjusted"
172
+ ] = "trend",
173
+ ) -> ApiResponse[list[RealWageRow]]:
174
+ """``GET /v1/real-wages`` — WPI YoY minus CPI YoY quarterly series.
175
+
176
+ Args:
177
+ start: Quarter start, ``YYYY`` or ``YYYY-Qn``. Bare years coerce
178
+ to Q1 server-side.
179
+ end: Quarter end. Defaults to the latest published quarter.
180
+ seasonal_adjustment: Match the ABS methodology you want.
181
+ """
182
+ params: dict[str, Any] = {
183
+ "start": start,
184
+ "seasonal_adjustment": seasonal_adjustment,
185
+ }
186
+ if end is not None:
187
+ params["end"] = end
188
+ body = self._request("GET", "/v1/real-wages", params=params)
189
+ return parse_api_response(body, data_list_model=RealWageRow)
190
+
191
+ def economic_dashboard(
192
+ self,
193
+ *,
194
+ period: str = "latest",
195
+ ) -> ApiResponse[EconomicDashboard]:
196
+ """``GET /v1/economic-dashboard`` — five headline macro indicators.
197
+
198
+ Composes RBA F1.1 cash rate + ABS CPI / LF / WPI / LEND_HOUSING.
199
+ """
200
+ body = self._request(
201
+ "GET", "/v1/economic-dashboard", params={"period": period}
202
+ )
203
+ return parse_api_response(body, data_model=EconomicDashboard)
204
+
205
+ def list_datasets(self, source: str) -> dict[str, Any]:
206
+ """``GET /v1/datasets/{source}`` — enumerate curated datasets for a source.
207
+
208
+ Useful for surveying what's available before drilling in. Pairs with
209
+ :meth:`describe` and :meth:`get_data` for the full discover →
210
+ introspect → fetch loop.
211
+
212
+ Args:
213
+ source: One of ``abs``, ``rba``, ``ato``, ``apra``, ``aihw``,
214
+ ``asic``, ``aemo``, ``wgea``.
215
+
216
+ Example::
217
+
218
+ ds = client.list_datasets("abs")
219
+ for d in ds["data"]:
220
+ print(d["id"], "→", d.get("name"))
221
+ """
222
+ return self._request("GET", f"/v1/datasets/{source}")
223
+
224
+ def describe(self, source: str, dataset_id: str) -> dict[str, Any]:
225
+ """``GET /v1/describe/{source}/{dataset_id}`` — schema introspection.
226
+
227
+ Returns the dataset's dimensions, valid filters, valid values per
228
+ filter, units, and source links. Call this after a 400 'Unknown
229
+ filter' / 'Unknown value' from :meth:`get_data` to discover the
230
+ correct shape.
231
+
232
+ Args:
233
+ source: One of ``abs``, ``rba``, ``ato``, ``apra``, ``aihw``,
234
+ ``asic``, ``aemo``, ``wgea``.
235
+ dataset_id: Dataset ID from search results (e.g. ``"LF"``,
236
+ ``"F1.1"``). Source-prefixed forms like ``"abs.LF"`` also
237
+ accepted — the prefix is stripped server-side.
238
+
239
+ Example::
240
+
241
+ schema = client.describe("abs", "LF")
242
+ for dim in schema["data"]["dimensions"]:
243
+ print(dim["name"], "→", [v["key"] for v in dim["values"]])
244
+ """
245
+ return self._request(
246
+ "GET", f"/v1/describe/{source}/{dataset_id}"
247
+ )
248
+
249
+ def get_data(
250
+ self,
251
+ source: str,
252
+ dataset_id: str,
253
+ *,
254
+ start: str | None = None,
255
+ end: str | None = None,
256
+ limit: int = 100,
257
+ format: Literal["json", "csv"] = "json",
258
+ **filters: Any,
259
+ ) -> ApiResponse[list[dict[str, Any]]]:
260
+ """``GET /v1/data/{source}/{dataset_id}`` — generic data passthrough.
261
+
262
+ Fetches data from any of the 9 sister MCPs. Discover IDs with
263
+ :meth:`search_datasets`.
264
+
265
+ Args:
266
+ source: One of ``abs``, ``rba``, ``ato``, ``apra``, ``aihw``,
267
+ ``asic``, ``aemo``, ``au_weather``, ``wgea``.
268
+ dataset_id: Dataset ID from search results (e.g. ``"CPI"``,
269
+ ``"F1.1"``, ``"ADI_KEY_STATS"``).
270
+ start: Inclusive start period. Format varies by source —
271
+ ``YYYY-Q1`` quarterly, ``YYYY-MM`` monthly, ``YYYY`` annual.
272
+ end: Inclusive end period (same format as ``start``).
273
+ limit: Max rows (1-1000, default 100).
274
+ format: ``"json"`` (default) or ``"csv"``.
275
+ **filters: Source-specific dimension filters forwarded as query
276
+ params (e.g. ``region="australia"``, ``measure="change_year"``).
277
+
278
+ Example::
279
+
280
+ cpi = client.get_data("abs", "CPI", region="australia",
281
+ measure="change_year", limit=12)
282
+ """
283
+ params: dict[str, Any] = {"limit": limit, "format": format, **filters}
284
+ if start is not None:
285
+ params["start"] = start
286
+ if end is not None:
287
+ params["end"] = end
288
+ body = self._request("GET", f"/v1/data/{source}/{dataset_id}", params=params)
289
+ return parse_api_response(body)
290
+
291
+ # ------------------------------------------------------------------
292
+ # Request execution + retry loop
293
+ # ------------------------------------------------------------------
294
+
295
+ def _request(
296
+ self,
297
+ method: str,
298
+ path: str,
299
+ *,
300
+ params: dict[str, Any] | None = None,
301
+ json: Any | None = None,
302
+ ) -> dict[str, Any]:
303
+ """Execute one request, retrying transient failures per the policy."""
304
+ attempt = 0
305
+ while True:
306
+ try:
307
+ response = self._httpx.request(
308
+ method, path, params=params, json=json
309
+ )
310
+ except Exception as exc:
311
+ if (
312
+ self._retry_policy.should_retry_exception(exc)
313
+ and attempt < self._retry_policy.max_retries
314
+ ):
315
+ attempt += 1
316
+ time.sleep(self._retry_policy.backoff_seconds(attempt))
317
+ continue
318
+ raise AusdataError(
319
+ f"Transport error contacting ausdata API: {exc}"
320
+ ) from exc
321
+
322
+ if response.is_success:
323
+ try:
324
+ return response.json()
325
+ except ValueError as exc:
326
+ raise AusdataError(
327
+ "API returned a non-JSON 2xx response.",
328
+ status_code=response.status_code,
329
+ ) from exc
330
+
331
+ if (
332
+ self._retry_policy.should_retry_status(response.status_code)
333
+ and attempt < self._retry_policy.max_retries
334
+ ):
335
+ attempt += 1
336
+ if response.status_code == 429:
337
+ wait = self._retry_policy.retry_after_seconds(response) or (
338
+ self._retry_policy.backoff_seconds(attempt)
339
+ )
340
+ else:
341
+ wait = self._retry_policy.backoff_seconds(attempt)
342
+ time.sleep(wait)
343
+ continue
344
+
345
+ # Out of retries — let raise_for_response translate to a typed error.
346
+ raise_for_response(response)
347
+ # raise_for_response always raises on non-2xx, but keep mypy happy.
348
+ return {} # pragma: no cover
349
+ # Unreachable — loop only exits via return or raise.
ausdata/exceptions.py ADDED
@@ -0,0 +1,101 @@
1
+ """Exception hierarchy for the ausdata SDK.
2
+
3
+ All errors inherit from :class:`AusdataError` so callers can do a single
4
+ ``except AusdataError`` if they don't care about specifics. Each specific
5
+ class carries the API's error message plus the actionable ``hint`` returned
6
+ by the server when present (see ``ErrorDetail`` in ausdata-api).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ class AusdataError(Exception):
15
+ """Base class for every error raised by the ausdata SDK.
16
+
17
+ Attributes:
18
+ message: Human-readable error string.
19
+ status_code: HTTP status code that caused the error (None for
20
+ client-side errors like missing API key).
21
+ hint: Optional "Try X" guidance returned by the API. Surfaced
22
+ prominently when present.
23
+ response_body: Raw JSON body from the API, if parsed successfully.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ *,
30
+ status_code: int | None = None,
31
+ hint: str | None = None,
32
+ response_body: dict[str, Any] | None = None,
33
+ ) -> None:
34
+ self.message = message
35
+ self.status_code = status_code
36
+ self.hint = hint
37
+ self.response_body = response_body
38
+ full = message
39
+ if hint:
40
+ full = f"{message} (hint: {hint})"
41
+ super().__init__(full)
42
+
43
+
44
+ class AuthenticationError(AusdataError):
45
+ """401 — API key is missing, malformed, or revoked.
46
+
47
+ Check your ``AUSDATA_API_KEY`` env var or the ``api_key`` argument
48
+ passed to ``Client(...)``. Rotate the key in the dashboard if you
49
+ suspect it has been compromised.
50
+ """
51
+
52
+
53
+ class PermissionError(AusdataError): # noqa: A001 — intentional shadow of builtin
54
+ """403 — your tier doesn't grant access to this endpoint or feature.
55
+
56
+ The ``hint`` typically names the tier required (e.g. "Upgrade to
57
+ ANALYST at https://ausdata.io/pricing").
58
+ """
59
+
60
+
61
+ class RateLimitError(AusdataError):
62
+ """429 — monthly call quota exceeded, or per-minute burst limit hit.
63
+
64
+ Attributes:
65
+ retry_after: Seconds to wait before retrying, parsed from the
66
+ ``Retry-After`` response header. ``None`` if absent.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ message: str,
72
+ *,
73
+ retry_after: float | None = None,
74
+ **kwargs: Any,
75
+ ) -> None:
76
+ self.retry_after = retry_after
77
+ super().__init__(message, **kwargs)
78
+
79
+
80
+ class ValidationError(AusdataError):
81
+ """400 — request rejected by the API.
82
+
83
+ Common causes: malformed period strings, unknown dataset IDs,
84
+ invalid enum values. The ``hint`` usually suggests the fix.
85
+ """
86
+
87
+
88
+ class UpstreamError(AusdataError):
89
+ """502/503 — a sister data source (ABS, RBA, etc.) is failing upstream.
90
+
91
+ Retries already happened automatically before this was raised. The
92
+ API may serve a stale cached response on subsequent calls (look for
93
+ ``meta.stale=True``).
94
+ """
95
+
96
+
97
+ class AusdataServerError(AusdataError):
98
+ """5xx — generic server-side failure not covered by :class:`UpstreamError`.
99
+
100
+ Raised after the SDK's automatic retries are exhausted.
101
+ """