perudata 0.1.1__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.
- perudata/__init__.py +19 -0
- perudata/_core.py +220 -0
- perudata/catalogs/eea_catalog.csv +605 -0
- perudata/catalogs/epen_catalog.csv +280 -0
- perudata/cli.py +79 -0
- perudata/eea.py +137 -0
- perudata/enaho.py +213 -0
- perudata/endes.py +176 -0
- perudata/epen.py +136 -0
- perudata/panel.py +261 -0
- perudata/validate.py +80 -0
- perudata-0.1.1.dist-info/METADATA +170 -0
- perudata-0.1.1.dist-info/RECORD +16 -0
- perudata-0.1.1.dist-info/WHEEL +4 -0
- perudata-0.1.1.dist-info/entry_points.txt +2 -0
- perudata-0.1.1.dist-info/licenses/LICENSE +21 -0
perudata/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
perudata — Peru's official INEI microdata, one import away.
|
|
3
|
+
|
|
4
|
+
from perudata import enaho, endes, epen, eea, panel, validate
|
|
5
|
+
|
|
6
|
+
df = enaho.load(2024, "34") # ENAHO sumaria: income, spending, poverty
|
|
7
|
+
validate.poverty(years=[2024]) # reproduces INEI's official 27.6%
|
|
8
|
+
|
|
9
|
+
Five surveys, one consistent API (download / load / catalog), every file
|
|
10
|
+
verified on download by opening it, and a validation gate that reproduces the
|
|
11
|
+
official poverty series to 0.0pp before you build anything on top.
|
|
12
|
+
|
|
13
|
+
Data lands in ./peru_raw by default — override per call with out= or globally
|
|
14
|
+
with the PERUDATA_DIR environment variable.
|
|
15
|
+
"""
|
|
16
|
+
from . import eea, enaho, endes, epen, panel, validate # noqa: F401
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.1"
|
|
19
|
+
__all__ = ["enaho", "endes", "epen", "eea", "panel", "validate"]
|
perudata/_core.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared plumbing for all perudata survey modules: HTTP with retries, zip
|
|
3
|
+
extraction, robust Stata/SPSS readers, data directory resolution and
|
|
4
|
+
per-survey download manifests.
|
|
5
|
+
|
|
6
|
+
All INEI microdata lives on one host, served as numbered "proyecto" zips:
|
|
7
|
+
|
|
8
|
+
https://proyectos.inei.gob.pe/iinei/srienaho/descarga/{FORMAT}/{CODE}-Modulo{NN}.zip
|
|
9
|
+
|
|
10
|
+
Every survey module in this package resolves (year, module) -> CODE and
|
|
11
|
+
delegates the transport to the helpers here.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import csv
|
|
16
|
+
import io
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import time
|
|
21
|
+
import zipfile
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from urllib.error import HTTPError, URLError
|
|
25
|
+
from urllib.request import Request, urlopen
|
|
26
|
+
|
|
27
|
+
BASE = "https://proyectos.inei.gob.pe/iinei/srienaho/descarga"
|
|
28
|
+
UA = "Mozilla/5.0 (perudata; https://github.com/cesarchavezp29/perudata)"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --------------------------------------------------------------------------- #
|
|
32
|
+
# data directory
|
|
33
|
+
# --------------------------------------------------------------------------- #
|
|
34
|
+
def data_dir(out: str | Path | None = None) -> Path:
|
|
35
|
+
"""Resolve the root folder for raw downloads.
|
|
36
|
+
|
|
37
|
+
Priority: explicit `out` argument > PERUDATA_DIR env var > ./peru_raw
|
|
38
|
+
"""
|
|
39
|
+
root = Path(out) if out else Path(os.environ.get("PERUDATA_DIR", "peru_raw"))
|
|
40
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
return root
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
# HTTP
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
# INEI's TLS chain fails default verification from some networks (observed on
|
|
48
|
+
# GitHub-hosted runners; the same host works fine with relaxed verification).
|
|
49
|
+
# First attempt verifies normally, later attempts fall back to an unverified
|
|
50
|
+
# context -- we only READ public zips from this host, integrity is checked by
|
|
51
|
+
# opening every file after download.
|
|
52
|
+
def _relaxed_ctx():
|
|
53
|
+
import ssl
|
|
54
|
+
ctx = ssl.create_default_context()
|
|
55
|
+
ctx.check_hostname = False
|
|
56
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
57
|
+
return ctx
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get(url: str, timeout: int = 300, tries: int = 5) -> bytes | None:
|
|
61
|
+
"""GET with backoff on throttling, no retry on a genuine 404."""
|
|
62
|
+
for i in range(tries):
|
|
63
|
+
try:
|
|
64
|
+
ctx = None if i == 0 else _relaxed_ctx()
|
|
65
|
+
with urlopen(Request(url, headers={"User-Agent": UA}),
|
|
66
|
+
timeout=timeout, context=ctx) as r:
|
|
67
|
+
if r.status == 200:
|
|
68
|
+
return r.read()
|
|
69
|
+
except HTTPError as e:
|
|
70
|
+
if e.code == 404:
|
|
71
|
+
return None
|
|
72
|
+
except (URLError, TimeoutError, OSError):
|
|
73
|
+
pass
|
|
74
|
+
time.sleep(5 * (i + 1))
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def head_ok(url: str, timeout: int = 15) -> bool:
|
|
79
|
+
try:
|
|
80
|
+
req = Request(url, headers={"User-Agent": UA}, method="HEAD")
|
|
81
|
+
with urlopen(req, timeout=timeout) as r:
|
|
82
|
+
return r.status == 200
|
|
83
|
+
except (HTTPError, URLError, TimeoutError, OSError):
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --------------------------------------------------------------------------- #
|
|
88
|
+
# readers (version-proof)
|
|
89
|
+
# --------------------------------------------------------------------------- #
|
|
90
|
+
def read_dta(path: str | Path, columns: list[str] | None = None):
|
|
91
|
+
"""Read a Stata file of ANY vintage.
|
|
92
|
+
|
|
93
|
+
pandas rejects Stata 7 (format 110) files, which INEI still ships for
|
|
94
|
+
ENAHO 2004-2011. pyreadstat handles those, so we fall back to it.
|
|
95
|
+
Value labels are NOT applied (you get the raw codes, as INEI documents them).
|
|
96
|
+
"""
|
|
97
|
+
import pandas as pd
|
|
98
|
+
try:
|
|
99
|
+
return pd.read_stata(path, convert_categoricals=False, columns=columns)
|
|
100
|
+
except ValueError:
|
|
101
|
+
import pyreadstat
|
|
102
|
+
df, _ = pyreadstat.read_dta(str(path), usecols=columns,
|
|
103
|
+
disable_datetime_conversion=True)
|
|
104
|
+
return df
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_sav(path: str | Path, columns: list[str] | None = None):
|
|
108
|
+
"""Read an SPSS .sav file (ENDES ships SPSS for every year)."""
|
|
109
|
+
import pyreadstat
|
|
110
|
+
df, _ = pyreadstat.read_sav(str(path), usecols=columns)
|
|
111
|
+
return df
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def verify_dta(path: Path) -> tuple[bool, int, int]:
|
|
115
|
+
"""Open the .dta and return (ok, n_rows, n_cols) without trusting byte size."""
|
|
116
|
+
try:
|
|
117
|
+
df = read_dta(path)
|
|
118
|
+
return (len(df) > 0 and df.shape[1] > 0), int(len(df)), int(df.shape[1])
|
|
119
|
+
except Exception:
|
|
120
|
+
return False, 0, 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def verify_sav(path: Path) -> tuple[bool, int, int]:
|
|
124
|
+
try:
|
|
125
|
+
import pyreadstat
|
|
126
|
+
_, m = pyreadstat.read_sav(str(path), metadataonly=True)
|
|
127
|
+
return m.number_rows > 0, m.number_rows, m.number_columns
|
|
128
|
+
except Exception:
|
|
129
|
+
return False, 0, 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def csv_shape(path: Path) -> tuple[int, int]:
|
|
133
|
+
"""Cheap (rows, cols) for a CSV without loading it into memory."""
|
|
134
|
+
try:
|
|
135
|
+
with open(path, "r", encoding="latin-1", errors="replace", newline="") as f:
|
|
136
|
+
rd = csv.reader(f)
|
|
137
|
+
header = next(rd, [])
|
|
138
|
+
return sum(1 for _ in rd), len(header)
|
|
139
|
+
except Exception:
|
|
140
|
+
return 0, 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --------------------------------------------------------------------------- #
|
|
144
|
+
# zip handling
|
|
145
|
+
# --------------------------------------------------------------------------- #
|
|
146
|
+
def open_zip(blob: bytes) -> zipfile.ZipFile | None:
|
|
147
|
+
try:
|
|
148
|
+
zf = zipfile.ZipFile(io.BytesIO(blob))
|
|
149
|
+
if zf.testzip() is not None:
|
|
150
|
+
return None
|
|
151
|
+
return zf
|
|
152
|
+
except zipfile.BadZipFile:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def extract_members(zf: zipfile.ZipFile, dest: Path, suffixes: tuple[str, ...]) -> list[Path]:
|
|
157
|
+
"""Extract only members with the given suffixes (INEI zips carry PDFs with
|
|
158
|
+
non-ASCII names that can break a blanket extractall)."""
|
|
159
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
out = []
|
|
161
|
+
for m in zf.namelist():
|
|
162
|
+
if m.lower().endswith(suffixes):
|
|
163
|
+
try:
|
|
164
|
+
zf.extract(m, dest)
|
|
165
|
+
out.append(dest / m)
|
|
166
|
+
except Exception:
|
|
167
|
+
continue
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def pick_main_dta(paths: list[Path]) -> Path | None:
|
|
172
|
+
"""Pick the canonical .dta among alternates.
|
|
173
|
+
|
|
174
|
+
Sumaria zips ship '-12'/'-12g' groupings that LACK analysis variables
|
|
175
|
+
(e.g. pobreza). Prefer the canonical file, then the largest.
|
|
176
|
+
"""
|
|
177
|
+
dtas = [p for p in paths if p.suffix.lower() == ".dta"]
|
|
178
|
+
if not dtas:
|
|
179
|
+
return None
|
|
180
|
+
exact = [p for p in dtas if re.fullmatch(r"sumaria-\d{4}\.dta", p.name.lower())]
|
|
181
|
+
if exact:
|
|
182
|
+
return max(exact, key=lambda p: p.stat().st_size)
|
|
183
|
+
pool = [p for p in dtas if not re.search(r"-12g?\.dta$", p.name.lower())] or dtas
|
|
184
|
+
return max(pool, key=lambda p: p.stat().st_size)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --------------------------------------------------------------------------- #
|
|
188
|
+
# manifest
|
|
189
|
+
# --------------------------------------------------------------------------- #
|
|
190
|
+
MANIFEST_COLS = ["survey", "year", "module", "code", "file", "n_rows", "n_cols",
|
|
191
|
+
"bytes", "downloaded_utc"]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def manifest_append(root: Path, row: dict) -> None:
|
|
195
|
+
"""Append one download record to <root>/_manifest.csv (idempotent per file)."""
|
|
196
|
+
mpath = root / "_manifest.csv"
|
|
197
|
+
rows: dict[str, dict] = {}
|
|
198
|
+
if mpath.exists():
|
|
199
|
+
with open(mpath, newline="", encoding="utf-8") as f:
|
|
200
|
+
for r in csv.DictReader(f):
|
|
201
|
+
rows[r["file"]] = r
|
|
202
|
+
row = {c: str(row.get(c, "")) for c in MANIFEST_COLS}
|
|
203
|
+
row["downloaded_utc"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
204
|
+
rows[row["file"]] = row
|
|
205
|
+
with open(mpath, "w", newline="", encoding="utf-8") as f:
|
|
206
|
+
w = csv.DictWriter(f, fieldnames=MANIFEST_COLS)
|
|
207
|
+
w.writeheader()
|
|
208
|
+
for _, r in sorted(rows.items()):
|
|
209
|
+
w.writerow(r)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def rmtree(path: Path) -> None:
|
|
213
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def clean_columns(df):
|
|
217
|
+
"""Lower-case column names and strip BOM artifacts (, mojibake)."""
|
|
218
|
+
df.columns = [c.lower().replace("", "").replace("", "").strip()
|
|
219
|
+
for c in df.columns]
|
|
220
|
+
return df
|