hdx-python-country 4.2.8__tar.gz → 4.3.0__tar.gz

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.
Files changed (55) hide show
  1. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/PKG-INFO +3 -3
  2. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/pyproject.toml +3 -3
  3. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/src/hdx/location/_version.py +2 -2
  4. hdx_python_country-4.3.0/src/hdx/location/wfp_api.py +496 -0
  5. hdx_python_country-4.3.0/tests/fixtures/wfp/Currency_List.csv +129 -0
  6. hdx_python_country-4.3.0/tests/fixtures/wfp/Currency_UsdIndirectQuotation_AFN.csv +8695 -0
  7. hdx_python_country-4.3.0/tests/fixtures/wfp/Currency_UsdIndirectQuotation_SYP.csv +4 -0
  8. hdx_python_country-4.3.0/tests/fixtures/wfp/MarketPrices_PriceMonthly_BTN.csv +5 -0
  9. hdx_python_country-4.3.0/tests/fixtures/wfp/Markets_List_AFG.csv +3 -0
  10. hdx_python_country-4.3.0/tests/hdx/location/test_wfp_api.py +230 -0
  11. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/uv.lock +197 -177
  12. hdx_python_country-4.2.8/src/hdx/location/wfp_api.py +0 -323
  13. hdx_python_country-4.2.8/tests/fixtures/wfp/Currency_List_1.json +0 -1
  14. hdx_python_country-4.2.8/tests/fixtures/wfp/Currency_List_2.json +0 -1
  15. hdx_python_country-4.2.8/tests/fixtures/wfp/Currency_UsdIndirectQuotation_1.json +0 -1
  16. hdx_python_country-4.2.8/tests/fixtures/wfp/Currency_UsdIndirectQuotation_10.json +0 -1
  17. hdx_python_country-4.2.8/tests/fixtures/wfp/Currency_UsdIndirectQuotation_2.json +0 -1
  18. hdx_python_country-4.2.8/tests/fixtures/wfp/Currency_UsdIndirectQuotation_3.json +0 -1
  19. hdx_python_country-4.2.8/tests/fixtures/wfp/Currency_UsdIndirectQuotation_4.json +0 -1
  20. hdx_python_country-4.2.8/tests/fixtures/wfp/Currency_UsdIndirectQuotation_5.json +0 -1
  21. hdx_python_country-4.2.8/tests/fixtures/wfp/Currency_UsdIndirectQuotation_6.json +0 -1
  22. hdx_python_country-4.2.8/tests/fixtures/wfp/Currency_UsdIndirectQuotation_7.json +0 -1
  23. hdx_python_country-4.2.8/tests/fixtures/wfp/Currency_UsdIndirectQuotation_8.json +0 -1
  24. hdx_python_country-4.2.8/tests/fixtures/wfp/Currency_UsdIndirectQuotation_9.json +0 -1
  25. hdx_python_country-4.2.8/tests/fixtures/wfp/Markets_List_AFG_1.json +0 -28
  26. hdx_python_country-4.2.8/tests/fixtures/wfp/Markets_List_AFG_2.json +0 -5
  27. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/.github/workflows/publish.yaml +0 -0
  28. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/.github/workflows/run-python-tests.yaml +0 -0
  29. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/.gitignore +0 -0
  30. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/.pre-commit-config.yaml +0 -0
  31. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/LICENSE +0 -0
  32. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/README.md +0 -0
  33. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/documentation/.readthedocs.yaml +0 -0
  34. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/documentation/index.md +0 -0
  35. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/documentation/mkdocs.yaml +0 -0
  36. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/src/hdx/location/Countries & Territories Taxonomy MVP - C&T Taxonomy.csv +0 -0
  37. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/src/hdx/location/__init__.py +0 -0
  38. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/src/hdx/location/adminlevel.py +0 -0
  39. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/src/hdx/location/country.py +0 -0
  40. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/src/hdx/location/currency.py +0 -0
  41. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/src/hdx/location/int_timestamp.py +0 -0
  42. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/src/hdx/location/wfp_exchangerates.py +0 -0
  43. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/tests/fixtures/adminlevel.yaml +0 -0
  44. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/tests/fixtures/adminlevelparent.yaml +0 -0
  45. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/tests/fixtures/fixtures-download-global-pcode-lengths.csv +0 -0
  46. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/tests/fixtures/fixtures-download-global-pcodes-adm-1-2.csv +0 -0
  47. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/tests/fixtures/secondary_historic_rates.csv +0 -0
  48. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/tests/fixtures/secondary_rates.json +0 -0
  49. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/tests/hdx/location/Countries_UZB_Deleted.csv +0 -0
  50. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/tests/hdx/location/__init__.py +0 -0
  51. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/tests/hdx/location/conftest.py +0 -0
  52. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/tests/hdx/location/test_adminlevel.py +0 -0
  53. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/tests/hdx/location/test_country.py +0 -0
  54. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/tests/hdx/location/test_currency.py +0 -0
  55. {hdx_python_country-4.2.8 → hdx_python_country-4.3.0}/tests/hdx/location/test_wfp_exchangerates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hdx-python-country
