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/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:
@@ -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(