hdx-python-country 4.2.9__tar.gz → 4.3.1__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 (46) hide show
  1. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/PKG-INFO +1 -1
  2. hdx_python_country-4.3.1/docs/decisions/0001-wfp-databridges-fetched-as-csv.md +37 -0
  3. hdx_python_country-4.3.1/docs/decisions/0002-split-price-date-range-on-504.md +28 -0
  4. hdx_python_country-4.3.1/docs/decisions/0003-memory-efficient-csv-parsing.md +46 -0
  5. hdx_python_country-4.3.1/docs/decisions/0004-pse-fetched-as-psw-and-psg.md +21 -0
  6. hdx_python_country-4.3.1/docs/decisions/0005-wfp-dates-assumed-utc.md +21 -0
  7. hdx_python_country-4.3.1/docs/decisions/README.md +35 -0
  8. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/src/hdx/location/_version.py +2 -2
  9. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/src/hdx/location/wfp_api.py +27 -17
  10. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/src/hdx/location/wfp_exchangerates.py +1 -3
  11. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/.github/workflows/publish.yaml +0 -0
  12. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/.github/workflows/run-python-tests.yaml +0 -0
  13. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/.gitignore +0 -0
  14. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/.pre-commit-config.yaml +0 -0
  15. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/LICENSE +0 -0
  16. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/README.md +0 -0
  17. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/documentation/.readthedocs.yaml +0 -0
  18. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/documentation/index.md +0 -0
  19. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/documentation/mkdocs.yaml +0 -0
  20. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/pyproject.toml +0 -0
  21. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/src/hdx/location/Countries & Territories Taxonomy MVP - C&T Taxonomy.csv +0 -0
  22. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/src/hdx/location/__init__.py +0 -0
  23. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/src/hdx/location/adminlevel.py +0 -0
  24. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/src/hdx/location/country.py +0 -0
  25. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/src/hdx/location/currency.py +0 -0
  26. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/src/hdx/location/int_timestamp.py +0 -0
  27. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/fixtures/adminlevel.yaml +0 -0
  28. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/fixtures/adminlevelparent.yaml +0 -0
  29. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/fixtures/fixtures-download-global-pcode-lengths.csv +0 -0
  30. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/fixtures/fixtures-download-global-pcodes-adm-1-2.csv +0 -0
  31. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/fixtures/secondary_historic_rates.csv +0 -0
  32. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/fixtures/secondary_rates.json +0 -0
  33. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/fixtures/wfp/Currency_List.csv +0 -0
  34. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/fixtures/wfp/Currency_UsdIndirectQuotation_AFN.csv +0 -0
  35. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/fixtures/wfp/Currency_UsdIndirectQuotation_SYP.csv +0 -0
  36. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/fixtures/wfp/MarketPrices_PriceMonthly_BTN.csv +0 -0
  37. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/fixtures/wfp/Markets_List_AFG.csv +0 -0
  38. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/hdx/location/Countries_UZB_Deleted.csv +0 -0
  39. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/hdx/location/__init__.py +0 -0
  40. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/hdx/location/conftest.py +0 -0
  41. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/hdx/location/test_adminlevel.py +0 -0
  42. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/hdx/location/test_country.py +0 -0
  43. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/hdx/location/test_currency.py +0 -0
  44. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/hdx/location/test_wfp_api.py +0 -0
  45. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/tests/hdx/location/test_wfp_exchangerates.py +0 -0
  46. {hdx_python_country-4.2.9 → hdx_python_country-4.3.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hdx-python-country
3
- Version: 4.2.9
3
+ Version: 4.3.1
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
@@ -0,0 +1,37 @@
1
+ # 0001: Fetch WFP DataBridges data as CSV rather than paged JSON
2
+
3
+ ## Status
4
+
5
+ Accepted — 2026-11-03
6
+
7
+ ## Context
8
+
9
+ JSON paging of `market_prices_price_monthly_get` is unstable. Confirmed live on NGA: each run
10
+ returned about 1,690 rows twice and left out the same number, so datasets built from it missed
11
+ about 1.4% of rows. The endpoint has no sort parameter that would make offset paging
12
+ deterministic.
13
+
14
+ `format="CSV"` returns every row once in a single response and ignores `page` (NGA: 121,669
15
+ distinct rows in about 10s, compared with about 800s paged). The other `WFPAPI` endpoints accept
16
+ CSV too.
17
+
18
+ ## Decision
19
+
20
+ Every `WFPAPI` endpoint is called with `format="CSV"` through the generated
21
+ `*_without_preload_content` methods, and the JSON paging code is removed.
22
+
23
+ - CSV headers are the JSON keys with the first letter capitalised, and every value is a string.
24
+ `_csv_converters` builds the int, float, bool and datetime converters from each model's pydantic
25
+ annotations.
26
+ - `_get_csv` raises `ApiException` on a non-200 status, as the JSON methods do, so `_with_retry`
27
+ still refreshes the token and retries.
28
+ - Saved data is the CSV that WFP returns, one file per call.
29
+
30
+ ## Consequences
31
+
32
+ - Blank text fields are `""` where JSON gave `None`.
33
+ - Commodity `qualities` and `processing` are always `None`, since CSV omits them.
34
+ - Datetimes lose milliseconds.
35
+ - Data saved in the old JSON format no longer loads.
36
+ - A whole country comes back in one response (SYR: 516,907 rows, 103 MB), which can hit the
37
+ gateway timeout (see 0002) and needs care with memory (see 0003).
@@ -0,0 +1,28 @@
1
+ # 0002: Split the monthly prices date range on a gateway timeout
2
+
3
+ ## Status
4
+
5
+ Accepted — 2026-11-03
6
+
7
+ ## Context
8
+
9
+ A single CSV request for a large country can exceed the API gateway's timeout of about 30s. SYR's
10
+ first full request got a 504 after 30s, and the second returned 200 in 35s. Relying on
11
+ `_with_retry` is not acceptable, because the country run uses `wait=1800`, i.e. 30 minutes per
12
+ retry.
13
+
14
+ `start_date` and `end_date` are inclusive and split the data cleanly. On BTN, 1990–2019 plus
15
+ 2020–2030 returned exactly the whole set (1,836 + 6,171 = 8,007 rows).
16
+
17
+ ## Decision
18
+
19
+ `_get_prices_csv_range` requests the whole range (1990-01-01 to 31 December five years ahead,
20
+ since WFP has forecast prices). On a 504 it splits the range at the midpoint and requests each
21
+ half, recursively. A 504 on a single day, or any other error, is raised. `_get_prices_csv` joins
22
+ the pieces, keeping only the first header row.
23
+
24
+ ## Consequences
25
+
26
+ - Most countries take one request. Each 504 costs about 30s before the split.
27
+ - On 2026-11-04 the server was slower, and IDN's range was split at least three times.
28
+ - Saved data is still one file per country, whatever the number of requests.
@@ -0,0 +1,46 @@
1
+ # 0003: Parse WFP CSV with shared values and fields sets
2
+
3
+ ## Status
4
+
5
+ Accepted — 2026-11-03
6
+
7
+ ## Context
8
+
9
+ Fetching and parsing IDN's prices (462,027 rows) peaked at 2.31 GB with the first CSV parser, and
10
+ the country run was killed on Jenkins (exit code 137). Each row's
11
+ `ViewExtendedMonthlyAggregatedPrice` took about 3.2 KB:
12
+
13
+ | Part | Bytes |
14
+ |---|---|
15
+ | Object | 72 |
16
+ | `__dict__` with 22 fields | 832 |
17
+ | `__pydantic_fields_set__`, the 22 field names set on this row | 2,264 |
18
+ | Empty `additional_properties` | 64 |
19
+
20
+ Every row also held its own copies of repeated values such as market names, dates and units.
21
+ `io.StringIO` copied the whole text into a buffer of 4 bytes per character.
22
+
23
+ ## Decision
24
+
25
+ `_parse_csv`:
26
+
27
+ - Splits lines lazily with `text.split("\n")` instead of `io.StringIO`, re-adding the `\n` to each
28
+ line so quoted fields containing line breaks parse the same.
29
+ - Reuses one object per distinct value in each column.
30
+ - Gives rows with the same fields one shared `__pydantic_fields_set__`. Pydantic only adds to it
31
+ when a field is assigned, and every field is already in it.
32
+
33
+ ## Consequences
34
+
35
+ Measured on the saved IDN CSV:
36
+
37
+ | | Peak memory | Parse time |
38
+ |---|---|---|
39
+ | `io.StringIO` | 2,310 MB | 9.5s |
40
+ | Lazy split | 2,059 MB | |
41
+ | Lazy split and reused values | 1,755 MB | |
42
+ | Lazy split, reused values and shared fields set | 746 MB | 5.7s |
43
+
44
+ - The parsed rows are identical to those from the `io.StringIO` parser.
45
+ - Rows share value objects. They are immutable (str, int, float, datetime), so this is not visible
46
+ to callers.
@@ -0,0 +1,21 @@
1
+ # 0004: Fetch PSE as PSW and PSG
2
+
3
+ ## Status
4
+
5
+ Accepted — 2024-10-31
6
+
7
+ ## Context
8
+
9
+ WFP treats the State of Palestine as two areas, the West Bank (`PSW`) and the Gaza Strip (`PSG`).
10
+ Asking WFP for `PSE` returns nothing. Checked live on 2026-11-04, the markets endpoint gave 0
11
+ markets for `PSE`, 12 for `PSW` and 47 for `PSG`.
12
+
13
+ ## Decision
14
+
15
+ `WFPAPI._countryiso3s` turns `PSE` into `["PSW", "PSG"]`, so every country-filtered endpoint makes
16
+ one request per area and returns the combined rows.
17
+
18
+ ## Consequences
19
+
20
+ - Callers use the ISO 3166 code `PSE` and get both areas' data.
21
+ - Saved files are per area, e.g. `MarketPrices_PriceMonthly_PSW.csv`.
@@ -0,0 +1,21 @@
1
+ # 0005: Treat WFP dates without a timezone as UTC
2
+
3
+ ## Status
4
+
5
+ Accepted — 2026-07-20
6
+
7
+ ## Context
8
+
9
+ WFP's dates have no timezone. Before the move to the DataBridges client (#88), they were parsed
10
+ with `parse_date`, which returns UTC datetimes. `data_bridges_client` returns naive datetimes
11
+ instead.
12
+
13
+ ## Decision
14
+
15
+ `WFPExchangeRates.get_currency_historic_rates` sets UTC on any naive quotation date before
16
+ converting it to a timestamp. hdx-scraper-wfp-foodprices does the same for price dates.
17
+
18
+ ## Consequences
19
+
20
+ - Historic rate timestamps are unchanged from the `parse_date` behaviour.
21
+ - If WFP starts returning dates with a timezone, they are used as given.
@@ -0,0 +1,35 @@
1
+ # docs/decisions/
2
+
3
+ Decision records: one immutable file per non-trivial design/architecture
4
+ decision — using Michael Nygard's minimal headers (Title, Status, Context,
5
+ Decision, Consequences), plus a `Date:` line under Status. Distill the
6
+ decision itself, not the full planning narrative behind it.
7
+
8
+ A reversed decision gets a new record whose Status reads
9
+ `Superseded by 00NN`; the old file stays as-is. Routine notes belong in
10
+ `CLAUDE.md` instead of here; PR-description-only detail doesn't need a
11
+ record at all.
12
+
13
+ Numbered `NNNN-title.md`, sequential, never reused.
14
+
15
+ ## Format
16
+
17
+ ```markdown
18
+ # NNNN: <Title>
19
+
20
+ ## Status
21
+
22
+ Accepted — YYYY-MM-DD
23
+
24
+ ## Context
25
+
26
+ ...
27
+
28
+ ## Decision
29
+
30
+ ...
31
+
32
+ ## Consequences
33
+
34
+ ...
35
+ ```
@@ -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.9'
22
- __version_tuple__ = version_tuple = (4, 2, 9)
21
+ __version__ = version = '4.3.1'
22
+ __version_tuple__ = version_tuple = (4, 3, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1,5 +1,4 @@
1
1
  import csv
2
- import io
3
2
  import logging
4
3
  import types
5
4
  from collections.abc import Callable
@@ -115,7 +114,9 @@ def _parse_csv(text: str, model_cls: type) -> list:
115
114
  """Parse WFP's CSV output into data_bridges_client model objects. CSV
116
115
  headers are the JSON keys with the first letter capitalised and every
117
116
  value is a string. Blank strings are kept as "" (as JSON returns them)
118
- but blank values of non-string fields become None.
117
+ but blank values of non-string fields become None. Values and pydantic
118
+ fields sets are shared between rows to limit memory use (see
119
+ docs/decisions/0003).
119
120
 
120
121
  Args:
121
122
  text: CSV text
@@ -125,30 +126,39 @@ def _parse_csv(text: str, model_cls: type) -> list:
125
126
  List of model_cls objects
126
127
  """
127
128
  converters = _csv_converters(model_cls)
128
- reader = csv.reader(io.StringIO(text))
129
+ reader = csv.reader(f"{line}\n" for line in text.split("\n"))
129
130
  keys = [header[0].lower() + header[1:] for header in next(reader, [])]
130
- columns = [(key, converters.get(key)) for key in keys]
131
+ columns = [(key, converters.get(key), {}) for key in keys]
132
+ fields_sets = {}
131
133
  items = []
132
134
  for row in reader:
133
135
  if not row:
134
136
  continue
135
137
  obj = {}
136
- for (key, converter), value in zip(columns, row):
137
- if converter:
138
- value = converter(value) if value else None
139
- obj[key] = value
140
- items.append(model_cls.from_dict(obj))
138
+ for (key, converter, values), value in zip(columns, row):
139
+ try:
140
+ obj[key] = values[value]
141
+ except KeyError:
142
+ if converter:
143
+ converted = converter(value) if value else None
144
+ else:
145
+ converted = value
146
+ values[value] = converted
147
+ obj[key] = converted
148
+ item = model_cls.from_dict(obj)
149
+ fields_set = frozenset(item.__pydantic_fields_set__)
150
+ fields_set = fields_sets.setdefault(fields_set, item.__pydantic_fields_set__)
151
+ object.__setattr__(item, "__pydantic_fields_set__", fields_set)
152
+ items.append(item)
141
153
  return items
142
154
 
143
155
 
144
156
  class WFPAPI:
145
157
  """Light wrapper around WFP's data-bridges-client library. All data is
146
- requested as CSV, which returns every row in one response, rather than
147
- JSON, whose paging returns some rows twice and leaves others out. It
148
- needs a retriever used only to decide whether to load from disk or hit
149
- the network and whether to persist the network result (as the CSV
150
- returned by WFP). Token refreshing is delegated to data_bridges_client's
151
- own WfpApiToken.
158
+ requested as CSV (see docs/decisions/0001). It needs a retriever used
159
+ only to decide whether to load from disk or hit the network and whether
160
+ to persist the network result (as the CSV returned by WFP). Token
161
+ refreshing is delegated to data_bridges_client's own WfpApiToken.
152
162
 
153
163
  Args:
154
164
  retriever: Retrieve object for interacting with WFP API
@@ -263,7 +273,7 @@ class WFPAPI:
263
273
 
264
274
  @staticmethod
265
275
  def _countryiso3s(countryiso3: str | None) -> list:
266
- if countryiso3 == "PSE": # hack as PSE is treated by WFP as 2 areas
276
+ if countryiso3 == "PSE": # see docs/decisions/0004
267
277
  return ["PSW", "PSG"]
268
278
  return [countryiso3]
269
279
 
@@ -405,7 +415,7 @@ class WFPAPI:
405
415
  ) -> list[str]:
406
416
  """Get monthly aggregated market prices between two dates (inclusive)
407
417
  as CSV. If the API gateway times out (504), the date range is split in
408
- two and each half is requested.
418
+ two and each half is requested (see docs/decisions/0002).
409
419
 
410
420
  Args:
411
421
  api_method: Bound market_prices_price_monthly_get_without_preload_content
@@ -59,9 +59,7 @@ class WFPExchangeRates:
59
59
  continue
60
60
  date = quote.var_date
61
61
  if date.tzinfo is None:
62
- # data_bridges_client parses WFP's timezone-less date strings
63
- # into naive datetimes; assume UTC to match previous
64
- # parse_date-based behaviour
62
+ # See docs/decisions/0005
65
63
  date = date.replace(tzinfo=UTC)
66
64
  timestamp = get_int_timestamp(date)
67
65
  historic_rates[timestamp] = quote.value