andes-open-data 2.0.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.
andes/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """ANDES — Open Data Platform.
2
+
3
+ Plataforma de acceso, integración, exploración y consulta de estadísticas y
4
+ datos abiertos. El paquete conserva el nombre histórico ``andes``
5
+ por compatibilidad de importación; la identidad pública es ANDES.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ APP_NAME = "ANDES"
11
+ APP_TAGLINE = "Open Data Platform"
12
+ APP_VERSION = "2.0.0"
13
+ __version__ = APP_VERSION
14
+
15
+ __all__ = ["APP_NAME", "APP_TAGLINE", "APP_VERSION", "__version__"]
Binary file
Binary file
Binary file
Binary file
andes/assets/icon.png ADDED
Binary file
andes/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Command line interface."""
andes/cli/main.py ADDED
@@ -0,0 +1,166 @@
1
+ """Command line interface built with Typer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from .. import APP_NAME, APP_VERSION
11
+ from ..core.api import (
12
+ EXPORT_FORMATS,
13
+ download_many,
14
+ download_series_data,
15
+ export_series,
16
+ export_table,
17
+ )
18
+ from ..core.metadata import KNOWN_CODE_COL, KNOWN_NAME_COL, filter_metadata, load_metadata
19
+
20
+ app = typer.Typer(
21
+ add_completion=False,
22
+ no_args_is_help=True,
23
+ help=f"{APP_NAME} {APP_VERSION}: estadísticas y datos abiertos.",
24
+ )
25
+
26
+
27
+ def _filtered(query: str, frequency: str | None, category: str | None):
28
+ df = load_metadata()
29
+ mask = (
30
+ df[KNOWN_CODE_COL].str.contains(query.lower(), regex=False)
31
+ | df[KNOWN_NAME_COL].str.contains(query.lower(), regex=False)
32
+ )
33
+ df = df[mask]
34
+ filters = {}
35
+ if frequency:
36
+ filters["frecuencia"] = frequency
37
+ if category:
38
+ filters["categoria"] = category
39
+ if filters:
40
+ df = filter_metadata(df, filters)
41
+ return df
42
+
43
+
44
+ @app.command()
45
+ def search(
46
+ query: str = typer.Argument(..., help="Texto a buscar en código o nombre."),
47
+ frequency: str | None = typer.Option(None, "--frequency", "-f", help="Diaria, Mensual, Trimestral o Anual."),
48
+ category: str | None = typer.Option(None, "--category", "-c", help="Categoría exacta."),
49
+ limit: int = typer.Option(20, "--limit", "-n", help="Máximo de resultados."),
50
+ ) -> None:
51
+ """Search series by code or name."""
52
+ df = _filtered(query, frequency, category)
53
+ if df.empty:
54
+ typer.echo("Sin resultados.")
55
+ raise typer.Exit(code=1)
56
+ for _, row in df.head(limit).iterrows():
57
+ typer.echo(f"{row[KNOWN_CODE_COL].upper():<14} {row[KNOWN_NAME_COL][:90]}")
58
+ total = len(df)
59
+ if total > limit:
60
+ typer.echo(f"... {total - limit} más ({total} en total)")
61
+
62
+
63
+ @app.command()
64
+ def download(
65
+ series_code: str = typer.Argument(..., help="Código de la serie, p. ej. PN00001MM."),
66
+ start: str = typer.Option(..., "--start", "-s", help="Periodo inicial (ej. 2020-1)."),
67
+ end: str = typer.Option(..., "--end", "-e", help="Periodo final (ej. 2024-12)."),
68
+ out: Path | None = typer.Option(None, "--out", "-o", help="Ruta de salida."),
69
+ fmt: str = typer.Option("csv", "--format", help="csv|json|xls|xml|txt|html."),
70
+ ) -> None:
71
+ """Download one series to a file."""
72
+ fmt = fmt.lower()
73
+ if fmt not in EXPORT_FORMATS.values():
74
+ typer.echo(f"Formato no soportado: {fmt}", err=True)
75
+ raise typer.Exit(code=2)
76
+ df = download_series_data(series_code, start, end)
77
+ if df.empty:
78
+ typer.echo("No hay datos para el rango consultado.", err=True)
79
+ raise typer.Exit(code=1)
80
+ out_dir = str(out.parent) if out else None
81
+ generated = export_series(df, series_code, start, end, fmt, out_dir=out_dir)
82
+ if out and Path(generated) != out:
83
+ shutil.move(generated, out)
84
+ generated = str(out)
85
+ typer.echo(f"Guardado: {generated} ({len(df)} periodos)")
86
+
87
+
88
+ @app.command("download-many")
89
+ def download_many_cmd(
90
+ codes: list[str] = typer.Argument(..., help="Dos o más códigos de serie."),
91
+ start: str = typer.Option(..., "--start", "-s", help="Periodo inicial (ej. 2024-1)."),
92
+ end: str = typer.Option(..., "--end", "-e", help="Periodo final (ej. 2024-12)."),
93
+ frequency: str | None = typer.Option(
94
+ None, "--frequency", "-f", help="Diaria, Mensual, Trimestral o Anual (se deduce si se omite)."
95
+ ),
96
+ out: Path | None = typer.Option(None, "--out", "-o", help="Ruta de salida (solo sin --split)."),
97
+ fmt: str = typer.Option("csv", "--format", help="csv|json|xml|txt|html."),
98
+ split: bool = typer.Option(False, "--split", help="Un archivo por serie."),
99
+ ) -> None:
100
+ """Download several series in batches (up to 10 per request)."""
101
+ fmt = fmt.lower()
102
+ if fmt not in EXPORT_FORMATS.values() or fmt == "xls":
103
+ typer.echo(f"Formato no soportado para lotes: {fmt}", err=True)
104
+ raise typer.Exit(code=2)
105
+ frame = download_many(codes, start, end, frequency)
106
+ if frame.empty:
107
+ typer.echo("No hay datos para el rango consultado.", err=True)
108
+ raise typer.Exit(code=1)
109
+
110
+ out_dir = str(out.parent) if out else None
111
+ if split:
112
+ generated = []
113
+ for code in [column for column in frame.columns if column != "fecha"]:
114
+ subset = frame[["fecha", code]].dropna(subset=[code])
115
+ if subset.empty:
116
+ continue
117
+ generated.append(
118
+ export_table(
119
+ subset,
120
+ f"bcrp_serie_{code}_{start}_to_{end}",
121
+ fmt,
122
+ out_dir=out_dir,
123
+ )
124
+ )
125
+ typer.echo(f"Guardados: {len(generated)} archivos ({len(frame)} periodos)")
126
+ return
127
+
128
+ path = export_table(frame, f"bcrp_lote_{start}_to_{end}", fmt, out_dir=out_dir)
129
+ if out and Path(path) != out:
130
+ shutil.move(path, out)
131
+ path = str(out)
132
+ typer.echo(f"Guardado: {path} ({len(frame)} periodos, {frame.shape[1] - 1} series)")
133
+
134
+
135
+ @app.command()
136
+ def gui(
137
+ web: bool = typer.Option(False, "--web", help="Abrir en el navegador."),
138
+ port: int = typer.Option(8550, "--port", help="Puerto para el modo web."),
139
+ ) -> None:
140
+ """Launch the Flet desktop interface."""
141
+ import flet as ft
142
+
143
+ from ..core.paths import ASSETS_DIR
144
+ from ..gui.views import main as gui_main
145
+
146
+ if web:
147
+ ft.run(
148
+ gui_main,
149
+ port=port,
150
+ view=ft.AppView.WEB_BROWSER,
151
+ assets_dir=ASSETS_DIR,
152
+ )
153
+ else:
154
+ ft.run(gui_main, assets_dir=ASSETS_DIR)
155
+
156
+
157
+ @app.command()
158
+ def mcp() -> None:
159
+ """Start the MCP server over stdio."""
160
+ from ..mcp.server import run
161
+
162
+ run()
163
+
164
+
165
+ if __name__ == "__main__":
166
+ app()
andes/core/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """Core backend: metadata catalogue, HTTP client and exporters."""
2
+
3
+ from .api import (
4
+ download_series_data,
5
+ download_series_file,
6
+ export_series,
7
+ export_table,
8
+ find_pdf_url,
9
+ periods_to_dataframe,
10
+ )
11
+ from .metadata import filter_metadata, load_metadata
12
+ from .paths import DATA_DIR, METADATA_PATH, METADATA_URL, OUTPUT_DIR
13
+
14
+ __all__ = [
15
+ "DATA_DIR",
16
+ "METADATA_PATH",
17
+ "METADATA_URL",
18
+ "OUTPUT_DIR",
19
+ "download_series_data",
20
+ "download_series_file",
21
+ "export_series",
22
+ "export_table",
23
+ "filter_metadata",
24
+ "find_pdf_url",
25
+ "load_metadata",
26
+ "periods_to_dataframe",
27
+ ]
@@ -0,0 +1,33 @@
1
+ """Registro de adaptadores nativos por institución."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .bcrp import BcrpAdapter
6
+ from .gobpe import GobpeAdapter
7
+ from .inei import IneiAdapter
8
+ from .inei_tematico import IneiTematicoAdapter
9
+ from .mef import MefAdapter
10
+
11
+ _ADAPTERS = {
12
+ "bcrp": BcrpAdapter(),
13
+ "mef": MefAdapter(),
14
+ "inei": IneiAdapter(),
15
+ "inei_tematico": IneiTematicoAdapter(),
16
+ "gobpe": GobpeAdapter(),
17
+ }
18
+
19
+
20
+ def get_adapter(source_id: str):
21
+ """Devuelve el adaptador de una institución registrada."""
22
+ key = source_id.strip().lower()
23
+ if key.startswith("gobpe:"):
24
+ key = "gobpe"
25
+ try:
26
+ return _ADAPTERS[key]
27
+ except (KeyError, AttributeError) as exc:
28
+ raise ValueError(f"Sin adaptador para: {source_id}") from exc
29
+
30
+
31
+ def registered() -> list[str]:
32
+ """Ids de portales con adaptador."""
33
+ return sorted(_ADAPTERS)
@@ -0,0 +1,23 @@
1
+ """Contrato común de adaptadores de portal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+ import pandas as pd
8
+
9
+ from ..portals import PortalSpec
10
+
11
+
12
+ class PortalAdapter(Protocol):
13
+ """Interfaz nativa que implementa cada portal."""
14
+
15
+ spec: PortalSpec
16
+
17
+ def search(self, query: str, *, filters: dict | None = None,
18
+ limit: int | None = 100, offset: int = 0) -> pd.DataFrame:
19
+ """Busca series en el catálogo local."""
20
+
21
+ def fetch(self, codes: list[str], start: str, end: str,
22
+ frequency: str | None = None) -> pd.DataFrame:
23
+ """Descarga series; debe hablar directo con el portal."""
@@ -0,0 +1,24 @@
1
+ """Adaptador nativo del BCRP (catálogo SQLite + API JSON)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+
7
+ from .. import api, metadata
8
+ from ..portals import spec_for
9
+
10
+
11
+ class BcrpAdapter:
12
+ spec = spec_for("bcrp")
13
+
14
+ def search(self, query: str, *, filters: dict | None = None,
15
+ limit: int | None = 100, offset: int = 0) -> pd.DataFrame:
16
+ scoped = dict(filters or {})
17
+ scoped["institucion"] = "bcrp"
18
+ return metadata.search_catalog(
19
+ query, filters=scoped, limit=limit, offset=offset
20
+ )
21
+
22
+ def fetch(self, codes: list[str], start: str, end: str,
23
+ frequency: str | None = None) -> pd.DataFrame:
24
+ return api.download_many(codes, start, end, frequency)
@@ -0,0 +1,185 @@
1
+ """Adaptador de portales estadísticos de Gob.pe.
2
+
3
+ Indexa el registro descubierto por ``core.gobpe`` como un catálogo de portales.
4
+ Solo los destinos tabulares (CSV, Excel, API/JSON) se pueden descargar; los
5
+ tableros y páginas web se ofrecen como enlace externo.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import time
14
+
15
+ import pandas as pd
16
+ import requests
17
+
18
+ from .. import catalog, gobpe
19
+ from ..paths import DATA_DIR
20
+
21
+ CACHE_DIR = os.path.join(DATA_DIR, "gobpe", "files")
22
+ INDEX_STAMP = os.path.join(DATA_DIR, "gobpe", "indexed.json")
23
+ HEADERS = {"User-Agent": "Mozilla/5.0 (ANDES/2.0)"}
24
+ _FILE_TTL = 7 * 86400
25
+
26
+
27
+ class GobpeAdapter:
28
+ """Portales publicados por las entidades del Estado peruano."""
29
+
30
+ institution = "gobpe"
31
+
32
+ def __init__(self) -> None:
33
+ self._spec = None
34
+
35
+ # ------------------------------------------------------------------ #
36
+ @staticmethod
37
+ def _digest(portals: list[dict]) -> str:
38
+ payload = json.dumps(
39
+ [[portal.get("id"), portal.get("institution_id")] for portal in portals],
40
+ sort_keys=True,
41
+ )
42
+ return hashlib.sha1(payload.encode()).hexdigest()
43
+
44
+ def _ensure_indexed(self, force: bool = False) -> int:
45
+ portals = gobpe.load_registry()
46
+ if not portals:
47
+ return 0
48
+ digest = self._digest(portals)
49
+ stamped = ""
50
+ if os.path.exists(INDEX_STAMP):
51
+ try:
52
+ with open(INDEX_STAMP, encoding="utf-8") as handle:
53
+ stamped = json.load(handle).get("digest", "")
54
+ except (ValueError, OSError):
55
+ stamped = ""
56
+ if (
57
+ not force
58
+ and stamped == digest
59
+ and catalog.count_prefix("GOBPE-") == len(portals)
60
+ ):
61
+ return 0
62
+ catalog.clear_prefix("GOBPE-")
63
+ rows: list[dict] = []
64
+ for portal in portals:
65
+ rows.append(
66
+ {
67
+ "institution": f"gobpe:{portal['institution_id']}",
68
+ "code": portal["id"],
69
+ "category": portal.get("platform", "web"),
70
+ "group_name": portal.get("kind", "") or "Portal",
71
+ "name": portal.get("name", ""),
72
+ "description": portal.get("url", ""),
73
+ "unit": "",
74
+ "source": "Gob.pe",
75
+ "frequency": "Portal",
76
+ "pub_group": portal.get("scope", ""),
77
+ "area": portal.get("institution", ""),
78
+ "start": "",
79
+ "end": "",
80
+ "memo": json.dumps(
81
+ {
82
+ "kind": "gobpe",
83
+ "url": portal.get("url", ""),
84
+ "platform": portal.get("platform", "web"),
85
+ },
86
+ ensure_ascii=False,
87
+ ),
88
+ }
89
+ )
90
+ written = catalog.upsert(rows)
91
+ os.makedirs(os.path.dirname(INDEX_STAMP), exist_ok=True)
92
+ with open(INDEX_STAMP, "w", encoding="utf-8") as handle:
93
+ json.dump({"digest": digest, "count": len(rows)}, handle)
94
+ return written
95
+
96
+ # ------------------------------------------------------------------ #
97
+ def search(
98
+ self,
99
+ query: str,
100
+ *,
101
+ filters: dict | None = None,
102
+ limit: int | None = 300,
103
+ ) -> pd.DataFrame:
104
+ from .. import metadata
105
+
106
+ self._ensure_indexed()
107
+ scoped = dict(filters or {})
108
+ return metadata.search_catalog(query, filters=scoped, limit=limit)
109
+
110
+ def _download(self, url: str) -> tuple[str, str]:
111
+ os.makedirs(CACHE_DIR, exist_ok=True)
112
+ digest = hashlib.sha1(url.encode()).hexdigest()[:16]
113
+ extension = gobpe.classify(url)
114
+ suffix = {"csv": "csv", "excel": "xlsx", "api": "json", "ckan": "json"}.get(
115
+ extension, "bin"
116
+ )
117
+ path = os.path.join(CACHE_DIR, f"{digest}.{suffix}")
118
+ if os.path.exists(path) and time.time() - os.path.getmtime(path) < _FILE_TTL:
119
+ return path, suffix
120
+ response = requests.get(url, headers=HEADERS, timeout=180)
121
+ response.raise_for_status()
122
+ with open(path, "wb") as handle:
123
+ handle.write(response.content)
124
+ return path, suffix
125
+
126
+ @staticmethod
127
+ def _parse(path: str, suffix: str) -> pd.DataFrame:
128
+ from .inei_tematico import IneiTematicoAdapter
129
+
130
+ if suffix in ("csv", "xlsx"):
131
+ return IneiTematicoAdapter._table(path, suffix)
132
+ with open(path, encoding="utf-8") as handle:
133
+ payload = json.load(handle)
134
+ records = payload
135
+ if isinstance(payload, dict):
136
+ for key in ("records", "results", "data", "datos"):
137
+ if isinstance(payload.get(key), list):
138
+ records = payload[key]
139
+ break
140
+ if isinstance(records, list) and records:
141
+ return pd.DataFrame(records)
142
+ return pd.DataFrame()
143
+
144
+ def fetch(
145
+ self,
146
+ codes: list[str],
147
+ start: str = "archivo",
148
+ end: str = "unico",
149
+ frequency: str | None = None,
150
+ ) -> pd.DataFrame:
151
+ frames: list[pd.DataFrame] = []
152
+ for code in codes:
153
+ row = catalog.get(code)
154
+ if not row or not row.get("memo"):
155
+ continue
156
+ try:
157
+ memo = json.loads(row["memo"])
158
+ except (TypeError, ValueError):
159
+ continue
160
+ if memo.get("platform") not in gobpe.TABULAR:
161
+ continue
162
+ try:
163
+ path, suffix = self._download(memo["url"])
164
+ frame = self._parse(path, suffix)
165
+ except Exception:
166
+ continue
167
+ if frame.empty:
168
+ continue
169
+ frames.append(frame)
170
+ if not frames:
171
+ return pd.DataFrame()
172
+ if len(frames) == 1:
173
+ return frames[0]
174
+ return pd.concat(frames, ignore_index=True)
175
+
176
+ def info(self, code: str) -> dict:
177
+ row = catalog.get(code) or {}
178
+ return {
179
+ "institution": "gobpe",
180
+ "code": code,
181
+ "source": "Gob.pe",
182
+ "name": row.get("name", ""),
183
+ "url": row.get("description", ""),
184
+ "updated": time.strftime("%Y-%m-%d"),
185
+ }