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.
- econchile-0.1.0/LICENSE +21 -0
- econchile-0.1.0/MANIFEST.in +25 -0
- econchile-0.1.0/PKG-INFO +133 -0
- econchile-0.1.0/README.md +112 -0
- econchile-0.1.0/econchile/__init__.py +9 -0
- econchile-0.1.0/econchile/cache.py +303 -0
- econchile-0.1.0/econchile/client.py +206 -0
- econchile-0.1.0/econchile/converters.py +141 -0
- econchile-0.1.0/econchile/fetcher.py +251 -0
- econchile-0.1.0/econchile/offline.py +128 -0
- econchile-0.1.0/econchile/parsers.py +127 -0
- econchile-0.1.0/econchile/series_map.py +125 -0
- econchile-0.1.0/econchile/types.py +167 -0
- econchile-0.1.0/econchile.egg-info/SOURCES.txt +21 -0
- econchile-0.1.0/pyproject.toml +36 -0
- econchile-0.1.0/setup.cfg +4 -0
- econchile-0.1.0/tests/test_cache.py +280 -0
- econchile-0.1.0/tests/test_client.py +339 -0
- econchile-0.1.0/tests/test_converters.py +267 -0
- econchile-0.1.0/tests/test_fetcher.py +307 -0
- econchile-0.1.0/tests/test_offline.py +326 -0
- econchile-0.1.0/tests/test_parsers.py +291 -0
- econchile-0.1.0/tests/test_series_map.py +145 -0
- econchile-0.1.0/tests/test_types.py +362 -0
econchile-0.1.0/LICENSE
ADDED
|
@@ -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
|
econchile-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
)
|