3
- Version: 4.2.8
3
+ Version: 4.3.0
4
4
  Summary: HDX Python country code and exchange rate (fx) utilities
5
5
  Project-URL: Homepage, https://github.com/OCHA-DAP/hdx-python-country
6
6
  Project-URL: Repository, https://github.com/OCHA-DAP/hdx-python-country
@@ -26,12 +26,12 @@ Classifier: Programming Language :: Python :: 3.13
26
26
  Classifier: Programming Language :: Python :: 3.14
27
27
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
28
28
  Requires-Python: >=3.11
29
- Requires-Dist: hdx-python-utilities<5,>=4.2.6
29
+ Requires-Dist: hdx-python-utilities<5,>=4.2.7
30
30
  Requires-Dist: tenacity
31
31
  Provides-Extra: docs
32
32
  Requires-Dist: mkapi; extra == 'docs'
33
33
  Provides-Extra: wfp
34
- Requires-Dist: data-bridges-client<10,>=9.0.0; extra == 'wfp'
34
+ Requires-Dist: data-bridges-client<10,>=9.0.3; extra == 'wfp'
35
35
  Description-Content-Type: text/markdown
36
36
 
37
37
  [![Build Status](https://github.com/OCHA-DAP/hdx-python-country/actions/workflows/run-python-tests.yaml/badge.svg)](https://github.com/OCHA-DAP/hdx-python-country/actions/workflows/run-python-tests.yaml)
@@ -35,13 +35,13 @@ dynamic = ["version"]
35
35
  requires-python = ">=3.11"
36
36
 
37
37
  dependencies = [
38
- "hdx-python-utilities>=4.2.6,<5",
38
+ "hdx-python-utilities>=4.2.7,<5",
39
39
  "tenacity",
40
40
  ]
41
41
 
42
42
  [project.optional-dependencies]
43
43
  docs = ["mkapi"]
44
- wfp = ["data-bridges-client>=9.0.0,<10"]
44
+ wfp = ["data-bridges-client>=9.0.3,<10"]
45
45
 
46
46
  [dependency-groups]
47
47
  dev = [
@@ -49,7 +49,7 @@ dev = [
49
49
  "pytest-cov",
50
50
  "pre-commit",
51
51
  "ruff==0.17.3",
52
- "data-bridges-client>=9.0.0,<10",
52
+ "data-bridges-client>=9.0.3,<10",
53
53
  ]
54
54
 
55
55
  [project.urls]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '4.2.8'
22
- __version_tuple__ = version_tuple = (4, 2, 8)
21
+ __version__ = version = '4.3.0'
22
+ __version_tuple__ = version_tuple = (4, 3, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,496 @@
1
+ import csv
2
+ import logging
3
+ import types
4
+ from collections.abc import Callable
5
+ from datetime import date, datetime, timedelta
6
+ from functools import cache, partial
7
+ from os import getenv
8
+ from typing import Annotated, Any, ClassVar, Union, get_args, get_origin
9
+
10
+ from data_bridges_client import (
11
+ ApiClient,
12
+ ApiException,
13
+ CommoditiesApi,
14
+ Configuration,
15
+ CurrencyApi,
16
+ MarketPricesApi,
17
+ MarketsApi,
18
+ )
19
+ from data_bridges_client.exceptions import ServiceException
20
+ from data_bridges_client.models.commodity_dto import CommodityDTO
21
+ from data_bridges_client.models.currency_dto import CurrencyDTO
22
+ from data_bridges_client.models.market_dto import MarketDTO
23
+ from data_bridges_client.models.usd_indirect_quotation import UsdIndirectQuotation
24
+ from data_bridges_client.models.view_extended_monthly_aggregated_price import (
25
+ ViewExtendedMonthlyAggregatedPrice,
26
+ )
27
+ from data_bridges_client.token import WfpApiToken
28
+ from hdx.utilities.retriever import Retrieve
29
+ from tenacity import (
30
+ Retrying,
31
+ after_log,
32
+ retry_if_exception_type,
33
+ stop_after_attempt,
34
+ wait_fixed,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ _CSV_DATE_FORMAT = "%m/%d/%Y %I:%M:%S %p"
40
+ _PRICES_START_DATE = date(1990, 1, 1)
41
+
42
+
43
+ def _default_end_date() -> date:
44
+ # WFP has forecast prices a year or more ahead
45
+ return date(date.today().year + 5, 12, 31)
46
+
47
+
48
+ def _to_date(value: date | None, default: date) -> date:
49
+ if value is None:
50
+ return default
51
+ if isinstance(value, datetime):
52
+ return value.date()
53
+ return value
54
+
55
+
56
+ def _csv_datetime(value: str) -> datetime:
57
+ return datetime.strptime(value, _CSV_DATE_FORMAT)
58
+
59
+
60
+ def _csv_bool(value: str) -> bool:
61
+ return value == "True"
62
+
63
+
64
+ def _csv_none(value: str) -> None:
65
+ return None
66
+
67
+
68
+ def _field_types(annotation: Any) -> set:
69
+ origin = get_origin(annotation)
70
+ if origin is Annotated:
71
+ return _field_types(get_args(annotation)[0])
72
+ if origin in (Union, types.UnionType):
73
+ return set().union(
74
+ *(_field_types(x) for x in get_args(annotation) if x is not type(None))
75
+ )
76
+ return {origin or annotation}
77
+
78
+
79
+ @cache
80
+ def _csv_converters(model_cls: type) -> dict[str, Callable]:
81
+ """Map the JSON key of each non-string field of a data_bridges_client
82
+ model to a function that converts its CSV string value, based on the
83
+ field's type. List fields (CommodityDTO's qualities and processing) are
84
+ not in WFP's CSV output, so are always None.
85
+
86
+ Args:
87
+ model_cls: data_bridges_client model class
88
+
89
+ Returns:
90
+ Map from JSON key to converter function
91
+ """
92
+ converters = {}
93
+ for name, field in model_cls.model_fields.items():
94
+ if name == "additional_properties":
95
+ continue
96
+ field_types = _field_types(field.annotation)
97
+ if list in field_types:
98
+ converter = _csv_none
99
+ elif bool in field_types:
100
+ converter = _csv_bool
101
+ elif float in field_types:
102
+ converter = float
103
+ elif int in field_types:
104
+ converter = int
105
+ elif datetime in field_types:
106
+ converter = _csv_datetime
107
+ else:
108
+ continue
109
+ converters[field.alias or name] = converter
110
+ return converters
111
+
112
+
113
+ def _parse_csv(text: str, model_cls: type) -> list:
114
+ """Parse WFP's CSV output into data_bridges_client model objects. CSV
115
+ headers are the JSON keys with the first letter capitalised and every
116
+ value is a string. Blank strings are kept as "" (as JSON returns them)
117
+ but blank values of non-string fields become None.
118
+
119
+ To limit memory use (IDN has 462,027 price rows), lines are split
120
+ lazily rather than with io.StringIO, which holds 4 bytes per character,
121
+ each column reuses one object per distinct value and rows with the same
122
+ fields share one pydantic fields set (2,264 bytes each otherwise).
123
+
124
+ Args:
125
+ text: CSV text
126
+ model_cls: data_bridges_client model class of each row
127
+
128
+ Returns:
129
+ List of model_cls objects
130
+ """
131
+ converters = _csv_converters(model_cls)
132
+ reader = csv.reader(f"{line}\n" for line in text.split("\n"))
133
+ keys = [header[0].lower() + header[1:] for header in next(reader, [])]
134
+ columns = [(key, converters.get(key), {}) for key in keys]
135
+ fields_sets = {}
136
+ items = []
137
+ for row in reader:
138
+ if not row:
139
+ continue
140
+ obj = {}
141
+ for (key, converter, values), value in zip(columns, row):
142
+ try:
143
+ obj[key] = values[value]
144
+ except KeyError:
145
+ if converter:
146
+ converted = converter(value) if value else None
147
+ else:
148
+ converted = value
149
+ values[value] = converted
150
+ obj[key] = converted
151
+ item = model_cls.from_dict(obj)
152
+ fields_set = frozenset(item.__pydantic_fields_set__)
153
+ fields_set = fields_sets.setdefault(fields_set, item.__pydantic_fields_set__)
154
+ object.__setattr__(item, "__pydantic_fields_set__", fields_set)
155
+ items.append(item)
156
+ return items
157
+
158
+
159
+ class WFPAPI:
160
+ """Light wrapper around WFP's data-bridges-client library. All data is
161
+ requested as CSV, which returns every row in one response, rather than
162
+ JSON, whose paging returns some rows twice and leaves others out. It
163
+ needs a retriever used only to decide whether to load from disk or hit
164
+ the network and whether to persist the network result (as the CSV
165
+ returned by WFP). Token refreshing is delegated to data_bridges_client's
166
+ own WfpApiToken.
167
+
168
+ Args:
169
+ retriever: Retrieve object for interacting with WFP API
170
+ """
171
+
172
+ default_retry_params: ClassVar[dict[str, Any]] = {
173
+ "retry": retry_if_exception_type(ApiException),
174
+ "after": after_log(logger, logging.INFO),
175
+ }
176
+
177
+ def __init__(
178
+ self,
179
+ retriever: Retrieve,
180
+ wfp_key: str | None = None,
181
+ wfp_secret: str | None = None,
182
+ ):
183
+ self.retriever = retriever
184
+ self.retry_params = {"attempts": 1, "wait": 1}
185
+ if wfp_key:
186
+ self._wfp_key = wfp_key
187
+ else:
188
+ self._wfp_key = getenv("WFP_KEY")
189
+ if wfp_secret:
190
+ self._wfp_secret = wfp_secret
191
+ else:
192
+ self._wfp_secret = getenv("WFP_SECRET")
193
+ self.token = WfpApiToken(api_key=self._wfp_key, api_secret=self._wfp_secret)
194
+ self.configuration = Configuration()
195
+ self.api_client = ApiClient(self.configuration)
196
+ self.currency_api = CurrencyApi(self.api_client)
197
+ self.markets_api = MarketsApi(self.api_client)
198
+ self.commodities_api = CommoditiesApi(self.api_client)
199
+ self.market_prices_api = MarketPricesApi(self.api_client)
200
+
201
+ def get_retry_params(self) -> dict:
202
+ return self.retry_params
203
+
204
+ def update_retry_params(self, attempts: int, wait: int) -> dict:
205
+ self.retry_params["attempts"] = attempts
206
+ self.retry_params["wait"] = wait
207
+ return self.retry_params
208
+
209
+ def refresh_token(self) -> None:
210
+ self.configuration.access_token = self.token.refresh()
211
+
212
+ def _with_retry(self, api_method: Callable, **kwargs: Any) -> Any:
213
+ retryer = Retrying(
214
+ retry=self.default_retry_params["retry"],
215
+ after=self.default_retry_params["after"],
216
+ stop=stop_after_attempt(self.retry_params["attempts"]),
217
+ wait=wait_fixed(self.retry_params["wait"]),
218
+ )
219
+ for attempt in retryer:
220
+ with attempt:
221
+ try:
222
+ return api_method(**kwargs)
223
+ except ApiException as err:
224
+ if err.status not in (104, 401, 403):
225
+ raise
226
+ self.refresh_token()
227
+ return api_method(**kwargs)
228
+
229
+ @staticmethod
230
+ def _get_csv(api_method: Callable, **kwargs: Any) -> str:
231
+ """Call a data_bridges_client API method's _without_preload_content
232
+ variant with format CSV, raising ApiException (as the methods that
233
+ parse JSON do) if the response is not OK.
234
+
235
+ Args:
236
+ api_method: Bound _without_preload_content method
237
+ **kwargs: Parameters to pass to api_method
238
+
239
+ Returns:
240
+ CSV text
241
+ """
242
+ response = api_method(format="CSV", **kwargs)
243
+ text = response.data.decode("utf-8-sig")
244
+ if response.status != 200:
245
+ ApiException.from_response(http_resp=response, body=text, data=None)
246
+ return text
247
+
248
+ def _call(
249
+ self, csv_method: Callable, filename: str, log: str, **kwargs: Any
250
+ ) -> str | None:
251
+ """Get CSV text from csv_method, loading from or saving to disk as
252
+ Retrieve does.
253
+
254
+ Args:
255
+ csv_method: Function returning CSV text
256
+ filename: Filename of saved file
257
+ log: Text to use in log string to describe the call
258
+ **kwargs: Parameters to pass to csv_method
259
+
260
+ Returns:
261
+ CSV text, or None if there is no saved data
262
+ """
263
+ filename, _ = self.retriever.get_filename("", filename)
264
+ saved_path = self.retriever.saved_dir / filename
265
+ if self.retriever.use_saved:
266
+ logger.info(f"Using saved {log} in {saved_path}")
267
+ try:
268
+ with open(saved_path, encoding="utf-8", newline="") as f:
269
+ return f.read()
270
+ except FileNotFoundError:
271
+ return None
272
+ text = self._with_retry(csv_method, **kwargs)
273
+ if self.retriever.save:
274
+ logger.info(f"Saving {log} in {saved_path}")
275
+ with open(saved_path, "w", encoding="utf-8", newline="") as f:
276
+ f.write(text)
277
+ return text
278
+
279
+ @staticmethod
280
+ def _countryiso3s(countryiso3: str | None) -> list:
281
+ if countryiso3 == "PSE": # hack as PSE is treated by WFP as 2 areas
282
+ return ["PSW", "PSG"]
283
+ return [countryiso3]
284
+
285
+ def _get_all(
286
+ self,
287
+ api_method: Callable,
288
+ model_cls: type,
289
+ base_filename: str,
290
+ countryiso3: str | None,
291
+ country_param: str,
292
+ extra_params: dict,
293
+ get_csv: Callable | None = None,
294
+ ) -> list:
295
+ csv_method = partial(get_csv or self._get_csv, api_method)
296
+ all_items = []
297
+ for country in self._countryiso3s(countryiso3):
298
+ kwargs = dict(extra_params)
299
+ if country is None:
300
+ filename = f"{base_filename}.csv"
301
+ log = base_filename
302
+ else:
303
+ kwargs[country_param] = country
304
+ filename = f"{base_filename}_{country}.csv"
305
+ log = f"{base_filename} for {country}"
306
+ text = self._call(csv_method, filename, log, **kwargs)
307
+ if text:
308
+ all_items.extend(_parse_csv(text, model_cls))
309
+ return all_items
310
+
311
+ def get_currencies(
312
+ self, countryiso3: str | None = None, currency: str | None = None
313
+ ) -> list[CurrencyDTO]:
314
+ """Get list of currencies from the WFP API.
315
+
316
+ Args:
317
+ countryiso3: Country for which to obtain data. Defaults to all countries.
318
+ currency: Currency 3-letter code to filter by. Defaults to all currencies.
319
+
320
+ Returns:
321
+ List of currencies from the WFP API
322
+ """
323
+ if currency:
324
+ base_filename = f"Currency_List_{currency.upper()}"
325
+ extra_params = {"currency_name": currency}
326
+ else:
327
+ base_filename = "Currency_List"
328
+ extra_params = {}
329
+ return self._get_all(
330
+ self.currency_api.currency_list_get_without_preload_content,
331
+ CurrencyDTO,
332
+ base_filename,
333
+ countryiso3,
334
+ "country_code",
335
+ extra_params,
336
+ )
337
+
338
+ def get_currency_usd_indirect_quotations(
339
+ self, currency: str, countryiso3: str | None = None
340
+ ) -> list[UsdIndirectQuotation]:
341
+ """Get USD indirect quotations for a currency from the WFP API.
342
+
343
+ Args:
344
+ currency: Currency 3-letter code
345
+ countryiso3: Country for which to obtain data. Defaults to all countries.
346
+
347
+ Returns:
348
+ List of USD indirect quotations from the WFP API
349
+ """
350
+ return self._get_all(
351
+ self.currency_api.currency_usd_indirect_quotation_get_without_preload_content,
352
+ UsdIndirectQuotation,
353
+ f"Currency_UsdIndirectQuotation_{currency.upper()}",
354
+ countryiso3,
355
+ # this endpoint uses country_iso3, unlike every other one here which
356
+ # uses country_code - don't "fix" this to country_code
357
+ "country_iso3",
358
+ {"currency_name": currency},
359
+ )
360
+
361
+ def get_markets(self, countryiso3: str | None = None) -> list[MarketDTO]:
362
+ """Get list of markets from the WFP API.
363
+
364
+ Args:
365
+ countryiso3: Country for which to obtain data. Defaults to all countries.
366
+
367
+ Returns:
368
+ List of markets from the WFP API
369
+ """
370
+ return self._get_all(
371
+ self.markets_api.markets_list_get_without_preload_content,
372
+ MarketDTO,
373
+ "Markets_List",
374
+ countryiso3,
375
+ "country_code",
376
+ {},
377
+ )
378
+
379
+ def get_commodities(self, countryiso3: str | None = None) -> list[CommodityDTO]:
380
+ """Get list of commodities from the WFP API. WFP's CSV output does not
381
+ include qualities and processing, so they are None.
382
+
383
+ Args:
384
+ countryiso3: Country for which to obtain data. Defaults to all countries.
385
+
386
+ Returns:
387
+ List of commodities from the WFP API
388
+ """
389
+ return self._get_all(
390
+ self.commodities_api.commodities_list_get_without_preload_content,
391
+ CommodityDTO,
392
+ "Commodities_List",
393
+ countryiso3,
394
+ "country_code",
395
+ {},
396
+ )
397
+
398
+ def get_commodity_categories(
399
+ self, countryiso3: str | None = None
400
+ ) -> list[CommodityDTO]:
401
+ """Get list of commodity categories from the WFP API.
402
+
403
+ Args:
404
+ countryiso3: Country for which to obtain data. Defaults to all countries.
405
+
406
+ Returns:
407
+ List of commodity categories from the WFP API
408
+ """
409
+ return self._get_all(
410
+ self.commodities_api.commodities_categories_list_get_without_preload_content,
411
+ CommodityDTO,
412
+ "Commodities_Categories_List",
413
+ countryiso3,
414
+ "country_code",
415
+ {},
416
+ )
417
+
418
+ def _get_prices_csv_range(
419
+ self, api_method: Callable, start_date: date, end_date: date, **kwargs: Any
420
+ ) -> list[str]:
421
+ """Get monthly aggregated market prices between two dates (inclusive)
422
+ as CSV. If the API gateway times out (504), the date range is split in
423
+ two and each half is requested.
424
+
425
+ Args:
426
+ api_method: Bound market_prices_price_monthly_get_without_preload_content
427
+ start_date: Start date
428
+ end_date: End date
429
+ **kwargs: Parameters to pass to api_method
430
+
431
+ Returns:
432
+ List of CSV texts, one per date range, in date order
433
+ """
434
+ try:
435
+ return [
436
+ self._get_csv(
437
+ api_method, start_date=start_date, end_date=end_date, **kwargs
438
+ )
439
+ ]
440
+ except ServiceException as err:
441
+ if err.status != 504 or start_date >= end_date:
442
+ raise
443
+ middle = start_date + (end_date - start_date) // 2
444
+ logger.info(
445
+ f"Timeout getting prices from {start_date} to {end_date}, splitting at {middle}"
446
+ )
447
+ return self._get_prices_csv_range(
448
+ api_method, start_date, middle, **kwargs
449
+ ) + self._get_prices_csv_range(
450
+ api_method, middle + timedelta(days=1), end_date, **kwargs
451
+ )
452
+
453
+ def _get_prices_csv(
454
+ self,
455
+ api_method: Callable,
456
+ start_date: date | None = None,
457
+ end_date: date | None = None,
458
+ **kwargs: Any,
459
+ ) -> str:
460
+ first, *rest = self._get_prices_csv_range(
461
+ api_method,
462
+ _to_date(start_date, _PRICES_START_DATE),
463
+ _to_date(end_date, _default_end_date()),
464
+ **kwargs,
465
+ )
466
+ if not rest:
467
+ return first
468
+ # keep only the first range's header row
469
+ parts = [first.rstrip("\r\n")]
470
+ parts.extend(text.partition("\n")[2].rstrip("\r\n") for text in rest)
471
+ return "\r\n".join(part for part in parts if part) + "\r\n"
472
+
473
+ def get_market_prices_monthly(
474
+ self, countryiso3: str | None = None, **kwargs: Any
475
+ ) -> list[ViewExtendedMonthlyAggregatedPrice]:
476
+ """Get monthly aggregated market prices from the WFP API.
477
+
478
+ Args:
479
+ countryiso3: Country for which to obtain data. Defaults to all countries.
480
+ **kwargs: Additional filters accepted by
481
+ data_bridges_client's MarketPricesApi.market_prices_price_monthly_get,
482
+ e.g. market_id, commodity_id, price_type_name, currency_id,
483
+ price_flag, start_date, end_date, latest_value_only
484
+
485
+ Returns:
486
+ List of monthly aggregated market prices from the WFP API
487
+ """
488
+ return self._get_all(
489
+ self.market_prices_api.market_prices_price_monthly_get_without_preload_content,
490
+ ViewExtendedMonthlyAggregatedPrice,
491
+ "MarketPrices_PriceMonthly",
492
+ countryiso3,
493
+ "country_code",
494
+ kwargs,
495
+ self._get_prices_csv,
496
+ )