undata 0.1.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.
- undata/__init__.py +86 -0
- undata/_endpoints.py +167 -0
- undata/_http.py +210 -0
- undata/_session.py +58 -0
- undata/_version.py +24 -0
- undata/ask.py +98 -0
- undata/data.py +238 -0
- undata/dcid.py +196 -0
- undata/errors.py +149 -0
- undata/export.py +92 -0
- undata/indicators.py +209 -0
- undata/places.py +343 -0
- undata/py.typed +0 -0
- undata/sdg.py +156 -0
- undata-0.1.0.dist-info/METADATA +157 -0
- undata-0.1.0.dist-info/RECORD +18 -0
- undata-0.1.0.dist-info/WHEEL +4 -0
- undata-0.1.0.dist-info/licenses/LICENSE +21 -0
undata/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""undata: UN data in Python, without the guessing.
|
|
2
|
+
|
|
3
|
+
A friendly client for the UN System Data Commons (data.un.org): statistics from 26
|
|
4
|
+
UN System entities, no API key needed. Unofficial, and part of Nerd Lab
|
|
5
|
+
(https://github.com/nerd-factory). Not affiliated with or endorsed by the United
|
|
6
|
+
Nations.
|
|
7
|
+
|
|
8
|
+
Start here::
|
|
9
|
+
|
|
10
|
+
import undata as un
|
|
11
|
+
|
|
12
|
+
un.search("maternal mortality") # find an indicator
|
|
13
|
+
un.about("undata/sdg/SH_STA_MORT.SEX--F") # what it is and how much data exists
|
|
14
|
+
table = un.get("undata/sdg/SH_STA_MORT.SEX--F", ["Bangladesh", "Nepal"])
|
|
15
|
+
un.latest("undata/sdg/EG_ACS_ELEC", "Western Africa")
|
|
16
|
+
un.sdg.series("16.1.2") # from an SDG indicator to its data
|
|
17
|
+
un.save(table, "maternal-mortality.xlsx") # data plus a sheet of citations
|
|
18
|
+
|
|
19
|
+
Every function has a docstring with an example: ``help(un.get)``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import warnings
|
|
23
|
+
|
|
24
|
+
from . import places, sdg
|
|
25
|
+
from ._http import DEFAULT_BASE_URL, Transport
|
|
26
|
+
from ._session import configure
|
|
27
|
+
from ._version import __version__
|
|
28
|
+
from .ask import Answer, ask
|
|
29
|
+
from .data import get, latest
|
|
30
|
+
from .dcid import build_dcid, child_countries_of, describe_dcid, parse_dcid
|
|
31
|
+
from .errors import (
|
|
32
|
+
AmbiguousPlaceError,
|
|
33
|
+
APIError,
|
|
34
|
+
BlockedRequestError,
|
|
35
|
+
ConnectionFailedError,
|
|
36
|
+
IndicatorNotFoundError,
|
|
37
|
+
MissingDataWarning,
|
|
38
|
+
MixedYearsWarning,
|
|
39
|
+
PlaceGuessedWarning,
|
|
40
|
+
PlaceNotFoundError,
|
|
41
|
+
UNDataError,
|
|
42
|
+
UNDataWarning,
|
|
43
|
+
)
|
|
44
|
+
from .export import cite, save
|
|
45
|
+
from .indicators import IndicatorInfo, about, search
|
|
46
|
+
from .places import Place, countries, regions
|
|
47
|
+
|
|
48
|
+
# Each warning is about the table just returned, so show it every time rather than
|
|
49
|
+
# only the first time Python sees it.
|
|
50
|
+
warnings.filterwarnings("always", category=UNDataWarning)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"DEFAULT_BASE_URL",
|
|
54
|
+
"APIError",
|
|
55
|
+
"AmbiguousPlaceError",
|
|
56
|
+
"Answer",
|
|
57
|
+
"BlockedRequestError",
|
|
58
|
+
"ConnectionFailedError",
|
|
59
|
+
"IndicatorInfo",
|
|
60
|
+
"IndicatorNotFoundError",
|
|
61
|
+
"MissingDataWarning",
|
|
62
|
+
"MixedYearsWarning",
|
|
63
|
+
"Place",
|
|
64
|
+
"PlaceGuessedWarning",
|
|
65
|
+
"PlaceNotFoundError",
|
|
66
|
+
"Transport",
|
|
67
|
+
"UNDataError",
|
|
68
|
+
"UNDataWarning",
|
|
69
|
+
"__version__",
|
|
70
|
+
"about",
|
|
71
|
+
"ask",
|
|
72
|
+
"build_dcid",
|
|
73
|
+
"child_countries_of",
|
|
74
|
+
"cite",
|
|
75
|
+
"configure",
|
|
76
|
+
"countries",
|
|
77
|
+
"describe_dcid",
|
|
78
|
+
"get",
|
|
79
|
+
"latest",
|
|
80
|
+
"parse_dcid",
|
|
81
|
+
"places",
|
|
82
|
+
"regions",
|
|
83
|
+
"save",
|
|
84
|
+
"sdg",
|
|
85
|
+
"search",
|
|
86
|
+
]
|
undata/_endpoints.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""One thin wrapper per API route. No interpretation happens here.
|
|
2
|
+
|
|
3
|
+
These return the API's JSON as-is; the public modules built on top of them are
|
|
4
|
+
where missing data, provenance and place resolution get handled. Keeping this
|
|
5
|
+
layer dumb means a change on the platform shows up in exactly one place.
|
|
6
|
+
|
|
7
|
+
The routes split across two surfaces:
|
|
8
|
+
|
|
9
|
+
* ``/core/api/v2/*`` is the Data Commons REST v2 API (graph, observations, resolve).
|
|
10
|
+
* ``/api/*`` is the website's own API, which carries search and variable metadata
|
|
11
|
+
that REST v2 does not expose.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from typing import Any, Final
|
|
18
|
+
|
|
19
|
+
from ._http import Transport
|
|
20
|
+
|
|
21
|
+
#: Fields requested from the observation endpoint. Without ``select`` it returns
|
|
22
|
+
#: an empty shell, and with ``select`` in a POST body the firewall answers 403,
|
|
23
|
+
#: which is why :func:`observation` is GET-only.
|
|
24
|
+
OBSERVATION_FIELDS: Final = ("date", "value", "variable", "entity", "facet")
|
|
25
|
+
|
|
26
|
+
#: Graph walks return at most this many values per page and set ``nextToken``.
|
|
27
|
+
NODE_PAGE_SIZE: Final = 500
|
|
28
|
+
|
|
29
|
+
#: Upper bound on pages followed for one graph walk; protects against a cycle.
|
|
30
|
+
MAX_NODE_PAGES: Final = 200
|
|
31
|
+
|
|
32
|
+
JSON = dict[str, Any]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _as_list(value: str | Sequence[str]) -> list[str]:
|
|
36
|
+
return [value] if isinstance(value, str) else list(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def observation(
|
|
40
|
+
transport: Transport,
|
|
41
|
+
variables: str | Sequence[str],
|
|
42
|
+
*,
|
|
43
|
+
entities: str | Sequence[str] | None = None,
|
|
44
|
+
entity_expression: str | None = None,
|
|
45
|
+
date: str = "LATEST",
|
|
46
|
+
) -> JSON:
|
|
47
|
+
"""Fetch observations from ``/core/api/v2/observation``, always by GET.
|
|
48
|
+
|
|
49
|
+
Pass either explicit ``entities`` or an ``entity_expression`` such as
|
|
50
|
+
``africa<-containedInPlace+{typeOf:Country}``. ``date`` is ``"LATEST"``, ``""``
|
|
51
|
+
for every date, or a single date like ``"2020"``. Note that ``"LATEST"`` means
|
|
52
|
+
each entity's own most recent date, which differs between countries.
|
|
53
|
+
"""
|
|
54
|
+
if (entities is None) == (entity_expression is None):
|
|
55
|
+
raise ValueError("Pass exactly one of entities or entity_expression.")
|
|
56
|
+
result: JSON = transport.get(
|
|
57
|
+
"/core/api/v2/observation",
|
|
58
|
+
{
|
|
59
|
+
"date": date,
|
|
60
|
+
"variable.dcids": _as_list(variables),
|
|
61
|
+
"entity.dcids": None if entities is None else _as_list(entities),
|
|
62
|
+
"entity.expression": entity_expression,
|
|
63
|
+
"select": OBSERVATION_FIELDS,
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def node(transport: Transport, nodes: str | Sequence[str], prop: str) -> JSON:
|
|
70
|
+
"""Follow a relation expression from ``/core/api/v2/node``, across every page.
|
|
71
|
+
|
|
72
|
+
The API caps each page at 500 values and returns a ``nextToken``; this follows
|
|
73
|
+
the tokens and merges the pages, so the result is the complete answer.
|
|
74
|
+
"""
|
|
75
|
+
params: dict[str, str | list[str] | None] = {"nodes": _as_list(nodes), "property": prop}
|
|
76
|
+
merged: JSON = transport.get("/core/api/v2/node", params)
|
|
77
|
+
token = merged.pop("nextToken", None)
|
|
78
|
+
pages = 1
|
|
79
|
+
while token:
|
|
80
|
+
if pages >= MAX_NODE_PAGES:
|
|
81
|
+
raise RuntimeError(f"Stopped after {MAX_NODE_PAGES} pages of {prop!r}.")
|
|
82
|
+
page: JSON = transport.get("/core/api/v2/node", {**params, "nextToken": token})
|
|
83
|
+
_merge_node_page(merged, page)
|
|
84
|
+
token = page.get("nextToken")
|
|
85
|
+
pages += 1
|
|
86
|
+
return merged
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _merge_node_page(merged: JSON, page: JSON) -> None:
|
|
90
|
+
"""Append one page's arc values onto the accumulated result, in place."""
|
|
91
|
+
for dcid, entry in page.get("data", {}).items():
|
|
92
|
+
target = merged.setdefault("data", {}).setdefault(dcid, {})
|
|
93
|
+
for arc, values in entry.get("arcs", {}).items():
|
|
94
|
+
existing = target.setdefault("arcs", {}).setdefault(arc, {"nodes": []})
|
|
95
|
+
existing.setdefault("nodes", []).extend(values.get("nodes", []))
|
|
96
|
+
if "properties" in entry:
|
|
97
|
+
props = target.setdefault("properties", [])
|
|
98
|
+
props.extend(p for p in entry["properties"] if p not in props)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve(
|
|
102
|
+
transport: Transport,
|
|
103
|
+
nodes: str | Sequence[str],
|
|
104
|
+
prop: str = "<-description->dcid",
|
|
105
|
+
) -> JSON:
|
|
106
|
+
"""Resolve names or other identifiers to dcids via ``/core/api/v2/resolve``."""
|
|
107
|
+
result: JSON = transport.get(
|
|
108
|
+
"/core/api/v2/resolve", {"nodes": _as_list(nodes), "property": prop}
|
|
109
|
+
)
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def series(
|
|
114
|
+
transport: Transport,
|
|
115
|
+
variables: str | Sequence[str],
|
|
116
|
+
entities: str | Sequence[str],
|
|
117
|
+
) -> JSON:
|
|
118
|
+
"""Fetch full time series for every variable/entity pair from the website API.
|
|
119
|
+
|
|
120
|
+
Entities with no data are absent from the response rather than empty.
|
|
121
|
+
"""
|
|
122
|
+
result: JSON = transport.post(
|
|
123
|
+
"/api/observations/series",
|
|
124
|
+
{"variables": _as_list(variables), "entities": _as_list(entities)},
|
|
125
|
+
)
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def stat_var_search(transport: Transport, query: str) -> JSON:
|
|
130
|
+
"""Search statistical variables by text.
|
|
131
|
+
|
|
132
|
+
Results include Data Commons' base variables (``Count_Person``) as well as the
|
|
133
|
+
UN collections (``undata/...``).
|
|
134
|
+
"""
|
|
135
|
+
result: JSON = transport.get("/api/stats/stat-var-search", {"query": query})
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def variable_info(transport: Transport, dcids: str | Sequence[str]) -> JSON:
|
|
140
|
+
"""Coverage and provenance summary per variable, from ``/api/variable/info``."""
|
|
141
|
+
result: JSON = transport.get("/api/variable/info", {"dcids": _as_list(dcids)})
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def place_names(transport: Transport, dcids: str | Sequence[str]) -> dict[str, str]:
|
|
146
|
+
"""Display names for place dcids."""
|
|
147
|
+
result: dict[str, str] = transport.get("/api/place/name", {"dcids": _as_list(dcids)})
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def variable_group_info(transport: Transport, dcid: str) -> JSON:
|
|
152
|
+
"""Children and indicator counts for a node in the variable-group tree."""
|
|
153
|
+
result: JSON = transport.post("/api/variable-group/info", {"dcid": dcid, "entities": []})
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def detect_and_fulfill(transport: Transport, question: str) -> JSON:
|
|
158
|
+
"""Run a question through the site's natural-language resolver.
|
|
159
|
+
|
|
160
|
+
The resolver always commits to a place. When the question names none it
|
|
161
|
+
recognises, it supplies one silently, so the result must be inspected before
|
|
162
|
+
it is trusted.
|
|
163
|
+
"""
|
|
164
|
+
result: JSON = transport.post(
|
|
165
|
+
"/api/explore/detect-and-fulfill", {"contextHistory": []}, params={"q": question}
|
|
166
|
+
)
|
|
167
|
+
return result
|
undata/_http.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Transport: the only module that talks to the network.
|
|
2
|
+
|
|
3
|
+
It exists to make the platform's request traps impossible to trip over:
|
|
4
|
+
|
|
5
|
+
* A line break anywhere in a POST body is answered with 403, so bodies are always
|
|
6
|
+
serialised compactly here rather than left to the caller.
|
|
7
|
+
* A POST body containing a ``"select"`` key is also answered with 403, so the
|
|
8
|
+
observation endpoint is only ever called with GET (see ``_endpoints``).
|
|
9
|
+
* Relation expressions (``->``, ``<-``, ``+``, ``{typeOf:Country}``) must be
|
|
10
|
+
percent-encoded or the API answers 400. Query strings are built here with every
|
|
11
|
+
reserved character percent-encoded, ``+`` included.
|
|
12
|
+
* Any GET whose query string contains the word ``ESH`` (Western Sahara's country
|
|
13
|
+
code) is answered with 403: the firewall mistakes it for the ``esh`` shell. The
|
|
14
|
+
friendly functions therefore fetch data by POST (see ``data._collect``).
|
|
15
|
+
* The server does not decode ``+`` as a space in a query string: ``nodes=Viet+Nam``
|
|
16
|
+
reaches it as ``Viet+Nam``. Spaces are therefore sent as ``%20``, never ``+``,
|
|
17
|
+
which is what httpx would do by default.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import json
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Mapping, Sequence
|
|
26
|
+
from typing import Any, Final
|
|
27
|
+
from urllib.parse import quote, urlencode
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from ._version import __version__
|
|
32
|
+
from .errors import APIError, BlockedRequestError, ConnectionFailedError
|
|
33
|
+
|
|
34
|
+
DEFAULT_BASE_URL: Final = "https://unsd-datacommons.gcp.un-icc.cloud"
|
|
35
|
+
DEFAULT_TIMEOUT: Final = 30.0
|
|
36
|
+
DEFAULT_RETRIES: Final = 3
|
|
37
|
+
|
|
38
|
+
USER_AGENT: Final = f"undata/{__version__} (+https://github.com/nerd-factory/undata)"
|
|
39
|
+
|
|
40
|
+
#: Statuses worth retrying: rate limiting and transient upstream failures.
|
|
41
|
+
RETRY_STATUSES: Final = frozenset({429, 502, 503, 504})
|
|
42
|
+
|
|
43
|
+
#: A query value is a string, or a sequence of strings sent as repeated keys.
|
|
44
|
+
QueryValue = str | Sequence[str]
|
|
45
|
+
|
|
46
|
+
_BLOCKED_MESSAGE: Final = (
|
|
47
|
+
"The data.un.org firewall refused this request. No API key exists or is needed, "
|
|
48
|
+
"so this is not an authentication problem. The firewall is known to refuse a POST "
|
|
49
|
+
'body containing a line break or a "select" key, and any GET request whose query '
|
|
50
|
+
"contains the word ESH (Western Sahara's country code), which it mistakes for a "
|
|
51
|
+
"shell command. undata's own functions avoid all three; if one of them raised this, "
|
|
52
|
+
"the rules have changed. Please report it at "
|
|
53
|
+
"https://github.com/nerd-factory/undata/issues with the URL above."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def encode_body(body: Mapping[str, Any]) -> bytes:
|
|
58
|
+
"""Serialise a JSON body on a single line, which this deployment requires."""
|
|
59
|
+
return json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def query_string(params: Mapping[str, QueryValue | None]) -> str:
|
|
63
|
+
"""Encode params as a query string the way this deployment needs.
|
|
64
|
+
|
|
65
|
+
Sequence values become repeated keys, ``None`` values are dropped, and every
|
|
66
|
+
reserved character is percent-encoded. Spaces become ``%20``: the server reads
|
|
67
|
+
``+`` literally.
|
|
68
|
+
|
|
69
|
+
Example:
|
|
70
|
+
>>> query_string({"nodes": ["Viet Nam", "Chad"], "property": "<-a+", "x": None})
|
|
71
|
+
'nodes=Viet%20Nam&nodes=Chad&property=%3C-a%2B'
|
|
72
|
+
"""
|
|
73
|
+
pairs: list[tuple[str, str]] = []
|
|
74
|
+
for key, value in params.items():
|
|
75
|
+
if value is None:
|
|
76
|
+
continue
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
pairs.append((key, value))
|
|
79
|
+
else:
|
|
80
|
+
pairs.extend((key, item) for item in value)
|
|
81
|
+
return urlencode(pairs, quote_via=quote, safe="")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Transport:
|
|
85
|
+
"""A synchronous HTTP transport with retries and explanatory errors.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
base_url: Root of the Data Commons deployment.
|
|
89
|
+
timeout: Seconds to wait for each response.
|
|
90
|
+
retries: How many times to retry a 429, 502, 503 or 504, or a dropped
|
|
91
|
+
connection, before giving up.
|
|
92
|
+
http_client: An ``httpx.Client`` to use instead of creating one. Useful for
|
|
93
|
+
proxies, custom certificates, and tests.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
100
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
101
|
+
retries: int = DEFAULT_RETRIES,
|
|
102
|
+
http_client: httpx.Client | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
self.base_url = base_url.rstrip("/")
|
|
105
|
+
self.retries = retries
|
|
106
|
+
self._owns_client = http_client is None
|
|
107
|
+
self._client = http_client or httpx.Client(
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
|
|
110
|
+
follow_redirects=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def get(self, path: str, params: Mapping[str, QueryValue | None] | None = None) -> Any:
|
|
114
|
+
"""GET ``path`` and return the decoded JSON body."""
|
|
115
|
+
request = self._client.build_request("GET", self._url(path, params))
|
|
116
|
+
return self._send(request)
|
|
117
|
+
|
|
118
|
+
def post(
|
|
119
|
+
self,
|
|
120
|
+
path: str,
|
|
121
|
+
body: Mapping[str, Any],
|
|
122
|
+
params: Mapping[str, QueryValue | None] | None = None,
|
|
123
|
+
) -> Any:
|
|
124
|
+
"""POST a single-line JSON ``body`` to ``path`` and return the decoded JSON."""
|
|
125
|
+
request = self._client.build_request(
|
|
126
|
+
"POST",
|
|
127
|
+
self._url(path, params),
|
|
128
|
+
content=encode_body(body),
|
|
129
|
+
headers={"Content-Type": "application/json"},
|
|
130
|
+
)
|
|
131
|
+
return self._send(request)
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
"""Close the underlying connection pool, if this transport created it."""
|
|
135
|
+
if self._owns_client:
|
|
136
|
+
self._client.close()
|
|
137
|
+
|
|
138
|
+
def __enter__(self) -> Transport:
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
142
|
+
self.close()
|
|
143
|
+
|
|
144
|
+
def _url(self, path: str, params: Mapping[str, QueryValue | None] | None) -> str:
|
|
145
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
146
|
+
query = query_string(params or {})
|
|
147
|
+
return f"{url}?{query}" if query else url
|
|
148
|
+
|
|
149
|
+
def _send(self, request: httpx.Request) -> Any:
|
|
150
|
+
url = str(request.url)
|
|
151
|
+
for attempt in range(self.retries + 1):
|
|
152
|
+
last_attempt = attempt == self.retries
|
|
153
|
+
try:
|
|
154
|
+
response = self._client.send(request)
|
|
155
|
+
except httpx.TransportError as exc:
|
|
156
|
+
if last_attempt:
|
|
157
|
+
raise ConnectionFailedError(
|
|
158
|
+
f"Could not reach data.un.org: {exc}", url=url
|
|
159
|
+
) from exc
|
|
160
|
+
_sleep_before_retry(attempt, None)
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if response.status_code in RETRY_STATUSES and not last_attempt:
|
|
164
|
+
_sleep_before_retry(attempt, response.headers.get("Retry-After"))
|
|
165
|
+
continue
|
|
166
|
+
return _decode(response, url)
|
|
167
|
+
|
|
168
|
+
raise AssertionError("unreachable: the final attempt always returns or raises")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _decode(response: httpx.Response, url: str) -> Any:
|
|
172
|
+
status = response.status_code
|
|
173
|
+
body = response.text[:500]
|
|
174
|
+
if status == httpx.codes.FORBIDDEN:
|
|
175
|
+
raise BlockedRequestError(_BLOCKED_MESSAGE, status=status, url=url, body=body)
|
|
176
|
+
if status >= httpx.codes.BAD_REQUEST:
|
|
177
|
+
raise APIError(_status_message(status), status=status, url=url, body=body)
|
|
178
|
+
try:
|
|
179
|
+
return response.json()
|
|
180
|
+
except ValueError as exc:
|
|
181
|
+
raise APIError(
|
|
182
|
+
"The API answered successfully but the body was not JSON.",
|
|
183
|
+
status=status,
|
|
184
|
+
url=url,
|
|
185
|
+
body=body,
|
|
186
|
+
) from exc
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _status_message(status: int) -> str:
|
|
190
|
+
if status == httpx.codes.BAD_REQUEST:
|
|
191
|
+
return (
|
|
192
|
+
"The API rejected the request as malformed. Check identifiers for typos; "
|
|
193
|
+
"an unknown property in a relation expression also causes this."
|
|
194
|
+
)
|
|
195
|
+
if status == httpx.codes.NOT_FOUND:
|
|
196
|
+
return "No such endpoint on this deployment."
|
|
197
|
+
if status == httpx.codes.METHOD_NOT_ALLOWED:
|
|
198
|
+
return "This endpoint does not accept that HTTP method."
|
|
199
|
+
if status >= httpx.codes.INTERNAL_SERVER_ERROR:
|
|
200
|
+
return "The API failed while answering. Retrying later usually works."
|
|
201
|
+
return "The API answered with an error."
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _sleep_before_retry(attempt: int, retry_after: str | None) -> None:
|
|
205
|
+
delay = 0.5 * 2**attempt
|
|
206
|
+
if retry_after is not None:
|
|
207
|
+
# An HTTP-date Retry-After is ignored; the exponential delay is close enough.
|
|
208
|
+
with contextlib.suppress(ValueError):
|
|
209
|
+
delay = max(delay, float(retry_after))
|
|
210
|
+
time.sleep(min(delay, 30.0))
|
undata/_session.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The shared connection and in-memory cache behind the module-level functions.
|
|
2
|
+
|
|
3
|
+
Most people never touch this: ``un.get(...)`` opens a connection the first time it
|
|
4
|
+
is needed and reuses it. Call :func:`configure` to change the timeout or point at
|
|
5
|
+
another deployment.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
from ._http import DEFAULT_BASE_URL, DEFAULT_RETRIES, DEFAULT_TIMEOUT, Transport
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _State:
|
|
19
|
+
transport: Transport | None = None
|
|
20
|
+
cache: dict[str, Any] = {} # noqa: RUF012 — one process-wide cache, deliberately
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def transport() -> Transport:
|
|
24
|
+
"""Return the shared transport, opening it on first use."""
|
|
25
|
+
if _State.transport is None:
|
|
26
|
+
_State.transport = Transport()
|
|
27
|
+
return _State.transport
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def configure(
|
|
31
|
+
*,
|
|
32
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
33
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
34
|
+
retries: int = DEFAULT_RETRIES,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Change how undata connects, and forget anything it has cached.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
base_url: Root of the Data Commons deployment.
|
|
40
|
+
timeout: Seconds to wait for each response. Raise it on a slow connection.
|
|
41
|
+
retries: How many times to retry a request that failed for a temporary reason.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
>>> import undata as un
|
|
45
|
+
>>> un.configure(timeout=60)
|
|
46
|
+
"""
|
|
47
|
+
if _State.transport is not None:
|
|
48
|
+
_State.transport.close()
|
|
49
|
+
_State.transport = Transport(base_url=base_url, timeout=timeout, retries=retries)
|
|
50
|
+
_State.cache.clear()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cached(key: str, factory: Callable[[], T]) -> T:
|
|
54
|
+
"""Return the cached value for ``key``, computing it with ``factory`` the first time."""
|
|
55
|
+
if key not in _State.cache:
|
|
56
|
+
_State.cache[key] = factory()
|
|
57
|
+
value: T = _State.cache[key]
|
|
58
|
+
return value
|
undata/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
undata/ask.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""See what data.un.org's search box makes of a question, before trusting its chart.
|
|
2
|
+
|
|
3
|
+
The search box always picks a place. When a question names no place it knows, it
|
|
4
|
+
uses a default one and says so only in small print. :func:`ask` shows the place it
|
|
5
|
+
chose, whether it chose it itself, and the indicators it picked.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from . import _endpoints as api
|
|
14
|
+
from . import _session
|
|
15
|
+
from .errors import PlaceGuessedWarning
|
|
16
|
+
|
|
17
|
+
__all__ = ["Answer", "ask"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Answer:
|
|
22
|
+
"""What the search box decided.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
question: The question as asked.
|
|
26
|
+
place: The place it answered about, by name.
|
|
27
|
+
place_id: The place's id.
|
|
28
|
+
place_was_guessed: True when the question named no place the search box
|
|
29
|
+
recognised and it supplied one of its own.
|
|
30
|
+
indicators: ``(id, name)`` for each indicator it chose.
|
|
31
|
+
messages: Any notes the search box returned.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
question: str
|
|
35
|
+
place: str | None
|
|
36
|
+
place_id: str | None
|
|
37
|
+
place_was_guessed: bool
|
|
38
|
+
indicators: list[tuple[str, str]] = field(default_factory=list)
|
|
39
|
+
messages: list[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
if self.place is None:
|
|
43
|
+
where = " place none"
|
|
44
|
+
elif self.place_was_guessed:
|
|
45
|
+
where = f" place {self.place} <- GUESSED: your question did not name it"
|
|
46
|
+
else:
|
|
47
|
+
where = f" place {self.place} (from your question)"
|
|
48
|
+
lines = [f"Question: {self.question}", where]
|
|
49
|
+
lines += [f" indicator {name} [{dcid}]" for dcid, name in self.indicators[:8]]
|
|
50
|
+
if len(self.indicators) > 8: # noqa: PLR2004
|
|
51
|
+
lines.append(f" ...and {len(self.indicators) - 8} more")
|
|
52
|
+
lines += [f" note {m}" for m in self.messages]
|
|
53
|
+
return "\n".join(lines)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ask(question: str) -> Answer:
|
|
57
|
+
"""Ask data.un.org's search box a question and see what it decided.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
question: A question as you would type it on data.un.org.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
An :class:`Answer`. A :class:`~undata.PlaceGuessedWarning` is shown when the
|
|
64
|
+
search box chose a place your question did not name.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
>>> import undata as un
|
|
68
|
+
>>> un.ask("child mortality in Bengal") # doctest: +SKIP
|
|
69
|
+
Question: child mortality in Bengal
|
|
70
|
+
place United States of America <- GUESSED: your question did not name it
|
|
71
|
+
...
|
|
72
|
+
"""
|
|
73
|
+
body = api.detect_and_fulfill(_session.transport(), question)
|
|
74
|
+
place = body.get("place") or {}
|
|
75
|
+
guessed = bool(place) and body.get("placeSource") != "CURRENT_QUERY"
|
|
76
|
+
|
|
77
|
+
chosen: dict[str, str] = {}
|
|
78
|
+
for category in (body.get("config") or {}).get("categories", []):
|
|
79
|
+
for key, spec in (category.get("statVarSpec") or {}).items():
|
|
80
|
+
dcid = spec.get("statVar") or key.removesuffix("_multiple_place_bar_block")
|
|
81
|
+
chosen.setdefault(dcid, spec.get("name", dcid))
|
|
82
|
+
|
|
83
|
+
answer = Answer(
|
|
84
|
+
question=question,
|
|
85
|
+
place=place.get("name"),
|
|
86
|
+
place_id=place.get("dcid"),
|
|
87
|
+
place_was_guessed=guessed,
|
|
88
|
+
indicators=list(chosen.items()),
|
|
89
|
+
messages=list(body.get("userMessages") or []),
|
|
90
|
+
)
|
|
91
|
+
if guessed:
|
|
92
|
+
warnings.warn(
|
|
93
|
+
f"data.un.org answered {question!r} about {answer.place}, a place your question "
|
|
94
|
+
"did not name. Name the country exactly, or use un.get() with a country you choose.",
|
|
95
|
+
PlaceGuessedWarning,
|
|
96
|
+
stacklevel=2,
|
|
97
|
+
)
|
|
98
|
+
return answer
|