hdx-python-utilities 4.1.1__tar.gz → 4.2.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 (137) hide show
  1. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/PKG-INFO +3 -2
  2. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/documentation/index.md +44 -1
  3. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/pyproject.toml +2 -0
  4. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/_version.py +2 -2
  5. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/downloader.py +81 -17
  6. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/matching.py +211 -3
  7. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/retriever.py +7 -1
  8. hdx_python_utilities-4.2.0/tests/fixtures/downloader/resume_test.dat +9 -0
  9. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_downloader.py +157 -1
  10. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_matching.py +250 -0
  11. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_retriever.py +21 -0
  12. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/uv.lock +228 -1
  13. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/.github/workflows/publish-test.yaml +0 -0
  14. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/.github/workflows/publish.yaml +0 -0
  15. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/.github/workflows/run-python-tests.yaml +0 -0
  16. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/.gitignore +0 -0
  17. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/.pre-commit-config.yaml +0 -0
  18. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/CLAUDE.md +0 -0
  19. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/LICENSE +0 -0
  20. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/README.md +0 -0
  21. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/documentation/.readthedocs.yaml +0 -0
  22. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/documentation/mkdocs.yaml +0 -0
  23. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/__init__.py +0 -0
  24. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/base_downloader.py +0 -0
  25. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/compare.py +0 -0
  26. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/dateparse.py +0 -0
  27. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/dictandlist.py +0 -0
  28. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/easy_logging.py +0 -0
  29. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/email.py +0 -0
  30. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/encoding.py +0 -0
  31. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/error_handler.py +0 -0
  32. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/errors_onexit.py +0 -0
  33. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/file_hashing.py +0 -0
  34. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/frictionless_wrapper.py +0 -0
  35. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/html.py +0 -0
  36. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/loader.py +0 -0
  37. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/path.py +0 -0
  38. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/saver.py +0 -0
  39. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/session.py +0 -0
  40. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/state.py +0 -0
  41. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/text.py +0 -0
  42. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/url.py +0 -0
  43. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/useragent.py +0 -0
  44. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/uuid.py +0 -0
  45. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/src/hdx/utilities/zip_crc.py +0 -0
  46. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/compare/test_csv_processing.csv +0 -0
  47. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/compare/test_csv_processing2.csv +0 -0
  48. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/empty.yaml +0 -0
  49. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/hdx_config.json +0 -0
  50. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/hdx_config.yaml +0 -0
  51. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/hdx_email_configuration.json +0 -0
  52. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/hdx_email_configuration.yaml +0 -0
  53. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/logging_config.json +0 -0
  54. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/logging_config.yaml +0 -0
  55. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/project_configuration.json +0 -0
  56. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/project_configuration.yaml +0 -0
  57. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/smtp_config.json +0 -0
  58. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/smtp_config.yaml +0 -0
  59. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/user_agent_config.yaml +0 -0
  60. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/user_agent_config2.yaml +0 -0
  61. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/user_agent_config3.yaml +0 -0
  62. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/config/user_agent_config_wrong.yaml +0 -0
  63. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/basicauth.txt +0 -0
  64. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/bearertoken.txt +0 -0
  65. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/extra_params.json +0 -0
  66. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/extra_params.yaml +0 -0
  67. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/extra_params_tree.yaml +0 -0
  68. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_csv_processing.csv +0 -0
  69. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_csv_processing_blanks.csv +0 -0
  70. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_data.csv +0 -0
  71. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_data.xlsx +0 -0
  72. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_data1.csv/empty.txt +0 -0
  73. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_data2.csv +0 -0
  74. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_json_processing.json +0 -0
  75. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_xls_processing.xls +0 -0
  76. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_xlsx_processing.xlsx +0 -0
  77. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/file_hashing/bad_header.zip +0 -0
  78. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/file_hashing/bad_index.xlsx +0 -0
  79. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/file_hashing/empty.zip +0 -0
  80. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/file_hashing/test.xlsx +0 -0
  81. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/file_hashing/test_shapefile.zip +0 -0
  82. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/file_hashing/valid_sig_invalid_body.zip +0 -0
  83. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/html/response.html +0 -0
  84. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/loader/empty.json +0 -0
  85. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/loader/empty.yaml +0 -0
  86. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/loader/empty_list.json +0 -0
  87. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/fallbacks/test.csv +0 -0
  88. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/fallbacks/test.json +0 -0
  89. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/fallbacks/test.txt +0 -0
  90. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/fallbacks/test.yaml +0 -0
  91. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/retriever-test.csv +0 -0
  92. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/test.csv +0 -0
  93. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/test.json +0 -0
  94. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/test.txt +0 -0
  95. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/test.yaml +0 -0
  96. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out.csv +0 -0
  97. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out.json +0 -0
  98. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out2.csv +0 -0
  99. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out2.json +0 -0
  100. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out5.json +0 -0
  101. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out6.json +0 -0
  102. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out7.json +0 -0
  103. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out8.csv +0 -0
  104. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out8.json +0 -0
  105. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-false_sortkeys-false.json +0 -0
  106. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-false_sortkeys-false.yaml +0 -0
  107. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-false_sortkeys-true.json +0 -0
  108. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-false_sortkeys-true.yaml +0 -0
  109. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-true_sortkeys-false.json +0 -0
  110. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-true_sortkeys-false.yaml +0 -0
  111. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-true_sortkeys-true.json +0 -0
  112. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-true_sortkeys-true.yaml +0 -0
  113. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/state/analysis_dates.txt +0 -0
  114. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/state/last_build_date.txt +0 -0
  115. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/test_data.csv +0 -0
  116. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/text/text1.txt +0 -0
  117. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/fixtures/text/text2.txt +0 -0
  118. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/conftest.py +0 -0
  119. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_compare.py +0 -0
  120. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_dateparse.py +0 -0
  121. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_dictandlist.py +0 -0
  122. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_easy_logging.py +0 -0
  123. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_email.py +0 -0
  124. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_encoding.py +0 -0
  125. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_error_handler.py +0 -0
  126. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_file_hashing.py +0 -0
  127. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_html.py +0 -0
  128. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_loader.py +0 -0
  129. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_path.py +0 -0
  130. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_saver.py +0 -0
  131. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_state.py +0 -0
  132. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_text.py +0 -0
  133. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_url.py +0 -0
  134. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_useragent.py +0 -0
  135. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_uuid.py +0 -0
  136. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_zip_crc.py +0 -0
  137. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/utils.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: hdx-python-utilities
