econdash 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.
- econdash/__init__.py +40 -0
- econdash/_client.py +349 -0
- econdash/errors.py +45 -0
- econdash/models.py +132 -0
- econdash/py.typed +0 -0
- econdash-0.1.0.dist-info/METADATA +162 -0
- econdash-0.1.0.dist-info/RECORD +9 -0
- econdash-0.1.0.dist-info/WHEEL +4 -0
- econdash-0.1.0.dist-info/licenses/LICENSE +21 -0
econdash/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Python client for the EconDash macroeconomic data API (https://econdash.org)."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from ._client import DEFAULT_BASE_URL, AsyncEconDash, EconDash # noqa: E402
|
|
6
|
+
from .errors import ( # noqa: E402
|
|
7
|
+
EconDashAuthError,
|
|
8
|
+
EconDashError,
|
|
9
|
+
EconDashNotFound,
|
|
10
|
+
EconDashPaymentRequired,
|
|
11
|
+
EconDashRateLimitError,
|
|
12
|
+
)
|
|
13
|
+
from .models import ( # noqa: E402
|
|
14
|
+
Citation,
|
|
15
|
+
Country,
|
|
16
|
+
Indicator,
|
|
17
|
+
Point,
|
|
18
|
+
Series,
|
|
19
|
+
TopCountries,
|
|
20
|
+
TopCountry,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DEFAULT_BASE_URL",
|
|
25
|
+
"AsyncEconDash",
|
|
26
|
+
"Citation",
|
|
27
|
+
"Country",
|
|
28
|
+
"EconDash",
|
|
29
|
+
"EconDashAuthError",
|
|
30
|
+
"EconDashError",
|
|
31
|
+
"EconDashNotFound",
|
|
32
|
+
"EconDashPaymentRequired",
|
|
33
|
+
"EconDashRateLimitError",
|
|
34
|
+
"Indicator",
|
|
35
|
+
"Point",
|
|
36
|
+
"Series",
|
|
37
|
+
"TopCountries",
|
|
38
|
+
"TopCountry",
|
|
39
|
+
"__version__",
|
|
40
|
+
]
|
econdash/_client.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import date
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from pydantic import BaseModel, TypeAdapter
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .errors import (
|
|
14
|
+
EconDashAuthError,
|
|
15
|
+
EconDashError,
|
|
16
|
+
EconDashNotFound,
|
|
17
|
+
EconDashPaymentRequired,
|
|
18
|
+
EconDashRateLimitError,
|
|
19
|
+
)
|
|
20
|
+
from .models import Citation, Country, Indicator, Series, TopCountries
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://econdash.org/api/v1"
|
|
23
|
+
M = TypeVar("M", bound=BaseModel)
|
|
24
|
+
DateLike = str | int | date
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _date(value: DateLike | None, *, end: bool) -> str | None:
|
|
28
|
+
"""'2000' -> 2000-01-01 (start) / 2000-12-31 (end); '2000-06' -> month bounds."""
|
|
29
|
+
if value is None:
|
|
30
|
+
return None
|
|
31
|
+
if isinstance(value, date):
|
|
32
|
+
return value.isoformat()
|
|
33
|
+
s = str(value)
|
|
34
|
+
if len(s) == 4 and s.isdigit():
|
|
35
|
+
return f"{s}-12-31" if end else f"{s}-01-01"
|
|
36
|
+
if len(s) == 7:
|
|
37
|
+
if not end:
|
|
38
|
+
return f"{s}-01"
|
|
39
|
+
y, m = int(s[:4]), int(s[5:])
|
|
40
|
+
nxt = date(y + m // 12, m % 12 + 1, 1)
|
|
41
|
+
return date.fromordinal(nxt.toordinal() - 1).isoformat()
|
|
42
|
+
return date.fromisoformat(s).isoformat()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_code(indicator: str) -> bool:
|
|
46
|
+
# Codes look like "world_bank__gdp_nominal", slugs like "gdp-nominal".
|
|
47
|
+
return "__" in indicator
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _search(indicators: list[Indicator], query: str, limit: int) -> list[Indicator]:
|
|
51
|
+
words = query.lower().split()
|
|
52
|
+
hits = []
|
|
53
|
+
for ind in indicators:
|
|
54
|
+
hay = " ".join(
|
|
55
|
+
x or ""
|
|
56
|
+
for x in (
|
|
57
|
+
ind.name_en,
|
|
58
|
+
ind.name_ru,
|
|
59
|
+
ind.slug,
|
|
60
|
+
ind.code,
|
|
61
|
+
ind.category,
|
|
62
|
+
ind.source,
|
|
63
|
+
ind.description,
|
|
64
|
+
)
|
|
65
|
+
).lower()
|
|
66
|
+
if all(w in hay for w in words):
|
|
67
|
+
hits.append(ind)
|
|
68
|
+
q = query.lower()
|
|
69
|
+
hits.sort(key=lambda i: (q not in (i.name_en or "").lower(), -i.data_points))
|
|
70
|
+
return hits[:limit]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _payment_required(resp: httpx.Response, message: str) -> EconDashPaymentRequired:
|
|
74
|
+
challenge = None
|
|
75
|
+
raw = resp.headers.get("payment-required")
|
|
76
|
+
if raw:
|
|
77
|
+
try:
|
|
78
|
+
challenge = json.loads(base64.b64decode(raw))
|
|
79
|
+
except ValueError:
|
|
80
|
+
challenge = None
|
|
81
|
+
return EconDashPaymentRequired(
|
|
82
|
+
message,
|
|
83
|
+
challenge=challenge,
|
|
84
|
+
www_authenticate=resp.headers.get_list("www-authenticate"),
|
|
85
|
+
status_code=402,
|
|
86
|
+
body=_body(resp),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _body(resp: httpx.Response) -> Any:
|
|
91
|
+
try:
|
|
92
|
+
return resp.json()
|
|
93
|
+
except ValueError:
|
|
94
|
+
return resp.text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _check(resp: httpx.Response) -> Any:
|
|
98
|
+
if resp.is_success:
|
|
99
|
+
return resp.json()
|
|
100
|
+
body = _body(resp)
|
|
101
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
102
|
+
msg = f"{resp.status_code}: {detail or body}"
|
|
103
|
+
code = resp.status_code
|
|
104
|
+
if code == 402:
|
|
105
|
+
raise _payment_required(resp, msg)
|
|
106
|
+
if code in (401, 403):
|
|
107
|
+
raise EconDashAuthError(msg, status_code=code, body=body)
|
|
108
|
+
if code == 404:
|
|
109
|
+
raise EconDashNotFound(msg, status_code=code, body=body)
|
|
110
|
+
if code == 429:
|
|
111
|
+
ra = resp.headers.get("retry-after")
|
|
112
|
+
raise EconDashRateLimitError(
|
|
113
|
+
msg, retry_after=int(ra) if ra and ra.isdigit() else None, status_code=code, body=body
|
|
114
|
+
)
|
|
115
|
+
raise EconDashError(msg, status_code=code, body=body)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _x402_client(signer: Any, max_usd_per_call: float, sync: bool) -> Any:
|
|
119
|
+
try:
|
|
120
|
+
from x402 import x402Client, x402ClientSync
|
|
121
|
+
from x402.http import x402HTTPClient, x402HTTPClientSync
|
|
122
|
+
from x402.mechanisms.evm.exact import register_exact_evm_client
|
|
123
|
+
except ImportError as e: # pragma: no cover
|
|
124
|
+
raise ImportError("x402 payments need the extra: pip install 'econdash[x402]'") from e
|
|
125
|
+
client: Any = x402ClientSync() if sync else x402Client()
|
|
126
|
+
register_exact_evm_client(client, signer)
|
|
127
|
+
# x402 refuses to sign anything above this cap; nothing is paid then.
|
|
128
|
+
client.set_spend_controls({"max_amount_per_payment": f"${max_usd_per_call}"})
|
|
129
|
+
return x402HTTPClientSync(client) if sync else x402HTTPClient(client)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _Base:
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
api_key: str | None = None,
|
|
136
|
+
*,
|
|
137
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
138
|
+
timeout: float = 30.0,
|
|
139
|
+
pay_with_x402: bool = False,
|
|
140
|
+
x402_signer: Any = None,
|
|
141
|
+
max_usd_per_call: float | None = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
self.api_key = api_key or os.environ.get("ECONDASH_API_KEY") or None
|
|
144
|
+
self.base_url = base_url.rstrip("/")
|
|
145
|
+
self.timeout = timeout
|
|
146
|
+
self.headers = {"User-Agent": f"econdash-python/{__version__}"}
|
|
147
|
+
if self.api_key:
|
|
148
|
+
self.headers["Authorization"] = f"Bearer {self.api_key}"
|
|
149
|
+
if pay_with_x402 and (x402_signer is None or not max_usd_per_call):
|
|
150
|
+
raise ValueError("pay_with_x402=True needs x402_signer and max_usd_per_call")
|
|
151
|
+
self.pay_with_x402 = pay_with_x402
|
|
152
|
+
self._x402_signer = x402_signer
|
|
153
|
+
self._max_usd = max_usd_per_call or 0.0
|
|
154
|
+
# Paid (x402) access lives under /m2m; plain /api/v1 answers trial limits, not 402.
|
|
155
|
+
self._data = self.base_url + ("/m2m" if pay_with_x402 else "")
|
|
156
|
+
self._indicators: list[Indicator] | None = None
|
|
157
|
+
self._codes: dict[str, str] = {}
|
|
158
|
+
|
|
159
|
+
def _x402_for(self, sync: bool) -> Any:
|
|
160
|
+
if not self.pay_with_x402:
|
|
161
|
+
return None
|
|
162
|
+
return _x402_client(self._x402_signer, self._max_usd, sync=sync)
|
|
163
|
+
|
|
164
|
+
def _remember(self, ind: Indicator) -> Indicator:
|
|
165
|
+
if ind.slug:
|
|
166
|
+
self._codes[ind.slug] = ind.code
|
|
167
|
+
return ind
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _one(model: type[M], data: Any) -> M:
|
|
171
|
+
return model.model_validate(data)
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _many(model: type[M], data: Any) -> list[M]:
|
|
175
|
+
return TypeAdapter(list[model]).validate_python(data) # type: ignore[valid-type]
|
|
176
|
+
|
|
177
|
+
def _series_params(
|
|
178
|
+
self, country: str, start: DateLike | None, end: DateLike | None
|
|
179
|
+
) -> dict[str, str]:
|
|
180
|
+
params = {"country": country.upper()}
|
|
181
|
+
if (s := _date(start, end=False)) is not None:
|
|
182
|
+
params["start_date"] = s
|
|
183
|
+
if (e := _date(end, end=True)) is not None:
|
|
184
|
+
params["end_date"] = e
|
|
185
|
+
return params
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class EconDash(_Base):
|
|
189
|
+
"""Synchronous client.
|
|
190
|
+
|
|
191
|
+
>>> ed = EconDash() # trial, or ECONDASH_API_KEY from env
|
|
192
|
+
>>> ed.get_series("gdp-nominal", "USA", start=2000).data[-1].value
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(self, api_key: str | None = None, **kw: Any) -> None:
|
|
196
|
+
super().__init__(api_key, **kw)
|
|
197
|
+
self._http = httpx.Client(headers=self.headers, timeout=self.timeout)
|
|
198
|
+
self._x402 = self._x402_for(sync=True)
|
|
199
|
+
|
|
200
|
+
def __enter__(self) -> EconDash:
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
def __exit__(self, *exc: object) -> None:
|
|
204
|
+
self.close()
|
|
205
|
+
|
|
206
|
+
def close(self) -> None:
|
|
207
|
+
self._http.close()
|
|
208
|
+
|
|
209
|
+
def _get(self, url: str, params: dict[str, Any] | None = None) -> Any:
|
|
210
|
+
resp = self._http.get(url, params=params)
|
|
211
|
+
if resp.status_code == 402 and self._x402 is not None:
|
|
212
|
+
try:
|
|
213
|
+
pay, _ = self._x402.handle_402_response(
|
|
214
|
+
dict(resp.headers), resp.content, str(resp.request.url)
|
|
215
|
+
)
|
|
216
|
+
except Exception as e:
|
|
217
|
+
raise _payment_required(resp, f"x402 payment not made: {e}") from e
|
|
218
|
+
resp = self._http.get(url, params=params, headers=pay)
|
|
219
|
+
return _check(resp)
|
|
220
|
+
|
|
221
|
+
def _code(self, indicator: str) -> str:
|
|
222
|
+
if _is_code(indicator):
|
|
223
|
+
return indicator
|
|
224
|
+
if indicator not in self._codes:
|
|
225
|
+
self.get_indicator(indicator)
|
|
226
|
+
return self._codes[indicator]
|
|
227
|
+
|
|
228
|
+
def list_indicators(self) -> list[Indicator]:
|
|
229
|
+
"""All active indicators (cached per client)."""
|
|
230
|
+
if self._indicators is None:
|
|
231
|
+
data = self._get(f"{self._data}/indicators", {"limit": 1200})
|
|
232
|
+
self._indicators = [self._remember(i) for i in self._many(Indicator, data)]
|
|
233
|
+
return self._indicators
|
|
234
|
+
|
|
235
|
+
def search_indicators(self, query: str, *, limit: int = 20) -> list[Indicator]:
|
|
236
|
+
"""Case-insensitive search over names, slugs, codes, categories and sources."""
|
|
237
|
+
return _search(self.list_indicators(), query, limit)
|
|
238
|
+
|
|
239
|
+
def get_indicator(self, indicator: str) -> Indicator:
|
|
240
|
+
"""Metadata by slug ("gdp-nominal") or code ("world_bank__gdp_nominal")."""
|
|
241
|
+
path = indicator if _is_code(indicator) else f"by-slug/{indicator}"
|
|
242
|
+
return self._remember(self._one(Indicator, self._get(f"{self._data}/indicators/{path}")))
|
|
243
|
+
|
|
244
|
+
def get_series(
|
|
245
|
+
self,
|
|
246
|
+
indicator: str,
|
|
247
|
+
country: str,
|
|
248
|
+
*,
|
|
249
|
+
start: DateLike | None = None,
|
|
250
|
+
end: DateLike | None = None,
|
|
251
|
+
) -> Series:
|
|
252
|
+
"""Time series for one country. `start`/`end`: 2000, "2000-06" or "2000-06-30"."""
|
|
253
|
+
url = f"{self._data}/timeseries/{self._code(indicator)}"
|
|
254
|
+
return self._one(Series, self._get(url, self._series_params(country, start, end)))
|
|
255
|
+
|
|
256
|
+
def list_countries(self) -> list[Country]:
|
|
257
|
+
return self._many(Country, self._get(f"{self._data}/countries"))
|
|
258
|
+
|
|
259
|
+
def top_countries(
|
|
260
|
+
self, indicator: str, *, year: int | None = None, limit: int = 10
|
|
261
|
+
) -> TopCountries:
|
|
262
|
+
"""Ranking by the latest value (or by `year`). Includes aggregates like WLD."""
|
|
263
|
+
params: dict[str, Any] = {"limit": limit}
|
|
264
|
+
if year is not None:
|
|
265
|
+
params["year"] = year
|
|
266
|
+
url = f"{self._data}/timeseries/{self._code(indicator)}/top-countries"
|
|
267
|
+
return self._one(TopCountries, self._get(url, params))
|
|
268
|
+
|
|
269
|
+
def cite(self, indicator: str, country: str) -> Citation:
|
|
270
|
+
"""Latest value with ready citation strings (text, short, markdown). Free endpoint."""
|
|
271
|
+
return self._one(Citation, self._get(f"{self.base_url}/cite/{country}/{indicator}"))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class AsyncEconDash(_Base):
|
|
275
|
+
"""Asynchronous client with the same methods as `EconDash`, all awaitable."""
|
|
276
|
+
|
|
277
|
+
def __init__(self, api_key: str | None = None, **kw: Any) -> None:
|
|
278
|
+
super().__init__(api_key, **kw)
|
|
279
|
+
self._http = httpx.AsyncClient(headers=self.headers, timeout=self.timeout)
|
|
280
|
+
self._x402 = self._x402_for(sync=False)
|
|
281
|
+
|
|
282
|
+
async def __aenter__(self) -> AsyncEconDash:
|
|
283
|
+
return self
|
|
284
|
+
|
|
285
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
286
|
+
await self.aclose()
|
|
287
|
+
|
|
288
|
+
async def aclose(self) -> None:
|
|
289
|
+
await self._http.aclose()
|
|
290
|
+
|
|
291
|
+
async def _get(self, url: str, params: dict[str, Any] | None = None) -> Any:
|
|
292
|
+
resp = await self._http.get(url, params=params)
|
|
293
|
+
if resp.status_code == 402 and self._x402 is not None:
|
|
294
|
+
try:
|
|
295
|
+
pay, _ = await self._x402.handle_402_response(
|
|
296
|
+
dict(resp.headers), resp.content, str(resp.request.url)
|
|
297
|
+
)
|
|
298
|
+
except Exception as e:
|
|
299
|
+
raise _payment_required(resp, f"x402 payment not made: {e}") from e
|
|
300
|
+
resp = await self._http.get(url, params=params, headers=pay)
|
|
301
|
+
return _check(resp)
|
|
302
|
+
|
|
303
|
+
async def _code(self, indicator: str) -> str:
|
|
304
|
+
if _is_code(indicator):
|
|
305
|
+
return indicator
|
|
306
|
+
if indicator not in self._codes:
|
|
307
|
+
await self.get_indicator(indicator)
|
|
308
|
+
return self._codes[indicator]
|
|
309
|
+
|
|
310
|
+
async def list_indicators(self) -> list[Indicator]:
|
|
311
|
+
if self._indicators is None:
|
|
312
|
+
data = await self._get(f"{self._data}/indicators", {"limit": 1200})
|
|
313
|
+
self._indicators = [self._remember(i) for i in self._many(Indicator, data)]
|
|
314
|
+
return self._indicators
|
|
315
|
+
|
|
316
|
+
async def search_indicators(self, query: str, *, limit: int = 20) -> list[Indicator]:
|
|
317
|
+
return _search(await self.list_indicators(), query, limit)
|
|
318
|
+
|
|
319
|
+
async def get_indicator(self, indicator: str) -> Indicator:
|
|
320
|
+
path = indicator if _is_code(indicator) else f"by-slug/{indicator}"
|
|
321
|
+
data = await self._get(f"{self._data}/indicators/{path}")
|
|
322
|
+
return self._remember(self._one(Indicator, data))
|
|
323
|
+
|
|
324
|
+
async def get_series(
|
|
325
|
+
self,
|
|
326
|
+
indicator: str,
|
|
327
|
+
country: str,
|
|
328
|
+
*,
|
|
329
|
+
start: DateLike | None = None,
|
|
330
|
+
end: DateLike | None = None,
|
|
331
|
+
) -> Series:
|
|
332
|
+
url = f"{self._data}/timeseries/{await self._code(indicator)}"
|
|
333
|
+
return self._one(Series, await self._get(url, self._series_params(country, start, end)))
|
|
334
|
+
|
|
335
|
+
async def list_countries(self) -> list[Country]:
|
|
336
|
+
return self._many(Country, await self._get(f"{self._data}/countries"))
|
|
337
|
+
|
|
338
|
+
async def top_countries(
|
|
339
|
+
self, indicator: str, *, year: int | None = None, limit: int = 10
|
|
340
|
+
) -> TopCountries:
|
|
341
|
+
params: dict[str, Any] = {"limit": limit}
|
|
342
|
+
if year is not None:
|
|
343
|
+
params["year"] = year
|
|
344
|
+
url = f"{self._data}/timeseries/{await self._code(indicator)}/top-countries"
|
|
345
|
+
return self._one(TopCountries, await self._get(url, params))
|
|
346
|
+
|
|
347
|
+
async def cite(self, indicator: str, country: str) -> Citation:
|
|
348
|
+
data = await self._get(f"{self.base_url}/cite/{country}/{indicator}")
|
|
349
|
+
return self._one(Citation, data)
|
econdash/errors.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EconDashError(Exception):
|
|
7
|
+
"""Base class. `status_code` and `body` carry the raw API answer."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, *, status_code: int | None = None, body: Any = None) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
self.body = body
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EconDashAuthError(EconDashError):
|
|
16
|
+
"""401/403: bad API key, inactive subscription, or data outside the free trial."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EconDashNotFound(EconDashError):
|
|
20
|
+
"""404: unknown indicator, country or series."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EconDashRateLimitError(EconDashError):
|
|
24
|
+
"""429: trial or key quota exhausted. `retry_after` is in seconds."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str, *, retry_after: int | None = None, **kw: Any) -> None:
|
|
27
|
+
super().__init__(message, **kw)
|
|
28
|
+
self.retry_after = retry_after
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EconDashPaymentRequired(EconDashError):
|
|
32
|
+
"""402: the call needs payment. `challenge` is the decoded x402 PAYMENT-REQUIRED
|
|
33
|
+
object, `www_authenticate` the raw MPP challenges. Nothing was paid."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
message: str,
|
|
38
|
+
*,
|
|
39
|
+
challenge: dict[str, Any] | None = None,
|
|
40
|
+
www_authenticate: list[str] | None = None,
|
|
41
|
+
**kw: Any,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(message, **kw)
|
|
44
|
+
self.challenge = challenge
|
|
45
|
+
self.www_authenticate = www_authenticate or []
|
econdash/models.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Response models. Field names and types follow the EconDash API schema
|
|
2
|
+
(`IndicatorResponse`, `TimeSeriesResponse`, `CountryResponse`, ... in the backend OpenAPI).
|
|
3
|
+
Unknown fields are ignored, so new API fields never break old clients."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import date
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Indicator(BaseModel):
|
|
13
|
+
code: str
|
|
14
|
+
slug: str | None
|
|
15
|
+
name_ru: str
|
|
16
|
+
name_en: str | None
|
|
17
|
+
category: str | None
|
|
18
|
+
source: str | None
|
|
19
|
+
unit: str | None
|
|
20
|
+
fetch_method: str | None
|
|
21
|
+
data_points: int = 0
|
|
22
|
+
description: str | None = None
|
|
23
|
+
updated_at: str | None = None
|
|
24
|
+
frequency: str | None = None
|
|
25
|
+
first_observation: str | None = None
|
|
26
|
+
last_observation: str | None = None
|
|
27
|
+
data_changed_at: str | None = None
|
|
28
|
+
data_checked_at: str | None = None
|
|
29
|
+
source_series_id: str | None = None
|
|
30
|
+
source_url: str | None = None
|
|
31
|
+
source_url_template: str | None = None
|
|
32
|
+
source_publisher: str | None = None
|
|
33
|
+
license_name: str | None = None
|
|
34
|
+
license_url: str | None = None
|
|
35
|
+
attribution_text: str | None = None
|
|
36
|
+
is_based_on: list[str] | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def name(self) -> str:
|
|
40
|
+
return self.name_en or self.name_ru
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def url(self) -> str | None:
|
|
44
|
+
"""Chart hub page on econdash.org."""
|
|
45
|
+
return f"https://econdash.org/chart/{self.slug}" if self.slug else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Point(BaseModel):
|
|
49
|
+
date: date
|
|
50
|
+
value: float
|
|
51
|
+
country: str
|
|
52
|
+
country_name: str | None = None
|
|
53
|
+
is_projection: bool | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Series(BaseModel):
|
|
57
|
+
indicator_code: str
|
|
58
|
+
indicator_name_ru: str
|
|
59
|
+
indicator_name_en: str | None
|
|
60
|
+
unit: str | None
|
|
61
|
+
frequency: str | None = None
|
|
62
|
+
first_observation: str | None = None
|
|
63
|
+
last_observation: str | None = None
|
|
64
|
+
data_points: int | None = None
|
|
65
|
+
data_changed_at: str | None = None
|
|
66
|
+
data_checked_at: str | None = None
|
|
67
|
+
data: list[Point]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Country(BaseModel):
|
|
71
|
+
iso3_code: str
|
|
72
|
+
iso2_code: str | None
|
|
73
|
+
name: str
|
|
74
|
+
region: str | None
|
|
75
|
+
income_level: str | None
|
|
76
|
+
data_points: int = 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TopCountry(BaseModel):
|
|
80
|
+
iso3_code: str
|
|
81
|
+
country_name: str | None
|
|
82
|
+
value: float
|
|
83
|
+
date: date
|
|
84
|
+
yoy_change_pct: float | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TopCountries(BaseModel):
|
|
88
|
+
indicator_code: str
|
|
89
|
+
unit: str | None
|
|
90
|
+
data: list[TopCountry]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CiteSource(BaseModel):
|
|
94
|
+
name: str
|
|
95
|
+
acronym: str | None = None
|
|
96
|
+
url: str | None = None
|
|
97
|
+
release_date: str | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CitationStrings(BaseModel):
|
|
101
|
+
text: str
|
|
102
|
+
short: str
|
|
103
|
+
markdown: str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class CiteMeta(BaseModel):
|
|
107
|
+
updated_at: str
|
|
108
|
+
next_update: str | None = None
|
|
109
|
+
frequency: str | None = None
|
|
110
|
+
canonical_url: str
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Citation(BaseModel):
|
|
114
|
+
type: str = "economic_indicator"
|
|
115
|
+
indicator: str
|
|
116
|
+
indicator_name: str
|
|
117
|
+
country: str
|
|
118
|
+
country_name: str
|
|
119
|
+
value: float
|
|
120
|
+
unit: str
|
|
121
|
+
change: str | None = None
|
|
122
|
+
period: str
|
|
123
|
+
previous_value: float | None = None
|
|
124
|
+
previous_period: str | None = None
|
|
125
|
+
trend: str | None = None
|
|
126
|
+
trend_magnitude: float | None = None
|
|
127
|
+
definition: str | None = None
|
|
128
|
+
source: CiteSource
|
|
129
|
+
citation: CitationStrings
|
|
130
|
+
meta: CiteMeta
|
|
131
|
+
is_projection: bool = False
|
|
132
|
+
projection_label: str | None = None
|
econdash/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: econdash
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the EconDash macroeconomic data API: 750+ indicators, 290+ countries, sources and licences.
|
|
5
|
+
Project-URL: Homepage, https://econdash.org
|
|
6
|
+
Project-URL: Documentation, https://econdash.org/docs/python
|
|
7
|
+
Project-URL: Repository, https://github.com/econdash/econdash-python
|
|
8
|
+
Project-URL: Issues, https://github.com/econdash/econdash-python/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/econdash/econdash-python/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: EconDash <noderguru@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: api,economics,gdp,imf,inflation,macroeconomics,time-series,world-bank,x402
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Science/Research
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Requires-Dist: pydantic>=2.5
|
|
29
|
+
Provides-Extra: x402
|
|
30
|
+
Requires-Dist: x402[evm]<3,>=2.25; extra == 'x402'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# econdash
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/econdash/)
|
|
36
|
+
[](https://github.com/econdash/econdash-python/actions/workflows/ci.yml)
|
|
37
|
+
[](https://pypi.org/project/econdash/)
|
|
38
|
+
|
|
39
|
+
Python client for the [EconDash](https://econdash.org) macroeconomic data API: 750+ indicators
|
|
40
|
+
(GDP, inflation, debt, trade, labour, money supply, …) for 290+ countries and regions, with
|
|
41
|
+
source, licence and freshness metadata for every series.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install econdash
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from econdash import EconDash
|
|
49
|
+
|
|
50
|
+
ed = EconDash() # free trial, no key needed
|
|
51
|
+
|
|
52
|
+
ed.search_indicators("inflation")[:3] # -> list[Indicator]
|
|
53
|
+
ed.get_indicator("gdp-nominal") # metadata, source, licence, freshness
|
|
54
|
+
s = ed.get_series("gdp-nominal", "USA", start=2000)
|
|
55
|
+
s.data[-1] # Point(date=2025-01-01, value=3.07e13, ...)
|
|
56
|
+
ed.list_countries() # -> list[Country]
|
|
57
|
+
ed.top_countries("gdp-nominal", year=2024, limit=10)
|
|
58
|
+
ed.cite(
|
|
59
|
+
"gdp-nominal", "USA"
|
|
60
|
+
).citation.text # 'United States GDP nominal was $30.77 tn in 2025 (World Bank, ...)'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Indicators can be addressed by slug (`gdp-nominal`, as in
|
|
64
|
+
`econdash.org/chart/gdp-nominal/USA`) or by code (`world_bank__gdp_nominal`).
|
|
65
|
+
`start`/`end` accept `2000`, `"2000-06"`, `"2000-06-30"` or a `date`.
|
|
66
|
+
|
|
67
|
+
## Free trial vs API key
|
|
68
|
+
|
|
69
|
+
Without a key the client works on the **free trial**:
|
|
70
|
+
|
|
71
|
+
| Limit | Trial |
|
|
72
|
+
|---|---|
|
|
73
|
+
| Requests | 300 per day, 30 per minute (per IP) |
|
|
74
|
+
| Countries | 10: USA, CHN, DEU, JPN, GBR, FRA, IND, BRA, RUS, KOR |
|
|
75
|
+
| Indicators | 50 core series (GDP, CPI, money supply, debt, trade, …) |
|
|
76
|
+
|
|
77
|
+
Outside the trial the API answers `403` and the client raises `EconDashAuthError`
|
|
78
|
+
("Country ZWE not available in trial"). That is the trial limit, not a bug.
|
|
79
|
+
|
|
80
|
+
For full access create a key at [econdash.org/dashboard/api-keys](https://econdash.org/dashboard/api-keys)
|
|
81
|
+
([plans](https://econdash.org/api-access)) and pass it:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
ed = EconDash(api_key="ed_...") # or set ECONDASH_API_KEY
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Async
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from econdash import AsyncEconDash
|
|
91
|
+
|
|
92
|
+
async with AsyncEconDash() as ed:
|
|
93
|
+
series = await ed.get_series("cpi-inflation-rate-percent", "DEU", start=2010)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Errors
|
|
97
|
+
|
|
98
|
+
| Exception | When |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `EconDashAuthError` | 401/403: bad key, inactive subscription, data outside the trial |
|
|
101
|
+
| `EconDashNotFound` | 404: unknown indicator or country |
|
|
102
|
+
| `EconDashRateLimitError` | 429, `retry_after` in seconds |
|
|
103
|
+
| `EconDashPaymentRequired` | 402, `challenge` holds the x402 payment requirements |
|
|
104
|
+
| `EconDashError` | base class, anything else |
|
|
105
|
+
|
|
106
|
+
## Pay per call with x402 (for AI agents)
|
|
107
|
+
|
|
108
|
+
Agents without an account can pay each call in USDC on Base via
|
|
109
|
+
[x402](https://www.x402.org) ($0.02 per call at the time of writing). It is **off by
|
|
110
|
+
default**: the client never spends money unless you turn it on and set a cap.
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install "econdash[x402]"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import os
|
|
118
|
+
|
|
119
|
+
from eth_account import Account
|
|
120
|
+
from econdash import EconDash
|
|
121
|
+
|
|
122
|
+
ed = EconDash(
|
|
123
|
+
pay_with_x402=True,
|
|
124
|
+
x402_signer=Account.from_key(os.environ["AGENT_PRIVATE_KEY"]),
|
|
125
|
+
max_usd_per_call=0.05, # x402 refuses to sign anything more expensive
|
|
126
|
+
)
|
|
127
|
+
ed.get_series("gdp-nominal", "ZWE")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
If the price is above `max_usd_per_call`, nothing is signed and `EconDashPaymentRequired`
|
|
131
|
+
is raised. Keep agent wallets funded with small amounts only.
|
|
132
|
+
|
|
133
|
+
## Examples
|
|
134
|
+
|
|
135
|
+
Notebooks in [`examples/`](examples/): search → series → pandas chart, comparing countries,
|
|
136
|
+
a LangChain agent, an agent paying with x402.
|
|
137
|
+
|
|
138
|
+
## Data and licences
|
|
139
|
+
|
|
140
|
+
Every `Indicator` carries `source`, `source_url`, `license_name`, `license_url` and
|
|
141
|
+
`attribution_text`. Source licences differ (World Bank CC BY 4.0, IMF, OECD, FRED, …);
|
|
142
|
+
see [econdash.org/data-license](https://econdash.org/data-license). Please keep the
|
|
143
|
+
attribution when you publish charts or numbers.
|
|
144
|
+
|
|
145
|
+
## Links
|
|
146
|
+
|
|
147
|
+
- Website: <https://econdash.org>
|
|
148
|
+
- API docs: <https://econdash.org/docs>, OpenAPI: <https://econdash.org/openapi.json>
|
|
149
|
+
- Python docs: <https://econdash.org/docs/python>
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
uv sync --all-extras
|
|
155
|
+
uv run pytest # offline tests on recorded API answers
|
|
156
|
+
uv run pytest -m live # contract tests against the real API
|
|
157
|
+
uv run ruff check . && uv run mypy
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Releases are published to PyPI from GitHub Actions on a GitHub Release (trusted publishing).
|
|
161
|
+
|
|
162
|
+
MIT licence. The data itself is licensed by its original sources.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
econdash/__init__.py,sha256=TW5KfoN0_RmanupFibPEuiQ1_AhEaHu9h-P3NFvJ_JE,820
|
|
2
|
+
econdash/_client.py,sha256=kldWLfaBIiDc4hWA1sjsXWt2vf5phuQUwwQGG_ufxh0,13060
|
|
3
|
+
econdash/errors.py,sha256=vih-ycvwhGCrptrczKFKLfdJRzvIM53bJ7rd-cuaxrc,1419
|
|
4
|
+
econdash/models.py,sha256=B_DJVBBX48Ci_RL0LJwgEp5-h1_s3DsSBrc0-I94HsE,3258
|
|
5
|
+
econdash/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
econdash-0.1.0.dist-info/METADATA,sha256=c3DqwiRHwDe-wMEMkCBDK8HewkffIpPLBotFilU-sXs,5925
|
|
7
|
+
econdash-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
econdash-0.1.0.dist-info/licenses/LICENSE,sha256=OupCmP07CGvR28W_kudL9YPyrT_gBW09XduaA9eEkJc,1065
|
|
9
|
+
econdash-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EconDash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|