sdgdata 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.
- sdgdata/__init__.py +5 -0
- sdgdata/client.py +305 -0
- sdgdata/debug.py +36 -0
- sdgdata/metadata.py +28 -0
- sdgdata/models.py +374 -0
- sdgdata/utilities.py +156 -0
- sdgdata-0.1.0.dist-info/METADATA +114 -0
- sdgdata-0.1.0.dist-info/RECORD +11 -0
- sdgdata-0.1.0.dist-info/WHEEL +5 -0
- sdgdata-0.1.0.dist-info/licenses/LICENSE +21 -0
- sdgdata-0.1.0.dist-info/top_level.txt +1 -0
sdgdata/__init__.py
ADDED
sdgdata/client.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from sdgdata.models import (
|
|
6
|
+
ApiDimension,
|
|
7
|
+
ApiGeoArea,
|
|
8
|
+
ApiGoal,
|
|
9
|
+
ApiIndicator,
|
|
10
|
+
ApiSerie,
|
|
11
|
+
ApiTarget,
|
|
12
|
+
ConceptsMasterData,
|
|
13
|
+
SDMXMetaDataResponse,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from . import debug
|
|
17
|
+
from .metadata import IndicatorSeriesMetadata, SeriesMetadata, TargetSeriesMetadata
|
|
18
|
+
from .utilities import (
|
|
19
|
+
DimensionArgument,
|
|
20
|
+
_coarsest_dimension_filters,
|
|
21
|
+
_dimension_payload,
|
|
22
|
+
_normalize_observation,
|
|
23
|
+
_period_query_params,
|
|
24
|
+
_release_sort_key,
|
|
25
|
+
_series_code_list,
|
|
26
|
+
)
|
|
27
|
+
from .utilities import (
|
|
28
|
+
is_single_time_series as is_single_time_series,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# standard UNSD API base URL
|
|
32
|
+
BASE_URL = "https://unstats.un.org/sdgapi/v1"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _group_series_metadata(
|
|
36
|
+
series_items: list[ApiSerie],
|
|
37
|
+
dimensions_by_code: Mapping[str, list[ApiDimension]],
|
|
38
|
+
) -> list[SeriesMetadata]:
|
|
39
|
+
"""
|
|
40
|
+
Groups repeated UNSD release rows into one discovery record per series code.
|
|
41
|
+
"""
|
|
42
|
+
series_by_code: dict[str, list[ApiSerie]] = {}
|
|
43
|
+
for series in series_items:
|
|
44
|
+
if series.code is None:
|
|
45
|
+
continue
|
|
46
|
+
series_by_code.setdefault(series.code, []).append(series)
|
|
47
|
+
|
|
48
|
+
grouped_series = []
|
|
49
|
+
for code, releases in series_by_code.items():
|
|
50
|
+
latest = max(releases, key=lambda item: _release_sort_key(item.release))
|
|
51
|
+
release_codes = sorted(
|
|
52
|
+
{release.release for release in releases if release.release is not None},
|
|
53
|
+
key=_release_sort_key,
|
|
54
|
+
)
|
|
55
|
+
grouped_series.append(
|
|
56
|
+
SeriesMetadata(
|
|
57
|
+
code=code,
|
|
58
|
+
description=latest.description,
|
|
59
|
+
uri=latest.uri,
|
|
60
|
+
latest_release=latest.release,
|
|
61
|
+
releases=release_codes,
|
|
62
|
+
dimensions=dimensions_by_code.get(code, []),
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return sorted(grouped_series, key=lambda series: series.code)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _indicator_series_metadata(
|
|
70
|
+
indicator: ApiIndicator,
|
|
71
|
+
dimensions_by_code: Mapping[str, list[ApiDimension]],
|
|
72
|
+
) -> IndicatorSeriesMetadata:
|
|
73
|
+
"""
|
|
74
|
+
Builds the public discovery model for one generated UNSD indicator model.
|
|
75
|
+
"""
|
|
76
|
+
return IndicatorSeriesMetadata(
|
|
77
|
+
code=indicator.code or "",
|
|
78
|
+
description=indicator.description,
|
|
79
|
+
tier=indicator.tier,
|
|
80
|
+
uri=indicator.uri,
|
|
81
|
+
series=_group_series_metadata(indicator.series or [], dimensions_by_code),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SDGClient:
|
|
86
|
+
def __init__(self):
|
|
87
|
+
self.client = httpx.Client(base_url=BASE_URL, timeout=30.0)
|
|
88
|
+
|
|
89
|
+
def _get(self, url: str, *, params: dict | None = None) -> httpx.Response:
|
|
90
|
+
request = self.client.build_request("GET", url, params=params)
|
|
91
|
+
debug.print_query(request)
|
|
92
|
+
return self.client.send(request)
|
|
93
|
+
|
|
94
|
+
def get_targets(self, include_children: bool = False) -> list[ApiTarget]:
|
|
95
|
+
"""
|
|
96
|
+
Fetches all targets, optionally including their indicators and series.
|
|
97
|
+
"""
|
|
98
|
+
response = self._get("/sdg/Target/List", params={"includechildren": include_children})
|
|
99
|
+
response.raise_for_status()
|
|
100
|
+
data = response.json()
|
|
101
|
+
return [ApiTarget(**item) for item in data]
|
|
102
|
+
|
|
103
|
+
def get_series_codes(
|
|
104
|
+
self, target_code: str | None = None, *, all_releases: bool = False
|
|
105
|
+
) -> list[ApiSerie]:
|
|
106
|
+
"""
|
|
107
|
+
Returns latest series codes and descriptions, optionally filtered by target code.
|
|
108
|
+
"""
|
|
109
|
+
targets = self.get_targets(include_children=True)
|
|
110
|
+
series_list = []
|
|
111
|
+
for target in targets:
|
|
112
|
+
if target_code and target.code != target_code:
|
|
113
|
+
continue
|
|
114
|
+
if target.indicators:
|
|
115
|
+
for indicator in target.indicators:
|
|
116
|
+
if indicator.series:
|
|
117
|
+
series_list.extend(indicator.series)
|
|
118
|
+
if all_releases:
|
|
119
|
+
return series_list
|
|
120
|
+
|
|
121
|
+
latest_by_code = {}
|
|
122
|
+
for series in series_list:
|
|
123
|
+
if series.code is None:
|
|
124
|
+
continue
|
|
125
|
+
current = latest_by_code.get(series.code)
|
|
126
|
+
if current is None or _release_sort_key(series.release) > _release_sort_key(
|
|
127
|
+
current.release
|
|
128
|
+
):
|
|
129
|
+
latest_by_code[series.code] = series
|
|
130
|
+
|
|
131
|
+
return list(latest_by_code.values())
|
|
132
|
+
|
|
133
|
+
def get_indicator_series(self, indicator_code: str) -> IndicatorSeriesMetadata:
|
|
134
|
+
"""
|
|
135
|
+
Returns grouped series metadata and dimensions for an indicator.
|
|
136
|
+
"""
|
|
137
|
+
for target in self.get_targets(include_children=True):
|
|
138
|
+
for indicator in target.indicators or []:
|
|
139
|
+
if indicator.code == indicator_code:
|
|
140
|
+
series_codes = {
|
|
141
|
+
series.code for series in indicator.series or [] if series.code is not None
|
|
142
|
+
}
|
|
143
|
+
dimensions_by_code = {
|
|
144
|
+
code: self.get_series_dimensions(code) for code in series_codes
|
|
145
|
+
}
|
|
146
|
+
return _indicator_series_metadata(indicator, dimensions_by_code)
|
|
147
|
+
|
|
148
|
+
raise ValueError(f"indicator code {indicator_code!r} was not found")
|
|
149
|
+
|
|
150
|
+
def get_target_series(self, target_code: str) -> TargetSeriesMetadata:
|
|
151
|
+
"""
|
|
152
|
+
Returns grouped indicator and series metadata for a target.
|
|
153
|
+
"""
|
|
154
|
+
for target in self.get_targets(include_children=True):
|
|
155
|
+
if target.code != target_code:
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
series_codes = {
|
|
159
|
+
series.code
|
|
160
|
+
for indicator in target.indicators or []
|
|
161
|
+
for series in indicator.series or []
|
|
162
|
+
if series.code is not None
|
|
163
|
+
}
|
|
164
|
+
dimensions_by_code = {code: self.get_series_dimensions(code) for code in series_codes}
|
|
165
|
+
return TargetSeriesMetadata(
|
|
166
|
+
code=target.code or "",
|
|
167
|
+
title=target.title,
|
|
168
|
+
description=target.description,
|
|
169
|
+
uri=target.uri,
|
|
170
|
+
indicators=[
|
|
171
|
+
_indicator_series_metadata(indicator, dimensions_by_code)
|
|
172
|
+
for indicator in target.indicators or []
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
raise ValueError(f"target code {target_code!r} was not found")
|
|
177
|
+
|
|
178
|
+
def get_geo_areas(self) -> list[ApiGeoArea]:
|
|
179
|
+
"""
|
|
180
|
+
Returns a list of geographic areas and their M49 codes.
|
|
181
|
+
"""
|
|
182
|
+
response = self._get("/sdg/GeoArea/List")
|
|
183
|
+
response.raise_for_status()
|
|
184
|
+
data = response.json()
|
|
185
|
+
return [ApiGeoArea(**item) for item in data]
|
|
186
|
+
|
|
187
|
+
def get_goals(self) -> list[ApiGoal]:
|
|
188
|
+
"""
|
|
189
|
+
Fetches all SDG goals.
|
|
190
|
+
"""
|
|
191
|
+
response = self._get("/sdg/Goal/List")
|
|
192
|
+
response.raise_for_status()
|
|
193
|
+
data = response.json()
|
|
194
|
+
return [ApiGoal(**item) for item in data]
|
|
195
|
+
|
|
196
|
+
def get_indicators(self, include_series: bool = True) -> list[ApiTarget]:
|
|
197
|
+
"""
|
|
198
|
+
Fetches all indicators, optionally including their series.
|
|
199
|
+
"""
|
|
200
|
+
response = self._get("/sdg/Indicator/List", params={"includechildren": include_series})
|
|
201
|
+
response.raise_for_status()
|
|
202
|
+
data = response.json()
|
|
203
|
+
return [ApiTarget(**item) for item in data]
|
|
204
|
+
|
|
205
|
+
def get_concepts(self) -> list[ConceptsMasterData]:
|
|
206
|
+
"""
|
|
207
|
+
Fetches all concepts.
|
|
208
|
+
"""
|
|
209
|
+
response = self._get("sdg/SDMXMetadata/GetConceptsMasterList")
|
|
210
|
+
response.raise_for_status()
|
|
211
|
+
return [ConceptsMasterData(**item) for item in response.json()]
|
|
212
|
+
|
|
213
|
+
def get_sdmx_series(self) -> list[SDMXMetaDataResponse]:
|
|
214
|
+
"""
|
|
215
|
+
Fetches all SDMX series metadata.
|
|
216
|
+
"""
|
|
217
|
+
response = self._get("sdg/SDMXMetadata/GetSeries")
|
|
218
|
+
response.raise_for_status()
|
|
219
|
+
return [SDMXMetaDataResponse(**item) for item in response.json()]
|
|
220
|
+
|
|
221
|
+
def get_series_dimensions(self, series_code: str) -> list[ApiDimension]:
|
|
222
|
+
"""
|
|
223
|
+
Fetches available disaggregation dimensions for a series.
|
|
224
|
+
"""
|
|
225
|
+
response = self._get(f"/sdg/Series/{series_code}/Dimensions")
|
|
226
|
+
response.raise_for_status()
|
|
227
|
+
return [ApiDimension(**item) for item in response.json()]
|
|
228
|
+
|
|
229
|
+
def get_series_data(
|
|
230
|
+
self,
|
|
231
|
+
series_codes: Sequence[str],
|
|
232
|
+
area_code: str | None = None,
|
|
233
|
+
start_period: str | None = None,
|
|
234
|
+
end_period: str | None = None,
|
|
235
|
+
release_code: str | None = None,
|
|
236
|
+
dimensions: DimensionArgument = "coarsest",
|
|
237
|
+
) -> list[dict]:
|
|
238
|
+
"""
|
|
239
|
+
Pulls actual data observations for given series codes across all pages.
|
|
240
|
+
Returns a list of dictionaries, making it easy to create a Polars or Pandas DataFrame.
|
|
241
|
+
"""
|
|
242
|
+
series_codes = _series_code_list(series_codes)
|
|
243
|
+
params = {"pageSize": 1000}
|
|
244
|
+
if area_code:
|
|
245
|
+
params["areaCode"] = area_code
|
|
246
|
+
if release_code:
|
|
247
|
+
params["releaseCode"] = release_code
|
|
248
|
+
time_period_params = _period_query_params(start_period, end_period)
|
|
249
|
+
if time_period_params == {}:
|
|
250
|
+
return []
|
|
251
|
+
if time_period_params is not None:
|
|
252
|
+
params.update(time_period_params)
|
|
253
|
+
|
|
254
|
+
if dimensions == "coarsest":
|
|
255
|
+
all_observations = []
|
|
256
|
+
for series_code in series_codes:
|
|
257
|
+
series_params = {**params, "seriesCode": series_code}
|
|
258
|
+
coarsest_dimensions = _coarsest_dimension_filters(
|
|
259
|
+
self.get_series_dimensions(series_code)
|
|
260
|
+
)
|
|
261
|
+
if coarsest_dimensions:
|
|
262
|
+
series_params["dimensions"] = _dimension_payload(coarsest_dimensions)
|
|
263
|
+
all_observations.extend(self._fetch_series_data(series_params))
|
|
264
|
+
return all_observations
|
|
265
|
+
|
|
266
|
+
params["seriesCode"] = ",".join(series_codes)
|
|
267
|
+
if dimensions != "all":
|
|
268
|
+
if not isinstance(dimensions, Mapping):
|
|
269
|
+
raise ValueError('dimensions must be "coarsest", "all", or a mapping')
|
|
270
|
+
params["dimensions"] = _dimension_payload(dimensions)
|
|
271
|
+
|
|
272
|
+
return self._fetch_series_data(params)
|
|
273
|
+
|
|
274
|
+
def _fetch_series_data(self, params: dict) -> list[dict]:
|
|
275
|
+
time_periods = params.get("timePeriod")
|
|
276
|
+
if isinstance(time_periods, list) and len(time_periods) > 1 and "dimensions" in params:
|
|
277
|
+
all_observations = []
|
|
278
|
+
for time_period in time_periods:
|
|
279
|
+
period_params = {**params, "timePeriod": [time_period]}
|
|
280
|
+
all_observations.extend(self._fetch_series_data(period_params))
|
|
281
|
+
return all_observations
|
|
282
|
+
|
|
283
|
+
params = {**params, "page": 1}
|
|
284
|
+
all_observations = []
|
|
285
|
+
|
|
286
|
+
while True:
|
|
287
|
+
# /sdg/Series/Data is often more direct than generic Observation for this
|
|
288
|
+
response = self._get("/sdg/Series/Data", params=params)
|
|
289
|
+
response.raise_for_status()
|
|
290
|
+
|
|
291
|
+
page_data = response.json()
|
|
292
|
+
observations = page_data.get("data", [])
|
|
293
|
+
all_observations.extend(_normalize_observation(item) for item in observations)
|
|
294
|
+
|
|
295
|
+
total_pages = page_data.get("totalPages")
|
|
296
|
+
if total_pages is not None and params["page"] >= int(total_pages):
|
|
297
|
+
break
|
|
298
|
+
|
|
299
|
+
# UNSD may omit totalPages, so fall back to response size.
|
|
300
|
+
if not observations or len(observations) < params.get("pageSize", 100):
|
|
301
|
+
break
|
|
302
|
+
|
|
303
|
+
params["page"] += 1
|
|
304
|
+
|
|
305
|
+
return all_observations
|
sdgdata/debug.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
_enabled = False
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def enable() -> None:
|
|
9
|
+
"""
|
|
10
|
+
Enables debug query output for all sdgdata clients in this process.
|
|
11
|
+
"""
|
|
12
|
+
global _enabled
|
|
13
|
+
_enabled = True
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def disable() -> None:
|
|
17
|
+
"""
|
|
18
|
+
Disables debug query output for all sdgdata clients in this process.
|
|
19
|
+
"""
|
|
20
|
+
global _enabled
|
|
21
|
+
_enabled = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_enabled() -> bool:
|
|
25
|
+
"""
|
|
26
|
+
Returns whether debug query output is enabled.
|
|
27
|
+
"""
|
|
28
|
+
return _enabled
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def print_query(request: httpx.Request) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Prints the fully constructed request URL when debug mode is enabled.
|
|
34
|
+
"""
|
|
35
|
+
if _enabled:
|
|
36
|
+
print(f"sdgdata query: {request.url}", file=sys.stderr)
|
sdgdata/metadata.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
from sdgdata.models import ApiDimension
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SeriesMetadata(BaseModel):
|
|
7
|
+
code: str
|
|
8
|
+
description: str | None = None
|
|
9
|
+
uri: str | None = None
|
|
10
|
+
latest_release: str | None = None
|
|
11
|
+
releases: list[str] = Field(default_factory=list)
|
|
12
|
+
dimensions: list[ApiDimension] = Field(default_factory=list)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class IndicatorSeriesMetadata(BaseModel):
|
|
16
|
+
code: str
|
|
17
|
+
description: str | None = None
|
|
18
|
+
tier: str | None = None
|
|
19
|
+
uri: str | None = None
|
|
20
|
+
series: list[SeriesMetadata] = Field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TargetSeriesMetadata(BaseModel):
|
|
24
|
+
code: str
|
|
25
|
+
title: str | None = None
|
|
26
|
+
description: str | None = None
|
|
27
|
+
uri: str | None = None
|
|
28
|
+
indicators: list[IndicatorSeriesMetadata] = Field(default_factory=list)
|
sdgdata/models.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# generated by scripts/generate_models.py
|
|
2
|
+
# source: data/un-api-openapi.json
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApiDisaggregatedDimenions(BaseModel):
|
|
12
|
+
id: str | None = None
|
|
13
|
+
goal: int | None = None
|
|
14
|
+
target: str | None = None
|
|
15
|
+
indicator: str | None = None
|
|
16
|
+
seriesCode: str | None = None
|
|
17
|
+
disaggregatedCategory: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ApiOneSeriesMultiArea(BaseModel):
|
|
21
|
+
indicator: str | None = None
|
|
22
|
+
seriesCode: str | None = None
|
|
23
|
+
seriesTitle: str | None = None
|
|
24
|
+
disaggregationType: str | None = None
|
|
25
|
+
units: str | None = None
|
|
26
|
+
countryData: list[ApiCountrywiseData] | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApiCountrywiseData(BaseModel):
|
|
30
|
+
geoAreaCode: str | None = None
|
|
31
|
+
geoAreaName: str | None = None
|
|
32
|
+
chartColor: str | None = None
|
|
33
|
+
yearWiseData: list[ApiYearWiseData] | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ApiYearWiseData(BaseModel):
|
|
37
|
+
year: str | None = None
|
|
38
|
+
value: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ApiMultiSeriesOneArea(BaseModel):
|
|
42
|
+
geoAreaCode: str | None = None
|
|
43
|
+
geoAreaName: str | None = None
|
|
44
|
+
ctSeriesWiseData: list[ApiSeriesData] | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ApiSeriesData(BaseModel):
|
|
48
|
+
indicator: str | None = None
|
|
49
|
+
seriesCode: str | None = None
|
|
50
|
+
seriesTitle: str | None = None
|
|
51
|
+
disaggregationType: str | None = None
|
|
52
|
+
units: str | None = None
|
|
53
|
+
chartColor: str | None = None
|
|
54
|
+
yearWiseData: list[ApiYearWiseData] | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ApiCompareIndicatorsAcrossCountries(BaseModel):
|
|
58
|
+
goalId: str | None = None
|
|
59
|
+
goalName: str | None = None
|
|
60
|
+
indicators: list[ApiIndicatorPercentage] | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ApiIndicatorPercentage(BaseModel):
|
|
64
|
+
code: str | None = None
|
|
65
|
+
description: str | None = None
|
|
66
|
+
percentage: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ApiCountriesAcrossAllGoals(BaseModel):
|
|
70
|
+
goals: list[SDGGoals] | None = None
|
|
71
|
+
goalsCountryDetails: list[GoalsCountrywiseData] | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SDGGoals(BaseModel):
|
|
75
|
+
goalId: int | None = None
|
|
76
|
+
goalName: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GoalsCountrywiseData(BaseModel):
|
|
80
|
+
geoAreaCode: int | None = None
|
|
81
|
+
geoAreaName: str | None = None
|
|
82
|
+
goal1: float | None = None
|
|
83
|
+
goal2: float | None = None
|
|
84
|
+
goal3: float | None = None
|
|
85
|
+
goal4: float | None = None
|
|
86
|
+
goal5: float | None = None
|
|
87
|
+
goal6: float | None = None
|
|
88
|
+
goal7: float | None = None
|
|
89
|
+
goal8: float | None = None
|
|
90
|
+
goal9: float | None = None
|
|
91
|
+
goal10: float | None = None
|
|
92
|
+
goal11: float | None = None
|
|
93
|
+
goal12: float | None = None
|
|
94
|
+
goal13: float | None = None
|
|
95
|
+
goal14: float | None = None
|
|
96
|
+
goal15: float | None = None
|
|
97
|
+
goal16: float | None = None
|
|
98
|
+
goal17: float | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ApiGeoArea(BaseModel):
|
|
102
|
+
geoAreaCode: str | None = Field(None, description="geoAreaCode is equivalent to M49")
|
|
103
|
+
geoAreaName: str | None = Field(
|
|
104
|
+
None, description="geoArea Name is the offician UN name for that country or region."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ApiGeoTree(BaseModel):
|
|
109
|
+
geoAreaCode: int | None = Field(None, description="Gets or Sets code")
|
|
110
|
+
geoAreaName: str | None = Field(None, description="Gets or Sets name")
|
|
111
|
+
type: str | None = Field(None, description="Gets or Sets type")
|
|
112
|
+
children: list[ApiGeoTree] | None = Field(None, description="Gets or Sets children")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ApiGoalData(BaseModel):
|
|
116
|
+
code: str | None = Field(None, description="Gets or Sets Code")
|
|
117
|
+
title: str | None = Field(None, description="Gets or Sets Title")
|
|
118
|
+
description: str | None = Field(None, description="Gets or Sets Description")
|
|
119
|
+
uri: str | None = Field(None, description="Gets or Sets URI")
|
|
120
|
+
targets: list[ApiTargetData] | None = Field(None, description="Gets or Sets Targets")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ApiTargetData(BaseModel):
|
|
124
|
+
code: str | None = Field(None, description="Gets or Sets Code")
|
|
125
|
+
title: str | None = Field(None, description="Gets or Sets Title")
|
|
126
|
+
description: str | None = Field(None, description="Gets or Sets Description")
|
|
127
|
+
URI: str | None = Field(None, description="Gets or Sets URI")
|
|
128
|
+
indicators: list[ApiIndicatorData] | None = Field(None, description="Gets or Sets Indicators")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ApiIndicatorData(BaseModel):
|
|
132
|
+
code: str | None = Field(None, description="Gets or Sets Code")
|
|
133
|
+
description: str | None = Field(None, description="Gets or Sets Description")
|
|
134
|
+
tier: str | None = Field(None, description="Gets or Sets Tier")
|
|
135
|
+
uri: str | None = Field(None, description="Gets or Sets URI")
|
|
136
|
+
series: list[ApiSerieData] | None = Field(None, description="Gets or Sets Series")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ApiSerieData(BaseModel):
|
|
140
|
+
release: str | None = Field(None, description="Gets or Sets Series")
|
|
141
|
+
code: str | None = None
|
|
142
|
+
description: str | None = Field(None, description="Gets or Sets Description")
|
|
143
|
+
uri: str | None = Field(None, description="Gets or Sets URI")
|
|
144
|
+
observations: list[ApiObservation] | None = Field(None, description="Gets or Sets attributes")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ApiObservation(BaseModel):
|
|
148
|
+
goal: list[str] | None = Field(None, description="Gets or Sets Goal")
|
|
149
|
+
target: list[str] | None = Field(None, description="Gets or Sets Target")
|
|
150
|
+
indicator: list[str] | None = Field(None, description="Gets or Sets Indicator")
|
|
151
|
+
series: str | None = Field(None, description="Gets or Sets Series")
|
|
152
|
+
seriesDescription: str | None = Field(None, description="Gets or Sets Series Description")
|
|
153
|
+
seriesCount: str | None = Field(None, description="Gets or Sets Series Count")
|
|
154
|
+
geoAreaCode: str | None = Field(None, description="Gets or Sets geoAreaCode")
|
|
155
|
+
geoAreaName: str | None = Field(None, description="Gets or Sets geoAreaName")
|
|
156
|
+
timePeriodStart: float | None = Field(None, description="Gets or Sets timePeriod Start")
|
|
157
|
+
value: str | None = Field(None, description="Gets or Sets Value")
|
|
158
|
+
valueType: str | None = Field(None, description="Gets or Sets ValueType")
|
|
159
|
+
time_detail: str | None = Field(None, description="Gets or Sets TimeDetail")
|
|
160
|
+
timeCoverage: str | None = Field(None, description="Gets or Sets TimeCoverage")
|
|
161
|
+
upperBound: str | None = Field(None, description="Gets or Sets UpperBound")
|
|
162
|
+
lowerBound: str | None = Field(None, description="Gets or Sets LowerBound")
|
|
163
|
+
basePeriod: str | None = Field(None, description="Gets or Sets BasePeriod")
|
|
164
|
+
source: str | None = Field(None, description="Gets or Sets Source")
|
|
165
|
+
geoInfoUrl: str | None = Field(None, description="Gets or Sets GeoInfoUrl")
|
|
166
|
+
footnotes: list[str] | None = Field(None, description="Gets or Sets Footnotes")
|
|
167
|
+
attributes: dict[str, str] | None = Field(None, description="Gets or Sets Attributes")
|
|
168
|
+
dimensions: dict[str, str] | None = Field(None, description="Gets or Sets Dimensions")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ApiGoal(BaseModel):
|
|
172
|
+
code: str | None = Field(None, description="Gets or Sets Code")
|
|
173
|
+
title: str | None = Field(None, description="Gets or Sets Title")
|
|
174
|
+
description: str | None = Field(None, description="Gets or Sets Description")
|
|
175
|
+
uri: str | None = Field(None, description="Gets or Sets URI")
|
|
176
|
+
targets: list[ApiTarget] | None = Field(None, description="Gets or Sets Targets")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ApiTarget(BaseModel):
|
|
180
|
+
goal: str | None = Field(None, description="Gets or Sets Code")
|
|
181
|
+
code: str | None = Field(None, description="Gets or Sets Code")
|
|
182
|
+
title: str | None = Field(None, description="Gets or Sets Title")
|
|
183
|
+
description: str | None = Field(None, description="Gets or Sets Description")
|
|
184
|
+
uri: str | None = Field(None, description="Gets or Sets URI")
|
|
185
|
+
indicators: list[ApiIndicator] | None = Field(None, description="Gets or Sets Indicators")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class ApiIndicator(BaseModel):
|
|
189
|
+
goal: str | None = Field(None, description="Gets or Sets Goal")
|
|
190
|
+
target: str | None = Field(None, description="Gets or Sets Code")
|
|
191
|
+
code: str | None = Field(None, description="Gets or Sets Code")
|
|
192
|
+
description: str | None = Field(None, description="Gets or Sets Description")
|
|
193
|
+
tier: str | None = Field(None, description="Gets or Sets Tier")
|
|
194
|
+
uri: str | None = Field(None, description="Gets or Sets URI")
|
|
195
|
+
series: list[ApiSerie] | None = Field(None, description="Gets or Sets Series")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class ApiSerie(BaseModel):
|
|
199
|
+
goal: list[str] | None = Field(None, description="Gets or Sets Goal")
|
|
200
|
+
target: list[str] | None = Field(None, description="Gets or Sets Target")
|
|
201
|
+
indicator: list[str] | None = Field(None, description="Gets or Sets Indicator")
|
|
202
|
+
release: str | None = Field(None, description="Gets or Sets Release")
|
|
203
|
+
code: str | None = Field(None, description="Gets or Sets Code")
|
|
204
|
+
description: str | None = Field(None, description="Gets or Sets Description")
|
|
205
|
+
uri: str | None = Field(None, description="Gets or Sets URI")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class ApiDimension(BaseModel):
|
|
209
|
+
id: str | None = Field(None, description="Gets or Sets id")
|
|
210
|
+
codes: list[ApiCodeList] | None = Field(None, description="Gets or Sets codeList")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class ApiCodeList(BaseModel):
|
|
214
|
+
code: str | None = Field(None, description="Gets or Sets code")
|
|
215
|
+
description: str | None = Field(None, description="Gets or Sets code")
|
|
216
|
+
sdmx: str | None = Field(None, description="Gets or Sets code")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class ApiObservationPage(BaseModel):
|
|
220
|
+
size: int | None = Field(
|
|
221
|
+
None, description="Gets or Sets Size\r\nThe number of elements in the page"
|
|
222
|
+
)
|
|
223
|
+
totalElements: int | None = Field(
|
|
224
|
+
None, description="Gets or Sets TotalElements\r\nThe total number of elements"
|
|
225
|
+
)
|
|
226
|
+
totalPages: int | None = Field(
|
|
227
|
+
None, description="Gets or Sets totalPages\r\nThe total number of pages"
|
|
228
|
+
)
|
|
229
|
+
pageNumber: int | None = Field(
|
|
230
|
+
None, description="Gets or Sets pageNumber\r\nThe current page number"
|
|
231
|
+
)
|
|
232
|
+
attributes: list[ApiDimension] | None = Field(None, description="Gets or Sets attributes")
|
|
233
|
+
dimensions: list[ApiDimension] | None = Field(None, description="Gets or Sets dimensions")
|
|
234
|
+
data: list[ApiObservation] | None = Field(None, description="Gets or Sets data")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class FileStreamResult(BaseModel):
|
|
238
|
+
fileStream: Stream | None = None
|
|
239
|
+
contentType: str | None = None
|
|
240
|
+
fileDownloadName: str | None = None
|
|
241
|
+
lastModified: datetime | None = None
|
|
242
|
+
entityTag: EntityTagHeaderValue | None = None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class Stream(BaseModel):
|
|
246
|
+
canRead: bool | None = None
|
|
247
|
+
canSeek: bool | None = None
|
|
248
|
+
canTimeout: bool | None = None
|
|
249
|
+
canWrite: bool | None = None
|
|
250
|
+
length: int | None = None
|
|
251
|
+
position: int | None = None
|
|
252
|
+
readTimeout: int | None = None
|
|
253
|
+
writeTimeout: int | None = None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class EntityTagHeaderValue(BaseModel):
|
|
257
|
+
tag: StringSegment | None = None
|
|
258
|
+
isWeak: bool | None = None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class StringSegment(BaseModel):
|
|
262
|
+
buffer: str | None = None
|
|
263
|
+
offset: int | None = None
|
|
264
|
+
length: int | None = None
|
|
265
|
+
value: str | None = None
|
|
266
|
+
hasValue: bool | None = None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class ApiObservationPivotPage(BaseModel):
|
|
270
|
+
size: int | None = Field(
|
|
271
|
+
None, description="Gets or Sets Size\r\nThe number of elements in the page"
|
|
272
|
+
)
|
|
273
|
+
totalElements: int | None = Field(
|
|
274
|
+
None, description="Gets or Sets TotalElements\r\nThe total number of elements"
|
|
275
|
+
)
|
|
276
|
+
totalPages: int | None = Field(
|
|
277
|
+
None, description="Gets or Sets totalPages\r\nThe total number of pages"
|
|
278
|
+
)
|
|
279
|
+
pageNumber: int | None = Field(
|
|
280
|
+
None, description="Gets or Sets pageNumber\r\nThe current page number"
|
|
281
|
+
)
|
|
282
|
+
attributes: list[ApiDimension] | None = Field(None, description="Gets or Sets attributes")
|
|
283
|
+
dimensions: list[ApiDimension] | None = Field(None, description="Gets or Sets dimensions")
|
|
284
|
+
data: list[ApiObservationPivot] | None = Field(None, description="Gets or Sets data")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class ApiObservationPivot(BaseModel):
|
|
288
|
+
goal: str | None = Field(None, description="Gets or Sets Goal")
|
|
289
|
+
target: str | None = Field(None, description="Gets or Sets Target")
|
|
290
|
+
indicator: str | None = Field(None, description="Gets or Sets Indicator")
|
|
291
|
+
series: str | None = Field(None, description="Gets or Sets Series")
|
|
292
|
+
seriesDescription: str | None = Field(None, description="Gets or Sets Series")
|
|
293
|
+
seriesCount: str | None = Field(None, description="Gets or Sets Series")
|
|
294
|
+
geoAreaCode: str | None = Field(None, description="Gets or Sets geoAreaCode")
|
|
295
|
+
geoAreaName: str | None = Field(None, description="Gets or Sets geoAreaName")
|
|
296
|
+
timeCoverage: str | None = Field(None, description="Gets or Sets TimeCoverage")
|
|
297
|
+
upperBound: str | None = Field(None, description="Gets or Sets UpperBound")
|
|
298
|
+
lowerBound: str | None = Field(None, description="Gets or Sets LowerBound")
|
|
299
|
+
basePeriod: str | None = Field(None, description="Gets or Sets BasePeriod")
|
|
300
|
+
source: str | None = Field(None, description="Gets or Sets Source")
|
|
301
|
+
geoInfoUrl: str | None = Field(None, description="Gets or Sets GeoInfoUrl")
|
|
302
|
+
age: str | None = Field(None, description="Gets or Sets age")
|
|
303
|
+
freq: str | None = Field(None, description="Gets or Sets freq")
|
|
304
|
+
sex: str | None = Field(None, description="Gets or Sets sex")
|
|
305
|
+
location: str | None = Field(None, description="Gets or Sets location")
|
|
306
|
+
units: str | None = Field(None, description="Gets or Sets Units")
|
|
307
|
+
level_status: str | None = Field(None, description="Gets or Sets level/status")
|
|
308
|
+
name_of_international_agreement: str | None = Field(
|
|
309
|
+
None, description="Gets or Sets name of international agreement"
|
|
310
|
+
)
|
|
311
|
+
education_level: str | None = Field(None, description="Gets or Sets education level")
|
|
312
|
+
type_of_product: str | None = Field(None, description="Gets or Sets type of product")
|
|
313
|
+
type_of_facilities: str | None = Field(None, description="Gets or Sets type of facilities")
|
|
314
|
+
name_of_international_institution: str | None = Field(
|
|
315
|
+
None, description="Gets or Sets name of international institution"
|
|
316
|
+
)
|
|
317
|
+
type_of_occupation: str | None = Field(None, description="Gets or Sets type of occupation")
|
|
318
|
+
tariff_regime_status: str | None = Field(None, description="Gets or Sets tariff regime status")
|
|
319
|
+
mode_of_transportation: str | None = Field(
|
|
320
|
+
None, description="Gets or Sets mode of transportation"
|
|
321
|
+
)
|
|
322
|
+
type_of_mobile_technology: str | None = Field(
|
|
323
|
+
None, description="Gets or Sets type of mobile technology"
|
|
324
|
+
)
|
|
325
|
+
name_of_non_communicable_disease: str | None = Field(
|
|
326
|
+
None, description="Gets or Sets name of non-communicable disease"
|
|
327
|
+
)
|
|
328
|
+
type_of_skill: str | None = Field(None, description="Gets or Sets type of skill")
|
|
329
|
+
type_of_speed: str | None = Field(None, description="Gets or Sets type of speed")
|
|
330
|
+
migratory_status: str | None = Field(None, description="Gets or Sets migratory status")
|
|
331
|
+
disability_status: str | None = Field(None, description="Gets or Sets disability status")
|
|
332
|
+
hazard_type: str | None = Field(None, description="Gets or Sets hazard type")
|
|
333
|
+
ihr_capacity: str | None = Field(None, description="Gets or Sets ihr capacity")
|
|
334
|
+
reporting_type: str | None = Field(None, description="Gets or Sets Units")
|
|
335
|
+
cities: str | None = Field(None, description="Gets or Sets cities")
|
|
336
|
+
activity: str | None = Field(None, description="Gets or Sets Activity")
|
|
337
|
+
policy_domains: str | None = Field(None, description="Gets or Sets Policy Domains")
|
|
338
|
+
years: str | None = Field(None, description="Gets or Sets years")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class SDMXMetaDataResponse(BaseModel):
|
|
342
|
+
series: str | None = None
|
|
343
|
+
seriesDesc: str | None = None
|
|
344
|
+
indicatorDesc: str | None = None
|
|
345
|
+
conceptId: str | None = None
|
|
346
|
+
conceptName: str | None = None
|
|
347
|
+
conceptDesc: str | None = None
|
|
348
|
+
conceptHTML: str | None = None
|
|
349
|
+
parentId: str | None = None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class ConceptsMasterData(BaseModel):
|
|
353
|
+
conceptId: str | None = None
|
|
354
|
+
conceptName: str | None = None
|
|
355
|
+
parentId: str | None = None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class ApiSliceData(BaseModel):
|
|
359
|
+
series: str | None = Field(None, description="Gets or Sets Series")
|
|
360
|
+
geoAreaCode: int | None = Field(None, description="Gets or Sets geoAreaCode")
|
|
361
|
+
geoAreaName: str | None = Field(None, description="Gets or Sets geoAreaName")
|
|
362
|
+
dimensions: list[dict[str, str]] | None = Field(
|
|
363
|
+
None, description="Gets or Sets Dimensions for slice data"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class FileResult(BaseModel):
|
|
368
|
+
contentType: str | None = None
|
|
369
|
+
fileDownloadName: str | None = None
|
|
370
|
+
lastModified: datetime | None = None
|
|
371
|
+
entityTag: EntityTagHeaderValue | None = None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
ApiGeoTree.model_rebuild()
|
sdgdata/utilities.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from sdgdata.models import ApiDimension
|
|
7
|
+
|
|
8
|
+
_RELEASE_PATTERN = re.compile(r"^(\d{4})\.Q(\d+)\.G\.(\d+)$")
|
|
9
|
+
DimensionMode = Literal["coarsest", "all"]
|
|
10
|
+
DimensionFilters = Mapping[str, str | Sequence[str]]
|
|
11
|
+
DimensionArgument = DimensionMode | DimensionFilters
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _series_code_list(series_codes: Sequence[str]) -> list[str]:
|
|
15
|
+
if isinstance(series_codes, str | bytes):
|
|
16
|
+
raise TypeError('series_codes must be a sequence of strings, such as ["SERIES_CODE"]')
|
|
17
|
+
return list(series_codes)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalize_singleton_list(value):
|
|
21
|
+
if isinstance(value, list) and len(value) == 1:
|
|
22
|
+
return value[0]
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_time_period_start(value):
|
|
27
|
+
if isinstance(value, float) and value.is_integer():
|
|
28
|
+
return int(value)
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize_observation(observation: dict) -> dict:
|
|
33
|
+
normalized = {**observation}
|
|
34
|
+
for key in ("goal", "target", "indicator"):
|
|
35
|
+
normalized[key] = _normalize_singleton_list(normalized.get(key))
|
|
36
|
+
if "timePeriodStart" in normalized:
|
|
37
|
+
normalized["timePeriodStart"] = _normalize_time_period_start(normalized["timePeriodStart"])
|
|
38
|
+
return normalized
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _release_sort_key(release: str | None) -> tuple[int, int, int]:
|
|
42
|
+
match = _RELEASE_PATTERN.match(release or "")
|
|
43
|
+
if match is None:
|
|
44
|
+
return (-1, -1, -1)
|
|
45
|
+
return tuple(int(part) for part in match.groups())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _period_value(period: str | None) -> int | None:
|
|
49
|
+
if period is None:
|
|
50
|
+
return None
|
|
51
|
+
return int(period)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _period_query_params(
|
|
55
|
+
start_period: str | None,
|
|
56
|
+
end_period: str | None,
|
|
57
|
+
) -> dict[str, list[str]] | None:
|
|
58
|
+
start = _period_value(start_period)
|
|
59
|
+
end = _period_value(end_period)
|
|
60
|
+
if start is None and end is None:
|
|
61
|
+
return None
|
|
62
|
+
if end is None:
|
|
63
|
+
raise ValueError("start_period and end_period must be provided together")
|
|
64
|
+
if start is None:
|
|
65
|
+
raise ValueError("start_period and end_period must be provided together")
|
|
66
|
+
if end < start:
|
|
67
|
+
return {}
|
|
68
|
+
return {"timePeriod": [str(period) for period in range(start, end + 1)]}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _dimension_payload(dimensions: DimensionFilters) -> str:
|
|
72
|
+
payload = []
|
|
73
|
+
for name, values in dimensions.items():
|
|
74
|
+
if isinstance(values, str):
|
|
75
|
+
values = [values]
|
|
76
|
+
payload.append({"name": name, "values": list(values)})
|
|
77
|
+
return json.dumps(payload, separators=(",", ":"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _coarsest_dimension_filters(dimensions: list[ApiDimension]) -> dict[str, str]:
|
|
81
|
+
filters = {}
|
|
82
|
+
preferred_codes = {
|
|
83
|
+
"age": ["ALLAGE"],
|
|
84
|
+
"sex": ["BOTHSEX"],
|
|
85
|
+
"location": ["ALLAREA"],
|
|
86
|
+
"reporting type": ["G", "N", "R"],
|
|
87
|
+
}
|
|
88
|
+
total_description_markers = (
|
|
89
|
+
"total",
|
|
90
|
+
"all ",
|
|
91
|
+
"all age",
|
|
92
|
+
"both",
|
|
93
|
+
"no break",
|
|
94
|
+
"no breakdown",
|
|
95
|
+
"national average",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
for dimension in dimensions:
|
|
99
|
+
if dimension.id is None or not dimension.codes:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
dimension_id = dimension.id.lower()
|
|
103
|
+
selected_code = None
|
|
104
|
+
|
|
105
|
+
for preferred_code in preferred_codes.get(dimension_id, []):
|
|
106
|
+
selected_code = next(
|
|
107
|
+
(code for code in dimension.codes if code.code == preferred_code),
|
|
108
|
+
None,
|
|
109
|
+
)
|
|
110
|
+
if selected_code is not None:
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
if selected_code is None:
|
|
114
|
+
selected_code = next(
|
|
115
|
+
(code for code in dimension.codes if code.code == "_T" or code.sdmx == "_T"),
|
|
116
|
+
None,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if selected_code is None:
|
|
120
|
+
selected_code = next(
|
|
121
|
+
(
|
|
122
|
+
code
|
|
123
|
+
for code in dimension.codes
|
|
124
|
+
if code.description
|
|
125
|
+
and any(
|
|
126
|
+
marker in code.description.lower() for marker in total_description_markers
|
|
127
|
+
)
|
|
128
|
+
),
|
|
129
|
+
None,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if selected_code is None:
|
|
133
|
+
selected_code = dimension.codes[0]
|
|
134
|
+
|
|
135
|
+
if selected_code.code is not None:
|
|
136
|
+
filters[dimension.id] = selected_code.code
|
|
137
|
+
|
|
138
|
+
return filters
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _time_series_key(record: dict) -> tuple:
|
|
142
|
+
dimensions = record.get("dimensions") or {}
|
|
143
|
+
return (
|
|
144
|
+
record.get("series"),
|
|
145
|
+
record.get("geoAreaCode"),
|
|
146
|
+
tuple(sorted(dimensions.items())),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def is_single_time_series(records: list[dict]) -> bool:
|
|
151
|
+
"""
|
|
152
|
+
Returns whether records contain exactly one series/area/dimension time series.
|
|
153
|
+
"""
|
|
154
|
+
if not records:
|
|
155
|
+
return False
|
|
156
|
+
return len({_time_series_key(record) for record in records}) == 1
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sdgdata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple Python client for the United Nations Statistics Division SDG API
|
|
5
|
+
Author-email: Vassily Trubetskoy <3219751+v-a-s-a@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/v-a-s-a/sdgdata
|
|
8
|
+
Project-URL: Repository, https://github.com/v-a-s-a/sdgdata
|
|
9
|
+
Project-URL: Issues, https://github.com/v-a-s-a/sdgdata/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/v-a-s-a/sdgdata#readme
|
|
11
|
+
Keywords: sdg,statistics,sustainable-development-goals,united-nations,unsd
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: httpx>=0.28.1
|
|
24
|
+
Requires-Dist: pydantic>=2.12.3
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# sdgdata
|
|
28
|
+
|
|
29
|
+
`sdgdata` is an unofficial Python client for SDG data from the UNSD SDG API.
|
|
30
|
+
|
|
31
|
+
It provides a simple client to retrieve Sustainable Development Goal metadata
|
|
32
|
+
and data from Python, with models derived from the UNSD SDG API schema.
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- Fetch SDG goals, targets, indicators, and series metadata.
|
|
37
|
+
- Look up geographic areas and M49 area codes.
|
|
38
|
+
- Retrieve paginated SDG series observations with simple Python calls.
|
|
39
|
+
- Validate structured API responses with Pydantic models.
|
|
40
|
+
- Load observation data into analysis tools such as pandas or Polars.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
Install from PyPI:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv add sdgdata
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
or:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install sdgdata
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quick Start
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from sdgdata import SDGClient
|
|
60
|
+
from sdgdata.client import is_single_time_series
|
|
61
|
+
|
|
62
|
+
client = SDGClient()
|
|
63
|
+
|
|
64
|
+
# Find available geographic areas and SDG targets.
|
|
65
|
+
areas = client.get_geo_areas()
|
|
66
|
+
targets = client.get_targets()
|
|
67
|
+
|
|
68
|
+
# Find latest-release series codes for a target.
|
|
69
|
+
series = client.get_series_codes(target_code="3.8")
|
|
70
|
+
series_code = series[-1].code
|
|
71
|
+
area_code = areas[0].geoAreaCode
|
|
72
|
+
|
|
73
|
+
# Inspect available disaggregation dimensions for a series.
|
|
74
|
+
dimensions = client.get_series_dimensions(series_code)
|
|
75
|
+
|
|
76
|
+
# Fetch the coarsest available disaggregation by default.
|
|
77
|
+
data = client.get_series_data(
|
|
78
|
+
series_codes=[series_code],
|
|
79
|
+
area_code=area_code,
|
|
80
|
+
start_period="2015",
|
|
81
|
+
end_period="2026",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
assert is_single_time_series(data)
|
|
85
|
+
|
|
86
|
+
# To fetch every disaggregation, opt into the unfiltered API response.
|
|
87
|
+
all_disaggregations = client.get_series_data(
|
|
88
|
+
series_codes=[series_code],
|
|
89
|
+
area_code=area_code,
|
|
90
|
+
start_period="2015",
|
|
91
|
+
end_period="2026",
|
|
92
|
+
dimensions="all",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Or request a specific disaggregation slice.
|
|
96
|
+
custom_slice = client.get_series_data(
|
|
97
|
+
series_codes=[series_code],
|
|
98
|
+
area_code=area_code,
|
|
99
|
+
dimensions={"Reporting Type": "G"},
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`get_series_data()` returns a list of dictionaries, making it straightforward
|
|
104
|
+
to create a dataframe for analysis. It normalizes singleton `goal`, `target`,
|
|
105
|
+
and `indicator` arrays to strings, and integral `timePeriodStart` values to
|
|
106
|
+
integers. By default, it filters to the coarsest available disaggregation, such
|
|
107
|
+
as all ages, both sexes, and total groups when those dimension values exist.
|
|
108
|
+
For the upstream observation field descriptions, see the
|
|
109
|
+
[UNSD SDG API Swagger documentation](https://unstats.un.org/sdgapi/swagger/).
|
|
110
|
+
|
|
111
|
+
## Documentation
|
|
112
|
+
|
|
113
|
+
- [Development](docs/development.md): setup, tests, fixture refreshes, builds, and CI behavior.
|
|
114
|
+
- [Model generation](docs/model-generation.md): generated models, stale checks, and OpenAPI source data.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
sdgdata/__init__.py,sha256=jPwpJcGdDu9MiEe4p-kvscpUz6opshz0e-M66dCeukI,62
|
|
2
|
+
sdgdata/client.py,sha256=c1qiN9qm-FG9sUpTST3rkXqnj-nmfrIvmIlFdLlYa4c,11237
|
|
3
|
+
sdgdata/debug.py,sha256=NMX0GHeFbkpMOKWg_QeGObAtORlAN8_1GJusCjKK8o8,686
|
|
4
|
+
sdgdata/metadata.py,sha256=aIwV3BpLJNJZs-gavUrsS_kA2scF8WPCc1HN0ltyp6c,780
|
|
5
|
+
sdgdata/models.py,sha256=ma1xm1Jwvk-dKYow-MV97vvvNeb9ky-CLS8F26ynhIg,16143
|
|
6
|
+
sdgdata/utilities.py,sha256=CgJP2Xh5xh_ECI2-hviNLDI8LfKHDyqaHqbYG0uIII8,4672
|
|
7
|
+
sdgdata-0.1.0.dist-info/licenses/LICENSE,sha256=76oGFukwtKfS25WTC9Y3ZJrtQmjsB49H1u3Pvq-C3h0,1075
|
|
8
|
+
sdgdata-0.1.0.dist-info/METADATA,sha256=QitqPHPpLy46VhDZu8AVQJjME4HZOttgJESO9TvfhoQ,3753
|
|
9
|
+
sdgdata-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
sdgdata-0.1.0.dist-info/top_level.txt,sha256=npcQxoAftbOi1FZqR9FhHOnF-JIrfRnclf2Kzqef_q0,8
|
|
11
|
+
sdgdata-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vassily Trubetskoy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sdgdata
|