bcb-mcp 0.1.1__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.
bcb_mcp/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """bcb-mcp: MCP server for Brazilian Central Bank (BCB-SGS) time series.
2
+
3
+ Python counterpart of the R package GetBCBData: same ids, long/wide output,
4
+ chunked downloads for long histories, plus a parquet/DuckDB cache.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .config import __version__
10
+ from .dictionary import SeriesDictionary, get_dictionary
11
+ from .service import SeriesService
12
+
13
+ __all__ = ["SeriesDictionary", "SeriesService", "__version__", "get_dictionary"]
bcb_mcp/bcb_client.py ADDED
@@ -0,0 +1,293 @@
1
+ """Async clients for the two BCB endpoints used by bcb-mcp.
2
+
3
+ 1. Data API (JSON): https://api.bcb.gov.br/dados/serie/bcdata.sgs.{id}/dados
4
+ 2. SGS catalog search (HTML form): https://www3.bcb.gov.br/sgspub/...
5
+
6
+ Both are public and unauthenticated, but the data API caps a single request at
7
+ about ten years of daily data, so long spans are fetched in chunks.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import datetime as dt
14
+ import html as html_lib
15
+ import logging
16
+ import re
17
+ import time
18
+ from typing import Any
19
+
20
+ import httpx
21
+
22
+ from .config import (
23
+ HTTP_TIMEOUT_SECONDS,
24
+ MAX_CONCURRENCY,
25
+ MAX_DAYS_PER_REQUEST,
26
+ MAX_RETRIES,
27
+ MIN_REQUEST_INTERVAL,
28
+ SGS_DATA_URL,
29
+ SGS_SEARCH_HOME,
30
+ SGS_SEARCH_URL,
31
+ USER_AGENT,
32
+ )
33
+
34
+ logger = logging.getLogger("bcb_mcp.client")
35
+
36
+ DATE_FMT_BCB = "%d/%m/%Y"
37
+
38
+
39
+ class BCBError(RuntimeError):
40
+ """Raised when the BCB API returns an unrecoverable error."""
41
+
42
+
43
+ class SeriesNotFound(BCBError):
44
+ """Raised when the SGS id does not exist or has no data in the range."""
45
+
46
+
47
+ def parse_bcb_date(text: str) -> dt.date:
48
+ """Parse the dd/mm/yyyy dates returned by the data API."""
49
+ return dt.datetime.strptime(text.strip(), DATE_FMT_BCB).date()
50
+
51
+
52
+ def split_date_range(
53
+ first_date: dt.date, last_date: dt.date, max_days: int = MAX_DAYS_PER_REQUEST
54
+ ) -> list[tuple[dt.date, dt.date]]:
55
+ """Split a range into chunks that the BCB API accepts."""
56
+ if last_date < first_date:
57
+ raise ValueError("last_date must be >= first_date")
58
+ chunks: list[tuple[dt.date, dt.date]] = []
59
+ start = first_date
60
+ while start <= last_date:
61
+ end = min(start + dt.timedelta(days=max_days), last_date)
62
+ chunks.append((start, end))
63
+ start = end + dt.timedelta(days=1)
64
+ return chunks
65
+
66
+
67
+ class _RateLimiter:
68
+ """Serialises requests so we never hammer the BCB servers."""
69
+
70
+ def __init__(self, min_interval: float) -> None:
71
+ self._min_interval = max(0.0, min_interval)
72
+ self._lock = asyncio.Lock()
73
+ self._last_call = 0.0
74
+
75
+ async def wait(self) -> None:
76
+ if self._min_interval <= 0:
77
+ return
78
+ async with self._lock:
79
+ elapsed = time.monotonic() - self._last_call
80
+ delay = self._min_interval - elapsed
81
+ if delay > 0:
82
+ await asyncio.sleep(delay)
83
+ self._last_call = time.monotonic()
84
+
85
+
86
+ class BCBClient:
87
+ """Thin async wrapper around the BCB SGS endpoints."""
88
+
89
+ def __init__(
90
+ self,
91
+ *,
92
+ timeout: float = HTTP_TIMEOUT_SECONDS,
93
+ max_retries: int = MAX_RETRIES,
94
+ min_interval: float = MIN_REQUEST_INTERVAL,
95
+ max_concurrency: int = MAX_CONCURRENCY,
96
+ client: httpx.AsyncClient | None = None,
97
+ ) -> None:
98
+ self.max_retries = max(1, max_retries)
99
+ self._limiter = _RateLimiter(min_interval)
100
+ self._semaphore = asyncio.Semaphore(max(1, max_concurrency))
101
+ self._owns_client = client is None
102
+ self._client = client or httpx.AsyncClient(
103
+ timeout=timeout,
104
+ follow_redirects=True,
105
+ headers={"User-Agent": USER_AGENT, "Accept": "application/json, text/html"},
106
+ limits=httpx.Limits(max_connections=max_concurrency + 2),
107
+ )
108
+
109
+ async def close(self) -> None:
110
+ if self._owns_client and not self._client.is_closed:
111
+ await self._client.aclose()
112
+
113
+ async def __aenter__(self) -> BCBClient:
114
+ return self
115
+
116
+ async def __aexit__(self, *_exc: object) -> None:
117
+ await self.close()
118
+
119
+ # -- data API ------------------------------------------------------------
120
+ async def fetch_series_range(
121
+ self, series_id: int, first_date: dt.date, last_date: dt.date
122
+ ) -> list[tuple[dt.date, float | None]]:
123
+ """Download one chunk of a series (no chunking logic here)."""
124
+ url = SGS_DATA_URL.format(series_id=series_id)
125
+ params = {
126
+ "formato": "json",
127
+ "dataInicial": first_date.strftime(DATE_FMT_BCB),
128
+ "dataFinal": last_date.strftime(DATE_FMT_BCB),
129
+ }
130
+ payload = await self._get_json(url, params)
131
+ if isinstance(payload, dict):
132
+ message = payload.get("error") or payload.get("message") or str(payload)
133
+ raise BCBError(f"SGS {series_id}: {message}")
134
+ if not isinstance(payload, list):
135
+ raise BCBError(f"SGS {series_id}: unexpected response {type(payload).__name__}")
136
+
137
+ rows: list[tuple[dt.date, float | None]] = []
138
+ for item in payload:
139
+ if not isinstance(item, dict) or "data" not in item:
140
+ continue
141
+ raw_value = str(item.get("valor", "")).strip()
142
+ value: float | None
143
+ try:
144
+ value = float(raw_value.replace(",", ".")) if raw_value not in ("", "-") else None
145
+ except ValueError:
146
+ value = None
147
+ rows.append((parse_bcb_date(str(item["data"])), value))
148
+ return rows
149
+
150
+ async def fetch_series(
151
+ self, series_id: int, first_date: dt.date, last_date: dt.date
152
+ ) -> list[tuple[dt.date, float | None]]:
153
+ """Download a series, splitting long spans into API-friendly chunks."""
154
+ chunks = split_date_range(first_date, last_date)
155
+ collected: dict[dt.date, float | None] = {}
156
+ for start, end in chunks:
157
+ for ref_date, value in await self.fetch_series_range(series_id, start, end):
158
+ collected[ref_date] = value
159
+ return sorted(collected.items())
160
+
161
+ async def _get_json(self, url: str, params: dict[str, Any]) -> Any:
162
+ last_error: Exception | None = None
163
+ for attempt in range(1, self.max_retries + 1):
164
+ try:
165
+ await self._limiter.wait()
166
+ async with self._semaphore:
167
+ response = await self._client.get(url, params=params)
168
+ if response.status_code == 404:
169
+ raise SeriesNotFound(f"BCB returned 404 for {url}")
170
+ if response.status_code == 429 or response.status_code >= 500:
171
+ raise BCBError(f"HTTP {response.status_code} from BCB")
172
+ response.raise_for_status()
173
+ try:
174
+ return response.json()
175
+ except ValueError:
176
+ text = response.text[:200]
177
+ raise BCBError(f"BCB returned non-JSON payload: {text!r}") from None
178
+ except SeriesNotFound:
179
+ raise
180
+ except (httpx.HTTPError, BCBError) as exc:
181
+ last_error = exc
182
+ if attempt >= self.max_retries:
183
+ break
184
+ wait = 2.0**attempt
185
+ logger.warning("request failed (%s); retrying in %.1fs", exc, wait)
186
+ await asyncio.sleep(wait)
187
+ raise BCBError(f"failed to fetch {url}: {last_error}")
188
+
189
+ # -- catalog search ------------------------------------------------------
190
+ async def search_catalog(self, text: str, max_results: int = 50) -> list[dict[str, Any]]:
191
+ """Search the official SGS catalog by text (English catalog names)."""
192
+ await self._ensure_session()
193
+ rows: list[dict[str, Any]] = []
194
+ seen: set[int] = set()
195
+ page = 1
196
+ total = 0
197
+ while len(rows) < max_results:
198
+ method = "localizarSeriesPorTexto" if page == 1 else "getPagina"
199
+ html_text = await self._post_search(method, text, page)
200
+ page_rows, total = parse_search_page(html_text)
201
+ fresh = [r for r in page_rows if r["id"] not in seen]
202
+ for row in fresh:
203
+ seen.add(row["id"])
204
+ rows.append(row)
205
+ if not fresh or not total or len(rows) >= total or page >= 40:
206
+ break
207
+ page += 1
208
+ return rows[:max_results]
209
+
210
+ async def _ensure_session(self) -> None:
211
+ """The legacy search service needs a session cookie."""
212
+ try:
213
+ await self._limiter.wait()
214
+ async with self._semaphore:
215
+ await self._client.get(SGS_SEARCH_HOME)
216
+ except httpx.HTTPError as exc: # pragma: no cover - network dependent
217
+ raise BCBError(f"could not start SGS search session: {exc}") from exc
218
+
219
+ async def _post_search(self, method: str, text: str, page: int) -> str:
220
+ data = {
221
+ "texto": text,
222
+ "hdTipoPesquisa": "6",
223
+ "hdTipoOrdenacao": "0",
224
+ "hdNumPagina": str(page),
225
+ "bilQtde": "1",
226
+ "periodicidade": "0",
227
+ "fonte": "341",
228
+ }
229
+ last_error: Exception | None = None
230
+ for attempt in range(1, self.max_retries + 1):
231
+ try:
232
+ await self._limiter.wait()
233
+ async with self._semaphore:
234
+ response = await self._client.post(
235
+ SGS_SEARCH_URL, params={"method": method}, data=data
236
+ )
237
+ response.raise_for_status()
238
+ text_body = response.content.decode("iso-8859-1", errors="replace").replace(
239
+ "\x00", ""
240
+ )
241
+ if "Session expired" in text_body:
242
+ await self._ensure_session()
243
+ raise BCBError("SGS search session expired")
244
+ return text_body
245
+ except (httpx.HTTPError, BCBError) as exc:
246
+ last_error = exc
247
+ if attempt >= self.max_retries:
248
+ break
249
+ await asyncio.sleep(2.0**attempt)
250
+ raise BCBError(f"SGS catalog search failed: {last_error}")
251
+
252
+
253
+ # --- HTML parsing -----------------------------------------------------------
254
+
255
+ _TOTAL_RE = re.compile(r"Amount of series found:.*?<b>(\d+)</b>", re.S)
256
+ _TABLE_RE = re.compile(r"id=.tabelaSeries.", re.S)
257
+
258
+
259
+ def _clean(fragment: str) -> str:
260
+ text = re.sub(r"<[^>]+>", "", fragment)
261
+ return " ".join(html_lib.unescape(text).split())
262
+
263
+
264
+ def parse_search_page(page_html: str) -> tuple[list[dict[str, Any]], int]:
265
+ """Parse one HTML page of the SGS catalog search results."""
266
+ total_match = _TOTAL_RE.search(page_html)
267
+ total = int(total_match.group(1)) if total_match else 0
268
+
269
+ parts = _TABLE_RE.split(page_html, maxsplit=1)
270
+ if len(parts) < 2:
271
+ return [], total
272
+
273
+ rows: list[dict[str, Any]] = []
274
+ for chunk in parts[1].split("<tr")[1:]:
275
+ if "cbxSelecionaSerie" not in chunk:
276
+ continue
277
+ cells = re.findall(r"<td[^>]*>(.*?)</td>", chunk, re.S)
278
+ if len(cells) < 8:
279
+ continue
280
+ id_match = re.search(r'value="(\d+)"', cells[0])
281
+ freq_match = re.search(r'title="([^"]+)"', cells[4])
282
+ rows.append(
283
+ {
284
+ "id": int(id_match.group(1)) if id_match else None,
285
+ "name": _clean(cells[2]),
286
+ "unit": _clean(cells[3]),
287
+ "frequency": _clean(freq_match.group(1)) if freq_match else _clean(cells[4]),
288
+ "first_date": _clean(cells[5]),
289
+ "last_date": _clean(cells[6]),
290
+ "source": _clean(cells[7]),
291
+ }
292
+ )
293
+ return [row for row in rows if row["id"] is not None], total