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