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 ADDED
@@ -0,0 +1,5 @@
1
+ from .client import SDGClient
2
+
3
+ __all__ = [
4
+ "SDGClient",
5
+ ]
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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