python-esios 2.0.2__py3-none-any.whl → 2.2.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.
- esios/.agents/skills/esios/SKILL.md +104 -129
- esios/catalog.py +478 -0
- esios/cli/app.py +2 -0
- esios/cli/catalog_cmd.py +177 -0
- esios/client.py +5 -0
- esios/data/archives.yaml +921 -0
- esios/data/geos.yaml +3 -0
- esios/data/indicators.yaml +43 -0
- esios/data/magnitudes.yaml +3 -0
- esios/data/time_periods.yaml +3 -0
- esios/managers/archives.py +2 -4
- {python_esios-2.0.2.dist-info → python_esios-2.2.0.dist-info}/METADATA +9 -2
- {python_esios-2.0.2.dist-info → python_esios-2.2.0.dist-info}/RECORD +16 -13
- esios/data/catalogs/__init__.py +0 -0
- esios/data/catalogs/archives/__init__.py +0 -5
- esios/data/catalogs/archives/catalog.py +0 -163
- esios/data/catalogs/archives/refresh.py +0 -95
- {python_esios-2.0.2.dist-info → python_esios-2.2.0.dist-info}/WHEEL +0 -0
- {python_esios-2.0.2.dist-info → python_esios-2.2.0.dist-info}/entry_points.txt +0 -0
- {python_esios-2.0.2.dist-info → python_esios-2.2.0.dist-info}/licenses/LICENSE +0 -0
esios/catalog.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""YAML-backed catalog for ESIOS indicators and archives.
|
|
2
|
+
|
|
3
|
+
Provides offline browsing of known indicators and archives, with
|
|
4
|
+
``refresh()`` to sync against the live API while preserving hand-curated
|
|
5
|
+
notes and tags.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.resources
|
|
11
|
+
import logging
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from esios.client import ESIOSClient
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("esios")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Data classes
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CatalogEntry:
|
|
34
|
+
"""A single catalog entry (indicator or archive)."""
|
|
35
|
+
|
|
36
|
+
id: int
|
|
37
|
+
name: str
|
|
38
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
def __getattr__(self, key: str) -> Any:
|
|
41
|
+
try:
|
|
42
|
+
return self.extra[key]
|
|
43
|
+
except KeyError:
|
|
44
|
+
raise AttributeError(f"CatalogEntry has no attribute {key!r}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class RefreshResult:
|
|
49
|
+
"""Summary of a catalog refresh operation."""
|
|
50
|
+
|
|
51
|
+
added: int = 0
|
|
52
|
+
updated: int = 0
|
|
53
|
+
removed: int = 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Helpers
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _yaml_path(filename: str) -> Path:
|
|
62
|
+
"""Resolve the absolute path to a YAML file inside ``esios.data``."""
|
|
63
|
+
ref = importlib.resources.files("esios.data").joinpath(filename)
|
|
64
|
+
# Traversable may not have a real filesystem path in zip installs,
|
|
65
|
+
# but for editable / sdist installs this works fine.
|
|
66
|
+
return Path(str(ref))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load_yaml(filename: str) -> dict:
|
|
70
|
+
path = _yaml_path(filename)
|
|
71
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
72
|
+
return yaml.safe_load(f) or {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _save_yaml(filename: str, data: dict) -> None:
|
|
76
|
+
path = _yaml_path(filename)
|
|
77
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
78
|
+
yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False, width=200)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Shared reference catalogs
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
_REF_FILES = {
|
|
86
|
+
"geos": "geos.yaml",
|
|
87
|
+
"magnitudes": "magnitudes.yaml",
|
|
88
|
+
"time_periods": "time_periods.yaml",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_reference(kind: str) -> dict[int, str]:
|
|
93
|
+
"""Load a shared reference catalog (geos, magnitudes, or time_periods).
|
|
94
|
+
|
|
95
|
+
Returns ``{id: name}`` with int keys.
|
|
96
|
+
"""
|
|
97
|
+
doc = _load_yaml(_REF_FILES[kind])
|
|
98
|
+
raw = doc.get(kind, {})
|
|
99
|
+
return {int(k): v for k, v in raw.items()}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _save_reference(kind: str, mapping: dict[int, str]) -> None:
|
|
103
|
+
"""Persist a shared reference catalog."""
|
|
104
|
+
doc = {
|
|
105
|
+
"version": 1,
|
|
106
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
107
|
+
kind: {k: v for k, v in sorted(mapping.items())},
|
|
108
|
+
}
|
|
109
|
+
_save_yaml(_REF_FILES[kind], doc)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# IndicatorsCatalog
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class IndicatorsCatalog:
|
|
118
|
+
"""Catalog of ESIOS indicators backed by ``indicators.yaml``."""
|
|
119
|
+
|
|
120
|
+
YAML_FILE = "indicators.yaml"
|
|
121
|
+
|
|
122
|
+
def __init__(self, client: ESIOSClient | None = None):
|
|
123
|
+
self._client = client
|
|
124
|
+
|
|
125
|
+
def _load_entries(self) -> list[CatalogEntry]:
|
|
126
|
+
doc = _load_yaml(self.YAML_FILE)
|
|
127
|
+
entries: list[CatalogEntry] = []
|
|
128
|
+
for item in doc.get("indicators", []):
|
|
129
|
+
entries.append(CatalogEntry(
|
|
130
|
+
id=item["id"],
|
|
131
|
+
name=item["name"],
|
|
132
|
+
extra={
|
|
133
|
+
"short_name": item.get("short_name", ""),
|
|
134
|
+
"notes": item.get("notes", ""),
|
|
135
|
+
"tags": item.get("tags", []),
|
|
136
|
+
"magnitude_id": item.get("magnitude_id"),
|
|
137
|
+
"time_period_id": item.get("time_period_id"),
|
|
138
|
+
"geo_ids": item.get("geo_ids", []),
|
|
139
|
+
},
|
|
140
|
+
))
|
|
141
|
+
return entries
|
|
142
|
+
|
|
143
|
+
def list(self, query: str | None = None) -> pd.DataFrame:
|
|
144
|
+
"""List catalog entries, optionally filtering by substring query."""
|
|
145
|
+
entries = self._load_entries()
|
|
146
|
+
rows = [
|
|
147
|
+
{"id": e.id, "name": e.name, "short_name": e.extra.get("short_name", ""), "notes": e.extra.get("notes", ""), "tags": ",".join(e.extra.get("tags", []))}
|
|
148
|
+
for e in entries
|
|
149
|
+
]
|
|
150
|
+
df = pd.DataFrame(rows)
|
|
151
|
+
if df.empty:
|
|
152
|
+
return df
|
|
153
|
+
df = df.set_index("id")
|
|
154
|
+
if query:
|
|
155
|
+
mask = df["name"].str.contains(query, case=False, na=False) | df["short_name"].str.contains(query, case=False, na=False)
|
|
156
|
+
df = df[mask]
|
|
157
|
+
return df
|
|
158
|
+
|
|
159
|
+
def get(self, indicator_id: int) -> CatalogEntry:
|
|
160
|
+
"""Get a single catalog entry by ID."""
|
|
161
|
+
for entry in self._load_entries():
|
|
162
|
+
if entry.id == indicator_id:
|
|
163
|
+
return entry
|
|
164
|
+
raise KeyError(f"Indicator {indicator_id} not found in catalog")
|
|
165
|
+
|
|
166
|
+
def _fetch_indicator_detail(self, indicator_id: int) -> dict | None:
|
|
167
|
+
"""Fetch per-indicator detail from ``/indicators/{id}``."""
|
|
168
|
+
try:
|
|
169
|
+
data = self._client.get(f"indicators/{indicator_id}")
|
|
170
|
+
return data.get("indicator", {})
|
|
171
|
+
except Exception:
|
|
172
|
+
logger.warning("Failed to fetch detail for indicator %d", indicator_id)
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def refresh(self, *, dry_run: bool = False) -> RefreshResult:
|
|
176
|
+
"""Sync catalog against the live API.
|
|
177
|
+
|
|
178
|
+
Fetches the full indicator list from the API and merges with the
|
|
179
|
+
existing YAML, preserving hand-curated ``notes`` and ``tags``.
|
|
180
|
+
|
|
181
|
+
Also fetches per-indicator detail to extract metadata IDs
|
|
182
|
+
(magnitude, time_period, geos) and populates the shared
|
|
183
|
+
reference catalogs.
|
|
184
|
+
"""
|
|
185
|
+
if self._client is None:
|
|
186
|
+
raise RuntimeError("Cannot refresh without a client — pass client to ESIOSCatalog")
|
|
187
|
+
|
|
188
|
+
# Fetch from API
|
|
189
|
+
data = self._client.get("indicators")
|
|
190
|
+
api_indicators = data.get("indicators", [])
|
|
191
|
+
|
|
192
|
+
# Build lookup of existing entries
|
|
193
|
+
doc = _load_yaml(self.YAML_FILE)
|
|
194
|
+
existing = {item["id"]: item for item in doc.get("indicators", [])}
|
|
195
|
+
|
|
196
|
+
# Only fetch detail for indicators already in the catalog
|
|
197
|
+
cataloged_ids = set(existing.keys())
|
|
198
|
+
|
|
199
|
+
# Load current shared reference catalogs
|
|
200
|
+
all_geos = load_reference("geos")
|
|
201
|
+
all_magnitudes = load_reference("magnitudes")
|
|
202
|
+
all_time_periods = load_reference("time_periods")
|
|
203
|
+
|
|
204
|
+
# Fetch per-indicator detail in parallel for cataloged indicators
|
|
205
|
+
detail_map: dict[int, dict] = {}
|
|
206
|
+
if cataloged_ids:
|
|
207
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
208
|
+
futures = {
|
|
209
|
+
pool.submit(self._fetch_indicator_detail, iid): iid
|
|
210
|
+
for iid in cataloged_ids
|
|
211
|
+
}
|
|
212
|
+
for future in as_completed(futures):
|
|
213
|
+
iid = futures[future]
|
|
214
|
+
detail = future.result()
|
|
215
|
+
if detail:
|
|
216
|
+
detail_map[iid] = detail
|
|
217
|
+
|
|
218
|
+
result = RefreshResult()
|
|
219
|
+
merged: list[dict] = []
|
|
220
|
+
|
|
221
|
+
for ind in api_indicators:
|
|
222
|
+
iid = ind["id"]
|
|
223
|
+
if iid in existing:
|
|
224
|
+
old = existing[iid]
|
|
225
|
+
|
|
226
|
+
# Extract metadata IDs from detail response
|
|
227
|
+
detail = detail_map.get(iid, {})
|
|
228
|
+
magnitude_id, time_period_id, geo_ids = self._extract_meta_ids(
|
|
229
|
+
detail, all_geos, all_magnitudes, all_time_periods,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
new_entry = {
|
|
233
|
+
"id": iid,
|
|
234
|
+
"name": ind.get("name", old.get("name", "")),
|
|
235
|
+
"short_name": ind.get("short_name", old.get("short_name", "")),
|
|
236
|
+
"notes": old.get("notes", ""),
|
|
237
|
+
"tags": old.get("tags", []),
|
|
238
|
+
"magnitude_id": magnitude_id or old.get("magnitude_id"),
|
|
239
|
+
"time_period_id": time_period_id or old.get("time_period_id"),
|
|
240
|
+
"geo_ids": geo_ids or old.get("geo_ids", []),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# Check if anything API-fetched changed
|
|
244
|
+
old_api_fields = (
|
|
245
|
+
old.get("name"), old.get("short_name"),
|
|
246
|
+
old.get("magnitude_id"), old.get("time_period_id"),
|
|
247
|
+
old.get("geo_ids", []),
|
|
248
|
+
)
|
|
249
|
+
new_api_fields = (
|
|
250
|
+
new_entry["name"], new_entry["short_name"],
|
|
251
|
+
new_entry["magnitude_id"], new_entry["time_period_id"],
|
|
252
|
+
new_entry["geo_ids"],
|
|
253
|
+
)
|
|
254
|
+
if old_api_fields != new_api_fields:
|
|
255
|
+
result.updated += 1
|
|
256
|
+
else:
|
|
257
|
+
new_entry = {
|
|
258
|
+
"id": iid,
|
|
259
|
+
"name": ind.get("name", ""),
|
|
260
|
+
"short_name": ind.get("short_name", ""),
|
|
261
|
+
"notes": "",
|
|
262
|
+
"tags": [],
|
|
263
|
+
}
|
|
264
|
+
result.added += 1
|
|
265
|
+
merged.append(new_entry)
|
|
266
|
+
|
|
267
|
+
api_ids = {ind["id"] for ind in api_indicators}
|
|
268
|
+
for iid, old in existing.items():
|
|
269
|
+
if iid not in api_ids:
|
|
270
|
+
result.removed += 1
|
|
271
|
+
|
|
272
|
+
merged.sort(key=lambda x: x["id"])
|
|
273
|
+
|
|
274
|
+
if not dry_run:
|
|
275
|
+
new_doc = {
|
|
276
|
+
"version": doc.get("version", 1),
|
|
277
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
278
|
+
"indicators": merged,
|
|
279
|
+
}
|
|
280
|
+
_save_yaml(self.YAML_FILE, new_doc)
|
|
281
|
+
|
|
282
|
+
# Persist shared reference catalogs
|
|
283
|
+
_save_reference("geos", all_geos)
|
|
284
|
+
_save_reference("magnitudes", all_magnitudes)
|
|
285
|
+
_save_reference("time_periods", all_time_periods)
|
|
286
|
+
|
|
287
|
+
return result
|
|
288
|
+
|
|
289
|
+
@staticmethod
|
|
290
|
+
def _extract_meta_ids(
|
|
291
|
+
detail: dict,
|
|
292
|
+
all_geos: dict[int, str],
|
|
293
|
+
all_magnitudes: dict[int, str],
|
|
294
|
+
all_time_periods: dict[int, str],
|
|
295
|
+
) -> tuple[int | None, int | None, list[int]]:
|
|
296
|
+
"""Extract metadata IDs from a detail response, updating shared catalogs.
|
|
297
|
+
|
|
298
|
+
Returns ``(magnitude_id, time_period_id, geo_ids)``.
|
|
299
|
+
"""
|
|
300
|
+
# Magnitude (units)
|
|
301
|
+
magnitude_id = None
|
|
302
|
+
for mag in detail.get("magnitud", []):
|
|
303
|
+
mid = mag.get("id")
|
|
304
|
+
mname = mag.get("name", "")
|
|
305
|
+
if mid is not None:
|
|
306
|
+
magnitude_id = mid
|
|
307
|
+
all_magnitudes[mid] = mname
|
|
308
|
+
break # first one
|
|
309
|
+
|
|
310
|
+
# Time period (granularity)
|
|
311
|
+
time_period_id = None
|
|
312
|
+
for tp in detail.get("tiempo", []):
|
|
313
|
+
tid = tp.get("id")
|
|
314
|
+
tname = tp.get("name", "")
|
|
315
|
+
if tid is not None:
|
|
316
|
+
time_period_id = tid
|
|
317
|
+
all_time_periods[tid] = tname
|
|
318
|
+
break # first one
|
|
319
|
+
|
|
320
|
+
# Geos
|
|
321
|
+
geo_ids: list[int] = []
|
|
322
|
+
for g in detail.get("geos", []):
|
|
323
|
+
gid = g.get("geo_id")
|
|
324
|
+
gname = g.get("geo_name", "")
|
|
325
|
+
if gid is not None:
|
|
326
|
+
geo_ids.append(gid)
|
|
327
|
+
all_geos[gid] = gname
|
|
328
|
+
|
|
329
|
+
return magnitude_id, time_period_id, sorted(geo_ids)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
# ArchivesCatalog
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class ArchivesCatalog:
|
|
338
|
+
"""Catalog of ESIOS archives backed by ``archives.yaml``."""
|
|
339
|
+
|
|
340
|
+
YAML_FILE = "archives.yaml"
|
|
341
|
+
|
|
342
|
+
def __init__(self, client: ESIOSClient | None = None):
|
|
343
|
+
self._client = client
|
|
344
|
+
|
|
345
|
+
def _load_entries(self) -> list[CatalogEntry]:
|
|
346
|
+
doc = _load_yaml(self.YAML_FILE)
|
|
347
|
+
entries: list[CatalogEntry] = []
|
|
348
|
+
for item in doc.get("archives", []):
|
|
349
|
+
entries.append(CatalogEntry(
|
|
350
|
+
id=item["id"],
|
|
351
|
+
name=item["name"],
|
|
352
|
+
extra={
|
|
353
|
+
"description": item.get("description", ""),
|
|
354
|
+
"horizon": item.get("horizon", ""),
|
|
355
|
+
"archive_type": item.get("archive_type", ""),
|
|
356
|
+
"notes": item.get("notes", ""),
|
|
357
|
+
},
|
|
358
|
+
))
|
|
359
|
+
return entries
|
|
360
|
+
|
|
361
|
+
def list(self, query: str | None = None) -> pd.DataFrame:
|
|
362
|
+
"""List catalog entries, optionally filtering by substring query."""
|
|
363
|
+
entries = self._load_entries()
|
|
364
|
+
rows = [
|
|
365
|
+
{"id": e.id, "name": e.name, "description": e.extra.get("description", ""), "horizon": e.extra.get("horizon", ""), "archive_type": e.extra.get("archive_type", ""), "notes": e.extra.get("notes", "")}
|
|
366
|
+
for e in entries
|
|
367
|
+
]
|
|
368
|
+
df = pd.DataFrame(rows)
|
|
369
|
+
if df.empty:
|
|
370
|
+
return df
|
|
371
|
+
df = df.set_index("id")
|
|
372
|
+
if query:
|
|
373
|
+
mask = df["name"].str.contains(query, case=False, na=False) | df["description"].str.contains(query, case=False, na=False)
|
|
374
|
+
df = df[mask]
|
|
375
|
+
return df
|
|
376
|
+
|
|
377
|
+
def get(self, archive_id: int) -> CatalogEntry:
|
|
378
|
+
"""Get a single catalog entry by ID."""
|
|
379
|
+
for entry in self._load_entries():
|
|
380
|
+
if entry.id == archive_id:
|
|
381
|
+
return entry
|
|
382
|
+
raise KeyError(f"Archive {archive_id} not found in catalog")
|
|
383
|
+
|
|
384
|
+
def refresh(self, *, dry_run: bool = False) -> RefreshResult:
|
|
385
|
+
"""Sync catalog against the live API.
|
|
386
|
+
|
|
387
|
+
Scans archive IDs 1-200 against the API and merges with the
|
|
388
|
+
existing YAML, preserving hand-curated ``notes``.
|
|
389
|
+
"""
|
|
390
|
+
if self._client is None:
|
|
391
|
+
raise RuntimeError("Cannot refresh without a client — pass client to ESIOSCatalog")
|
|
392
|
+
|
|
393
|
+
# Build lookup of existing entries
|
|
394
|
+
doc = _load_yaml(self.YAML_FILE)
|
|
395
|
+
existing = {item["id"]: item for item in doc.get("archives", [])}
|
|
396
|
+
|
|
397
|
+
result = RefreshResult()
|
|
398
|
+
merged: list[dict] = []
|
|
399
|
+
|
|
400
|
+
for i in range(1, 201):
|
|
401
|
+
try:
|
|
402
|
+
data = self._client.get(f"archives/{i}")
|
|
403
|
+
a = data.get("archive", {})
|
|
404
|
+
except Exception:
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
if i in existing:
|
|
408
|
+
old = existing[i]
|
|
409
|
+
new_entry = {
|
|
410
|
+
"id": i,
|
|
411
|
+
"name": a.get("name", old.get("name", "")),
|
|
412
|
+
"description": a.get("description", old.get("description", "")),
|
|
413
|
+
"horizon": a.get("horizon", old.get("horizon", "")),
|
|
414
|
+
"archive_type": a.get("archive_type", old.get("archive_type", "")),
|
|
415
|
+
"notes": old.get("notes", ""),
|
|
416
|
+
}
|
|
417
|
+
if (
|
|
418
|
+
new_entry["name"] != old.get("name")
|
|
419
|
+
or new_entry["description"] != old.get("description")
|
|
420
|
+
or new_entry["horizon"] != old.get("horizon")
|
|
421
|
+
or new_entry["archive_type"] != old.get("archive_type")
|
|
422
|
+
):
|
|
423
|
+
result.updated += 1
|
|
424
|
+
else:
|
|
425
|
+
new_entry = {
|
|
426
|
+
"id": i,
|
|
427
|
+
"name": a.get("name", ""),
|
|
428
|
+
"description": a.get("description", ""),
|
|
429
|
+
"horizon": a.get("horizon", ""),
|
|
430
|
+
"archive_type": a.get("archive_type", ""),
|
|
431
|
+
"notes": "",
|
|
432
|
+
}
|
|
433
|
+
result.added += 1
|
|
434
|
+
|
|
435
|
+
merged.append(new_entry)
|
|
436
|
+
|
|
437
|
+
api_ids = {e["id"] for e in merged}
|
|
438
|
+
for iid in existing:
|
|
439
|
+
if iid not in api_ids:
|
|
440
|
+
result.removed += 1
|
|
441
|
+
|
|
442
|
+
merged.sort(key=lambda x: x["id"])
|
|
443
|
+
|
|
444
|
+
if not dry_run:
|
|
445
|
+
new_doc = {
|
|
446
|
+
"version": doc.get("version", 1),
|
|
447
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
448
|
+
"archives": merged,
|
|
449
|
+
}
|
|
450
|
+
_save_yaml(self.YAML_FILE, new_doc)
|
|
451
|
+
|
|
452
|
+
return result
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
# ESIOSCatalog (umbrella)
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class ESIOSCatalog:
|
|
461
|
+
"""Unified catalog giving access to indicators and archives.
|
|
462
|
+
|
|
463
|
+
Usage::
|
|
464
|
+
|
|
465
|
+
catalog = ESIOSCatalog(client)
|
|
466
|
+
catalog.indicators.list()
|
|
467
|
+
catalog.archives.get(2)
|
|
468
|
+
catalog.indicators.refresh(dry_run=True)
|
|
469
|
+
|
|
470
|
+
Can also be used without a client for read-only access::
|
|
471
|
+
|
|
472
|
+
catalog = ESIOSCatalog()
|
|
473
|
+
catalog.indicators.list()
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
def __init__(self, client: ESIOSClient | None = None):
|
|
477
|
+
self.indicators = IndicatorsCatalog(client)
|
|
478
|
+
self.archives = ArchivesCatalog(client)
|
esios/cli/app.py
CHANGED
|
@@ -34,11 +34,13 @@ from esios.cli.indicators import indicators_app # noqa: E402
|
|
|
34
34
|
from esios.cli.archives import archives_app # noqa: E402
|
|
35
35
|
from esios.cli.cache_cmd import cache_app # noqa: E402
|
|
36
36
|
from esios.cli.config_cmd import config_app # noqa: E402
|
|
37
|
+
from esios.cli.catalog_cmd import catalog_app # noqa: E402
|
|
37
38
|
|
|
38
39
|
app.add_typer(indicators_app, name="indicators", help="Indicator operations")
|
|
39
40
|
app.add_typer(archives_app, name="archives", help="Archive operations")
|
|
40
41
|
app.add_typer(cache_app, name="cache", help="Cache management")
|
|
41
42
|
app.add_typer(config_app, name="config", help="Configuration management")
|
|
43
|
+
app.add_typer(catalog_app, name="catalog", help="Offline catalog of indicators and archives")
|
|
42
44
|
|
|
43
45
|
|
|
44
46
|
def main() -> None:
|
esios/cli/catalog_cmd.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""CLI subcommands for the ESIOS catalog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
catalog_app = typer.Typer(no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
# -- list ------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
list_app = typer.Typer(no_args_is_help=True)
|
|
17
|
+
catalog_app.add_typer(list_app, name="list", help="List catalog entries")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@list_app.command("indicators")
|
|
21
|
+
def list_indicators(
|
|
22
|
+
query: Optional[str] = typer.Argument(None, help="Filter by name (substring)"),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""List indicators in the catalog."""
|
|
25
|
+
from esios.catalog import ESIOSCatalog
|
|
26
|
+
|
|
27
|
+
cat = ESIOSCatalog()
|
|
28
|
+
df = cat.indicators.list(query=query)
|
|
29
|
+
|
|
30
|
+
if df.empty:
|
|
31
|
+
typer.echo("No indicators found.")
|
|
32
|
+
raise typer.Exit()
|
|
33
|
+
|
|
34
|
+
table = Table(title=f"Indicators ({len(df)})")
|
|
35
|
+
table.add_column("ID", style="cyan")
|
|
36
|
+
table.add_column("Name")
|
|
37
|
+
table.add_column("Short Name")
|
|
38
|
+
table.add_column("Tags")
|
|
39
|
+
|
|
40
|
+
for idx, row in df.iterrows():
|
|
41
|
+
table.add_row(str(idx), row["name"], row.get("short_name", ""), row.get("tags", ""))
|
|
42
|
+
|
|
43
|
+
console.print(table)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@list_app.command("archives")
|
|
47
|
+
def list_archives(
|
|
48
|
+
query: Optional[str] = typer.Argument(None, help="Filter by name/description (substring)"),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""List archives in the catalog."""
|
|
51
|
+
from esios.catalog import ESIOSCatalog
|
|
52
|
+
|
|
53
|
+
cat = ESIOSCatalog()
|
|
54
|
+
df = cat.archives.list(query=query)
|
|
55
|
+
|
|
56
|
+
if df.empty:
|
|
57
|
+
typer.echo("No archives found.")
|
|
58
|
+
raise typer.Exit()
|
|
59
|
+
|
|
60
|
+
table = Table(title=f"Archives ({len(df)})")
|
|
61
|
+
table.add_column("ID", style="cyan")
|
|
62
|
+
table.add_column("Name")
|
|
63
|
+
table.add_column("Description", max_width=50)
|
|
64
|
+
table.add_column("Horizon")
|
|
65
|
+
table.add_column("Type")
|
|
66
|
+
|
|
67
|
+
for idx, row in df.iterrows():
|
|
68
|
+
table.add_row(
|
|
69
|
+
str(idx), row["name"], row.get("description", ""),
|
|
70
|
+
row.get("horizon", ""), row.get("archive_type", ""),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
console.print(table)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# -- show ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
show_app = typer.Typer(no_args_is_help=True)
|
|
79
|
+
catalog_app.add_typer(show_app, name="show", help="Show a single catalog entry")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@show_app.command("indicator")
|
|
83
|
+
def show_indicator(
|
|
84
|
+
indicator_id: int = typer.Argument(..., help="Indicator ID"),
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Show details of a single indicator."""
|
|
87
|
+
from esios.catalog import ESIOSCatalog
|
|
88
|
+
|
|
89
|
+
cat = ESIOSCatalog()
|
|
90
|
+
try:
|
|
91
|
+
entry = cat.indicators.get(indicator_id)
|
|
92
|
+
except KeyError:
|
|
93
|
+
typer.echo(f"Indicator {indicator_id} not found in catalog.", err=True)
|
|
94
|
+
raise typer.Exit(1)
|
|
95
|
+
|
|
96
|
+
from esios.catalog import load_reference
|
|
97
|
+
|
|
98
|
+
typer.echo(f"ID: {entry.id}")
|
|
99
|
+
typer.echo(f"Name: {entry.name}")
|
|
100
|
+
typer.echo(f"Short Name: {entry.extra.get('short_name', '')}")
|
|
101
|
+
typer.echo(f"Notes: {entry.extra.get('notes', '')}")
|
|
102
|
+
typer.echo(f"Tags: {', '.join(entry.extra.get('tags', []))}")
|
|
103
|
+
|
|
104
|
+
# Resolve magnitude → units
|
|
105
|
+
mag_id = entry.extra.get("magnitude_id")
|
|
106
|
+
if mag_id is not None:
|
|
107
|
+
magnitudes = load_reference("magnitudes")
|
|
108
|
+
typer.echo(f"Units: {magnitudes.get(mag_id, f'(magnitude {mag_id})')}")
|
|
109
|
+
|
|
110
|
+
# Resolve time_period → granularity
|
|
111
|
+
tp_id = entry.extra.get("time_period_id")
|
|
112
|
+
if tp_id is not None:
|
|
113
|
+
time_periods = load_reference("time_periods")
|
|
114
|
+
typer.echo(f"Granularity: {time_periods.get(tp_id, f'(time_period {tp_id})')}")
|
|
115
|
+
|
|
116
|
+
# Resolve geo_ids → geo names
|
|
117
|
+
geo_ids = entry.extra.get("geo_ids", [])
|
|
118
|
+
if geo_ids:
|
|
119
|
+
geos = load_reference("geos")
|
|
120
|
+
typer.echo("Geos:")
|
|
121
|
+
for gid in geo_ids:
|
|
122
|
+
typer.echo(f" {gid}: {geos.get(gid, '?')}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@show_app.command("archive")
|
|
126
|
+
def show_archive(
|
|
127
|
+
archive_id: int = typer.Argument(..., help="Archive ID"),
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Show details of a single archive."""
|
|
130
|
+
from esios.catalog import ESIOSCatalog
|
|
131
|
+
|
|
132
|
+
cat = ESIOSCatalog()
|
|
133
|
+
try:
|
|
134
|
+
entry = cat.archives.get(archive_id)
|
|
135
|
+
except KeyError:
|
|
136
|
+
typer.echo(f"Archive {archive_id} not found in catalog.", err=True)
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
|
|
139
|
+
typer.echo(f"ID: {entry.id}")
|
|
140
|
+
typer.echo(f"Name: {entry.name}")
|
|
141
|
+
typer.echo(f"Description: {entry.extra.get('description', '')}")
|
|
142
|
+
typer.echo(f"Horizon: {entry.extra.get('horizon', '')}")
|
|
143
|
+
typer.echo(f"Type: {entry.extra.get('archive_type', '')}")
|
|
144
|
+
typer.echo(f"Notes: {entry.extra.get('notes', '')}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# -- refresh ---------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@catalog_app.command("refresh")
|
|
151
|
+
def refresh(
|
|
152
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
|
|
153
|
+
token: Optional[str] = typer.Option(None, "--token", "-t", envvar="ESIOS_API_KEY", help="API token"),
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Refresh catalog from the live ESIOS API."""
|
|
156
|
+
from esios.cli.app import get_client
|
|
157
|
+
|
|
158
|
+
client = get_client(token)
|
|
159
|
+
|
|
160
|
+
from esios.catalog import ESIOSCatalog
|
|
161
|
+
|
|
162
|
+
cat = ESIOSCatalog(client)
|
|
163
|
+
|
|
164
|
+
typer.echo("Refreshing indicators catalog...")
|
|
165
|
+
ind_result = cat.indicators.refresh(dry_run=dry_run)
|
|
166
|
+
typer.echo(f" Indicators: +{ind_result.added} added, ~{ind_result.updated} updated, -{ind_result.removed} removed")
|
|
167
|
+
|
|
168
|
+
typer.echo("Refreshing archives catalog...")
|
|
169
|
+
arc_result = cat.archives.refresh(dry_run=dry_run)
|
|
170
|
+
typer.echo(f" Archives: +{arc_result.added} added, ~{arc_result.updated} updated, -{arc_result.removed} removed")
|
|
171
|
+
|
|
172
|
+
if dry_run:
|
|
173
|
+
typer.echo("\n(dry run — no files were modified)")
|
|
174
|
+
else:
|
|
175
|
+
typer.echo("\nCatalog updated.")
|
|
176
|
+
|
|
177
|
+
client.close()
|
esios/client.py
CHANGED
|
@@ -100,6 +100,11 @@ class ESIOSClient:
|
|
|
100
100
|
self.archives = ArchivesManager(self)
|
|
101
101
|
self.offer_indicators = OfferIndicatorsManager(self)
|
|
102
102
|
|
|
103
|
+
# Catalog (offline YAML-based)
|
|
104
|
+
from esios.catalog import ESIOSCatalog
|
|
105
|
+
|
|
106
|
+
self.catalog = ESIOSCatalog(self)
|
|
107
|
+
|
|
103
108
|
# -- HTTP primitives -------------------------------------------------------
|
|
104
109
|
|
|
105
110
|
@retry(
|