econchile 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cristóbal Almendra
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.
@@ -0,0 +1,25 @@
1
+ # MANIFEST.in — controls what goes in the sdist (source distribution)
2
+ # The wheel is governed by pyproject.toml [tool.setuptools.packages.find].
3
+ #
4
+ # Goal: ship source + tests + specs, but EXCLUDE the 8MB API fixture
5
+ # (tests need it in the repo, but it should not bloat the sdist) and
6
+ # exclude any leftover build artifacts / private study notes.
7
+
8
+ # Include the real test fixture only if explicitly wanted — for v0.1 we
9
+ # exclude it from the sdist to keep the download small. Tests that depend
10
+ # on it are skipped/guarded in CI when the file is absent.
11
+ exclude sample_response.json
12
+ exclude multi_series_sample.json
13
+
14
+ # Private annotated learning notes never ship
15
+ recursive-exclude econchile/study *
16
+
17
+ # Build artifacts
18
+ recursive-exclude dist *
19
+ recursive-exclude build *
20
+ recursive-exclude *.egg-info *
21
+
22
+ # Python caches
23
+ recursive-exclude **/__pycache__ *
24
+ global-exclude *.py[cod]
25
+ global-exclude *.db
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: econchile
3
+ Version: 0.1.0
4
+ Summary: Python library to retrieve Chilean macroeconomic data
5
+ Author: Cristóbal Almendra
6
+ License-Expression: MIT
7
+ Keywords: chile,economics,macro,banco-central,bde
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: requests>=2.31
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=7.0; extra == "test"
20
+ Dynamic: license-file
21
+
22
+ # econchile
23
+
24
+ Chilean macroeconomic data (Banco Central de Chile) for Python.
25
+
26
+ `econchile` is a thin, practical client for the BCCh SIE REST web service. It downloads official series (UF, USD, TPM, IPC_VAR, IPC_INDEX, IMACEC, PIB), parses them into clean, typed data, and keeps a local SQLite cache so repeat queries are instant and your scripts survive API outages.
27
+
28
+ ## Install
29
+
30
+ Once v0.1 is on PyPI: `pip install econchile`. Until then, install from source:
31
+
32
+ ```bash
33
+ git clone <repo-url> econchile
34
+ cd econchile
35
+ pip install -e .
36
+ ```
37
+
38
+ Requires Python 3.10+.
39
+
40
+ ## Authentication
41
+
42
+ Request a free API token from the BCCh statistics database (SIE), see the official API documentation.
43
+
44
+ ```bash
45
+ export BCCH_TOKEN="your-token-here"
46
+ ```
47
+
48
+ The library reads `BCCH_TOKEN` from the environment — it does not load `.env` files itself. The token is required for v0.1.
49
+
50
+ ## Quickstart
51
+
52
+ ```python
53
+ from econchile import BcchClient, Series
54
+
55
+ client = BcchClient()
56
+
57
+ # Last 3 months of the UF (daily)
58
+ result = client.get(Series.UF, "2024-01-01", "2024-03-31")
59
+
60
+ for obs in result.observations:
61
+ print(obs.date, obs.value)
62
+ ```
63
+
64
+ Dates are always `YYYY-MM-DD`. Missing observations have `value=None`.
65
+
66
+ ## The two clients
67
+
68
+ | Client | Strategy | Best for |
69
+ |--------|----------|----------|
70
+ | `BcchClient` | **Cache-first** — serves from the local cache when fresh, hits the API only on a miss | Interactive use, repeated queries |
71
+ | `OfflineClient` | **API-first** — always tries the API, falls back to the cache when it fails | Cron jobs and scripts that must not crash |
72
+
73
+ ```python
74
+ from econchile.offline import OfflineClient
75
+
76
+ client = OfflineClient()
77
+ result = client.get(Series.USD, "2024-01-01", "2024-03-31") # survives API outages
78
+ ```
79
+
80
+ ## Available series (v0.1)
81
+
82
+ | Series | BCCh code | Frequency | Meaning |
83
+ |--------|-----------|-----------|---------|
84
+ | `Series.UF` | `F073.UFF.PRE.Z.D` | daily | Unidad de Fomento |
85
+ | `Series.USD` | `F073.TCO.PRE.Z.D` | daily | Nominal exchange rate (CLP/USD) |
86
+ | `Series.TPM` | `F022.TPM.TIN.D001.NO.Z.D` | daily | Monetary policy rate |
87
+ | `Series.IPC_VAR` | `F074.IPC.VAR.Z.Z.C.M` | monthly | CPI, month-over-month change |
88
+ | `Series.IPC_INDEX` | `F074.IPC.IND.Z.2023.C.M` | monthly | CPI general index (base 2023=100) |
89
+ | `Series.IMACEC` | `F032.IMC.IND.Z.Z.EP18.Z.Z.0.M` | monthly | Economic activity index (base 2018=100) |
90
+ | `Series.PIB` | `F032.PIB.FLU.R.CLP.EP18.Z.Z.0.T` | quarterly | GDP, chained volumes (base 2018) |
91
+
92
+ More series are planned. Use `client.list_series()` for the full catalog and `client.search("ipc")` to find series by keyword:
93
+
94
+ ```python
95
+ hits = client.search("ipc") # matches name, code, Spanish and English titles
96
+ for meta in hits:
97
+ print(meta.series_id, meta.spanish_title)
98
+ ```
99
+
100
+ ## Gotchas
101
+
102
+ - **Date format**: pass `YYYY-MM-DD` to `get()`; the library converts the API's native `DD-MM-YYYY` for you.
103
+ - **Missing data**: the BCCh API marks gaps as "ND". These become `value=None`, not zeros or exceptions, check for `None` before using a value.
104
+ - **Representations**: `IPC_VAR` is a monthly % change, `IPC_INDEX` is a base-2023 index. Same variable, different meaning.
105
+ - **Cache freshness**: cached results are reused for 24 hours by default; configure via `ttl_seconds` on `BcchClient(...)` or `OfflineClient(...)` (the cache lives at `~/.econchile/cache.db`).
106
+ - **Errors**: unknown series raise `KeyError`, malformed dates raise `ValueError`, API failures raise `BcchApiError` and `BcchOfflineError` when the offline fallback is also exhausted.
107
+
108
+ ## API
109
+
110
+ - `BcchClient.get(series, desde, hasta, use_cache=True)` — fetch a series over a date range; returns a `SeriesResult`.
111
+ - `OfflineClient.get(series, desde, hasta)` — same, but API-first with cache fallback.
112
+ - `BcchClient.search(keyword)` — case- and accent-insensitive catalog search → `list[SeriesMeta]`.
113
+ - `BcchClient.list_series()` — all series metadata → `list[SeriesMeta]`.
114
+ - `BcchClient.clear_cache()` — empty the local cache (returns the number of rows removed).
115
+
116
+ A `SeriesResult` has:
117
+
118
+ - `series` — the `Series` member
119
+ - `observations` — list of `Observation(date: str, value: float | None)`
120
+ - `fetched_at` — timestamp (UTC)
121
+ - `source` — `"api"`, `"cache"`, or `"partial"`
122
+ - `metadata` — series info (titles, frequency, representation)
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ pip install -e . && pip install pytest
128
+ python -m pytest tests/
129
+ ```
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,112 @@
1
+ # econchile
2
+
3
+ Chilean macroeconomic data (Banco Central de Chile) for Python.
4
+
5
+ `econchile` is a thin, practical client for the BCCh SIE REST web service. It downloads official series (UF, USD, TPM, IPC_VAR, IPC_INDEX, IMACEC, PIB), parses them into clean, typed data, and keeps a local SQLite cache so repeat queries are instant and your scripts survive API outages.
6
+
7
+ ## Install
8
+
9
+ Once v0.1 is on PyPI: `pip install econchile`. Until then, install from source:
10
+
11
+ ```bash
12
+ git clone <repo-url> econchile
13
+ cd econchile
14
+ pip install -e .
15
+ ```
16
+
17
+ Requires Python 3.10+.
18
+
19
+ ## Authentication
20
+
21
+ Request a free API token from the BCCh statistics database (SIE), see the official API documentation.
22
+
23
+ ```bash
24
+ export BCCH_TOKEN="your-token-here"
25
+ ```
26
+
27
+ The library reads `BCCH_TOKEN` from the environment — it does not load `.env` files itself. The token is required for v0.1.
28
+
29
+ ## Quickstart
30
+
31
+ ```python
32
+ from econchile import BcchClient, Series
33
+
34
+ client = BcchClient()
35
+
36
+ # Last 3 months of the UF (daily)
37
+ result = client.get(Series.UF, "2024-01-01", "2024-03-31")
38
+
39
+ for obs in result.observations:
40
+ print(obs.date, obs.value)
41
+ ```
42
+
43
+ Dates are always `YYYY-MM-DD`. Missing observations have `value=None`.
44
+
45
+ ## The two clients
46
+
47
+ | Client | Strategy | Best for |
48
+ |--------|----------|----------|
49
+ | `BcchClient` | **Cache-first** — serves from the local cache when fresh, hits the API only on a miss | Interactive use, repeated queries |
50
+ | `OfflineClient` | **API-first** — always tries the API, falls back to the cache when it fails | Cron jobs and scripts that must not crash |
51
+
52
+ ```python
53
+ from econchile.offline import OfflineClient
54
+
55
+ client = OfflineClient()
56
+ result = client.get(Series.USD, "2024-01-01", "2024-03-31") # survives API outages
57
+ ```
58
+
59
+ ## Available series (v0.1)
60
+
61
+ | Series | BCCh code | Frequency | Meaning |
62
+ |--------|-----------|-----------|---------|
63
+ | `Series.UF` | `F073.UFF.PRE.Z.D` | daily | Unidad de Fomento |
64
+ | `Series.USD` | `F073.TCO.PRE.Z.D` | daily | Nominal exchange rate (CLP/USD) |
65
+ | `Series.TPM` | `F022.TPM.TIN.D001.NO.Z.D` | daily | Monetary policy rate |
66
+ | `Series.IPC_VAR` | `F074.IPC.VAR.Z.Z.C.M` | monthly | CPI, month-over-month change |
67
+ | `Series.IPC_INDEX` | `F074.IPC.IND.Z.2023.C.M` | monthly | CPI general index (base 2023=100) |
68
+ | `Series.IMACEC` | `F032.IMC.IND.Z.Z.EP18.Z.Z.0.M` | monthly | Economic activity index (base 2018=100) |
69
+ | `Series.PIB` | `F032.PIB.FLU.R.CLP.EP18.Z.Z.0.T` | quarterly | GDP, chained volumes (base 2018) |
70
+
71
+ More series are planned. Use `client.list_series()` for the full catalog and `client.search("ipc")` to find series by keyword:
72
+
73
+ ```python
74
+ hits = client.search("ipc") # matches name, code, Spanish and English titles
75
+ for meta in hits:
76
+ print(meta.series_id, meta.spanish_title)
77
+ ```
78
+
79
+ ## Gotchas
80
+
81
+ - **Date format**: pass `YYYY-MM-DD` to `get()`; the library converts the API's native `DD-MM-YYYY` for you.
82
+ - **Missing data**: the BCCh API marks gaps as "ND". These become `value=None`, not zeros or exceptions, check for `None` before using a value.
83
+ - **Representations**: `IPC_VAR` is a monthly % change, `IPC_INDEX` is a base-2023 index. Same variable, different meaning.
84
+ - **Cache freshness**: cached results are reused for 24 hours by default; configure via `ttl_seconds` on `BcchClient(...)` or `OfflineClient(...)` (the cache lives at `~/.econchile/cache.db`).
85
+ - **Errors**: unknown series raise `KeyError`, malformed dates raise `ValueError`, API failures raise `BcchApiError` and `BcchOfflineError` when the offline fallback is also exhausted.
86
+
87
+ ## API
88
+
89
+ - `BcchClient.get(series, desde, hasta, use_cache=True)` — fetch a series over a date range; returns a `SeriesResult`.
90
+ - `OfflineClient.get(series, desde, hasta)` — same, but API-first with cache fallback.
91
+ - `BcchClient.search(keyword)` — case- and accent-insensitive catalog search → `list[SeriesMeta]`.
92
+ - `BcchClient.list_series()` — all series metadata → `list[SeriesMeta]`.
93
+ - `BcchClient.clear_cache()` — empty the local cache (returns the number of rows removed).
94
+
95
+ A `SeriesResult` has:
96
+
97
+ - `series` — the `Series` member
98
+ - `observations` — list of `Observation(date: str, value: float | None)`
99
+ - `fetched_at` — timestamp (UTC)
100
+ - `source` — `"api"`, `"cache"`, or `"partial"`
101
+ - `metadata` — series info (titles, frequency, representation)
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ pip install -e . && pip install pytest
107
+ python -m pytest tests/
108
+ ```
109
+
110
+ ## License
111
+
112
+ MIT
@@ -0,0 +1,9 @@
1
+ """econchile — Chilean macroeconomic data, made simple."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from econchile.client import BcchClient
6
+ from econchile.series_map import Series
7
+ from econchile.types import SeriesMeta, SeriesResult
8
+
9
+ __all__ = ["BcchClient", "Series", "SeriesMeta", "SeriesResult", "__version__"]
@@ -0,0 +1,303 @@
1
+ """
2
+ SQLite-backed cache for BCCh API responses.
3
+
4
+ Stores :class:`~econchile.types.SeriesResult` objects with a 24-hour TTL,
5
+ keyed by ``{series}|{desde}|{hasta}``. The fetcher checks the cache
6
+ before hitting the network; ``offline`` falls back to it when the API is
7
+ down. Second layer of the fallback chain: API → cache → error.
8
+
9
+ Stdlib only: ``sqlite3``, ``json``, ``datetime``, ``pathlib``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sqlite3
16
+ from datetime import datetime, timedelta
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from econchile.series_map import Series
21
+ from econchile.types import BcchCacheError, Observation, SeriesResult
22
+
23
+ # ─── Defaults ──────────────────────────────────────────────────────────
24
+
25
+ DEFAULT_DB_PATH: Path = Path.home() / ".econchile" / "cache.db"
26
+ """Default SQLite database location (created on first use)."""
27
+
28
+ DEFAULT_TTL_SECONDS: int = 86400
29
+ """Default time-to-live: 24 hours (BCCh daily series change at most once/day)."""
30
+
31
+
32
+ def make_key(series: str, desde: str, hasta: str) -> str:
33
+ """Build the cache key encoding exactly which query produced the data.
34
+
35
+ Different date ranges of the same series are DIFFERENT entries —
36
+ a cached full-history response must never be served when the user
37
+ asked for last week.
38
+
39
+ Args:
40
+ series: BCCh series code (e.g. ``"F073.TCO.PRE.Z.D"``) or a
41
+ :class:`~econchile.series_map.Series` enum member.
42
+ desde: Start date, ``YYYY-MM-DD``.
43
+ hasta: End date, ``YYYY-MM-DD``.
44
+
45
+ Returns:
46
+ The cache key ``f"{code}|{desde}|{hasta}"`` where ``code`` is the
47
+ series' BCCh code (enum members normalised to their ``.value``).
48
+ """
49
+ return f"{_series_code(series)}|{desde}|{hasta}"
50
+
51
+
52
+ def _series_code(series: str | Series) -> str:
53
+ """Normalise a series identifier to its BCCh code string."""
54
+ if isinstance(series, Series):
55
+ return series.value
56
+ return str(series)
57
+
58
+
59
+ def _observation_to_dict(obs: Observation | dict[str, Any]) -> dict[str, Any]:
60
+ """Serialise one observation, accepting Observation objects or dicts."""
61
+ if isinstance(obs, Observation):
62
+ return {"date": obs.date, "value": obs.value}
63
+ return {"date": obs["date"], "value": obs["value"]}
64
+
65
+
66
+ class Cache:
67
+ """SQLite-backed cache for :class:`~econchile.types.SeriesResult`.
68
+
69
+ Args:
70
+ db_path: Path to the SQLite database file. Defaults to
71
+ ``~/.econchile/cache.db`` (parent directory auto-created).
72
+ ttl_seconds: How long entries stay fresh. Default 86400 (24h).
73
+
74
+ Raises:
75
+ BcchCacheError: If the database cannot be opened or created.
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ db_path: str | Path | None = None,
81
+ ttl_seconds: int = DEFAULT_TTL_SECONDS,
82
+ ) -> None:
83
+ self._db_path = Path(db_path) if db_path is not None else DEFAULT_DB_PATH
84
+ self._ttl_seconds = ttl_seconds
85
+ self._memory_conn: sqlite3.Connection | None = None
86
+ if str(self._db_path) == ":memory:":
87
+ # :memory: databases live INSIDE a connection — keep one open
88
+ # for the lifetime of the Cache so the schema survives.
89
+ self._memory_conn = sqlite3.connect(":memory:")
90
+ self._init_db()
91
+
92
+ # ── Setup ─────────────────────────────────────────────────────────
93
+
94
+ def _init_db(self) -> None:
95
+ """Create the database file and the ``cache`` table if missing."""
96
+ try:
97
+ if self._memory_conn is None:
98
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
99
+ conn = self._connect()
100
+ try:
101
+ conn.execute(
102
+ """
103
+ CREATE TABLE IF NOT EXISTS cache (
104
+ key TEXT PRIMARY KEY,
105
+ payload TEXT NOT NULL,
106
+ fetched_at TEXT NOT NULL,
107
+ expires_at TEXT NOT NULL
108
+ )
109
+ """
110
+ )
111
+ finally:
112
+ # The old `with self._connect() as conn:` committed the
113
+ # transaction on exit — replicate that explicitly so file
114
+ # DBs persist writes before the connection closes.
115
+ conn.commit()
116
+ if self._memory_conn is None:
117
+ conn.close()
118
+ except (sqlite3.Error, OSError) as exc:
119
+ raise BcchCacheError(
120
+ f"cannot open cache database {self._db_path}: {exc}"
121
+ ) from exc
122
+
123
+ def _connect(self) -> sqlite3.Connection:
124
+ """Return a connection to the cache database.
125
+
126
+ For ``db_path=":memory:"`` this is the one persistent connection
127
+ held for the life of the Cache (the schema lives inside it); for
128
+ file-backed caches a fresh connection is opened per call.
129
+ """
130
+ if self._memory_conn is not None:
131
+ return self._memory_conn
132
+ return sqlite3.connect(str(self._db_path))
133
+
134
+ # ── Core API ──────────────────────────────────────────────────────
135
+
136
+ def get(self, key: str) -> SeriesResult | None:
137
+ """Return the cached result for ``key``, or None if missing/expired.
138
+
139
+ Expired rows are treated as a miss AND deleted — stale data is
140
+ never served.
141
+
142
+ Raises:
143
+ BcchCacheError: On SQLite I/O failure.
144
+ """
145
+ try:
146
+ conn = self._connect()
147
+ try:
148
+ row = conn.execute(
149
+ "SELECT payload, expires_at FROM cache WHERE key = ?",
150
+ (key,),
151
+ ).fetchone()
152
+ if row is None:
153
+ # Not in the cache at all — a plain miss, not an error.
154
+ return None
155
+ payload, expires_at_str = row
156
+ if datetime.fromisoformat(expires_at_str) < datetime.now():
157
+ # Expired: never serve stale data, and delete the row
158
+ # so the cache sweeps itself clean on read.
159
+ conn.execute("DELETE FROM cache WHERE key = ?", (key,))
160
+ return None
161
+ finally:
162
+ # The old `with self._connect() as conn:` committed the
163
+ # transaction on exit — replicate that explicitly so file
164
+ # DBs persist writes before the connection closes.
165
+ conn.commit()
166
+ if self._memory_conn is None:
167
+ conn.close()
168
+ except sqlite3.Error as exc:
169
+ raise BcchCacheError(f"cache read failed for {key!r}: {exc}") from exc
170
+ return self._reconstruct(payload)
171
+
172
+ def set(self, key: str, result: SeriesResult) -> None:
173
+ """Store ``result`` under ``key`` (upsert).
174
+
175
+ Sets ``fetched_at`` = now and ``expires_at`` = now + TTL.
176
+
177
+ Raises:
178
+ BcchCacheError: On SQLite I/O failure.
179
+ """
180
+ payload = self._serialize(result)
181
+ now = datetime.now()
182
+ expires_at = now + timedelta(seconds=self._ttl_seconds)
183
+ try:
184
+ conn = self._connect()
185
+ try:
186
+ conn.execute(
187
+ """
188
+ INSERT OR REPLACE INTO cache (key, payload, fetched_at, expires_at)
189
+ VALUES (?, ?, ?, ?)
190
+ """,
191
+ (key, payload, now.isoformat(), expires_at.isoformat()),
192
+ )
193
+ finally:
194
+ # The old `with self._connect() as conn:` committed the
195
+ # transaction on exit — replicate that explicitly so file
196
+ # DBs persist writes before the connection closes.
197
+ conn.commit()
198
+ if self._memory_conn is None:
199
+ conn.close()
200
+ except sqlite3.Error as exc:
201
+ raise BcchCacheError(f"cache write failed for {key!r}: {exc}") from exc
202
+
203
+ # ── Convenience: series + date range ──────────────────────────────
204
+
205
+ def get_series(
206
+ self, series: str | Series, desde: str, hasta: str
207
+ ) -> SeriesResult | None:
208
+ """Convenience: :meth:`get` with a key built from components."""
209
+ return self.get(make_key(_series_code(series), desde, hasta))
210
+
211
+ def set_series(
212
+ self, series: str | Series, desde: str, hasta: str, result: SeriesResult
213
+ ) -> None:
214
+ """Convenience: :meth:`set` with a key built from components."""
215
+ self.set(make_key(_series_code(series), desde, hasta), result)
216
+
217
+ # ── Cache management ──────────────────────────────────────────────
218
+
219
+ def clear(self) -> int:
220
+ """Delete every row. Returns the number of rows removed.
221
+
222
+ Raises:
223
+ BcchCacheError: On SQLite I/O failure.
224
+ """
225
+ try:
226
+ conn = self._connect()
227
+ try:
228
+ cursor = conn.execute("DELETE FROM cache")
229
+ return cursor.rowcount
230
+ finally:
231
+ # The old `with self._connect() as conn:` committed the
232
+ # transaction on exit — replicate that explicitly so file
233
+ # DBs persist writes before the connection closes.
234
+ conn.commit()
235
+ if self._memory_conn is None:
236
+ conn.close()
237
+ except sqlite3.Error as exc:
238
+ raise BcchCacheError(f"cache clear failed: {exc}") from exc
239
+
240
+ def size(self) -> int:
241
+ """Return the number of entries currently stored.
242
+
243
+ Raises:
244
+ BcchCacheError: On SQLite I/O failure.
245
+ """
246
+ try:
247
+ conn = self._connect()
248
+ try:
249
+ return conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
250
+ finally:
251
+ # The old `with self._connect() as conn:` committed the
252
+ # transaction on exit — replicate that explicitly so file
253
+ # DBs persist writes before the connection closes.
254
+ conn.commit()
255
+ if self._memory_conn is None:
256
+ conn.close()
257
+ except sqlite3.Error as exc:
258
+ raise BcchCacheError(f"cache size query failed: {exc}") from exc
259
+
260
+ # ── Serialisation ─────────────────────────────────────────────────
261
+
262
+ @staticmethod
263
+ def _serialize(result: SeriesResult) -> str:
264
+ """JSON-serialise a SeriesResult for the ``payload`` column.
265
+
266
+ Mirrors ``SeriesResult.to_dict()`` but accepts observations as
267
+ either ``Observation`` objects or plain dicts.
268
+ """
269
+ return json.dumps(
270
+ {
271
+ "series": (
272
+ result.series.value
273
+ if hasattr(result.series, "value")
274
+ else str(result.series)
275
+ ),
276
+ "observations": [_observation_to_dict(o) for o in result.observations],
277
+ "fetched_at": result.fetched_at.isoformat(),
278
+ "source": result.source,
279
+ "metadata": result.metadata,
280
+ }
281
+ )
282
+
283
+ @staticmethod
284
+ def _reconstruct(payload: str) -> SeriesResult:
285
+ """Rebuild a SeriesResult from a stored JSON payload."""
286
+ data: dict[str, Any] = json.loads(payload)
287
+ try:
288
+ # Recover the enum member when the code is in the v0.1 catalog…
289
+ series = Series.from_code(data["series"])
290
+ except KeyError:
291
+ # …otherwise keep the raw code string (graceful degradation).
292
+ series = data["series"]
293
+ observations = [
294
+ Observation(date=obs["date"], value=obs["value"])
295
+ for obs in data["observations"]
296
+ ]
297
+ return SeriesResult(
298
+ series=series,
299
+ observations=observations,
300
+ fetched_at=datetime.fromisoformat(data["fetched_at"]),
301
+ source=data.get("source", "cache"),
302
+ metadata=data.get("metadata", {}),
303
+ )