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 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