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/__init__.py ADDED
@@ -0,0 +1,76 @@
1
+ """Python SDK for the Australian public data API at https://ausdata.io.
2
+
3
+ Quick start::
4
+
5
+ from ausdata import Client
6
+
7
+ client = Client(api_key="ak_xxx") # or set AUSDATA_API_KEY
8
+ result = client.real_wages(start="2019-Q1", end="2024-Q4")
9
+ df = result.to_dataframe()
10
+
11
+ Async equivalent::
12
+
13
+ import asyncio
14
+ from ausdata import AsyncClient
15
+
16
+ async def main():
17
+ async with AsyncClient(api_key="ak_xxx") as client:
18
+ return await client.real_wages(start="2019-Q1")
19
+
20
+ asyncio.run(main())
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from ._version import __version__
26
+ from .async_client import AsyncClient
27
+ from .client import Client
28
+ from .exceptions import (
29
+ AusdataError,
30
+ AusdataServerError,
31
+ AuthenticationError,
32
+ PermissionError,
33
+ RateLimitError,
34
+ UpstreamError,
35
+ ValidationError,
36
+ )
37
+ from .models import (
38
+ AccountResponse,
39
+ ApiResponse,
40
+ DatasetSummary,
41
+ EconomicDashboard,
42
+ HealthResponse,
43
+ Links,
44
+ Meta,
45
+ RealWageRow,
46
+ SearchResponse,
47
+ SourceCitation,
48
+ WhoamiResponse,
49
+ )
50
+
51
+ __all__ = [
52
+ "__version__",
53
+ # clients
54
+ "AsyncClient",
55
+ "Client",
56
+ # exceptions
57
+ "AusdataError",
58
+ "AusdataServerError",
59
+ "AuthenticationError",
60
+ "PermissionError",
61
+ "RateLimitError",
62
+ "UpstreamError",
63
+ "ValidationError",
64
+ # models
65
+ "AccountResponse",
66
+ "ApiResponse",
67
+ "DatasetSummary",
68
+ "EconomicDashboard",
69
+ "HealthResponse",
70
+ "Links",
71
+ "Meta",
72
+ "RealWageRow",
73
+ "SearchResponse",
74
+ "SourceCitation",
75
+ "WhoamiResponse",
76
+ ]
@@ -0,0 +1,5 @@
1
+ """Internal helpers — not part of the public API.
2
+
3
+ Symbols inside ``ausdata._internal`` may change between minor versions.
4
+ Public callers should depend on the surfaces exposed in ``ausdata.__init__``.
5
+ """
@@ -0,0 +1,143 @@
1
+ """Shared HTTP plumbing for the sync and async clients.
2
+
3
+ Responsibilities kept here:
4
+ - API-key resolution (constructor arg → env var → informative error)
5
+ - Default headers (Authorization, User-Agent)
6
+ - Mapping HTTP status codes to typed :class:`AusdataError` subclasses
7
+
8
+ The actual request execution + retry loop lives in the sync/async clients
9
+ because the two transport styles can't share that wrapper cleanly.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from .._version import __version__
20
+ from ..exceptions import (
21
+ AusdataError,
22
+ AusdataServerError,
23
+ AuthenticationError,
24
+ PermissionError,
25
+ RateLimitError,
26
+ UpstreamError,
27
+ ValidationError,
28
+ )
29
+
30
+ DEFAULT_BASE_URL = "https://api.ausdata.io"
31
+ DEFAULT_TIMEOUT = 30.0
32
+ API_KEY_ENV_VAR = "AUSDATA_API_KEY"
33
+ BASE_URL_ENV_VAR = "AUSDATA_BASE_URL"
34
+
35
+
36
+ def resolve_api_key(api_key: str | None) -> str:
37
+ """Return ``api_key``, falling back to the ``AUSDATA_API_KEY`` env var.
38
+
39
+ Raises ``AuthenticationError`` with a directly actionable message when
40
+ neither source is set — failing here is much better than a cryptic 401
41
+ from the API on the first call.
42
+ """
43
+ if api_key:
44
+ return api_key
45
+ env_value = os.environ.get(API_KEY_ENV_VAR)
46
+ if env_value:
47
+ return env_value
48
+ raise AuthenticationError(
49
+ "No API key provided.",
50
+ hint=(
51
+ f"Pass api_key='ak_...' to Client(), or set the {API_KEY_ENV_VAR} "
52
+ "environment variable. Get a free key at https://ausdata.io."
53
+ ),
54
+ )
55
+
56
+
57
+ def build_default_headers(api_key: str) -> dict[str, str]:
58
+ return {
59
+ "Authorization": f"Bearer {api_key}",
60
+ "User-Agent": f"ausdata-python/{__version__}",
61
+ "Accept": "application/json",
62
+ }
63
+
64
+
65
+ def normalise_base_url(base_url: str | None) -> str:
66
+ """Resolve base URL: explicit arg → ``AUSDATA_BASE_URL`` env → default.
67
+
68
+ Trailing slash is stripped so endpoint paths can always start with ``/v1/...``.
69
+ """
70
+ resolved = base_url or os.environ.get(BASE_URL_ENV_VAR) or DEFAULT_BASE_URL
71
+ return resolved.rstrip("/")
72
+
73
+
74
+ def parse_error_body(response: httpx.Response) -> tuple[str, str | None, dict[str, Any] | None]:
75
+ """Best-effort extraction of ``(message, hint, raw_body)`` from a non-2xx body.
76
+
77
+ The API's :class:`ErrorDetail` shape is ``{"error", "message", "hint"}``;
78
+ FastAPI's default validation failures use ``{"detail"}``. We accept either.
79
+ """
80
+ try:
81
+ body = response.json()
82
+ except (ValueError, httpx.DecodingError):
83
+ return response.text or response.reason_phrase or "API error", None, None
84
+
85
+ if not isinstance(body, dict):
86
+ return str(body), None, None
87
+
88
+ hint = body.get("hint")
89
+ if "message" in body:
90
+ return str(body["message"]), hint, body
91
+ if "detail" in body:
92
+ detail = body["detail"]
93
+ if isinstance(detail, str):
94
+ return detail, hint, body
95
+ return str(detail), hint, body
96
+ if "error" in body:
97
+ return str(body["error"]), hint, body
98
+ return response.reason_phrase or "API error", hint, body
99
+
100
+
101
+ def raise_for_response(response: httpx.Response) -> None:
102
+ """Map an HTTP status code to the right :class:`AusdataError` subclass.
103
+
104
+ Called by the sync/async clients after exhausting the retry policy.
105
+ """
106
+ if response.is_success:
107
+ return
108
+
109
+ status = response.status_code
110
+ message, hint, raw = parse_error_body(response)
111
+
112
+ if status == 400:
113
+ raise ValidationError(message, status_code=status, hint=hint, response_body=raw)
114
+ if status == 401:
115
+ raise AuthenticationError(message, status_code=status, hint=hint, response_body=raw)
116
+ if status == 402:
117
+ # Treat 402 ("payment required" / tier-blocked) as a permission error.
118
+ raise PermissionError(message, status_code=status, hint=hint, response_body=raw)
119
+ if status == 403:
120
+ raise PermissionError(message, status_code=status, hint=hint, response_body=raw)
121
+ if status == 404:
122
+ raise ValidationError(message, status_code=status, hint=hint, response_body=raw)
123
+ if status == 429:
124
+ retry_after_header = response.headers.get("Retry-After")
125
+ retry_after: float | None = None
126
+ if retry_after_header:
127
+ try:
128
+ retry_after = float(retry_after_header)
129
+ except ValueError:
130
+ retry_after = None
131
+ raise RateLimitError(
132
+ message,
133
+ status_code=status,
134
+ hint=hint,
135
+ response_body=raw,
136
+ retry_after=retry_after,
137
+ )
138
+ if status in (502, 503, 504):
139
+ raise UpstreamError(message, status_code=status, hint=hint, response_body=raw)
140
+ if 500 <= status < 600:
141
+ raise AusdataServerError(message, status_code=status, hint=hint, response_body=raw)
142
+
143
+ raise AusdataError(message, status_code=status, hint=hint, response_body=raw)
@@ -0,0 +1,63 @@
1
+ """Retry policy shared by the sync and async clients.
2
+
3
+ The policy:
4
+ - Auto-retry on 5xx with exponential backoff (default 1s, 2s, 4s, max 3 attempts)
5
+ - Retry on 429 if a ``Retry-After`` header is present (capped at ``max_retry_after_seconds``)
6
+ - Never retry on other 4xx
7
+ - Caller-side ``ConnectError`` / ``ReadTimeout`` failures retry like 5xx
8
+
9
+ Sleeping is delegated to a caller-provided callable so the async client can
10
+ ``await asyncio.sleep`` while the sync client uses ``time.sleep``. This keeps
11
+ the policy decision in one place.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+ import httpx
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class RetryPolicy:
23
+ """Configuration for the SDK's automatic retry behaviour.
24
+
25
+ Defaults are conservative — 3 attempts (so 2 retries after the first
26
+ failure), 2x exponential backoff starting at 1 second. ``Retry-After``
27
+ headers on 429 responses are always respected up to
28
+ ``max_retry_after_seconds`` (default 60s).
29
+ """
30
+
31
+ max_retries: int = 3
32
+ backoff_factor: float = 2.0
33
+ initial_backoff: float = 1.0
34
+ max_retry_after_seconds: float = 60.0
35
+
36
+ def should_retry_status(self, status_code: int) -> bool:
37
+ if status_code == 429:
38
+ return True
39
+ return 500 <= status_code < 600
40
+
41
+ def should_retry_exception(self, exc: BaseException) -> bool:
42
+ # httpx network-layer failures get the same retry treatment as 5xx.
43
+ return isinstance(
44
+ exc,
45
+ (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, httpx.RemoteProtocolError),
46
+ )
47
+
48
+ def backoff_seconds(self, attempt: int) -> float:
49
+ """Backoff for the ``attempt``-th retry (1-indexed)."""
50
+ return self.initial_backoff * (self.backoff_factor ** (attempt - 1))
51
+
52
+ def retry_after_seconds(self, response: httpx.Response) -> float | None:
53
+ """Parse the ``Retry-After`` header. Returns None if absent or bogus."""
54
+ header = response.headers.get("Retry-After") or response.headers.get(
55
+ "retry-after"
56
+ )
57
+ if not header:
58
+ return None
59
+ try:
60
+ value = float(header)
61
+ except ValueError:
62
+ return None
63
+ return min(value, self.max_retry_after_seconds)
ausdata/_version.py ADDED
@@ -0,0 +1,11 @@
1
+ """Single source of truth for the SDK version string.
2
+
3
+ Kept in its own module so internal helpers can import it without forcing
4
+ the full ``ausdata/__init__.py`` to initialise (which would cause a circular
5
+ import: ``__init__`` imports ``Client``, which imports ``_internal.http``,
6
+ which used to import back from ``__init__`` for ``__version__``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __version__ = "0.2.0"
@@ -0,0 +1,253 @@
1
+ """Asynchronous ``AsyncClient`` for the ausdata REST API.
2
+
3
+ Mirrors :class:`ausdata.Client` one-to-one; only the I/O is awaitable. The
4
+ two classes deliberately share neither a base class nor a transport so the
5
+ sync version stays usable without an event loop.
6
+
7
+ Usage::
8
+
9
+ async with AsyncClient(api_key="ak_xxx") as client:
10
+ result = await client.real_wages(start="2019-Q1")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from typing import Any, Literal, Self
17
+
18
+ import httpx
19
+
20
+ from ._internal.http import (
21
+ DEFAULT_BASE_URL,
22
+ DEFAULT_TIMEOUT,
23
+ build_default_headers,
24
+ normalise_base_url,
25
+ raise_for_response,
26
+ resolve_api_key,
27
+ )
28
+ from ._internal.retry import RetryPolicy
29
+ from .exceptions import AusdataError
30
+ from .models import (
31
+ AccountResponse,
32
+ ApiResponse,
33
+ DatasetSummary,
34
+ EconomicDashboard,
35
+ HealthResponse,
36
+ RealWageRow,
37
+ WhoamiResponse,
38
+ parse_api_response,
39
+ )
40
+
41
+
42
+ class _AsyncAccountNamespace:
43
+ """Async equivalent of :class:`ausdata.client._AccountNamespace`."""
44
+
45
+ def __init__(self, client: "AsyncClient") -> None:
46
+ self._client = client
47
+
48
+ async def api_key(self) -> AccountResponse:
49
+ """Fetch the current account's API key (creates one if first call)."""
50
+ body = await self._client._request("GET", "/v1/account/api-key")
51
+ return AccountResponse.model_validate(body)
52
+
53
+ async def rotate_key(self) -> AccountResponse:
54
+ """Revoke the current key and issue a new one. Plaintext returned once."""
55
+ body = await self._client._request(
56
+ "POST", "/v1/account/api-key", params={"rotate": "true"}
57
+ )
58
+ return AccountResponse.model_validate(body)
59
+
60
+
61
+ class AsyncClient:
62
+ """Asynchronous client for the ausdata REST API.
63
+
64
+ Construction args mirror :class:`ausdata.Client`. Use as an async
65
+ context manager so the underlying ``httpx.AsyncClient`` connection
66
+ pool is released::
67
+
68
+ async with AsyncClient(api_key="ak_xxx") as client:
69
+ ...
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ api_key: str | None = None,
75
+ *,
76
+ base_url: str | None = None,
77
+ timeout: float = DEFAULT_TIMEOUT,
78
+ max_retries: int = 3,
79
+ retry_backoff_factor: float = 2.0,
80
+ transport: httpx.AsyncBaseTransport | None = None,
81
+ ) -> None:
82
+ resolved_key = resolve_api_key(api_key)
83
+ self._base_url = normalise_base_url(base_url) or DEFAULT_BASE_URL
84
+ self._retry_policy = RetryPolicy(
85
+ max_retries=max_retries,
86
+ backoff_factor=retry_backoff_factor,
87
+ )
88
+ self._httpx = httpx.AsyncClient(
89
+ base_url=self._base_url,
90
+ headers=build_default_headers(resolved_key),
91
+ timeout=timeout,
92
+ transport=transport,
93
+ )
94
+ self.account = _AsyncAccountNamespace(self)
95
+
96
+ async def __aenter__(self) -> Self:
97
+ return self
98
+
99
+ async def __aexit__(self, *exc: Any) -> None:
100
+ await self.aclose()
101
+
102
+ async def aclose(self) -> None:
103
+ """Release the underlying httpx connection pool."""
104
+ await self._httpx.aclose()
105
+
106
+ # ------------------------------------------------------------------
107
+ # Endpoint methods (parameter docs live on the sync Client)
108
+ # ------------------------------------------------------------------
109
+
110
+ async def health(self) -> HealthResponse:
111
+ """``GET /v1/health`` — service status; doesn't consume your quota."""
112
+ body = await self._request("GET", "/v1/health")
113
+ return HealthResponse.model_validate(body)
114
+
115
+ async def whoami(self) -> WhoamiResponse:
116
+ """``GET /v1/whoami`` — verify credentials and see your tier/usage."""
117
+ body = await self._request("GET", "/v1/whoami")
118
+ return WhoamiResponse.model_validate(body)
119
+
120
+ async def search_datasets(
121
+ self,
122
+ q: str,
123
+ *,
124
+ source: str | list[str] | None = None,
125
+ limit: int = 10,
126
+ ) -> ApiResponse[list[DatasetSummary]]:
127
+ """``GET /v1/search-datasets`` — fan-out search across all 9 sources."""
128
+ params: dict[str, Any] = {"q": q, "limit": limit}
129
+ if source is not None:
130
+ params["source"] = (
131
+ source if isinstance(source, str) else ",".join(source)
132
+ )
133
+ body = await self._request("GET", "/v1/search-datasets", params=params)
134
+ return parse_api_response(body, data_list_model=DatasetSummary)
135
+
136
+ async def real_wages(
137
+ self,
138
+ *,
139
+ start: str = "2019-Q1",
140
+ end: str | None = None,
141
+ seasonal_adjustment: Literal[
142
+ "original", "trend", "seasonally_adjusted"
143
+ ] = "trend",
144
+ ) -> ApiResponse[list[RealWageRow]]:
145
+ """``GET /v1/real-wages`` — WPI YoY minus CPI YoY quarterly series."""
146
+ params: dict[str, Any] = {
147
+ "start": start,
148
+ "seasonal_adjustment": seasonal_adjustment,
149
+ }
150
+ if end is not None:
151
+ params["end"] = end
152
+ body = await self._request("GET", "/v1/real-wages", params=params)
153
+ return parse_api_response(body, data_list_model=RealWageRow)
154
+
155
+ async def economic_dashboard(
156
+ self,
157
+ *,
158
+ period: str = "latest",
159
+ ) -> ApiResponse[EconomicDashboard]:
160
+ """``GET /v1/economic-dashboard`` — five headline macro indicators."""
161
+ body = await self._request(
162
+ "GET", "/v1/economic-dashboard", params={"period": period}
163
+ )
164
+ return parse_api_response(body, data_model=EconomicDashboard)
165
+
166
+ async def list_datasets(self, source: str) -> dict[str, Any]:
167
+ """``GET /v1/datasets/{source}`` — see sync client docs."""
168
+ return await self._request("GET", f"/v1/datasets/{source}")
169
+
170
+ async def describe(self, source: str, dataset_id: str) -> dict[str, Any]:
171
+ """``GET /v1/describe/{source}/{dataset_id}`` — see sync client docs."""
172
+ return await self._request(
173
+ "GET", f"/v1/describe/{source}/{dataset_id}"
174
+ )
175
+
176
+ async def get_data(
177
+ self,
178
+ source: str,
179
+ dataset_id: str,
180
+ *,
181
+ start: str | None = None,
182
+ end: str | None = None,
183
+ limit: int = 100,
184
+ format: Literal["json", "csv"] = "json",
185
+ **filters: Any,
186
+ ) -> ApiResponse[list[dict[str, Any]]]:
187
+ """``GET /v1/data/{source}/{dataset_id}`` — generic data passthrough.
188
+
189
+ See :meth:`ausdata.Client.get_data` for parameter docs.
190
+ """
191
+ params: dict[str, Any] = {"limit": limit, "format": format, **filters}
192
+ if start is not None:
193
+ params["start"] = start
194
+ if end is not None:
195
+ params["end"] = end
196
+ body = await self._request("GET", f"/v1/data/{source}/{dataset_id}", params=params)
197
+ return parse_api_response(body)
198
+
199
+ # ------------------------------------------------------------------
200
+ # Request execution + retry loop
201
+ # ------------------------------------------------------------------
202
+
203
+ async def _request(
204
+ self,
205
+ method: str,
206
+ path: str,
207
+ *,
208
+ params: dict[str, Any] | None = None,
209
+ json: Any | None = None,
210
+ ) -> dict[str, Any]:
211
+ attempt = 0
212
+ while True:
213
+ try:
214
+ response = await self._httpx.request(
215
+ method, path, params=params, json=json
216
+ )
217
+ except Exception as exc:
218
+ if (
219
+ self._retry_policy.should_retry_exception(exc)
220
+ and attempt < self._retry_policy.max_retries
221
+ ):
222
+ attempt += 1
223
+ await asyncio.sleep(self._retry_policy.backoff_seconds(attempt))
224
+ continue
225
+ raise AusdataError(
226
+ f"Transport error contacting ausdata API: {exc}"
227
+ ) from exc
228
+
229
+ if response.is_success:
230
+ try:
231
+ return response.json()
232
+ except ValueError as exc:
233
+ raise AusdataError(
234
+ "API returned a non-JSON 2xx response.",
235
+ status_code=response.status_code,
236
+ ) from exc
237
+
238
+ if (
239
+ self._retry_policy.should_retry_status(response.status_code)
240
+ and attempt < self._retry_policy.max_retries
241
+ ):
242
+ attempt += 1
243
+ if response.status_code == 429:
244
+ wait = self._retry_policy.retry_after_seconds(response) or (
245
+ self._retry_policy.backoff_seconds(attempt)
246
+ )
247
+ else:
248
+ wait = self._retry_policy.backoff_seconds(attempt)
249
+ await asyncio.sleep(wait)
250
+ continue
251
+
252
+ raise_for_response(response)
253
+ return {} # pragma: no cover