misocialpy 0.4.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.
- misocialpy/__init__.py +66 -0
- misocialpy/_cache.py +83 -0
- misocialpy/_client.py +162 -0
- misocialpy/_format.py +91 -0
- misocialpy/_msg.py +31 -0
- misocialpy/_response.py +170 -0
- misocialpy/data/dictionary.csv +133 -0
- misocialpy/data/themes.csv +8 -0
- misocialpy/data.py +297 -0
- misocialpy/fields.py +151 -0
- misocialpy/localities.py +312 -0
- misocialpy/periods.py +179 -0
- misocialpy/publication.py +191 -0
- misocialpy/validation.py +103 -0
- misocialpy-0.4.0.dist-info/METADATA +170 -0
- misocialpy-0.4.0.dist-info/RECORD +18 -0
- misocialpy-0.4.0.dist-info/WHEEL +4 -0
- misocialpy-0.4.0.dist-info/licenses/LICENSE +21 -0
misocialpy/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""misocialpy — access the MDS MI Social API from Python.
|
|
2
|
+
|
|
3
|
+
A pandas-friendly port of the R package `misocialr`. Monthly municipal series
|
|
4
|
+
of the Cadastro Unico, the Bolsa Familia programme and its conditionalities,
|
|
5
|
+
the BPC, the social assistance network and its funding, and food security
|
|
6
|
+
programmes, from the Social Information Matrix of the
|
|
7
|
+
Brazilian Ministry of Development and Social Assistance (MDS/SAGI).
|
|
8
|
+
|
|
9
|
+
Quick start
|
|
10
|
+
-----------
|
|
11
|
+
>>> import misocialpy
|
|
12
|
+
>>> misocialpy.mds_bpc_suas(periods=2025, localities="PE") # doctest: +SKIP
|
|
13
|
+
>>> misocialpy.mds_periods("bpc_ben_i", localities="PE") # doctest: +SKIP
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from ._cache import clear_cache as mds_clear_cache
|
|
19
|
+
from ._cache import set_cache_ttl
|
|
20
|
+
from ._client import DataConflictError, HttpError, MdsError, ResponseError, set_timeout
|
|
21
|
+
from ._msg import set_verbose
|
|
22
|
+
from .data import (
|
|
23
|
+
mds_bolsa_familia,
|
|
24
|
+
mds_bpc_suas,
|
|
25
|
+
mds_cadunico,
|
|
26
|
+
mds_data,
|
|
27
|
+
mds_pbf_condicionalidades,
|
|
28
|
+
mds_seguranca_alimentar,
|
|
29
|
+
mds_suas_financiamento,
|
|
30
|
+
mds_suas_rede,
|
|
31
|
+
)
|
|
32
|
+
from .fields import mds_api_fields, mds_fields, mds_themes
|
|
33
|
+
from .localities import ibge_code7, mds_localities
|
|
34
|
+
from .periods import mds_periods
|
|
35
|
+
from .publication import mds_drop_unpublished
|
|
36
|
+
from .validation import ValidationError
|
|
37
|
+
|
|
38
|
+
__version__ = "0.4.0"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"DataConflictError",
|
|
42
|
+
"HttpError",
|
|
43
|
+
"MdsError",
|
|
44
|
+
"ResponseError",
|
|
45
|
+
"ValidationError",
|
|
46
|
+
"__version__",
|
|
47
|
+
"ibge_code7",
|
|
48
|
+
"mds_api_fields",
|
|
49
|
+
"mds_bolsa_familia",
|
|
50
|
+
"mds_bpc_suas",
|
|
51
|
+
"mds_cadunico",
|
|
52
|
+
"mds_clear_cache",
|
|
53
|
+
"mds_data",
|
|
54
|
+
"mds_drop_unpublished",
|
|
55
|
+
"mds_fields",
|
|
56
|
+
"mds_localities",
|
|
57
|
+
"mds_pbf_condicionalidades",
|
|
58
|
+
"mds_periods",
|
|
59
|
+
"mds_seguranca_alimentar",
|
|
60
|
+
"mds_suas_financiamento",
|
|
61
|
+
"mds_suas_rede",
|
|
62
|
+
"mds_themes",
|
|
63
|
+
"set_cache_ttl",
|
|
64
|
+
"set_timeout",
|
|
65
|
+
"set_verbose",
|
|
66
|
+
]
|
misocialpy/_cache.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""In-memory, per-session cache (equivalent to the R package's environment).
|
|
2
|
+
|
|
3
|
+
Holds localities, field lists, period facets and fields already confirmed to
|
|
4
|
+
exist.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
_CACHE: dict[str, Any] = {}
|
|
14
|
+
_STORED_AT: dict[str, float] = {}
|
|
15
|
+
_TTL = math.inf
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def set_cache_ttl(seconds: float = math.inf) -> None:
|
|
19
|
+
"""Set how long, in seconds, a cached result is used (default: the whole
|
|
20
|
+
session). Must be a non-negative number; on error the previous value is
|
|
21
|
+
kept."""
|
|
22
|
+
global _TTL
|
|
23
|
+
if (
|
|
24
|
+
not isinstance(seconds, (int, float))
|
|
25
|
+
or isinstance(seconds, bool)
|
|
26
|
+
or math.isnan(seconds)
|
|
27
|
+
or seconds < 0
|
|
28
|
+
):
|
|
29
|
+
raise ValueError("`seconds` must be a non-negative number.")
|
|
30
|
+
_TTL = float(seconds)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _copy(value: Any) -> Any:
|
|
34
|
+
"""Independent copy of a cached table, with its attrs.
|
|
35
|
+
|
|
36
|
+
Pandas objects are mutable: handing out the stored instance would let an
|
|
37
|
+
edit of a result change the metadata used by later calls (periods feed
|
|
38
|
+
the inference of publication).
|
|
39
|
+
"""
|
|
40
|
+
if hasattr(value, "copy") and hasattr(value, "attrs"):
|
|
41
|
+
out = value.copy(deep=True)
|
|
42
|
+
out.attrs = dict(value.attrs)
|
|
43
|
+
return out
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cache_get(key: str, refresh: bool = False) -> Any:
|
|
48
|
+
"""Read a copy of a cached value, unless a refresh is requested."""
|
|
49
|
+
if refresh or key not in _CACHE:
|
|
50
|
+
return None
|
|
51
|
+
if time.monotonic() - _STORED_AT.get(key, 0.0) > _TTL:
|
|
52
|
+
return None
|
|
53
|
+
return _copy(_CACHE[key])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cache_set(key: str, value: Any) -> Any:
|
|
57
|
+
"""Store a copy of a value and return another (only complete, validated
|
|
58
|
+
responses reach here)."""
|
|
59
|
+
from datetime import datetime, timezone
|
|
60
|
+
|
|
61
|
+
if hasattr(value, "attrs"):
|
|
62
|
+
value.attrs["retrieved_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
63
|
+
_CACHE[key] = _copy(value)
|
|
64
|
+
_STORED_AT[key] = time.monotonic()
|
|
65
|
+
return _copy(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def locality_key(loc: dict) -> str:
|
|
69
|
+
"""Canonical key: ['PE', 'PB'] and ['PB', 'PE'] share the cached result."""
|
|
70
|
+
return f"uf={','.join(sorted(loc['states']))};mun={','.join(sorted(loc['municipalities']))}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def clear_cache() -> None:
|
|
74
|
+
"""Remove all cached localities, field lists, periods and field checks.
|
|
75
|
+
|
|
76
|
+
Forces fresh API calls on subsequent requests. Public alias:
|
|
77
|
+
:func:`misocialpy.mds_clear_cache`.
|
|
78
|
+
"""
|
|
79
|
+
_CACHE.clear()
|
|
80
|
+
_STORED_AT.clear()
|
|
81
|
+
from . import _msg
|
|
82
|
+
|
|
83
|
+
_msg.success("MI Social cache cleared.")
|
misocialpy/_client.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""HTTP layer for the MI Social API.
|
|
2
|
+
|
|
3
|
+
The MI Social API is an open Apache Solr ``select`` handler: every call is a
|
|
4
|
+
``q=*:*`` query narrowed with filter queries (``fq``), with paging
|
|
5
|
+
(``start``, ``rows``), field selection (``fl``) and facets.
|
|
6
|
+
|
|
7
|
+
Transient failures are retried (mirroring ``httr2::req_retry``) and errors
|
|
8
|
+
raise :class:`MdsError` with the Solr message when there is one.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
from requests.adapters import HTTPAdapter
|
|
19
|
+
|
|
20
|
+
try: # urllib3 ships with requests; import path is stable
|
|
21
|
+
from urllib3.util.retry import Retry
|
|
22
|
+
except ImportError: # pragma: no cover
|
|
23
|
+
from requests.packages.urllib3.util.retry import Retry # type: ignore
|
|
24
|
+
|
|
25
|
+
from . import _msg
|
|
26
|
+
|
|
27
|
+
MDS_BASE_URL = "https://aplicacoes.mds.gov.br/sagi/servicos/misocial"
|
|
28
|
+
|
|
29
|
+
_TIMEOUT = 180.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _user_agent() -> str:
|
|
33
|
+
"""User agent with the package version."""
|
|
34
|
+
from . import __version__
|
|
35
|
+
|
|
36
|
+
return f"misocialpy/{__version__} (Python package; {MDS_BASE_URL})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Seconds between retries grow as _BACKOFF * 2 ** (retry - 1); the first
|
|
40
|
+
# retry is immediate (urllib3).
|
|
41
|
+
_BACKOFF = 2.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def set_timeout(seconds: float) -> None:
|
|
45
|
+
"""Set the timeout, in seconds, of every request (default 180).
|
|
46
|
+
|
|
47
|
+
Must be a finite, strictly positive number; on error the previous timeout
|
|
48
|
+
is kept.
|
|
49
|
+
"""
|
|
50
|
+
global _TIMEOUT
|
|
51
|
+
if (
|
|
52
|
+
not isinstance(seconds, (int, float))
|
|
53
|
+
or isinstance(seconds, bool)
|
|
54
|
+
or not math.isfinite(seconds)
|
|
55
|
+
or seconds <= 0
|
|
56
|
+
):
|
|
57
|
+
raise ValueError("`seconds` must be a finite, positive number.")
|
|
58
|
+
_TIMEOUT = float(seconds)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MdsError(RuntimeError):
|
|
62
|
+
"""Base class of every misocialpy error."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HttpError(MdsError):
|
|
66
|
+
"""The request failed (transport or HTTP status). ``status`` is the HTTP
|
|
67
|
+
status code, or ``None`` when no response arrived."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, message: str, status: int | None = None):
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
self.status = status
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ResponseError(MdsError):
|
|
75
|
+
"""The API answered, but the answer is incomplete or malformed."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DataConflictError(MdsError):
|
|
79
|
+
"""The API contradicts itself (two values for the same observation)."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# A Retry-After header is obeyed, but never for more than this many seconds
|
|
83
|
+
# per try (the same cap as the R package).
|
|
84
|
+
_RETRY_AFTER_MAX = 30.0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _CappedRetry(Retry):
|
|
88
|
+
"""urllib3 Retry whose Retry-After wait is capped."""
|
|
89
|
+
|
|
90
|
+
def get_retry_after(self, response):
|
|
91
|
+
wait = super().get_retry_after(response)
|
|
92
|
+
return None if wait is None else min(wait, _RETRY_AFTER_MAX)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _session() -> requests.Session:
|
|
96
|
+
session = requests.Session()
|
|
97
|
+
# 400 means the query itself is wrong (e.g. an undefined field): retrying
|
|
98
|
+
# would only repeat the same answer. Connection failures (DNS, reset,
|
|
99
|
+
# timeout) are retried too, within the same total.
|
|
100
|
+
# urllib3 counts retries after the first request: 2 retries = 3 tries,
|
|
101
|
+
# the same as max_tries = 3 in the R package.
|
|
102
|
+
retry = _CappedRetry(
|
|
103
|
+
total=2,
|
|
104
|
+
connect=2,
|
|
105
|
+
read=2,
|
|
106
|
+
backoff_factor=_BACKOFF,
|
|
107
|
+
status_forcelist=(429, 500, 502, 503, 504),
|
|
108
|
+
# The query is sent by POST but only reads: retrying it is safe.
|
|
109
|
+
allowed_methods=frozenset(["GET", "POST"]),
|
|
110
|
+
raise_on_status=False,
|
|
111
|
+
)
|
|
112
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
113
|
+
session.mount("https://", adapter)
|
|
114
|
+
session.mount("http://", adapter)
|
|
115
|
+
session.headers.update({"User-Agent": _user_agent()})
|
|
116
|
+
return session
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _http_error_hint(resp: requests.Response) -> str:
|
|
120
|
+
"""Explain an HTTP error, surfacing Solr's ``error.msg`` when present."""
|
|
121
|
+
try:
|
|
122
|
+
msg = resp.json()["error"]["msg"]
|
|
123
|
+
except (ValueError, KeyError, TypeError):
|
|
124
|
+
msg = None
|
|
125
|
+
if msg:
|
|
126
|
+
return f"HTTP error {resp.status_code}: {msg}"
|
|
127
|
+
return f"HTTP error {resp.status_code}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def mds_request(query: Mapping[str, Any] | None = None, label: str = "data") -> Any:
|
|
131
|
+
"""Build and execute a request to the MI Social API.
|
|
132
|
+
|
|
133
|
+
Repeated parameters (several ``fq``) are given as a list under one key.
|
|
134
|
+
"""
|
|
135
|
+
params = {"q": "*:*", "wt": "json"}
|
|
136
|
+
params.update({k: v for k, v in (query or {}).items() if v is not None})
|
|
137
|
+
|
|
138
|
+
_msg.step(f"Fetching {label} from MDS MI Social API...")
|
|
139
|
+
try:
|
|
140
|
+
# The query goes in the body (POST form): with many months, fields and
|
|
141
|
+
# codes a GET URL passes the server limit (HTTP 414 measured at about
|
|
142
|
+
# 8,000 characters). The Solr select handler answers both the same way.
|
|
143
|
+
resp = _session().post(MDS_BASE_URL, data=params, timeout=_TIMEOUT)
|
|
144
|
+
except requests.RequestException as exc:
|
|
145
|
+
raise HttpError(
|
|
146
|
+
f"Failed to access the MDS MI Social API.\n x {exc}\n i URL: {MDS_BASE_URL}"
|
|
147
|
+
) from exc
|
|
148
|
+
|
|
149
|
+
if resp.status_code >= 400:
|
|
150
|
+
raise HttpError(
|
|
151
|
+
"Failed to access the MDS MI Social API.\n"
|
|
152
|
+
f" x {_http_error_hint(resp)}\n i URL: {resp.url}",
|
|
153
|
+
status=resp.status_code,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# The server labels its JSON as text/plain; requests does not check it.
|
|
157
|
+
try:
|
|
158
|
+
return resp.json()
|
|
159
|
+
except ValueError as exc:
|
|
160
|
+
raise ResponseError(
|
|
161
|
+
"Unexpected response from the MI Social API.\n x The body is not valid JSON."
|
|
162
|
+
) from exc
|
misocialpy/_format.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Solr filter builders and small helpers shared by the modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import numbers
|
|
7
|
+
from collections.abc import Iterable, Sequence
|
|
8
|
+
from datetime import date
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
# Columns that identify a document (municipality x month). Everything else in
|
|
12
|
+
# a document is a measure.
|
|
13
|
+
ID_FIELDS = ["codigo_ibge", "municipio", "sigla_uf", "anomes_s"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def as_list(x: Any) -> list:
|
|
17
|
+
"""A scalar (text, number, numpy scalar) becomes a one-element list; any
|
|
18
|
+
other iterable becomes a list."""
|
|
19
|
+
if isinstance(x, (str, bytes, numbers.Number)) or not hasattr(x, "__iter__"):
|
|
20
|
+
return [x]
|
|
21
|
+
return list(x)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_number(x: Any) -> bool:
|
|
25
|
+
"""A real number (int, float, numpy number), but not a bool."""
|
|
26
|
+
return isinstance(x, numbers.Real) and not isinstance(x, bool)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def whole_text(x: Any) -> str:
|
|
30
|
+
"""Text of a value; a number with a whole value is written without
|
|
31
|
+
decimals (26.0 -> "26"), as in the R package."""
|
|
32
|
+
if is_number(x) and math.isfinite(x) and x == int(x):
|
|
33
|
+
return str(int(x))
|
|
34
|
+
return str(x).strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Years accepted in `periods`: the API starts in 01/2003; anything outside
|
|
38
|
+
# 2000 to next year is a typo, not a query.
|
|
39
|
+
MIN_YEAR = 2000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def solr_in(field: str, values: Iterable) -> str:
|
|
43
|
+
"""Solr disjunction of terms for one field: ``field:(a OR b OR c)``."""
|
|
44
|
+
return f"{field}:({' OR '.join(str(v) for v in values)})"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def solr_has(field: str) -> str:
|
|
48
|
+
"""Solr filter matching documents where a field has a value."""
|
|
49
|
+
return f"{field}:[* TO *]"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def chunk_vector(x: Sequence, size: int) -> list[list]:
|
|
53
|
+
"""Split a sequence into chunks of at most ``size`` elements."""
|
|
54
|
+
x = list(x)
|
|
55
|
+
if not x:
|
|
56
|
+
return [x]
|
|
57
|
+
return [x[i : i + size] for i in range(0, len(x), size)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def unit_from_suffix(field: str) -> str:
|
|
61
|
+
"""Unit implied by the field-name suffix.
|
|
62
|
+
|
|
63
|
+
MI Social field names end with the Solr dynamic-field type: ``_i``/``_l``
|
|
64
|
+
integer count, ``_f`` float (currency in most cases), ``_d`` double
|
|
65
|
+
(percentages and rates). ``_s`` is how the value is stored (a string),
|
|
66
|
+
not what it measures, so its unit is ``"unknown"``.
|
|
67
|
+
"""
|
|
68
|
+
for suffix, unit in (("_i", "count"), ("_l", "count"), ("_f", "value"), ("_d", "rate")):
|
|
69
|
+
if field.endswith(suffix):
|
|
70
|
+
return unit
|
|
71
|
+
return "unknown"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def storage_from_suffix(field: str) -> str:
|
|
75
|
+
"""Solr storage type implied by the field-name suffix."""
|
|
76
|
+
for suffix, kind in (
|
|
77
|
+
("_i", "integer"),
|
|
78
|
+
("_l", "long"),
|
|
79
|
+
("_f", "float"),
|
|
80
|
+
("_d", "double"),
|
|
81
|
+
("_s", "string"),
|
|
82
|
+
):
|
|
83
|
+
if field.endswith(suffix):
|
|
84
|
+
return kind
|
|
85
|
+
return "untyped"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def period_of(today: date | None = None) -> int:
|
|
89
|
+
"""Month of ``today`` (default: now) as a ``YYYYMM`` integer."""
|
|
90
|
+
today = today or date.today()
|
|
91
|
+
return today.year * 100 + today.month
|
misocialpy/_msg.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Lightweight console messages, mirroring the R package's cli alerts.
|
|
2
|
+
|
|
3
|
+
Messages go to stderr and can be silenced with :func:`set_verbose`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
_VERBOSE = True
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def set_verbose(verbose: bool) -> None:
|
|
14
|
+
"""Enable or disable informational messages (success/step/inform)."""
|
|
15
|
+
global _VERBOSE
|
|
16
|
+
_VERBOSE = bool(verbose)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def success(message: str) -> None:
|
|
20
|
+
if _VERBOSE:
|
|
21
|
+
print(f"✔ {message}", file=sys.stderr)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def step(message: str) -> None:
|
|
25
|
+
if _VERBOSE:
|
|
26
|
+
print(f"ℹ {message}", file=sys.stderr)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def inform(message: str) -> None:
|
|
30
|
+
if _VERBOSE:
|
|
31
|
+
print(message, file=sys.stderr)
|
misocialpy/_response.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Response contract, document identity, scalar values and conflicts.
|
|
2
|
+
|
|
3
|
+
Every response is checked before it is interpreted or cached: a partial,
|
|
4
|
+
truncated or malformed answer is an error, never an empty result (deep review
|
|
5
|
+
of 0.2.1, items B1, B2, B4, B5, B8 and M5). Mirrors ``R/resposta.R``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ._client import DataConflictError, ResponseError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_count(x: Any) -> bool:
|
|
18
|
+
return (
|
|
19
|
+
isinstance(x, (int, float))
|
|
20
|
+
and not isinstance(x, bool)
|
|
21
|
+
and math.isfinite(x)
|
|
22
|
+
and x >= 0
|
|
23
|
+
and x == int(x)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_response(res: Any, start: int = 0, kind: str = "docs") -> None:
|
|
28
|
+
"""Check the shape of a Solr response before using it.
|
|
29
|
+
|
|
30
|
+
``kind`` is ``"docs"`` (a page of documents), ``"count"`` (``rows=0``,
|
|
31
|
+
only ``numFound`` is used) or ``"facet"`` (month counts).
|
|
32
|
+
"""
|
|
33
|
+
where = f"page after {start} documents" if kind == "docs" else kind
|
|
34
|
+
resp = res.get("response") if isinstance(res, dict) else None
|
|
35
|
+
if not isinstance(resp, dict):
|
|
36
|
+
raise ResponseError(
|
|
37
|
+
f"Unexpected response from the MI Social API.\n x No `response` element ({where})."
|
|
38
|
+
)
|
|
39
|
+
header = res.get("responseHeader") or {}
|
|
40
|
+
status = header.get("status")
|
|
41
|
+
if status is not None and status != 0:
|
|
42
|
+
raise ResponseError(
|
|
43
|
+
f"The MI Social API reported an error.\n x responseHeader status {status} ({where})."
|
|
44
|
+
)
|
|
45
|
+
# Solr flags results cut short by a time limit.
|
|
46
|
+
if header.get("partialResults"):
|
|
47
|
+
raise ResponseError(
|
|
48
|
+
"Partial results from the MI Social API.\n"
|
|
49
|
+
f" x The server stopped the query before the end ({where}). "
|
|
50
|
+
"Try a smaller selection."
|
|
51
|
+
)
|
|
52
|
+
if not _is_count(resp.get("numFound")):
|
|
53
|
+
raise ResponseError(
|
|
54
|
+
"Unexpected response from the MI Social API.\n"
|
|
55
|
+
f" x `numFound` must be a non-negative whole number ({where})."
|
|
56
|
+
)
|
|
57
|
+
if kind == "docs":
|
|
58
|
+
docs = resp.get("docs")
|
|
59
|
+
if not isinstance(docs, list) or not all(isinstance(d, dict) for d in docs):
|
|
60
|
+
raise ResponseError(
|
|
61
|
+
"Unexpected response from the MI Social API.\n"
|
|
62
|
+
f" x `docs` must be a list of documents ({where})."
|
|
63
|
+
)
|
|
64
|
+
if kind == "facet":
|
|
65
|
+
f = ((res.get("facet_counts") or {}).get("facet_fields") or {}).get("anomes_s")
|
|
66
|
+
ok = isinstance(f, list) and len(f) % 2 == 0
|
|
67
|
+
if ok and f:
|
|
68
|
+
months = f[0::2]
|
|
69
|
+
ok = (
|
|
70
|
+
all(is_month(p) for p in months)
|
|
71
|
+
and len(set(months)) == len(months)
|
|
72
|
+
and all(_is_count(n) for n in f[1::2])
|
|
73
|
+
)
|
|
74
|
+
if not ok:
|
|
75
|
+
raise ResponseError(
|
|
76
|
+
"Unexpected response from the MI Social API.\n"
|
|
77
|
+
f" x Missing or malformed `facet_counts` ({where})."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_month(x: Any) -> bool:
|
|
82
|
+
"""A month as 'YYYYMM' with the month between 01 and 12 (the one calendar
|
|
83
|
+
rule used for input, documents and facets)."""
|
|
84
|
+
return isinstance(x, str) and bool(re.fullmatch(r"[0-9]{6}", x)) and 1 <= int(x[4:]) <= 12
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_doc_identity(docs: list[dict[str, Any]]) -> None:
|
|
88
|
+
"""Every data document must identify its municipality and month."""
|
|
89
|
+
bad = [
|
|
90
|
+
d
|
|
91
|
+
for d in docs
|
|
92
|
+
if not re.fullmatch(r"[0-9]{6}", str(d.get("codigo_ibge") or ""))
|
|
93
|
+
or not is_month(d.get("anomes_s"))
|
|
94
|
+
]
|
|
95
|
+
if bad:
|
|
96
|
+
raise ResponseError(
|
|
97
|
+
"Unexpected document in the MI Social response.\n"
|
|
98
|
+
f" x {len(bad)} document(s) without a valid `codigo_ibge` (6 digits) "
|
|
99
|
+
"or `anomes_s` (YYYYMM, month 01 to 12)."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def doc_value(field: str, value: Any) -> float:
|
|
104
|
+
"""One numeric value per document; a list or a text is an error."""
|
|
105
|
+
from .validation import ValidationError
|
|
106
|
+
|
|
107
|
+
if isinstance(value, (list, tuple, dict, set)):
|
|
108
|
+
raise ResponseError(
|
|
109
|
+
f"Field `{field}` is not a single value per document.\n"
|
|
110
|
+
" x A document carries a list or several values."
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
number = float(value)
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
raise ValidationError(
|
|
116
|
+
f"Field `{field}` is not numeric.\n"
|
|
117
|
+
f" x The API returned {value!r}.\n"
|
|
118
|
+
" i misocialpy returns numeric values only."
|
|
119
|
+
) from None
|
|
120
|
+
# "NaN", "Inf" or "1e999" are numbers that cannot be a measure; accepting
|
|
121
|
+
# them would create missing or infinite values the source never meant.
|
|
122
|
+
if not math.isfinite(number):
|
|
123
|
+
raise ResponseError(
|
|
124
|
+
f"Field `{field}` has non-finite values.\n x The API returned {value!r}."
|
|
125
|
+
)
|
|
126
|
+
return number
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def check_ids(docs: list[dict[str, Any]]) -> None:
|
|
130
|
+
"""A document id read twice means the source changed while paging."""
|
|
131
|
+
seen: set = set()
|
|
132
|
+
dup = []
|
|
133
|
+
for d in docs:
|
|
134
|
+
i = d.get("id")
|
|
135
|
+
if i is None:
|
|
136
|
+
continue
|
|
137
|
+
if i in seen:
|
|
138
|
+
dup.append(i)
|
|
139
|
+
seen.add(i)
|
|
140
|
+
if dup:
|
|
141
|
+
raise ResponseError(
|
|
142
|
+
"The MI Social API returned the same document twice.\n"
|
|
143
|
+
f" x Repeated id(s): {dup[:3]}.\n"
|
|
144
|
+
" i The source probably changed while it was being read; run the query again."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def dedupe_observations(out):
|
|
149
|
+
"""Drop repeated observations; two different values for the same key are an error."""
|
|
150
|
+
key = ["locality_id", "period", "field"]
|
|
151
|
+
dup = out.duplicated(key, keep=False)
|
|
152
|
+
if not dup.any():
|
|
153
|
+
return out
|
|
154
|
+
# A missing value never replaces a known one, whatever the order of the
|
|
155
|
+
# rows: when a key has a known value, its missing rows are dropped first.
|
|
156
|
+
has_known = out[dup & out["value"].notna()].set_index(key).index.unique()
|
|
157
|
+
keys = out.set_index(key).index
|
|
158
|
+
out = out[~(dup & out["value"].isna() & keys.isin(has_known))].reset_index(drop=True)
|
|
159
|
+
dup = out.duplicated(key, keep=False)
|
|
160
|
+
if not dup.any():
|
|
161
|
+
return out
|
|
162
|
+
n_values = out[dup].groupby(key)["value"].nunique(dropna=True)
|
|
163
|
+
conflicts = n_values[n_values > 1]
|
|
164
|
+
if len(conflicts):
|
|
165
|
+
shown = [" / ".join(k) for k in conflicts.index[:3]]
|
|
166
|
+
raise DataConflictError(
|
|
167
|
+
"The MI Social API returned conflicting values.\n"
|
|
168
|
+
f" x Different values for the same municipality / month / field: {shown}."
|
|
169
|
+
)
|
|
170
|
+
return out.drop_duplicates(key).reset_index(drop=True)
|