langchain-econdash 0.1.0__tar.gz

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.
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .ipynb_checkpoints/
11
+ .env
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-econdash
3
+ Version: 0.1.0
4
+ Summary: LangChain tools for EconDash macroeconomic data. Same free trial, API key and pay-per-call rules as the econdash client.
5
+ Project-URL: Homepage, https://econdash.org
6
+ Project-URL: Repository, https://github.com/econdash/econdash-python
7
+ Project-URL: Documentation, https://econdash.org/docs/python
8
+ Author-email: EconDash <noderguru@gmail.com>
9
+ License-Expression: MIT
10
+ Keywords: econdash,economics,langchain,tools,x402
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: econdash>=0.2.1
13
+ Requires-Dist: langchain-core>=0.3
14
+ Requires-Dist: pydantic>=2.5
15
+ Description-Content-Type: text/markdown
16
+
17
+ # langchain-econdash
18
+
19
+ LangChain tools for [EconDash](https://econdash.org). They call the `econdash` client, so they follow the same access rules. They do not open a free back door to the archive.
20
+
21
+ ```bash
22
+ pip install langchain-econdash
23
+ ```
24
+
25
+ Releases are tags `langchain-vX.Y.Z` on `econdash-python` (the `X.Y.Z` matches this
26
+ package's version). A `v*` tag publishes the `econdash` client, not this package.
27
+
28
+ ```python
29
+ from langchain_econdash import EconDashToolkit
30
+
31
+ tools = EconDashToolkit().get_tools() # free trial
32
+ # tools = EconDashToolkit(api_key="ed_...").get_tools()
33
+ # from eth_account import Account
34
+ # tools = EconDashToolkit(
35
+ # x402_signer=Account.from_key("0x..."), max_usd_per_call=0.05
36
+ # ).get_tools()
37
+ ```
38
+
39
+ | Tool | What the model gets |
40
+ |---|---|
41
+ | `econdash_search_indicators` | Slug, name, unit, source, frequency. No numbers. |
42
+ | `econdash_get_series` | One country. Last 20 points, unit, freshness, a citation and the chart link. `full=true` for the whole reply. |
43
+ | `econdash_get_metadata` | Description, source, licence, freshness, a sample of countries. |
44
+ | `econdash_compare_countries` | Latest point for up to 8 ISO3 codes. |
45
+
46
+ ## What is free
47
+
48
+ Without a key and without a wallet the tools only receive the free trial: 10 countries (USA, CHN, DEU, JPN, GBR, FRA, IND, BRA, RUS, KOR), 50 indicators, 300 requests a day and 30 a minute per IP.
49
+
50
+ A country or indicator outside that trial makes the API answer 402. The tool turns that into a sentence for the model (`Set ECONDASH_API_KEY` or enable x402/MPP) and does **not** include the figures. Nothing is charged.
51
+
52
+ The indicator catalog (names and slugs) is metadata and is readable inside the trial quota. The time series is not.
53
+
54
+ ## How to pay
55
+
56
+ - **API key** from an EconDash subscription (`api_key=` or `ECONDASH_API_KEY`): full archive, no per-call charge, 10,000 requests a month.
57
+ - **Wallet:** pass `x402_signer` (Base), `tempo_account` (Tempo) or `solana_keypair` (Solana) **and** `max_usd_per_call`. The client pays one call only when the price is within that cap. If you omit the cap, it refuses to sign. Several wallets are tried in that order.
58
+
59
+ A wallet on the toolkit means an agent can spend on every series outside the trial, up to the cap. Leave the wallet off unless that is what you want.
@@ -0,0 +1,43 @@
1
+ # langchain-econdash
2
+
3
+ LangChain tools for [EconDash](https://econdash.org). They call the `econdash` client, so they follow the same access rules. They do not open a free back door to the archive.
4
+
5
+ ```bash
6
+ pip install langchain-econdash
7
+ ```
8
+
9
+ Releases are tags `langchain-vX.Y.Z` on `econdash-python` (the `X.Y.Z` matches this
10
+ package's version). A `v*` tag publishes the `econdash` client, not this package.
11
+
12
+ ```python
13
+ from langchain_econdash import EconDashToolkit
14
+
15
+ tools = EconDashToolkit().get_tools() # free trial
16
+ # tools = EconDashToolkit(api_key="ed_...").get_tools()
17
+ # from eth_account import Account
18
+ # tools = EconDashToolkit(
19
+ # x402_signer=Account.from_key("0x..."), max_usd_per_call=0.05
20
+ # ).get_tools()
21
+ ```
22
+
23
+ | Tool | What the model gets |
24
+ |---|---|
25
+ | `econdash_search_indicators` | Slug, name, unit, source, frequency. No numbers. |
26
+ | `econdash_get_series` | One country. Last 20 points, unit, freshness, a citation and the chart link. `full=true` for the whole reply. |
27
+ | `econdash_get_metadata` | Description, source, licence, freshness, a sample of countries. |
28
+ | `econdash_compare_countries` | Latest point for up to 8 ISO3 codes. |
29
+
30
+ ## What is free
31
+
32
+ Without a key and without a wallet the tools only receive the free trial: 10 countries (USA, CHN, DEU, JPN, GBR, FRA, IND, BRA, RUS, KOR), 50 indicators, 300 requests a day and 30 a minute per IP.
33
+
34
+ A country or indicator outside that trial makes the API answer 402. The tool turns that into a sentence for the model (`Set ECONDASH_API_KEY` or enable x402/MPP) and does **not** include the figures. Nothing is charged.
35
+
36
+ The indicator catalog (names and slugs) is metadata and is readable inside the trial quota. The time series is not.
37
+
38
+ ## How to pay
39
+
40
+ - **API key** from an EconDash subscription (`api_key=` or `ECONDASH_API_KEY`): full archive, no per-call charge, 10,000 requests a month.
41
+ - **Wallet:** pass `x402_signer` (Base), `tempo_account` (Tempo) or `solana_keypair` (Solana) **and** `max_usd_per_call`. The client pays one call only when the price is within that cap. If you omit the cap, it refuses to sign. Several wallets are tried in that order.
42
+
43
+ A wallet on the toolkit means an agent can spend on every series outside the trial, up to the cap. Leave the wallet off unless that is what you want.
@@ -0,0 +1,72 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "7fb27b941602401d91542211134fc71a",
6
+ "metadata": {},
7
+ "source": [
8
+ "# EconDash tools in a LangChain agent\n",
9
+ "\n",
10
+ "The tools use the free trial unless you pass an API key or a wallet.\n",
11
+ "A series outside the trial comes back as a payment message, not as numbers.\n"
12
+ ]
13
+ },
14
+ {
15
+ "cell_type": "code",
16
+ "execution_count": null,
17
+ "id": "acae54e37e7d407bbb7b55eff062a284",
18
+ "metadata": {},
19
+ "outputs": [],
20
+ "source": [
21
+ "from langchain_econdash import EconDashToolkit\n",
22
+ "\n",
23
+ "tools = EconDashToolkit().get_tools()\n",
24
+ "by_name = {tool.name: tool for tool in tools}\n",
25
+ "\n",
26
+ "print(by_name[\"econdash_search_indicators\"].invoke({\"query\": \"inflation\", \"limit\": 3}))\n",
27
+ "print(\n",
28
+ " by_name[\"econdash_get_series\"].invoke(\n",
29
+ " {\"indicator\": \"gdp-nominal\", \"country\": \"USA\", \"start\": \"2020\"}\n",
30
+ " )\n",
31
+ ")\n",
32
+ "print(\n",
33
+ " by_name[\"econdash_compare_countries\"].invoke(\n",
34
+ " {\"indicator\": \"cpi-inflation\", \"countries\": [\"USA\", \"DEU\", \"JPN\"], \"year\": 2023}\n",
35
+ " )\n",
36
+ ")\n",
37
+ "# Zimbabwe is outside the trial: payment required, no figure.\n",
38
+ "print(by_name[\"econdash_get_series\"].invoke({\"indicator\": \"gdp-nominal\", \"country\": \"ZWE\"}))"
39
+ ]
40
+ },
41
+ {
42
+ "cell_type": "markdown",
43
+ "id": "9a63283cbaf04dbcab1f6479b197f3a8",
44
+ "metadata": {},
45
+ "source": [
46
+ "To let a model call the tools, pass `tools` to `create_agent` together with any chat model.\n",
47
+ "Keep the toolkit without a wallet if the agent must not spend.\n",
48
+ "\n",
49
+ "```python\n",
50
+ "from langchain.agents import create_agent\n",
51
+ "\n",
52
+ "agent = create_agent(model, tools=EconDashToolkit().get_tools())\n",
53
+ "agent.invoke({\n",
54
+ " \"messages\": [{\n",
55
+ " \"role\": \"user\",\n",
56
+ " \"content\": \"Compare inflation in the US, Germany and Japan since 2020. Cite econdash.org.\",\n",
57
+ " }]\n",
58
+ "})\n",
59
+ "```\n"
60
+ ]
61
+ }
62
+ ],
63
+ "metadata": {
64
+ "kernelspec": {
65
+ "display_name": "Python 3",
66
+ "language": "python",
67
+ "name": "python3"
68
+ }
69
+ },
70
+ "nbformat": 4,
71
+ "nbformat_minor": 5
72
+ }
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langchain-econdash"
7
+ version = "0.1.0"
8
+ description = "LangChain tools for EconDash macroeconomic data. Same free trial, API key and pay-per-call rules as the econdash client."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "EconDash", email = "noderguru@gmail.com" }]
13
+ keywords = ["langchain", "econdash", "economics", "tools", "x402"]
14
+ dependencies = [
15
+ "econdash>=0.2.1",
16
+ "langchain-core>=0.3",
17
+ "pydantic>=2.5",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://econdash.org"
22
+ Repository = "https://github.com/econdash/econdash-python"
23
+ Documentation = "https://econdash.org/docs/python"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/langchain_econdash"]
27
+
28
+ [tool.hatch.build.targets.sdist]
29
+ include = ["src", "tests", "README.md", "examples"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ asyncio_mode = "auto"
@@ -0,0 +1,10 @@
1
+ """LangChain tools for the EconDash API.
2
+
3
+ Data access is the econdash client's access. Nothing here bypasses the trial,
4
+ an API key, or pay-per-call.
5
+ """
6
+
7
+ from langchain_econdash.toolkit import EconDashToolkit
8
+
9
+ __version__ = "0.1.0"
10
+ __all__ = ["EconDashToolkit", "__version__"]
@@ -0,0 +1,90 @@
1
+ """Turn API errors and series into short text a model can read."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from econdash import (
6
+ EconDashAuthError,
7
+ EconDashError,
8
+ EconDashNotFound,
9
+ EconDashPaymentRequired,
10
+ EconDashRateLimitError,
11
+ Indicator,
12
+ Series,
13
+ )
14
+
15
+ PAYMENT_HINT = (
16
+ "Payment required. No figures were returned and nothing was charged. "
17
+ "The free trial is 10 countries (USA, CHN, DEU, JPN, GBR, FRA, IND, BRA, RUS, KOR), "
18
+ "50 indicators, 300 requests per day and 30 per minute. "
19
+ "Set ECONDASH_API_KEY for a subscription, or pass a wallet and max_usd_per_call "
20
+ "to EconDashToolkit to pay per call with x402 or MPP. "
21
+ "A wallet pays only when it is set and the price is within the cap."
22
+ )
23
+
24
+ _ISO = "Use a 3-letter ISO3 code such as USA, DEU or JPN, not a country name."
25
+
26
+
27
+ def country_error(code: str) -> str | None:
28
+ if len(code) == 3 and code.isalpha():
29
+ return None
30
+ return f"Invalid country code {code!r}. {_ISO}"
31
+
32
+
33
+ def explain(exc: Exception) -> str:
34
+ if isinstance(exc, EconDashPaymentRequired):
35
+ return PAYMENT_HINT
36
+ if isinstance(exc, EconDashRateLimitError):
37
+ wait = f" Retry after {exc.retry_after} seconds." if exc.retry_after else ""
38
+ return f"Rate limit reached.{wait} No data in this call."
39
+ if isinstance(exc, EconDashNotFound):
40
+ return f"Not found ({exc}). Search with econdash_search_indicators. {_ISO}"
41
+ if isinstance(exc, EconDashAuthError):
42
+ return f"API key was rejected ({exc}). Check ECONDASH_API_KEY."
43
+ if isinstance(exc, EconDashError):
44
+ return f"EconDash error: {exc}"
45
+ return f"EconDash error: {exc}"
46
+
47
+
48
+ def search_text(rows: list[Indicator]) -> str:
49
+ if not rows:
50
+ return "No indicators matched. Try a shorter query such as gdp or inflation."
51
+ lines = ["slug | name | unit | source | frequency"]
52
+ for ind in rows:
53
+ lines.append(
54
+ " | ".join(
55
+ [
56
+ ind.slug or ind.code,
57
+ ind.name,
58
+ ind.unit or "",
59
+ ind.source or "",
60
+ ind.frequency or "",
61
+ ]
62
+ )
63
+ )
64
+ return "\n".join(lines)
65
+
66
+
67
+ def series_text(series: Series, *, slug: str, full: bool) -> str:
68
+ points = series.data
69
+ shown = points if full else points[-20:]
70
+ name = series.indicator_name_en or series.indicator_name_ru
71
+ country = shown[-1].country if shown else ""
72
+ country_name = shown[-1].country_name if shown else country
73
+ last = shown[-1] if shown else None
74
+ chart = f"https://econdash.org/chart/{slug}/{country}" if slug and country else ""
75
+ head = [
76
+ f"{name} — {country_name or country}",
77
+ f"unit: {series.unit or 'n/a'}",
78
+ f"frequency: {series.frequency or 'n/a'}",
79
+ f"data_changed_at: {series.data_changed_at or 'n/a'}",
80
+ f"points: {len(points)} total, showing {len(shown)}",
81
+ ]
82
+ if last is not None:
83
+ head.append(
84
+ f"citation: {country_name or country} {name} was {last.value} {series.unit or ''} "
85
+ f"on {last.date.isoformat()}. {chart}".rstrip()
86
+ )
87
+ body = [f"{p.date.isoformat()} {p.value}" for p in shown]
88
+ if not full and len(points) > len(shown):
89
+ body.append("Set full=true only when the user asked for the whole history.")
90
+ return "\n".join(head + body)
@@ -0,0 +1,39 @@
1
+ """Build the four tools around one EconDash client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from langchain_core.tools import BaseTool
8
+
9
+ from econdash import AsyncEconDash, EconDash
10
+ from langchain_econdash.tools import CompareCountries, GetMetadata, GetSeries, SearchIndicators
11
+
12
+
13
+ class EconDashToolkit:
14
+ """Tools that use the same access as `EconDash`.
15
+
16
+ No key and no wallet: the free trial only. A 402 becomes a sentence for the model.
17
+ Pass `api_key=` for a subscription. Pass a wallet plus `max_usd_per_call` to pay
18
+ per call; without `max_usd_per_call` the client refuses to sign anything.
19
+ """
20
+
21
+ def __init__(self, api_key: str | None = None, **client_kwargs: Any) -> None:
22
+ self.client = EconDash(api_key, **client_kwargs)
23
+ self.async_client = AsyncEconDash(api_key, **client_kwargs)
24
+
25
+ def get_tools(self) -> list[BaseTool]:
26
+ shared = {"client": self.client, "async_client": self.async_client}
27
+ return [
28
+ SearchIndicators(**shared),
29
+ GetSeries(**shared),
30
+ GetMetadata(**shared),
31
+ CompareCountries(**shared),
32
+ ]
33
+
34
+ def close(self) -> None:
35
+ self.client.close()
36
+
37
+ async def aclose(self) -> None:
38
+ self.client.close()
39
+ await self.async_client.aclose()
@@ -0,0 +1,254 @@
1
+ """Four tools. Each one calls the econdash client and returns text, including on 402."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from langchain_core.tools import BaseTool
8
+ from pydantic import BaseModel, Field
9
+
10
+ from econdash import EconDashError, Indicator, Series
11
+ from langchain_econdash._format import country_error, explain, search_text, series_text
12
+
13
+ _TRIAL = (
14
+ "Free trial only: 10 countries (USA, CHN, DEU, JPN, GBR, FRA, IND, BRA, RUS, KOR) "
15
+ "and 50 indicators, 300 requests/day. Anything else comes back as a payment message, "
16
+ "not as numbers. Find the slug with econdash_search_indicators first."
17
+ )
18
+
19
+
20
+ class _ClientTool(BaseTool):
21
+ client: Any = Field(exclude=True)
22
+ async_client: Any = Field(exclude=True)
23
+
24
+ model_config = {"arbitrary_types_allowed": True}
25
+
26
+ def _fail(self, exc: Exception) -> str:
27
+ return explain(exc)
28
+
29
+
30
+ class SearchInput(BaseModel):
31
+ query: str = Field(description="Words to match against indicator names, slugs and sources.")
32
+ limit: int = Field(default=10, ge=1, le=20, description="How many matches to return.")
33
+
34
+
35
+ class SearchIndicators(_ClientTool):
36
+ name: str = "econdash_search_indicators"
37
+ description: str = (
38
+ "Search the EconDash indicator catalog. Returns slug, name, unit, source and frequency. "
39
+ "Call this before a series. The catalog is metadata; it has no numbers. " + _TRIAL
40
+ )
41
+ args_schema: type[BaseModel] = SearchInput
42
+
43
+ def _run(self, query: str, limit: int = 10, **_: Any) -> str:
44
+ try:
45
+ return search_text(self.client.search_indicators(query, limit=limit))
46
+ except EconDashError as exc:
47
+ return self._fail(exc)
48
+
49
+ async def _arun(self, query: str, limit: int = 10, **_: Any) -> str:
50
+ try:
51
+ return search_text(await self.async_client.search_indicators(query, limit=limit))
52
+ except EconDashError as exc:
53
+ return self._fail(exc)
54
+
55
+
56
+ class SeriesInput(BaseModel):
57
+ indicator: str = Field(description="Slug from econdash_search_indicators, e.g. gdp-nominal.")
58
+ country: str = Field(description="ISO3 code, e.g. USA. Not a country name.")
59
+ start: str | None = Field(default=None, description="Start year or date, e.g. 2000.")
60
+ end: str | None = Field(default=None, description="End year or date.")
61
+ full: bool = Field(
62
+ default=False,
63
+ description="Return the whole series the API sent. Default is the last 20 points.",
64
+ )
65
+
66
+
67
+ class GetSeries(_ClientTool):
68
+ name: str = "econdash_get_series"
69
+ description: str = (
70
+ "Load one indicator for one country: unit, when the data last changed, a citation "
71
+ "with the econdash.org chart link, and the last 20 observations. " + _TRIAL
72
+ )
73
+ args_schema: type[BaseModel] = SeriesInput
74
+
75
+ def _text(self, series: Series, indicator: str, full: bool) -> str:
76
+ slug = indicator if "__" not in indicator else ""
77
+ if not slug:
78
+ try:
79
+ slug = self.client.get_indicator(series.indicator_code).slug or ""
80
+ except EconDashError:
81
+ slug = ""
82
+ return series_text(series, slug=slug, full=full)
83
+
84
+ def _run(
85
+ self,
86
+ indicator: str,
87
+ country: str,
88
+ start: str | None = None,
89
+ end: str | None = None,
90
+ full: bool = False,
91
+ **_: Any,
92
+ ) -> str:
93
+ if message := country_error(country):
94
+ return message
95
+ try:
96
+ series = self.client.get_series(indicator, country, start=start, end=end)
97
+ except EconDashError as exc:
98
+ return self._fail(exc)
99
+ return self._text(series, indicator, full)
100
+
101
+ async def _arun(
102
+ self,
103
+ indicator: str,
104
+ country: str,
105
+ start: str | None = None,
106
+ end: str | None = None,
107
+ full: bool = False,
108
+ **_: Any,
109
+ ) -> str:
110
+ if message := country_error(country):
111
+ return message
112
+ try:
113
+ series = await self.async_client.get_series(indicator, country, start=start, end=end)
114
+ except EconDashError as exc:
115
+ return self._fail(exc)
116
+ slug = indicator if "__" not in indicator else ""
117
+ return series_text(series, slug=slug, full=full)
118
+
119
+
120
+ class MetadataInput(BaseModel):
121
+ indicator: str = Field(description="Indicator slug or code.")
122
+
123
+
124
+ class GetMetadata(_ClientTool):
125
+ name: str = "econdash_get_metadata"
126
+ description: str = (
127
+ "Describe one indicator: source, licence, freshness and some countries. "
128
+ "Does not return the time series. " + _TRIAL
129
+ )
130
+ args_schema: type[BaseModel] = MetadataInput
131
+
132
+ def _text(self, ind: Indicator, countries: list[str] | None, country_note: str) -> str:
133
+ sample = ", ".join(countries[:12]) if countries else "n/a"
134
+ extra = f" ({len(countries)} countries)" if countries else ""
135
+ lines = [
136
+ f"{ind.slug or ind.code}: {ind.name}",
137
+ f"code: {ind.code}",
138
+ f"description: {ind.description or 'n/a'}",
139
+ f"source: {ind.source or 'n/a'}",
140
+ f"source_url: {ind.source_url or 'n/a'}",
141
+ f"license: {ind.license_name or 'n/a'} {ind.license_url or ''}".rstrip(),
142
+ f"attribution: {ind.attribution_text or 'n/a'}",
143
+ f"frequency: {ind.frequency or 'n/a'}",
144
+ f"data_changed_at: {ind.data_changed_at or 'n/a'}",
145
+ f"data_checked_at: {ind.data_checked_at or 'n/a'}",
146
+ f"observations: {ind.first_observation or '?'} .. {ind.last_observation or '?'}",
147
+ f"chart: {ind.url or 'n/a'}",
148
+ f"countries{extra}: {sample}",
149
+ ]
150
+ if country_note:
151
+ lines.append(country_note)
152
+ return "\n".join(lines)
153
+
154
+ def _run(self, indicator: str, **_: Any) -> str:
155
+ try:
156
+ ind = self.client.get_indicator(indicator)
157
+ except EconDashError as exc:
158
+ return self._fail(exc)
159
+ countries: list[str] | None = None
160
+ note = ""
161
+ try:
162
+ raw = self.client._get(f"{self.client.base_url}/indicators/{ind.code}/countries")
163
+ countries = [str(code) for code in raw]
164
+ except EconDashError as exc:
165
+ note = explain(exc)
166
+ return self._text(ind, countries, note)
167
+
168
+ async def _arun(self, indicator: str, **_: Any) -> str:
169
+ try:
170
+ ind = await self.async_client.get_indicator(indicator)
171
+ except EconDashError as exc:
172
+ return self._fail(exc)
173
+ countries: list[str] | None = None
174
+ note = ""
175
+ try:
176
+ raw = await self.async_client._get(
177
+ f"{self.async_client.base_url}/indicators/{ind.code}/countries"
178
+ )
179
+ countries = [str(code) for code in raw]
180
+ except EconDashError as exc:
181
+ note = explain(exc)
182
+ return self._text(ind, countries, note)
183
+
184
+
185
+ class CompareInput(BaseModel):
186
+ indicator: str = Field(description="Indicator slug, e.g. gdp-nominal.")
187
+ countries: list[str] = Field(description="Up to 8 ISO3 codes, e.g. ['USA', 'DEU', 'JPN'].")
188
+ year: int | None = Field(default=None, description="Calendar year. Omit for the latest point.")
189
+
190
+
191
+ class CompareCountries(_ClientTool):
192
+ name: str = "econdash_compare_countries"
193
+ description: str = (
194
+ "Latest value of one indicator for a few countries, each requested on its own. "
195
+ "A country outside the trial is a payment message on that line; the other countries "
196
+ "are not unlocked by it. " + _TRIAL
197
+ )
198
+ args_schema: type[BaseModel] = CompareInput
199
+
200
+ def _one(self, fetch: Any, indicator: str, country: str, year: int | None) -> str:
201
+ if message := country_error(country):
202
+ return f"{country}: {message}"
203
+ try:
204
+ if year is None:
205
+ series = fetch(indicator, country)
206
+ else:
207
+ series = fetch(indicator, country, start=year, end=year)
208
+ except EconDashError as exc:
209
+ return f"{country}: {explain(exc)}"
210
+ if not series.data:
211
+ return f"{country}: no observations."
212
+ last = series.data[-1]
213
+ return f"{country}: {last.value} {series.unit or ''} on {last.date.isoformat()}"
214
+
215
+ def _run(self, indicator: str, countries: list[str], year: int | None = None, **_: Any) -> str:
216
+ if len(countries) > 8:
217
+ return "Pass at most 8 countries per call."
218
+ lines = [
219
+ self._one(
220
+ lambda ind, ctry, **kw: self.client.get_series(ind, ctry, **kw),
221
+ indicator,
222
+ country,
223
+ year,
224
+ )
225
+ for country in countries
226
+ ]
227
+ return "\n".join(lines)
228
+
229
+ async def _arun(
230
+ self, indicator: str, countries: list[str], year: int | None = None, **_: Any
231
+ ) -> str:
232
+ if len(countries) > 8:
233
+ return "Pass at most 8 countries per call."
234
+ lines = []
235
+ for country in countries:
236
+ if message := country_error(country):
237
+ lines.append(f"{country}: {message}")
238
+ continue
239
+ try:
240
+ if year is None:
241
+ series = await self.async_client.get_series(indicator, country)
242
+ else:
243
+ series = await self.async_client.get_series(
244
+ indicator, country, start=year, end=year
245
+ )
246
+ except EconDashError as exc:
247
+ lines.append(f"{country}: {explain(exc)}")
248
+ continue
249
+ if not series.data:
250
+ lines.append(f"{country}: no observations.")
251
+ continue
252
+ last = series.data[-1]
253
+ lines.append(f"{country}: {last.value} {series.unit or ''} on {last.date.isoformat()}")
254
+ return "\n".join(lines)
@@ -0,0 +1,167 @@
1
+ """Tools talk to a fake client: trial data comes back, paid data comes back as a hint."""
2
+
3
+ from datetime import date
4
+
5
+ import pytest
6
+ from langchain_econdash.tools import CompareCountries, GetMetadata, GetSeries, SearchIndicators
7
+
8
+ from econdash import EconDashNotFound, EconDashPaymentRequired, Indicator, Point, Series
9
+
10
+
11
+ def _indicator() -> Indicator:
12
+ return Indicator(
13
+ code="world_bank__gdp_nominal",
14
+ slug="gdp-nominal",
15
+ name_ru="ВВП",
16
+ name_en="GDP",
17
+ unit="USD",
18
+ source="World Bank",
19
+ frequency="annual",
20
+ category=None,
21
+ fetch_method=None,
22
+ source_url="https://data.worldbank.org/indicator/NY.GDP.MKTP.CD",
23
+ license_name="CC BY 4.0",
24
+ description="Nominal GDP",
25
+ data_changed_at="2026-01-01",
26
+ )
27
+
28
+
29
+ def _series(country: str) -> Series:
30
+ points = [Point(date=date(2000 + i, 1, 1), value=float(i), country=country) for i in range(25)]
31
+ return Series(
32
+ indicator_code="world_bank__gdp_nominal",
33
+ indicator_name_ru="ВВП",
34
+ indicator_name_en="GDP",
35
+ unit="USD",
36
+ data_changed_at="2026-01-01",
37
+ data=points,
38
+ )
39
+
40
+
41
+ class Fake:
42
+ def __init__(self) -> None:
43
+ self.calls: list[tuple] = []
44
+ self.base_url = "https://econdash.org/api/v1"
45
+
46
+ def search_indicators(self, query: str, limit: int = 10) -> list[Indicator]:
47
+ self.calls.append(("search", query, limit))
48
+ return [_indicator()] if query else []
49
+
50
+ def get_series(self, indicator: str, country: str, start=None, end=None) -> Series:
51
+ self.calls.append(("series", indicator, country.upper(), start, end))
52
+ if country.upper() == "ZWE":
53
+ raise EconDashPaymentRequired("outside trial", status_code=402)
54
+ return _series(country.upper())
55
+
56
+ def get_indicator(self, indicator: str) -> Indicator:
57
+ self.calls.append(("meta", indicator))
58
+ if indicator == "missing":
59
+ raise EconDashNotFound("missing", status_code=404)
60
+ return _indicator()
61
+
62
+ def _get(self, url: str) -> list[str]:
63
+ self.calls.append(("countries", url))
64
+ return ["USA", "DEU"]
65
+
66
+ async def search_indicators_async(self, query: str, limit: int = 10) -> list[Indicator]:
67
+ return self.search_indicators(query, limit)
68
+
69
+ async def get_series_async(self, indicator: str, country: str, start=None, end=None) -> Series:
70
+ return self.get_series(indicator, country, start, end)
71
+
72
+
73
+ class AsyncFake(Fake):
74
+ async def search_indicators(self, query: str, limit: int = 10) -> list[Indicator]:
75
+ return super().search_indicators(query, limit)
76
+
77
+ async def get_series(self, indicator: str, country: str, start=None, end=None) -> Series:
78
+ return super().get_series(indicator, country, start, end)
79
+
80
+ async def get_indicator(self, indicator: str) -> Indicator:
81
+ return super().get_indicator(indicator)
82
+
83
+ async def _get(self, url: str) -> list[str]:
84
+ return super()._get(url)
85
+
86
+
87
+ def test_search_returns_slug_and_not_values() -> None:
88
+ tool = SearchIndicators(client=Fake(), async_client=Fake())
89
+ text = tool.invoke({"query": "gdp", "limit": 5})
90
+ assert "gdp-nominal" in text
91
+ assert "World Bank" in text
92
+ assert "2000-01-01" not in text
93
+
94
+
95
+ def test_series_is_last_20_and_cites_the_chart() -> None:
96
+ fake = Fake()
97
+ tool = GetSeries(client=fake, async_client=fake)
98
+ text = tool.invoke({"indicator": "gdp-nominal", "country": "usa"})
99
+ assert "2000-01-01" not in text
100
+ assert "2005-01-01" in text
101
+ assert "2024-01-01 24.0" in text
102
+ assert "https://econdash.org/chart/gdp-nominal/USA" in text
103
+ assert "data_changed_at: 2026-01-01" in text
104
+ assert fake.calls[0][2] == "USA"
105
+
106
+
107
+ def test_full_series_includes_the_start() -> None:
108
+ tool = GetSeries(client=Fake(), async_client=Fake())
109
+ text = tool.invoke({"indicator": "gdp-nominal", "country": "USA", "full": True})
110
+ assert "2000-01-01 0.0" in text
111
+
112
+
113
+ def test_outside_trial_is_a_payment_hint_without_figures() -> None:
114
+ fake = Fake()
115
+ tool = GetSeries(client=fake, async_client=fake)
116
+ text = tool.invoke({"indicator": "gdp-nominal", "country": "ZWE"})
117
+ assert "Payment required" in text
118
+ assert "ECONDASH_API_KEY" in text
119
+ assert "x402" in text
120
+ assert "24.0" not in text
121
+ assert len(fake.calls) == 1
122
+
123
+
124
+ def test_bad_country_code_never_calls_the_api() -> None:
125
+ fake = Fake()
126
+ tool = GetSeries(client=fake, async_client=fake)
127
+ text = tool.invoke({"indicator": "gdp-nominal", "country": "Germany"})
128
+ assert "Invalid country code" in text
129
+ assert fake.calls == []
130
+
131
+
132
+ def test_unknown_indicator_tells_the_model_to_search() -> None:
133
+ tool = GetMetadata(client=Fake(), async_client=Fake())
134
+ text = tool.invoke({"indicator": "missing"})
135
+ assert "Not found" in text
136
+ assert "econdash_search_indicators" in text
137
+
138
+
139
+ def test_metadata_includes_licence_and_countries() -> None:
140
+ tool = GetMetadata(client=Fake(), async_client=Fake())
141
+ text = tool.invoke({"indicator": "gdp-nominal"})
142
+ assert "CC BY 4.0" in text
143
+ assert "USA, DEU" in text
144
+ assert "source_url:" in text
145
+
146
+
147
+ def test_compare_keeps_paid_countries_out_of_the_answer() -> None:
148
+ tool = CompareCountries(client=Fake(), async_client=Fake())
149
+ text = tool.invoke({"indicator": "gdp-nominal", "countries": ["USA", "ZWE"]})
150
+ assert "USA: 24.0" in text
151
+ zwe = next(line for line in text.splitlines() if line.startswith("ZWE"))
152
+ assert "Payment required" in zwe
153
+ assert "24.0" not in zwe
154
+
155
+
156
+ def test_compare_rejects_a_long_list() -> None:
157
+ tool = CompareCountries(client=Fake(), async_client=Fake())
158
+ text = tool.invoke({"indicator": "gdp-nominal", "countries": [f"A{i:02d}" for i in range(9)]})
159
+ assert "at most 8" in text
160
+
161
+
162
+ @pytest.mark.asyncio
163
+ async def test_async_series_uses_the_async_client() -> None:
164
+ tool = GetSeries(client=Fake(), async_client=AsyncFake())
165
+ text = await tool.ainvoke({"indicator": "gdp-nominal", "country": "DEU"})
166
+ assert "DEU" in text
167
+ assert "Payment required" not in text