hdx-python-utilities 4.1.2__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.2 → hdx_python_utilities-4.2.0}/PKG-INFO +3 -2
  2. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/documentation/index.md +33 -0
  3. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/pyproject.toml +1 -0
  4. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/_version.py +2 -2
  5. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/matching.py +211 -3
  6. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_downloader.py +1 -1
  7. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_matching.py +250 -0
  8. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/uv.lock +214 -1
  9. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/.github/workflows/publish-test.yaml +0 -0
  10. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/.github/workflows/publish.yaml +0 -0
  11. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/.github/workflows/run-python-tests.yaml +0 -0
  12. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/.gitignore +0 -0
  13. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/.pre-commit-config.yaml +0 -0
  14. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/CLAUDE.md +0 -0
  15. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/LICENSE +0 -0
  16. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/README.md +0 -0
  17. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/documentation/.readthedocs.yaml +0 -0
  18. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/documentation/mkdocs.yaml +0 -0
  19. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/__init__.py +0 -0
  20. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/base_downloader.py +0 -0
  21. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/compare.py +0 -0
  22. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/dateparse.py +0 -0
  23. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/dictandlist.py +0 -0
  24. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/downloader.py +0 -0
  25. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/easy_logging.py +0 -0
  26. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/email.py +0 -0
  27. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/encoding.py +0 -0
  28. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/error_handler.py +0 -0
  29. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/errors_onexit.py +0 -0
  30. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/file_hashing.py +0 -0
  31. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/frictionless_wrapper.py +0 -0
  32. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/html.py +0 -0
  33. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/loader.py +0 -0
  34. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/path.py +0 -0
  35. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/retriever.py +0 -0
  36. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/saver.py +0 -0
  37. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/session.py +0 -0
  38. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/state.py +0 -0
  39. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/text.py +0 -0
  40. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/url.py +0 -0
  41. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/useragent.py +0 -0
  42. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/uuid.py +0 -0
  43. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/src/hdx/utilities/zip_crc.py +0 -0
  44. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/compare/test_csv_processing.csv +0 -0
  45. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/compare/test_csv_processing2.csv +0 -0
  46. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/empty.yaml +0 -0
  47. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/hdx_config.json +0 -0
  48. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/hdx_config.yaml +0 -0
  49. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/hdx_email_configuration.json +0 -0
  50. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/hdx_email_configuration.yaml +0 -0
  51. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/logging_config.json +0 -0
  52. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/logging_config.yaml +0 -0
  53. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/project_configuration.json +0 -0
  54. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/project_configuration.yaml +0 -0
  55. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/smtp_config.json +0 -0
  56. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/smtp_config.yaml +0 -0
  57. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/user_agent_config.yaml +0 -0
  58. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/user_agent_config2.yaml +0 -0
  59. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/user_agent_config3.yaml +0 -0
  60. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/config/user_agent_config_wrong.yaml +0 -0
  61. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/basicauth.txt +0 -0
  62. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/bearertoken.txt +0 -0
  63. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/extra_params.json +0 -0
  64. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/extra_params.yaml +0 -0
  65. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/extra_params_tree.yaml +0 -0
  66. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/resume_test.dat +0 -0
  67. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_csv_processing.csv +0 -0
  68. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_csv_processing_blanks.csv +0 -0
  69. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_data.csv +0 -0
  70. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_data.xlsx +0 -0
  71. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_data1.csv/empty.txt +0 -0
  72. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_data2.csv +0 -0
  73. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_json_processing.json +0 -0
  74. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_xls_processing.xls +0 -0
  75. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/downloader/test_xlsx_processing.xlsx +0 -0
  76. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/file_hashing/bad_header.zip +0 -0
  77. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/file_hashing/bad_index.xlsx +0 -0
  78. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/file_hashing/empty.zip +0 -0
  79. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/file_hashing/test.xlsx +0 -0
  80. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/file_hashing/test_shapefile.zip +0 -0
  81. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/file_hashing/valid_sig_invalid_body.zip +0 -0
  82. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/html/response.html +0 -0
  83. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/loader/empty.json +0 -0
  84. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/loader/empty.yaml +0 -0
  85. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/loader/empty_list.json +0 -0
  86. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/fallbacks/test.csv +0 -0
  87. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/fallbacks/test.json +0 -0
  88. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/fallbacks/test.txt +0 -0
  89. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/fallbacks/test.yaml +0 -0
  90. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/retriever-test.csv +0 -0
  91. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/test.csv +0 -0
  92. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/test.json +0 -0
  93. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/test.txt +0 -0
  94. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/retriever/test.yaml +0 -0
  95. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out.csv +0 -0
  96. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out.json +0 -0
  97. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out2.csv +0 -0
  98. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out2.json +0 -0
  99. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out5.json +0 -0
  100. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out6.json +0 -0
  101. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out7.json +0 -0
  102. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out8.csv +0 -0
  103. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/out8.json +0 -0
  104. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-false_sortkeys-false.json +0 -0
  105. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-false_sortkeys-false.yaml +0 -0
  106. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-false_sortkeys-true.json +0 -0
  107. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-false_sortkeys-true.yaml +0 -0
  108. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-true_sortkeys-false.json +0 -0
  109. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-true_sortkeys-false.yaml +0 -0
  110. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-true_sortkeys-true.json +0 -0
  111. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/saver/pretty-true_sortkeys-true.yaml +0 -0
  112. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/state/analysis_dates.txt +0 -0
  113. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/state/last_build_date.txt +0 -0
  114. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/test_data.csv +0 -0
  115. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/text/text1.txt +0 -0
  116. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/fixtures/text/text2.txt +0 -0
  117. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/conftest.py +0 -0
  118. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_compare.py +0 -0
  119. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_dateparse.py +0 -0
  120. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_dictandlist.py +0 -0
  121. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_easy_logging.py +0 -0
  122. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_email.py +0 -0
  123. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_encoding.py +0 -0
  124. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_error_handler.py +0 -0
  125. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_file_hashing.py +0 -0
  126. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_html.py +0 -0
  127. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_loader.py +0 -0
  128. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_path.py +0 -0
  129. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_retriever.py +0 -0
  130. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_saver.py +0 -0
  131. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_state.py +0 -0
  132. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_text.py +0 -0
  133. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_url.py +0 -0
  134. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_useragent.py +0 -0
  135. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_uuid.py +0 -0
  136. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.0}/tests/hdx/utilities/test_zip_crc.py +0 -0
  137. {hdx_python_utilities-4.1.2 → 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.2
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
 
@@ -877,7 +882,19 @@ Efficient Hashing of files that produces a stable hash even for zip and xlsx fil
877
882
 
878
883
  Examples:
879
884
 
885
+ # Fuzzy match a name to one of a list of names using rapidfuzz scoring
880
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
881
898
  phonetics = Phonetics()
882
899
  assert phonetics.match(possible_names, "al dali") == 1
883
900
 
@@ -887,6 +904,22 @@ Examples:
887
904
  }
