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 +76 -0
- ausdata/_internal/__init__.py +5 -0
- ausdata/_internal/http.py +143 -0
- ausdata/_internal/retry.py +63 -0
- ausdata/_version.py +11 -0
- ausdata/async_client.py +253 -0
- ausdata/client.py +349 -0
- ausdata/exceptions.py +101 -0
- ausdata/models.py +288 -0
- ausdata/py.typed +0 -0
- ausdata_sdk-0.2.0.dist-info/METADATA +193 -0
- ausdata_sdk-0.2.0.dist-info/RECORD +14 -0
- ausdata_sdk-0.2.0.dist-info/WHEEL +4 -0
- ausdata_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
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,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"
|
ausdata/async_client.py
ADDED
|
@@ -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
|