aemetxfb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aemetxfb/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Main module for aemetxfb package."""
2
+
3
+ from aemetxfb import cache, clim, obs, pred
4
+ from aemetxfb.utils import AEMetConfig, CONFIG, set_config
5
+
6
+ __all__ = [
7
+ "AEMetConfig",
8
+ "CONFIG",
9
+ "cache",
10
+ "clim",
11
+ "obs",
12
+ "pred",
13
+ "set_config",
14
+ ]
aemetxfb/cache.py ADDED
@@ -0,0 +1,222 @@
1
+ """Optional disk-based response cache for AEMet API calls.
2
+
3
+ This module provides a ``@cached`` decorator backed by a single SQLite
4
+ database file. Cached entries expire after the TTL defined in
5
+ :data:`aemetxfb.utils.CONFIG` (``cache_ttl`` seconds).
6
+
7
+ The cache is **disabled by default**; enable it via
8
+
9
+ .. code-block:: python
10
+
11
+ from aemetxfb import AEMetConfig, set_config
12
+
13
+ set_config(AEMetConfig(cache_enabled=True, cache_ttl=86400))
14
+
15
+ Only pure data-returning functions should be decorated. Functions that
16
+ perform side-effects (downloading images, writing files) must **not**
17
+ use ``@cached`` because their return values are typically file paths
18
+ that are meaningless when replayed from cache.
19
+
20
+ Typical use case — climate data that changes rarely:
21
+
22
+ .. code-block:: python
23
+
24
+ from aemetxfb.cache import cached
25
+ from aemetxfb.clim import get_normals
26
+
27
+ @cached
28
+ def my_get_normals(station: str) -> pd.DataFrame:
29
+ return get_normals(station)
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import pickle
36
+ import sqlite3
37
+ import time
38
+ from functools import wraps
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ from aemetxfb import utils as _utils
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # SQLite helpers
47
+ # ---------------------------------------------------------------------------
48
+
49
+ _DB_SCHEMA = """
50
+ CREATE TABLE IF NOT EXISTS cache (
51
+ key TEXT PRIMARY KEY,
52
+ value BLOB NOT NULL,
53
+ created REAL NOT NULL
54
+ );
55
+ """
56
+
57
+
58
+ def _get_db_path() -> Path:
59
+ """Return the path to the SQLite cache database file."""
60
+ cache_dir = Path(_utils.CONFIG.cache_dir)
61
+ cache_dir.mkdir(parents=True, exist_ok=True)
62
+ return cache_dir / "cache.db"
63
+
64
+
65
+ def _get_connection() -> sqlite3.Connection:
66
+ """Open (and initialise) the SQLite connection."""
67
+ conn = sqlite3.connect(str(_get_db_path()))
68
+ conn.execute(_DB_SCHEMA)
69
+ conn.commit()
70
+ return conn
71
+
72
+
73
+ def _make_key(func_name: str, args: tuple, kwargs: tuple) -> str:
74
+ """Create a deterministic cache key from function name and arguments.
75
+
76
+ The key is the SHA-256 hex digest of a pickled tuple containing the
77
+ function name, positional arguments, and sorted keyword arguments.
78
+ """
79
+ raw = (func_name, args, kwargs)
80
+ digest = hashlib.sha256(pickle.dumps(raw)).hexdigest()
81
+ return digest
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Low-level cache API
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ def cache_get(key: str) -> Any | None:
90
+ """Retrieve a value from the cache by key.
91
+
92
+ Returns ``None`` if the key is missing or has expired.
93
+
94
+ Examples
95
+ --------
96
+ >>> cache_get("nonexistent_key") is None # cache disabled by default
97
+ True
98
+ """
99
+ if not _utils.CONFIG.cache_enabled:
100
+ return None
101
+
102
+ conn = _get_connection()
103
+ try:
104
+ row = conn.execute(
105
+ "SELECT value, created FROM cache WHERE key = ?", (key,)
106
+ ).fetchone()
107
+ if row is None:
108
+ return None
109
+ value, created = row
110
+ age = time.time() - created
111
+ if age > _utils.CONFIG.cache_ttl:
112
+ conn.execute("DELETE FROM cache WHERE key = ?", (key,))
113
+ conn.commit()
114
+ return None
115
+ return pickle.loads(value)
116
+ finally:
117
+ conn.close()
118
+
119
+
120
+ def cache_set(key: str, value: Any) -> None:
121
+ """Store a value in the cache.
122
+
123
+ If the key already exists it is overwritten.
124
+ """
125
+ if not _utils.CONFIG.cache_enabled:
126
+ return
127
+
128
+ conn = _get_connection()
129
+ try:
130
+ conn.execute(
131
+ "INSERT OR REPLACE INTO cache (key, value, created) VALUES (?, ?, ?)",
132
+ (key, pickle.dumps(value), time.time()),
133
+ )
134
+ conn.commit()
135
+ finally:
136
+ conn.close()
137
+
138
+
139
+ def clear_cache() -> None:
140
+ """Delete all entries from the cache database.
141
+
142
+ The database file itself is kept so that subsequent inserts do not
143
+ need to re-create the schema.
144
+
145
+ Examples
146
+ --------
147
+ >>> clear_cache() # safe to call even when cache is disabled
148
+ """
149
+ conn = _get_connection()
150
+ try:
151
+ conn.execute("DELETE FROM cache")
152
+ conn.commit()
153
+ finally:
154
+ conn.close()
155
+
156
+
157
+ def get_cache_stats() -> dict[str, int]:
158
+ """Return basic statistics about the current cache.
159
+
160
+ Returns
161
+ -------
162
+ dict
163
+ ``{"size": ..., "entries": ...}`` where *size* is the file size
164
+ in bytes and *entries* is the number of rows in the cache table.
165
+ If the database does not exist yet both values are ``0``.
166
+
167
+ Examples
168
+ --------
169
+ >>> stats = get_cache_stats()
170
+ >>> "size" in stats and "entries" in stats
171
+ True
172
+ """
173
+ db_path = _get_db_path()
174
+ if not db_path.exists():
175
+ return {"size": 0, "entries": 0}
176
+ size = db_path.stat().st_size
177
+ conn = _get_connection()
178
+ try:
179
+ count = conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
180
+ finally:
181
+ conn.close()
182
+ return {"size": size, "entries": count}
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Decorator
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ def cached(func: Any) -> Any:
191
+ """Decorator that caches the return value of a function.
192
+
193
+ The cache key is derived from the function name, positional arguments,
194
+ and keyword arguments. Entries expire after ``CONFIG.cache_ttl``
195
+ seconds. If ``CONFIG.cache_enabled`` is ``False`` the decorator is a
196
+ no-op and the underlying function is called every time.
197
+
198
+ Parameters that are not hashable (e.g. mutable lists) will cause a
199
+ ``TypeError`` when pickling the cache key.
200
+
201
+ Examples
202
+ --------
203
+ >>> from aemetxfb import AEMetConfig, set_config # doctest: +SKIP
204
+ >>> from aemetxfb.cache import cached # doctest: +SKIP
205
+ >>> set_config(AEMetConfig(cache_enabled=True, cache_ttl=3600)) # doctest: +SKIP
206
+ >>> @cached # doctest: +SKIP
207
+ ... def fetch_normals(station: str): # doctest: +SKIP
208
+ ... return get_normals(station) # doctest: +SKIP
209
+ """
210
+
211
+ @wraps(func)
212
+ def wrapper(*args, **kwargs):
213
+ sorted_kwargs = tuple(sorted(kwargs.items()))
214
+ key = _make_key(func.__qualname__, args, sorted_kwargs)
215
+ cached_value = cache_get(key)
216
+ if cached_value is not None:
217
+ return cached_value
218
+ result = func(*args, **kwargs)
219
+ cache_set(key, result)
220
+ return result
221
+
222
+ return wrapper
@@ -0,0 +1,29 @@
1
+ """Climate module for AEMet data.
2
+
3
+ This module provides access to climate-related data including
4
+ meteorological station information.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from aemetxfb.clim._masts import STATIONS
10
+ from aemetxfb.clim.clim_ephem import get_clim_ephem
11
+ from aemetxfb.clim.clim_extremes import CLIM_EXTREMES_STATIONS, get_clim_extremes
12
+ from aemetxfb.clim.clim_normals import get_normals, get_normals_map
13
+ from aemetxfb.clim.clim_thresholds import (
14
+ get_clim_threshold_day,
15
+ get_clim_threshold_month,
16
+ VALID_EXTREME_PARAMS,
17
+ )
18
+
19
+ __all__ = [
20
+ "STATIONS",
21
+ "CLIM_EXTREMES_STATIONS",
22
+ "VALID_EXTREME_PARAMS",
23
+ "get_normals",
24
+ "get_normals_map",
25
+ "get_clim_extremes",
26
+ "get_clim_threshold_day",
27
+ "get_clim_threshold_month",
28
+ "get_clim_ephem",
29
+ ]