888
905
  assert get_code_from_name("NATIONAL_NGO", lookup, [], fuzzy_match=False) == "441"
889
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
+
890
923
  a = "The quick brown fox jumped over the lazy dog. It was so fast!"
891
924
 
892
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",
@@ -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.2'
22
- __version_tuple__ = version_tuple = (4, 1, 2)
21
+ __version__ = version = '4.2.0'
22
+ __version_tuple__ = version_tuple = (4, 2, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -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,
@@ -462,7 +462,7 @@ class TestDownloader:
462
462
  with Download() as downloader:
463
463
  text = downloader.download_text(fixtureurl)
464
464
  assert downloader.get_status() == 200
465
- assert len(downloader.get_headers()) in (24, 25)
465
+ assert len(downloader.get_headers()) == 26
466
466
  assert (
467
467
  bool(re.match(r"7\d\d", downloader.get_header("Content-Length")))
468
468
  is True
@@ -2,6 +2,7 @@
2
2
 
3
3
  from hdx.utilities.matching import (
4
4
  Phonetics,
5
+ RapidFuzzMatcher,
5
6
  earliest_index,
6
7
  get_code_from_name,
7
8
  get_matching_text,
@@ -9,6 +10,7 @@ from hdx.utilities.matching import (
9
10
  get_matching_then_nonmatching_text,
10
11
  match_template_variables,
11
12
  multiple_replace,
13
+ resolve_name_parts,
12
14
  )
13
15
  from hdx.utilities.text import normalise
14
16
 
@@ -29,6 +31,161 @@ class TestMatching:
29
31
  is None
30
32
  )
31
33
 
34
+ def test_rapidfuzz_matcher(self):
35
+ possible_names = ["Al Maharah", "Ad Dali", "Dhamar"]
36
+ matcher = RapidFuzzMatcher()
37
+ assert matcher.match(possible_names, "al dali") == 1
38
+ assert matcher.match(possible_names, "xxx", "Damar") == 2
39
+ transform_possible_names = [lambda x: None]
40
+ assert (
41
+ matcher.match(possible_names, "xxx", "yyy", transform_possible_names)
42
+ is None
43
+ )
44
+
45
+ def test_rapidfuzz_matcher_transliteration(self):
46
+ # UKR admin_name_mappings entries that Phonetics cannot match
47
+ possible_names = [
48
+ "Avtonomna Respublika Krym",
49
+ "Vinnytska",
50
+ "Volynska",
51
+ "Dnipropetrovska",
52
+ "Donetska",
53
+ "Zhytomyrska",
54
+ "Zakarpatska",
55
+ "Zaporizka",
56
+ "Ivano Frankivska",
57
+ "Kyivska",
58
+ "Kirovohradska",
59
+ "Luhanska",
60
+ "Lvivska",
61
+ "Mykolaivska",
62
+ "Odeska",
63
+ "Poltavska",
64
+ "Rivnenska",
65
+ "Sumska",
66
+ "Ternopilska",
67
+ "Kharkivska",
68
+ "Khersonska",
69
+ "Khmelnytska",
70
+ "Cherkaska",
71
+ "Chernivetska",
72
+ "Chernihivska",
73
+ "Sevastopol",
74
+ ]
75
+ replacements = {" oblast": ""}
76
+
77
+ def resolve(matcher, query):
78
+ normalised_query = normalise(query)
79
+ alt = multiple_replace(normalised_query, replacements)
80
+ index = matcher.match(
81
+ possible_names, normalised_query, alternative_name=alt
82
+ )
83
+ return possible_names[index] if index is not None else None
84
+
85
+ phonetics = Phonetics()
86
+ rapidfuzz_matcher = RapidFuzzMatcher()
87
+ for query, expected in (
88
+ ("Chernіvеtskaja Oblast", "Chernivetska"),
89
+ ("Luganskaja Oblast", "Luhanska"),
90
+ ("Rovenskaja Oblast", "Rivnenska"),
91
+ ("Sevastopol City", "Sevastopol"),
92
+ ("Zapоrіzskaja Oblast", "Zaporizka"),
93
+ ):
94
+ assert resolve(phonetics, query) is None
95
+ assert resolve(rapidfuzz_matcher, query) == expected
96
+
97
+ # A naming-scheme difference, not a spelling variant
98
+ assert resolve(phonetics, "Chernigovskaja Oblast") is None
99
+ assert resolve(rapidfuzz_matcher, "Chernigovskaja Oblast") != "Chernihivska"
100
+
101
+ def test_rapidfuzz_matcher_naming_scheme_mismatch(self):
102
+ # Unrelated names that only admin_name_mappings can resolve
103
+ phonetics = Phonetics()
104
+ rapidfuzz_matcher = RapidFuzzMatcher()
105
+ cases = (
106
+ (["Federal Capital Territory"], "Abuja"), # NGA: informal vs formal name
107
+ (["Nando"], "Centre-Ouest"), # BFA: region renamed
108
+ (["Avtonomna Respublika Krym"], "Crimea"), # UKR: colloquial vs formal
109
+ )
110
+ for possible_names, query in cases:
111
+ assert phonetics.match(possible_names, normalise(query)) is None
112
+ assert rapidfuzz_matcher.match(possible_names, normalise(query)) is None
113
+
114
+ def test_rapidfuzz_matcher_extra_word_caveat(self):
115
+ # Phonetics rejects both; RapidFuzzMatcher accepts both
116
+ phonetics = Phonetics()
117
+ rapidfuzz_matcher = RapidFuzzMatcher()
118
+ for query in ("sevastopol city", "kenge city"):
119
+ name = query.split(" ")[0].capitalize()
120
+ assert phonetics.match([name], query) is None
121
+ assert rapidfuzz_matcher.match([name], query) == 0
122
+
123
+ def test_rapidfuzz_matcher_word_split(self):
124
+ # Admin names from pipeline inputs that differ from the COD name only
125
+ # in spacing/punctuation. token_set_ratio alone scores these < 65.
126
+ rapidfuzz_matcher = RapidFuzzMatcher()
127
+ for possible_names, query, expected in (
128
+ (["dawlat abad", "kaldar", "sharak e hayratan"], "dawlatabad", 0),
129
+ (["waghaz", "qara bagh", "jaghuri"], "qarabagh", 1),
130
+ (["pemagatshel", "paro", "punakha"], "pema gatshel", 0),
131
+ (["north east", "grande'anse", "artibonite"], "grande anse", 1),
132
+ (["west", "north east", "north west"], "nord ouest", 2),
133
+ ):
134
+ assert rapidfuzz_matcher.match(possible_names, query) == expected
135
+
136
+ def test_rapidfuzz_matcher_different_extra_words(self):
137
+ # Names sharing a word where each has another word the other lacks.
138
+ # default_scorer matches "central kalimantan" to "kalimantan utara"
139
+ # and "bas sassandra" to "haut sassandra".
140
+ rapidfuzz_matcher = RapidFuzzMatcher(RapidFuzzMatcher.place_name_scorer)
141
+ kalimantan = [
142
+ "kalimantan barat",
143
+ "kalimantan tengah",
144
+ "kalimantan selatan",
145
+ "kalimantan timur",
146
+ "kalimantan utara",
147
+ ]
148
+ for possible_names, query, expected in (
149
+ (kalimantan, "central kalimantan", None),
150
+ (kalimantan, "north kalimantan", None),
151
+ (["haut sassandra", "bas uele"], "bas sassandra", None),
152
+ (["centre east", "centre west"], "central west", 1),
153
+ (["hodh gharbi", "hodh chargui"], "hosh ech chargui", 1),
154
+ (["ad dali", "al maharah"], "al dali", 0),
155
+ ):
156
+ assert rapidfuzz_matcher.match(possible_names, query) == expected
157
+
158
+ def test_rapidfuzz_matcher_subset_tie_break(self):
159
+ # Haiti has both "West" and "North West" departments. For the
160
+ # translated alternative "north west", token_set_ratio scores "west",
161
+ # "north" and "north west" all at 100; fuzz.ratio breaks the tie.
162
+ possible_names = [
163
+ "west",
164
+ "south east",
165
+ "north",
166
+ "north east",
167
+ "artibonite",
168
+ "centre",
169
+ "south",
170
+ "grande'anse",
171
+ "north west",
172
+ "nippes",
173
+ ]
174
+ replacements = {"sud": "south", "ouest": "west", "est": "east", "nord": "north"}
175
+ rapidfuzz_matcher = RapidFuzzMatcher()
176
+
177
+ normalised_query = normalise("Nord-Ouest")
178
+ translated_query = multiple_replace(normalised_query, replacements)
179
+ assert translated_query == "north west"
180
+
181
+ index = rapidfuzz_matcher.match(possible_names, normalised_query)
182
+ assert possible_names[index] == "north west"
183
+
184
+ index = rapidfuzz_matcher.match(
185
+ possible_names, normalised_query, alternative_name=translated_query
186
+ )
187
+ assert possible_names[index] == "north west"
188
+
32
189
  def test_get_code_from_name_org_type(self):
33
190
  org_type_lookup = {
34
191
  "Academic / Research": "431",
@@ -172,6 +329,99 @@ class TestMatching:
172
329
  is None
173
330
  )
174
331
 
332
+ def test_resolve_name_parts(self):
333
+ sector_lookup = {
334
+ "food security": "FSC",
335
+ "nutrition": "NUT",
336
+ "protection": "PRO",
337
+ "gbv": "PRO-GBV",
338
+ "wash": "WSH",
339
+ "abris": "SHL",
340
+ "sante": "HEA",
341
+ }
342
+ for name, expected in (
343
+ ("Wash & Protection", (None, True)),
344
+ ("Santé/Nutrition", (None, True)),
345
+ ("Santé\nProtection", (None, True)),
346
+ ("developpement, paix, genre", (None, True)),
347
+ ("Protection - GBV", ("PRO-GBV", False)),
348
+ ("GBV/Protection", ("PRO-GBV", False)),
349
+ ("Wash - Wash", ("WSH", False)),
350
+ ("Food Security and Livelihood", (None, False)),
351
+ ("Abris et Non vivres", (None, False)),
352
+ ("Protection", (None, False)),
353
+ ):
354
+ assert resolve_name_parts(name, sector_lookup) == expected
355
+ falcon_lookup = {"acosta": "VE1101", "falcon": "VE1109"}
356
+ assert resolve_name_parts("Falcón, Acosta", falcon_lookup) == (None, True)
357
+ for name, expected in (
358
+ ("Falcón, Acosta", ("VE1101", False)),
359
+ ("Acosta, Falcón", ("VE1101", False)),
360
+ ("Falcón, Falcón", ("VE1109", False)),
361
+ ("Falcón, Acostta", (None, False)),
362
+ ):
363
+ assert resolve_name_parts(name, falcon_lookup, "falcon") == expected
364
+ aze_lookup = {"guba": "AZ30300001", "khachmaz": "AZ30200001"}
365
+ for name, expected in (
366
+ ("Guba-Khachmaz", (None, True)),
367
+ ("Guba - Khachmaz", (None, True)),
368
+ ("Guba-Khizi", (None, False)),
369
+ ):
370
+ assert resolve_name_parts(name, aze_lookup) == expected
371
+ assert resolve_name_parts("Mbanza-Ngungu", {"mbanza ngungu": "CD2013"}) == (
372
+ None,
373
+ False,
374
+ )
375
+
376
+ def test_get_code_from_name_list_of_names(self):
377
+ sector_lookup = {
378
+ "Food Security": "FSC",
379
+ "Nutrition": "NUT",
380
+ "Protection": "PRO",
381
+ "Emergency Shelter and NFI": "SHL",
382
+ "Health": "HEA",
383
+ "Gender Based Violence": "PRO-GBV",
384
+ "GBV": "PRO-GBV",
385
+ }
386
+ actual_sector_lookup = {normalise(k): v for k, v in sector_lookup.items()}
387
+ actual_sector_lookup.update(sector_lookup)
388
+ matcher = RapidFuzzMatcher()
389
+ for name, code in (
390
+ ("Protection - GBV", "PRO-GBV"),
391
+ ("GBV - Protection", "PRO-GBV"),
392
+ ("Health-Nutrition", None),
393
+ ("Nutrition, Protection", None),
394
+ ("Food Security and Livelihood", "FSC"),
395
+ ("Emergency Shelter and NFI", "SHL"),
396
+ ):
397
+ assert (
398
+ get_code_from_name(name, actual_sector_lookup, [], matcher=matcher)
399
+ == code
400
+ )
401
+
402
+ def test_get_code_from_name_rapidfuzz_matcher(self):
403
+ sector_lookup = {
404
+ "Emergency Shelter and NFI": "SHL",
405
+ "Camp Coordination / Management": "CCM",
406
+ "Education": "EDU",
407
+ "Health": "HEA",
408
+ }
409
+ actual_sector_lookup = {normalise(k): v for k, v in sector_lookup.items()}
410
+ org_type_lookup = {
411
+ "International NGO": "437",
412
+ "National NGO": "441",
413
+ "United Nations": "447",
414
+ }
415
+ actual_org_type_lookup = {normalise(k): v for k, v in org_type_lookup.items()}
416
+ matcher = RapidFuzzMatcher()
417
+ for name, lookup, expected in (
418
+ ("Educaton", actual_sector_lookup, "EDU"),
419
+ ("Healht", actual_sector_lookup, "HEA"),
420
+ # WRatio matches this to "International NGO"
421
+ ("COOPÉRATION_INTERNATIONALE", actual_org_type_lookup, None),
422
+ ):
423
+ assert get_code_from_name(name, lookup, [], matcher=matcher) == expected
424
+
175
425
  def test_multiple_replace(self):
176
426
  result = multiple_replace(
177
427
  self.a, {"quick": "slow", "fast": "slow", "lazy": "busy"}