3
- Version: 4.1.1
3
+ Version: 4.2.0
4
4
  Summary: HDX Python Utilities for streaming tabular data, date and time handling and other helpful functions
5
5
  Project-URL: Homepage, https://github.com/OCHA-DAP/hdx-python-utilities
6
6
  Author-email: Michael Rans <rans@email.com>
@@ -32,6 +32,7 @@ Requires-Dist: loguru
32
32
  Requires-Dist: openpyxl>=3.1.2
33
33
  Requires-Dist: pyphonetics
34
34
  Requires-Dist: python-dateutil<2.9.1,>=2.9.0
35
+ Requires-Dist: rapidfuzz
35
36
  Requires-Dist: ratelimit
36
37
  Requires-Dist: requests-file
37
38
  Requires-Dist: ruamel-yaml
@@ -33,6 +33,11 @@ The code for the library is [here](https://github.com/OCHA-DAP/hdx-python-utilit
33
33
  The library has detailed API documentation which can be found in the menu at the top.
34
34
 
35
35
  ## Breaking Changes
36
+ From 4.2.0, get_code_from_name fuzzy matches using RapidFuzzMatcher rather than
37
+ Phonetics by default. Pass matcher=Phonetics() for the previous behaviour. A name
38
+ made of parts (eg. "Protection - GBV") is resolved from its parts, and a list of
39
+ names (eg. "Wash & Protection") is not fuzzy matched.
40
+
36
41
  From 4.1.0, save_hxlated_output and Download.hxl_row removed. Parameter use_hxl removed
37
42
  from all calls in Download and Retrieve.
38
43
 
@@ -205,7 +210,15 @@ Other useful functions:
205
210
  # if no folder supplied
206
211
  # path = Download.get_path_for_url(url, folder)
207
212
 
208
- For more detail and additional functions, check the API documentation.
213
+ ### Resumable downloads
214
+
215
+ Pass `resume=True` and a fixed `path` to continue an interrupted download using
216
+ HTTP Range requests. If the server returns 200 instead of 206, the file is
217
+ downloaded from scratch. Add `retries` to automatically retry mid-stream
218
+ failures; each retry continues from the current end of the partial file.
219
+ `retries` has no effect without `resume=True`.
220
+
221
+ path = downloader.download_file(url, path=mypath, resume=True, retries=3)
209
222
 
210
223
  ## Retrieving files
211
224
 
@@ -261,6 +274,8 @@ Examples:
261
274
  retriever = Retrieve(downloader, fallback_dir, saved_dir, temp_dir, save=False, use_saved=True)
262
275
  data = retriever.download_json(url, filename, logstr="test json", fallback=False, log_level=logging.DEBUG)
263
276
 
277
+ `Retrieve.download_file` also accepts `resume=True` and `retries=N` — see [Resumable downloads](#resumable-downloads).
278
+
264
279
  ## Date utilities
265
280
 
266
281
  There are utilities to parse dates. By default, *no timezone information will be parsed
@@ -867,7 +882,19 @@ Efficient Hashing of files that produces a stable hash even for zip and xlsx fil
867
882
 
868
883
  Examples:
869
884
 
885
+ # Fuzzy match a name to one of a list of names using rapidfuzz scoring
870
886
  possible_names = ["Al Maharah", "Ad Dali", "Dhamar"]
887
+ matcher = RapidFuzzMatcher()
888
+ assert matcher.match(possible_names, "al dali") == 1
889
+
890
+ # place_name_scorer does not match place names that share a word but differ
891
+ # in another that distinguishes places
892
+ kalimantan = ["kalimantan utara", "kalimantan tengah"]
893
+ assert matcher.match(kalimantan, "central kalimantan") == 0
894
+ matcher = RapidFuzzMatcher(RapidFuzzMatcher.place_name_scorer)
895
+ assert matcher.match(kalimantan, "central kalimantan") is None
896
+
897
+ # Phonetics is an alternative matcher using Refined Soundex
871
898
  phonetics = Phonetics()
872
899
  assert phonetics.match(possible_names, "al dali") == 1
873
900
 
@@ -877,6 +904,22 @@ Examples:
877
904
  }
878
905
  assert get_code_from_name("NATIONAL_NGO", lookup, [], fuzzy_match=False) == "441"
879
906
 
907
+ # get_code_from_name fuzzy matches with RapidFuzzMatcher unless given
908
+ # another matcher. It resolves names made of parts and does not fuzzy
909
+ # match lists of names.
910
+ sector_lookup = {"food security": "FSC", "protection": "PRO", "gbv": "PRO-GBV", "wash": "WSH"}
911
+ assert get_code_from_name("Food Securty", sector_lookup, []) == "FSC"
912
+ assert get_code_from_name("Food Securty", sector_lookup, [], matcher=Phonetics()) == "FSC"
913
+ assert get_code_from_name("Protection - GBV", sector_lookup, []) == "PRO-GBV"
914
+ assert get_code_from_name("Wash & Protection", sector_lookup, []) is None
915
+
916
+ # Resolve a name made of parts, returning the code and whether it is a
917
+ # list of names. The third parameter is a part that qualifies the other,
918
+ # eg. a parent admin name.
919
+ admin_lookup = {"acosta": "VE1101", "falcon": "VE1109"}
920
+ assert resolve_name_parts("Falcón, Acosta", admin_lookup) == (None, True)
921
+ assert resolve_name_parts("Falcón, Acosta", admin_lookup, "falcon") == ("VE1101", False)
922
+
880
923
  a = "The quick brown fox jumped over the lazy dog. It was so fast!"
881
924
 
882
925
  # Replace multiple strings in a string simultaneously
@@ -52,6 +52,7 @@ dependencies = [
52
52
  "typing_extensions",
53
53
  "loguru",
54
54
  "pyphonetics",
55
+ "rapidfuzz",
55
56
  "python-dateutil>=2.9.0, <2.9.1",
56
57
  "ratelimit",
57
58
  "requests-file",
@@ -70,6 +71,7 @@ dev = [
70
71
  "pytest",
71
72
  "pytest-cov",
72
73
  "pytest-loguru",
74
+ "pytest-mock",
73
75
  "pre-commit",
74
76
  "ruff==0.14.13",
75
77
  ]
@@ -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.1.1'
22
- __version_tuple__ = version_tuple = (4, 1, 1)
21
+ __version__ = version = '4.2.0'
22
+ __version_tuple__ = version_tuple = (4, 2, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -250,13 +250,16 @@ class Download(BaseDownload):
250
250
  f"Download of {url} failed in retrieval of stream!" % url
251
251
  )
252
252
 
253
- def stream_path(self, path: Path | str, errormsg: str) -> Path:
253
+ def stream_path(
254
+ self, path: Path | str, errormsg: str, append: bool = False
255
+ ) -> Path:
254
256
  """Stream file from url and store in provided path. Must call setup
255
257
  method first.
256
258
 
257
259
  Args:
258
260
  path: Path for downloaded file
259
261
  errormsg: Error message to display if there is a problem
262
+ append: Whether to append to an existing file. Defaults to False.
260
263
 
261
264
  Returns:
262
265
  Path of downloaded file
@@ -264,7 +267,7 @@ class Download(BaseDownload):
264
267
  f = None
265
268
  try:
266
269
  path = Path(path)
267
- f = path.open("wb")
270
+ f = path.open("ab" if append else "wb")
268
271
  for chunk in self.response.iter_content(chunk_size=10240):
269
272
  if chunk: # filter out keep-alive new chunks
270
273
  f.write(chunk)
@@ -325,6 +328,8 @@ class Download(BaseDownload):
325
328
  path (str): Full path to use for downloaded file instead of folder and filename.
326
329
  overwrite (bool): Whether to overwrite existing file. Defaults to False.
327
330
  keep (bool): Whether to keep already downloaded file. Defaults to False.
331
+ resume (bool): Whether to resume a partial download using Range requests where the server supports it. Defaults to False.
332
+ retries (int): Number of times to retry a mid-stream failure. Only effective when resume=True so that each retry can continue from the partial file. Defaults to 0.
328
333
  post (bool): Whether to use POST instead of GET. Defaults to False.
329
334
  parameters (dict): Parameters to pass. Defaults to None.
330
335
  timeout (float): Timeout for connecting to URL. Defaults to None (no timeout).
@@ -340,25 +345,84 @@ class Download(BaseDownload):
340
345
  path = kwargs.get("path")
341
346
  overwrite = kwargs.get("overwrite", False)
342
347
  keep = kwargs.get("keep", False)
348
+ resume = kwargs.get("resume", False)
349
+ retries = kwargs.get("retries", 0)
343
350
  try:
344
- path = get_path_for_url(url, folder, filename, path, overwrite, keep)
351
+ # When resuming, skip uniqueness renaming so we get back the exact target path
352
+ path = get_path_for_url(
353
+ url, folder, filename, path, overwrite, keep or resume
354
+ )
345
355
  except ValueError as ex:
346
356
  raise DownloadError(ex) from ex
347
- if keep and exists(path):
357
+ if keep and not resume and exists(path):
348
358
  return path
349
- self.setup(
350
- url,
351
- stream=True,
352
- post=kwargs.get("post", False),
353
- parameters=kwargs.get("parameters"),
354
- timeout=kwargs.get("timeout"),
355
- headers=kwargs.get("headers"),
356
- encoding=kwargs.get("encoding"),
357
- json_string=kwargs.get("json_string", False),
358
- )
359
- return self.stream_path(
360
- path, f"Download of {url} failed in retrieval of stream!"
361
- )
359
+ errormsg = f"Download of {url} failed in retrieval of stream!"
360
+ setup_headers = dict(kwargs.get("headers") or {})
361
+ append = False
362
+ if resume and path.exists() and path.stat().st_size > 0:
363
+ setup_headers["Range"] = f"bytes={path.stat().st_size}-"
364
+ # Range bytes must come from the raw stream: partial gzip streams
365
+ # lack the header and cannot be decoded, so request uncompressed.
366
+ setup_headers.setdefault("Accept-Encoding", "identity")
367
+ try:
368
+ self.setup(
369
+ url,
370
+ stream=True,
371
+ post=kwargs.get("post", False),
372
+ parameters=kwargs.get("parameters"),
373
+ timeout=kwargs.get("timeout"),
374
+ headers=setup_headers,
375
+ encoding=kwargs.get("encoding"),
376
+ json_string=kwargs.get("json_string", False),
377
+ )
378
+ except DownloadError:
379
+ if self.response is not None and self.response.status_code == 416:
380
+ return path
381
+ raise
382
+ append = self.response.status_code == 206
383
+ else:
384
+ self.setup(
385
+ url,
386
+ stream=True,
387
+ post=kwargs.get("post", False),
388
+ parameters=kwargs.get("parameters"),
389
+ timeout=kwargs.get("timeout"),
390
+ headers=setup_headers or None,
391
+ encoding=kwargs.get("encoding"),
392
+ json_string=kwargs.get("json_string", False),
393
+ )
394
+ attempts_remaining = retries
395
+ while True:
396
+ try:
397
+ return self.stream_path(path, errormsg, append=append)
398
+ except DownloadError:
399
+ if not resume or attempts_remaining == 0:
400
+ raise
401
+ attempts_remaining -= 1
402
+ logger.warning(
403
+ "Download of %s interrupted, retrying (attempt %d of %d)...",
404
+ url,
405
+ retries - attempts_remaining,
406
+ retries,
407
+ )
408
+ setup_headers["Range"] = f"bytes={path.stat().st_size}-"
409
+ setup_headers.setdefault("Accept-Encoding", "identity")
410
+ try:
411
+ self.setup(
412
+ url,
413
+ stream=True,
414
+ post=kwargs.get("post", False),
415
+ parameters=kwargs.get("parameters"),
416
+ timeout=kwargs.get("timeout"),
417
+ headers=setup_headers,
418
+ encoding=kwargs.get("encoding"),
419
+ json_string=kwargs.get("json_string", False),
420
+ )
421
+ except DownloadError:
422
+ if self.response is not None and self.response.status_code == 416:
423
+ return path
424
+ raise
425
+ append = self.response.status_code == 206
362
426
 
363
427
  def download(self, url: Path | str, **kwargs: Any) -> requests.Response:
364
428
  """Download url.
@@ -3,10 +3,14 @@ import re
3
3
  from collections.abc import Callable, Sequence
4
4
 
5
5
  from pyphonetics import RefinedSoundex
6
+ from rapidfuzz import fuzz as rapidfuzz_fuzz
6
7
 
7
8
  from hdx.utilities.text import normalise
8
9
 
9
10
  TEMPLATE_VARIABLES = re.compile("{{.*?}}")
11
+ NON_WORD_CHARACTERS = re.compile(r"[\W_]+")
12
+ LIST_SEPARATORS = re.compile(r"\s*(?:[,;/:&\n]|\s(?:and|et)\s)\s*", re.IGNORECASE)
13
+ HYPHEN = re.compile(r"\s*-\s*")
10
14
 
11
15
 
12
16
  class Phonetics(RefinedSoundex):
@@ -58,15 +62,212 @@ class Phonetics(RefinedSoundex):
58
62
  return matching_index
59
63
 
60
64
 
65
+ class RapidFuzzMatcher:
66
+ """
67
+ Default matcher for names, using rapidfuzz edit-distance scoring instead
68
+ of phonetic (Soundex-family) encoding as Phonetics does. Phonetic algorithms
69
+ encode English pronunciation rules, so they are a poor fit for matching
70
+ multilingual or transliterated names (eg. "Sana'a" vs "Sanaa") that are
71
+ spelling variants rather than being phonetically similar in English
72
+ terms. Edit distance on normalised strings tends to place such variants
73
+ closer together.
74
+
75
+ Args:
76
+ scorer: Function taking two strings and returning a similarity score
77
+ from 0-100. Defaults to default_scorer. Use place_name_scorer for
78
+ place names. Ties are broken by rapidfuzz.fuzz.ratio over the
79
+ whole strings.
80
+
81
+ Caveat: "<name> city" scores ~100 against "<name>", as for any name whose
82
+ words are a subset of the other's. So withholding a text replacement that
83
+ strips " city" does not stop "Kenge city" matching "Kenge".
84
+ """
85
+
86
+ def __init__(self, scorer: Callable[[str, str], float] | None = None) -> None:
87
+ self.scorer = scorer or self.default_scorer
88
+
89
+ @staticmethod
90
+ def default_scorer(name: str, possible_name: str) -> float:
91
+ """
92
+ Score two strings with token_set_ratio, or with ratio on the strings
93
+ with spaces and punctuation removed if that is at least 90 and higher.
94
+
95
+ Args:
96
+ name: Name to match
97
+ possible_name: Possible name
98
+
99
+ Returns:
100
+ Similarity score from 0-100
101
+ """
102
+ score = rapidfuzz_fuzz.token_set_ratio(name, possible_name)
103
+ joined_score = rapidfuzz_fuzz.ratio(
104
+ NON_WORD_CHARACTERS.sub("", name),
105
+ NON_WORD_CHARACTERS.sub("", possible_name),
106
+ )
107
+ if joined_score >= 90:
108
+ return max(score, joined_score)
109
+ return score
110
+
111
+ @staticmethod
112
+ def place_name_scorer(name: str, possible_name: str) -> float:
113
+ """
114
+ Score two strings with default_scorer, but score 0 if they share a
115
+ word, each has other words of 3+ characters, and one of the words on
116
+ the side with fewer has ratio < 60 to all those on the other side (eg.
117
+ "central kalimantan" vs "kalimantan utara", but not "central west" vs
118
+ "centre west"). Suits place names, where the differing word usually
119
+ distinguishes places, but not names like sectors, where extra words
120
+ are often descriptive (eg. "food security and livelihood").
121
+
122
+ Args:
123
+ name: Name to match
124
+ possible_name: Possible name
125
+
126
+ Returns:
127
+ Similarity score from 0-100
128
+ """
129
+ words = set(name.split())
130
+ possible_words = set(possible_name.split())
131
+
132
+ def long_words(words: set[str]) -> set[str]:
133
+ return {word for word in words if len(word) > 2}
134
+
135
+ extra_words = long_words(words - possible_words)
136
+ possible_extra_words = long_words(possible_words - words)
137
+ if words & possible_words and extra_words and possible_extra_words:
138
+ fewer, more = sorted((extra_words, possible_extra_words), key=len)
139
+ for word in fewer:
140
+ if max(rapidfuzz_fuzz.ratio(word, other) for other in more) < 60:
141
+ return 0.0
142
+ return RapidFuzzMatcher.default_scorer(name, possible_name)
143
+
144
+ def match(
145
+ self,
146
+ possible_names: Sequence,
147
+ name: str,
148
+ alternative_name: str | None = None,
149
+ transform_possible_names: Sequence[Callable] = [],
150
+ threshold: float = 65.0,
151
+ ) -> int | None:
152
+ """
153
+ Match name to one of the given possible names. Returns None if no
154
+ match or the index of the matching name.
155
+
156
+ Args:
157
+ possible_names: Possible names
158
+ name: Name to match
159
+ alternative_name: Alternative name to match. Defaults to None.
160
+ transform_possible_names: Functions to transform possible names.
161
+ threshold: Minimum similarity score, 0-100. Defaults to 65.0.
162
+
163
+ Returns:
164
+ Index of matching name from possible names or None
165
+ """
166
+ maxscore = None
167
+ matching_index = None
168
+ names = [name.lower()]
169
+ if alternative_name:
170
+ names.append(alternative_name.lower())
171
+ all_transforms = (lambda x: x, *transform_possible_names)
172
+ for i, possible_name in enumerate(possible_names):
173
+ for transform_possible_name in all_transforms:
174
+ transformed_possible_name = transform_possible_name(possible_name)
175
+ if not transformed_possible_name:
176
+ continue
177
+ transformed_possible_name = transformed_possible_name.lower()
178
+ for query in names:
179
+ score = (
180
+ self.scorer(query, transformed_possible_name),
181
+ rapidfuzz_fuzz.ratio(query, transformed_possible_name),
182
+ )
183
+ if maxscore is None or score > maxscore:
184
+ maxscore = score
185
+ matching_index = i
186
+ if maxscore is None or maxscore[0] < threshold:
187
+ return None
188
+ return matching_index
189
+
190
+
191
+ def split_name(name: str, separators: re.Pattern) -> list[str]:
192
+ """
193
+ Split name on separators, dropping parts that are empty once normalised.
194
+
195
+ Args:
196
+ name: Name to split
197
+ separators: Compiled regular expression matching separators
198
+
199
+ Returns:
200
+ List of parts
201
+ """
202
+ parts = [part.strip() for part in separators.split(name)]
203
+ return [part for part in parts if normalise(part)]
204
+
205
+
206
+ def resolve_name_parts(
207
+ name: str, name_to_code: dict[str, str], ignore: str | None = None
208
+ ) -> tuple[str | None, bool]:
209
+ """
210
+ Resolve a name made of parts split by separators (",", ";", "/", ":", "&",
211
+ newline, "and", "et") or, if there are none, by a hyphen. Parts are looked
212
+ up in name_to_code. A name with two parts gives a code if:
213
+ - one part's normalised form equals ignore and the other part has a code
214
+ (eg. "Falcón, Acosta" with the parent admin name "falcon")
215
+ - one part's code followed by "-" starts the other's (eg. "Protection -
216
+ GBV" gives PRO-GBV)
217
+ - both parts have the same code.
218
+ It is a list of names (eg. "Wash & Protection") if it has more than two
219
+ parts, or two parts with different codes (eg. "Guba-Khachmaz").
220
+
221
+ Args:
222
+ name: Name to resolve
223
+ name_to_code: Mapping from names (raw or normalised) to codes
224
+ ignore: Normalised name of a part that qualifies the other part.
225
+ Defaults to None.
226
+
227
+ Returns:
228
+ Tuple of code (or None) and whether name is a list of names
229
+ """
230
+ parts = split_name(name, LIST_SEPARATORS)
231
+ if len(parts) > 2:
232
+ return None, True
233
+ if len(parts) < 2:
234
+ parts = split_name(name, HYPHEN)
235
+ if len(parts) != 2:
236
+ return None, False
237
+ normalised_parts = [normalise(part) for part in parts]
238
+ if ignore in normalised_parts:
239
+ index = 1 - normalised_parts.index(ignore)
240
+ parts = [parts[index]]
241
+ normalised_parts = [normalised_parts[index]]
242
+ codes = []
243
+ for part, normalised_part in zip(parts, normalised_parts):
244
+ code = name_to_code.get(part, name_to_code.get(normalised_part))
245
+ if not code:
246
+ return None, False
247
+ codes.append(code)
248
+ code = codes[0]
249
+ if len(codes) == 1:
250
+ return code, False
251
+ other_code = codes[1]
252
+ if code == other_code or other_code.startswith(f"{code}-"):
253
+ return other_code, False
254
+ if code.startswith(f"{other_code}-"):
255
+ return code, False
256
+ return None, True
257
+
258
+
61
259
  def get_code_from_name(
62
260
  name: str,
63
261
  code_lookup: dict[str, str],
64
262
  unmatched: list[str],
65
263
  fuzzy_match: bool = True,
66
264
  match_threshold: int = 5,
265
+ matcher: Phonetics | RapidFuzzMatcher | None = None,
67
266
  ) -> str | None:
68
267
  """
69
- Given a name (org type, sector, etc), return the corresponding code.
268
+ Given a name (org type, sector, etc), return the corresponding code. A
269
+ name made of parts is resolved by resolve_name_parts, and is not fuzzy
270
+ matched if it is a list of names.
70
271
 
71
272
  Args:
72
273
  name: Name to match
@@ -74,6 +275,7 @@ def get_code_from_name(
74
275
  unmatched: List of unmatched names
75
276
  fuzzy_match: Allow fuzzy matching or not
76
277
  match_threshold: Match threshold
278
+ matcher: Fuzzy matcher to use. Defaults to None (RapidFuzzMatcher).
77
279
 
78
280
  Returns:
79
281
  Matching code
@@ -91,11 +293,17 @@ def get_code_from_name(
91
293
  if len(name) <= match_threshold:
92
294
  unmatched.append(name)
93
295
  return None
94
- if not fuzzy_match:
296
+ code, is_list = resolve_name_parts(name, code_lookup)
297
+ if code:
298
+ code_lookup[name] = code
299
+ return code
300
+ if not fuzzy_match or is_list:
95
301
  unmatched.append(name)
96
302
  return None
97
303
  names = [x for x in code_lookup.keys() if len(x) > match_threshold]
98
- name_index = Phonetics().match(
304
+ if matcher is None:
305
+ matcher = RapidFuzzMatcher()
306
+ name_index = matcher.match(
99
307
  possible_names=names,
100
308
  name=name,
101
309
  alternative_name=name_clean,
@@ -184,6 +184,8 @@ class Retrieve(BaseDownload):
184
184
  filename: str | None = None,
185
185
  logstr: str | None = None,
186
186
  fallback: bool = False,
187
+ resume: bool = False,
188
+ retries: int = 0,
187
189
  log_level: int = None,
188
190
  **kwargs: Any,
189
191
  ) -> Path:
@@ -194,6 +196,8 @@ class Retrieve(BaseDownload):
194
196
  filename: Filename of saved file. Defaults to getting from url.
195
197
  logstr: Text to use in log string to describe download. Defaults to filename.
196
198
  fallback: Whether to use static fallback if download fails. Defaults to False.
199
+ resume: Whether to resume a partial download using Range requests where the server supports it. Defaults to False.
200
+ retries: Number of times to retry a mid-stream failure. Only effective when resume=True. Defaults to 0.
197
201
  log_level: Level at which to log messages. Overrides level from constructor.
198
202
  **kwargs: Parameters to pass to download_file call
199
203
 
@@ -219,7 +223,9 @@ class Retrieve(BaseDownload):
219
223
  log_level,
220
224
  f"Downloading {logstr} from {self.get_url_logstr(url)} into {output_path}",
221
225
  )
222
- return self.downloader.download_file(url, path=output_path, **kwargs)
226
+ return self.downloader.download_file(
227
+ url, path=output_path, resume=resume, retries=retries, **kwargs
228
+ )
223
229
  except DownloadError:
224
230
  if not fallback:
225
231
  raise
@@ -0,0 +1,9 @@
1
+ id,name,value
2
+ 1,alpha,10
3
+ 2,beta,20
4
+ 3,gamma,30
5
+ 4,delta,40
6
+ 5,epsilon,50
7
+ 6,zeta,60
8
+ 7,eta,70
9
+ 8,theta,80