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 +15 -0
- andes/assets/andes_isotipo_claro.png +0 -0
- andes/assets/andes_isotipo_oscuro.png +0 -0
- andes/assets/andes_logo_claro.png +0 -0
- andes/assets/andes_logo_oscuro.png +0 -0
- andes/assets/icon.png +0 -0
- andes/cli/__init__.py +1 -0
- andes/cli/main.py +166 -0
- andes/core/__init__.py +27 -0
- andes/core/adapters/__init__.py +33 -0
- andes/core/adapters/base.py +23 -0
- andes/core/adapters/bcrp.py +24 -0
- andes/core/adapters/gobpe.py +185 -0
- andes/core/adapters/inei.py +237 -0
- andes/core/adapters/inei_tematico.py +356 -0
- andes/core/adapters/mef.py +407 -0
- andes/core/api.py +248 -0
- andes/core/branding.py +166 -0
- andes/core/catalog.py +594 -0
- andes/core/fetcher.py +287 -0
- andes/core/gobpe.py +253 -0
- andes/core/http.py +72 -0
- andes/core/mef_stats.py +233 -0
- andes/core/metadata.py +264 -0
- andes/core/paths.py +20 -0
- andes/core/portals.py +170 -0
- andes/core/sources.py +149 -0
- andes/gui/__init__.py +5 -0
- andes/gui/theme.py +104 -0
- andes/gui/views.py +2718 -0
- andes/mcp/__init__.py +1 -0
- andes/mcp/server.py +128 -0
- andes/runtime.py +86 -0
- andes_open_data-2.0.0.dist-info/METADATA +405 -0
- andes_open_data-2.0.0.dist-info/RECORD +40 -0
- andes_open_data-2.0.0.dist-info/WHEEL +5 -0
- andes_open_data-2.0.0.dist-info/entry_points.txt +2 -0
- andes_open_data-2.0.0.dist-info/licenses/LICENSE +674 -0
- andes_open_data-2.0.0.dist-info/top_level.txt +2 -0
- app.py +28 -0
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
|
+
}
|