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