hdx-python-utilities 4.1.2__tar.gz → 4.2.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 (140) hide show
  1. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/PKG-INFO +3 -2
  2. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/documentation/index.md +39 -0
  3. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/pyproject.toml +1 -0
  4. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/_version.py +2 -2
  5. hdx_python_utilities-4.2.1/src/hdx/utilities/matching.py +598 -0
  6. hdx_python_utilities-4.2.1/tests/fixtures/matching/wfp_admin_matcher_calls.json +5489 -0
  7. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_downloader.py +1 -1
  8. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_matching.py +276 -0
  9. hdx_python_utilities-4.2.1/tests/hdx/utilities/test_matching_wfp_admin.py +76 -0
  10. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/uv.lock +214 -1
  11. hdx_python_utilities-4.1.2/src/hdx/utilities/matching.py +0 -332
  12. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/.github/workflows/publish-test.yaml +0 -0
  13. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/.github/workflows/publish.yaml +0 -0
  14. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/.github/workflows/run-python-tests.yaml +0 -0
  15. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/.gitignore +0 -0
  16. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/.pre-commit-config.yaml +0 -0
  17. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/CLAUDE.md +0 -0
  18. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/LICENSE +0 -0
  19. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/README.md +0 -0
  20. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/documentation/.readthedocs.yaml +0 -0
  21. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/documentation/mkdocs.yaml +0 -0
  22. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/__init__.py +0 -0
  23. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/base_downloader.py +0 -0
  24. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/compare.py +0 -0
  25. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/dateparse.py +0 -0
  26. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/dictandlist.py +0 -0
  27. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/downloader.py +0 -0
  28. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/easy_logging.py +0 -0
  29. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/email.py +0 -0
  30. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/encoding.py +0 -0
  31. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/error_handler.py +0 -0
  32. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/errors_onexit.py +0 -0
  33. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/file_hashing.py +0 -0
  34. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/frictionless_wrapper.py +0 -0
  35. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/html.py +0 -0
  36. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/loader.py +0 -0
  37. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/path.py +0 -0
  38. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/retriever.py +0 -0
  39. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/saver.py +0 -0
  40. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/session.py +0 -0
  41. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/state.py +0 -0
  42. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/text.py +0 -0
  43. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/url.py +0 -0
  44. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/useragent.py +0 -0
  45. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/uuid.py +0 -0
  46. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/src/hdx/utilities/zip_crc.py +0 -0
  47. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/compare/test_csv_processing.csv +0 -0
  48. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/compare/test_csv_processing2.csv +0 -0
  49. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/empty.yaml +0 -0
  50. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/hdx_config.json +0 -0
  51. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/hdx_config.yaml +0 -0
  52. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/hdx_email_configuration.json +0 -0
  53. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/hdx_email_configuration.yaml +0 -0
  54. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/logging_config.json +0 -0
  55. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/logging_config.yaml +0 -0
  56. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/project_configuration.json +0 -0
  57. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/project_configuration.yaml +0 -0
  58. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/smtp_config.json +0 -0
  59. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/smtp_config.yaml +0 -0
  60. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/user_agent_config.yaml +0 -0
  61. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/user_agent_config2.yaml +0 -0
  62. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/user_agent_config3.yaml +0 -0
  63. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/config/user_agent_config_wrong.yaml +0 -0
  64. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/basicauth.txt +0 -0
  65. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/bearertoken.txt +0 -0
  66. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/extra_params.json +0 -0
  67. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/extra_params.yaml +0 -0
  68. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/extra_params_tree.yaml +0 -0
  69. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/resume_test.dat +0 -0
  70. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/test_csv_processing.csv +0 -0
  71. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/test_csv_processing_blanks.csv +0 -0
  72. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/test_data.csv +0 -0
  73. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/test_data.xlsx +0 -0
  74. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/test_data1.csv/empty.txt +0 -0
  75. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/test_data2.csv +0 -0
  76. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/test_json_processing.json +0 -0
  77. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/test_xls_processing.xls +0 -0
  78. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/downloader/test_xlsx_processing.xlsx +0 -0
  79. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/file_hashing/bad_header.zip +0 -0
  80. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/file_hashing/bad_index.xlsx +0 -0
  81. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/file_hashing/empty.zip +0 -0
  82. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/file_hashing/test.xlsx +0 -0
  83. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/file_hashing/test_shapefile.zip +0 -0
  84. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/file_hashing/valid_sig_invalid_body.zip +0 -0
  85. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/html/response.html +0 -0
  86. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/loader/empty.json +0 -0
  87. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/loader/empty.yaml +0 -0
  88. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/loader/empty_list.json +0 -0
  89. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/retriever/fallbacks/test.csv +0 -0
  90. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/retriever/fallbacks/test.json +0 -0
  91. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/retriever/fallbacks/test.txt +0 -0
  92. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/retriever/fallbacks/test.yaml +0 -0
  93. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/retriever/retriever-test.csv +0 -0
  94. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/retriever/test.csv +0 -0
  95. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/retriever/test.json +0 -0
  96. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/retriever/test.txt +0 -0
  97. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/retriever/test.yaml +0 -0
  98. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/out.csv +0 -0
  99. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/out.json +0 -0
  100. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/out2.csv +0 -0
  101. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/out2.json +0 -0
  102. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/out5.json +0 -0
  103. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/out6.json +0 -0
  104. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/out7.json +0 -0
  105. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/out8.csv +0 -0
  106. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/out8.json +0 -0
  107. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/pretty-false_sortkeys-false.json +0 -0
  108. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/pretty-false_sortkeys-false.yaml +0 -0
  109. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/pretty-false_sortkeys-true.json +0 -0
  110. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/pretty-false_sortkeys-true.yaml +0 -0
  111. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/pretty-true_sortkeys-false.json +0 -0
  112. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/pretty-true_sortkeys-false.yaml +0 -0
  113. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/pretty-true_sortkeys-true.json +0 -0
  114. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/saver/pretty-true_sortkeys-true.yaml +0 -0
  115. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/state/analysis_dates.txt +0 -0
  116. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/state/last_build_date.txt +0 -0
  117. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/test_data.csv +0 -0
  118. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/text/text1.txt +0 -0
  119. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/fixtures/text/text2.txt +0 -0
  120. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/conftest.py +0 -0
  121. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_compare.py +0 -0
  122. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_dateparse.py +0 -0
  123. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_dictandlist.py +0 -0
  124. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_easy_logging.py +0 -0
  125. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_email.py +0 -0
  126. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_encoding.py +0 -0
  127. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_error_handler.py +0 -0
  128. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_file_hashing.py +0 -0
  129. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_html.py +0 -0
  130. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_loader.py +0 -0
  131. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_path.py +0 -0
  132. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_retriever.py +0 -0
  133. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_saver.py +0 -0
  134. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_state.py +0 -0
  135. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_text.py +0 -0
  136. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_url.py +0 -0
  137. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_useragent.py +0 -0
  138. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_uuid.py +0 -0
  139. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/tests/hdx/utilities/test_zip_crc.py +0 -0
  140. {hdx_python_utilities-4.1.2 → hdx_python_utilities-4.2.1}/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.1
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,25 @@ 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
+ # place_name_scorer also expands a leading initial to a word starting
898
+ # with it and requires a trailing single letter in the possible name
899
+ shewa = ["west shewa", "east shewa"]
900
+ assert matcher.match(shewa, "e shewa") == 1
901
+ assert matcher.match(["region iv a calabarzon"], "region iv b") is None
902
+
903
+ # Phonetics is an alternative matcher using Refined Soundex
881
904
  phonetics = Phonetics()
882
905
  assert phonetics.match(possible_names, "al dali") == 1
883
906
 
@@ -887,6 +910,22 @@ Examples:
887
910
  }
888
911
  assert get_code_from_name("NATIONAL_NGO", lookup, [], fuzzy_match=False) == "441"
889
912
 
913
+ # get_code_from_name fuzzy matches with RapidFuzzMatcher unless given
914
+ # another matcher. It resolves names made of parts and does not fuzzy
915
+ # match lists of names.
916
+ sector_lookup = {"food security": "FSC", "protection": "PRO", "gbv": "PRO-GBV", "wash": "WSH"}
917
+ assert get_code_from_name("Food Securty", sector_lookup, []) == "FSC"
918
+ assert get_code_from_name("Food Securty", sector_lookup, [], matcher=Phonetics()) == "FSC"
919
+ assert get_code_from_name("Protection - GBV", sector_lookup, []) == "PRO-GBV"
920
+ assert get_code_from_name("Wash & Protection", sector_lookup, []) is None
921
+
922
+ # Resolve a name made of parts, returning the code and whether it is a
923
+ # list of names. The third parameter is a part that qualifies the other,
924
+ # eg. a parent admin name.
925
+ admin_lookup = {"acosta": "VE1101", "falcon": "VE1109"}
926
+ assert resolve_name_parts("Falcón, Acosta", admin_lookup) == (None, True)
927
+ assert resolve_name_parts("Falcón, Acosta", admin_lookup, "falcon") == ("VE1101", False)
928
+
890
929
  a = "The quick brown fox jumped over the lazy dog. It was so fast!"
891
930
 
892
931
  # 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.1'
22
+ __version_tuple__ = version_tuple = (4, 2, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,598 @@
1
+ import difflib
2
+ import re
3
+ from collections.abc import Callable, Sequence
4
+
5
+ from pyphonetics import RefinedSoundex
6
+ from rapidfuzz import fuzz as rapidfuzz_fuzz
7
+
8
+ from hdx.utilities.text import normalise
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*")
14
+
15
+
16
+ def expand_initials(words: list[str], other_words: list[str]) -> list[str] | None:
17
+ """Check single letters at the start or end of words against
18
+ other_words. A leading letter is an initial and is replaced by an unused
19
+ word in other_words that is not in words and starts with it ("e shewa"
20
+ -> "east shewa", "b gumuz" -> "benishangul gumuz"). A trailing letter
21
+ distinguishes places ("region iv b") so must be in other_words. Single
22
+ letters between other words are left alone as they are often conjunctions
23
+ (eg. Spanish "y").
24
+
25
+ Args:
26
+ words: Words that may contain single letters
27
+ other_words: Words to expand single letters to
28
+
29
+ Returns:
30
+ Expanded words or None if a single letter has no counterpart
31
+ """
32
+ leading = []
33
+ for word in words:
34
+ if len(word) != 1:
35
+ break
36
+ leading.append(word)
37
+ trailing = []
38
+ for word in reversed(words[len(leading) :]):
39
+ if len(word) != 1:
40
+ break
41
+ trailing.append(word)
42
+ for letter in trailing:
43
+ if letter.isalpha() and letter not in other_words:
44
+ return None
45
+ unmatched = [word for word in other_words if word not in words and len(word) > 1]
46
+ expanded = list(words)
47
+ for i, letter in enumerate(leading):
48
+ if not letter.isalpha() or letter in other_words:
49
+ continue
50
+ for other in unmatched:
51
+ if other[0] == letter:
52
+ unmatched.remove(other)
53
+ expanded[i] = other
54
+ break
55
+ else:
56
+ return None
57
+ return expanded
58
+
59
+
60
+ class Phonetics(RefinedSoundex):
61
+ def match(
62
+ self,
63
+ possible_names: Sequence,
64
+ name: str,
65
+ alternative_name: str | None = None,
66
+ transform_possible_names: Sequence[Callable] = [],
67
+ threshold: int = 2,
68
+ ) -> int | None:
69
+ """
70
+ Match name to one of the given possible names. Returns None if no match
71
+ or the index of the matching name
72
+
73
+ Args:
74
+ possible_names: Possible names
75
+ name: Name to match
76
+ alternative_name: Alternative name to match. Defaults to None.
77
+ transform_possible_names: Functions to transform possible names.
78
+ threshold: Match threshold. Defaults to 2.
79
+
80
+ Returns:
81
+ Index of matching name from possible names or None
82
+ """
83
+ mindistance = None
84
+ matching_index = None
85
+
86
+ transform_possible_names.insert(0, lambda x: x)
87
+
88
+ def check_name(name, possible_name):
89
+ nonlocal mindistance, matching_index # noqa: E999
90
+
91
+ distance = self.distance(name, possible_name)
92
+ if mindistance is None or distance < mindistance:
93
+ mindistance = distance
94
+ matching_index = i
95
+
96
+ for i, possible_name in enumerate(possible_names):
97
+ for transform_possible_name in transform_possible_names:
98
+ transformed_possible_name = transform_possible_name(possible_name)
99
+ if not transformed_possible_name:
100
+ continue
101
+ check_name(name, transformed_possible_name)
102
+ if alternative_name:
103
+ check_name(alternative_name, transformed_possible_name)
104
+ if mindistance is None or mindistance > threshold:
105
+ return None
106
+ return matching_index
107
+
108
+
109
+ class RapidFuzzMatcher:
110
+ """
111
+ Default matcher for names, using rapidfuzz edit-distance scoring instead
112
+ of phonetic (Soundex-family) encoding as Phonetics does. Phonetic algorithms
113
+ encode English pronunciation rules, so they are a poor fit for matching
114
+ multilingual or transliterated names (eg. "Sana'a" vs "Sanaa") that are
115
+ spelling variants rather than being phonetically similar in English
116
+ terms. Edit distance on normalised strings tends to place such variants
117
+ closer together.
118
+
119
+ Args:
120
+ scorer: Function taking two strings and returning a similarity score
121
+ from 0-100. Defaults to default_scorer. Use place_name_scorer for
122
+ place names. Ties are broken by rapidfuzz.fuzz.ratio over the
123
+ whole strings.
124
+
125
+ Caveat: "<name> city" scores ~100 against "<name>", as for any name whose
126
+ words are a subset of the other's. So withholding a text replacement that
127
+ strips " city" does not stop "Kenge city" matching "Kenge".
128
+ """
129
+
130
+ def __init__(self, scorer: Callable[[str, str], float] | None = None) -> None:
131
+ self.scorer = scorer or self.default_scorer
132
+
133
+ @staticmethod
134
+ def default_scorer(name: str, possible_name: str) -> float:
135
+ """
136
+ Score two strings with token_set_ratio, or with ratio on the strings
137
+ with spaces and punctuation removed if that is at least 90 and higher.
138
+
139
+ Args:
140
+ name: Name to match
141
+ possible_name: Possible name
142
+
143
+ Returns:
144
+ Similarity score from 0-100
145
+ """
146
+ score = rapidfuzz_fuzz.token_set_ratio(name, possible_name)
147
+ joined_score = rapidfuzz_fuzz.ratio(
148
+ NON_WORD_CHARACTERS.sub("", name),
149
+ NON_WORD_CHARACTERS.sub("", possible_name),
150
+ )
151
+ if joined_score >= 90:
152
+ return max(score, joined_score)
153
+ return score
154
+
155
+ @staticmethod
156
+ def place_name_scorer(name: str, possible_name: str) -> float:
157
+ """
158
+ Score two strings with default_scorer, but score 0 if they share a
159
+ word, each has other words of 3+ characters, and one of the words on
160
+ the side with fewer has ratio < 60 to all those on the other side (eg.
161
+ "central kalimantan" vs "kalimantan utara", but not "central west" vs
162
+ "centre west"). Suits place names, where the differing word usually
163
+ distinguishes places, but not names like sectors, where extra words
164
+ are often descriptive (eg. "food security and livelihood").
165
+
166
+ Single letters at the start or end of name are checked first with
167
+ expand_initials ("e shewa" scores 0 against "west shewa" but matches
168
+ "east shewa"), unless the names differ only in spaces and punctuation
169
+ (eg. "sana a" vs "sanaa").
170
+
171
+ Args:
172
+ name: Name to match
173
+ possible_name: Possible name
174
+
175
+ Returns:
176
+ Similarity score from 0-100
177
+ """
178
+ joined_score = rapidfuzz_fuzz.ratio(
179
+ NON_WORD_CHARACTERS.sub("", name),
180
+ NON_WORD_CHARACTERS.sub("", possible_name),
181
+ )
182
+ if joined_score < 90:
183
+ name_words = expand_initials(name.split(), possible_name.split())
184
+ if name_words is None:
185
+ return 0.0
186
+ name = " ".join(name_words)
187
+ words = set(name.split())
188
+ possible_words = set(possible_name.split())
189
+
190
+ def long_words(words: set[str]) -> set[str]:
191
+ return {word for word in words if len(word) > 2}
192
+
193
+ extra_words = long_words(words - possible_words)
194
+ possible_extra_words = long_words(possible_words - words)
195
+ if words & possible_words and extra_words and possible_extra_words:
196
+ fewer, more = sorted((extra_words, possible_extra_words), key=len)
197
+ for word in fewer:
198
+ if max(rapidfuzz_fuzz.ratio(word, other) for other in more) < 60:
199
+ return 0.0
200
+ return RapidFuzzMatcher.default_scorer(name, possible_name)
201
+
202
+ def match(
203
+ self,
204
+ possible_names: Sequence,
205
+ name: str,
206
+ alternative_name: str | None = None,
207
+ transform_possible_names: Sequence[Callable] = [],
208
+ threshold: float = 65.0,
209
+ ) -> int | None:
210
+ """
211
+ Match name to one of the given possible names. Returns None if no
212
+ match or the index of the matching name.
213
+
214
+ Args:
215
+ possible_names: Possible names
216
+ name: Name to match
217
+ alternative_name: Alternative name to match. Defaults to None.
218
+ transform_possible_names: Functions to transform possible names.
219
+ threshold: Minimum similarity score, 0-100. Defaults to 65.0.
220
+
221
+ Returns:
222
+ Index of matching name from possible names or None
223
+ """
224
+ maxscore = None
225
+ matching_index = None
226
+ names = [name.lower()]
227
+ if alternative_name:
228
+ names.append(alternative_name.lower())
229
+ all_transforms = (lambda x: x, *transform_possible_names)
230
+ for i, possible_name in enumerate(possible_names):
231
+ for transform_possible_name in all_transforms:
232
+ transformed_possible_name = transform_possible_name(possible_name)
233
+ if not transformed_possible_name:
234
+ continue
235
+ transformed_possible_name = transformed_possible_name.lower()
236
+ for query in names:
237
+ score = (
238
+ self.scorer(query, transformed_possible_name),
239
+ rapidfuzz_fuzz.ratio(query, transformed_possible_name),
240
+ )
241
+ if maxscore is None or score > maxscore:
242
+ maxscore = score
243
+ matching_index = i
244
+ if maxscore is None or maxscore[0] < threshold:
245
+ return None
246
+ return matching_index
247
+
248
+
249
+ def split_name(name: str, separators: re.Pattern) -> list[str]:
250
+ """
251
+ Split name on separators, dropping parts that are empty once normalised.
252
+
253
+ Args:
254
+ name: Name to split
255
+ separators: Compiled regular expression matching separators
256
+
257
+ Returns:
258
+ List of parts
259
+ """
260
+ parts = [part.strip() for part in separators.split(name)]
261
+ return [part for part in parts if normalise(part)]
262
+
263
+
264
+ def resolve_name_parts(
265
+ name: str, name_to_code: dict[str, str], ignore: str | None = None
266
+ ) -> tuple[str | None, bool]:
267
+ """
268
+ Resolve a name made of parts split by separators (",", ";", "/", ":", "&",
269
+ newline, "and", "et") or, if there are none, by a hyphen. Parts are looked
270
+ up in name_to_code. A name with two parts gives a code if:
271
+ - one part's normalised form equals ignore and the other part has a code
272
+ (eg. "Falcón, Acosta" with the parent admin name "falcon")
273
+ - one part's code followed by "-" starts the other's (eg. "Protection -
274
+ GBV" gives PRO-GBV)
275
+ - both parts have the same code.
276
+ It is a list of names (eg. "Wash & Protection") if it has more than two
277
+ parts, or two parts with different codes (eg. "Guba-Khachmaz").
278
+
279
+ Args:
280
+ name: Name to resolve
281
+ name_to_code: Mapping from names (raw or normalised) to codes
282
+ ignore: Normalised name of a part that qualifies the other part.
283
+ Defaults to None.
284
+
285
+ Returns:
286
+ Tuple of code (or None) and whether name is a list of names
287
+ """
288
+ parts = split_name(name, LIST_SEPARATORS)
289
+ if len(parts) > 2:
290
+ return None, True
291
+ if len(parts) < 2:
292
+ parts = split_name(name, HYPHEN)
293
+ if len(parts) != 2:
294
+ return None, False
295
+ normalised_parts = [normalise(part) for part in parts]
296
+ if ignore in normalised_parts:
297
+ index = 1 - normalised_parts.index(ignore)
298
+ parts = [parts[index]]
299
+ normalised_parts = [normalised_parts[index]]
300
+ codes = []
301
+ for part, normalised_part in zip(parts, normalised_parts):
302
+ code = name_to_code.get(part, name_to_code.get(normalised_part))
303
+ if not code:
304
+ return None, False
305
+ codes.append(code)
306
+ code = codes[0]
307
+ if len(codes) == 1:
308
+ return code, False
309
+ other_code = codes[1]
310
+ if code == other_code or other_code.startswith(f"{code}-"):
311
+ return other_code, False
312
+ if code.startswith(f"{other_code}-"):
313
+ return code, False
314
+ return None, True
315
+
316
+
317
+ def get_code_from_name(
318
+ name: str,
319
+ code_lookup: dict[str, str],
320
+ unmatched: list[str],
321
+ fuzzy_match: bool = True,
322
+ match_threshold: int = 5,
323
+ matcher: Phonetics | RapidFuzzMatcher | None = None,
324
+ ) -> str | None:
325
+ """
326
+ Given a name (org type, sector, etc), return the corresponding code. A
327
+ name made of parts is resolved by resolve_name_parts, and is not fuzzy
328
+ matched if it is a list of names.
329
+
330
+ Args:
331
+ name: Name to match
332
+ code_lookup: Dictionary of official names and codes
333
+ unmatched: List of unmatched names
334
+ fuzzy_match: Allow fuzzy matching or not
335
+ match_threshold: Match threshold
336
+ matcher: Fuzzy matcher to use. Defaults to None (RapidFuzzMatcher).
337
+
338
+ Returns:
339
+ Matching code
340
+ """
341
+ code = code_lookup.get(name)
342
+ if code:
343
+ return code
344
+ if name in unmatched:
345
+ return None
346
+ name_clean = normalise(name)
347
+ code = code_lookup.get(name_clean)
348
+ if code:
349
+ code_lookup[name] = code
350
+ return code
351
+ if len(name) <= match_threshold:
352
+ unmatched.append(name)
353
+ return None
354
+ code, is_list = resolve_name_parts(name, code_lookup)
355
+ if code:
356
+ code_lookup[name] = code
357
+ return code
358
+ if not fuzzy_match or is_list:
359
+ unmatched.append(name)
360
+ return None
361
+ names = [x for x in code_lookup.keys() if len(x) > match_threshold]
362
+ if matcher is None:
363
+ matcher = RapidFuzzMatcher()
364
+ name_index = matcher.match(
365
+ possible_names=names,
366
+ name=name,
367
+ alternative_name=name_clean,
368
+ )
369
+ if name_index is None:
370
+ unmatched.append(name)
371
+ return None
372
+ code = code_lookup.get(names[name_index])
373
+ if code:
374
+ code_lookup[name] = code
375
+ code_lookup[name_clean] = code
376
+ return code
377
+
378
+
379
+ def multiple_replace(string: str, replacements: dict[str, str]) -> str:
380
+ """Simultaneously replace multiple strings in a string.
381
+
382
+ Args:
383
+ string: Input string
384
+ replacements: Replacements dictionary
385
+
386
+ Returns:
387
+ String with replacements
388
+ """
389
+ if not replacements:
390
+ return string
391
+ pattern = re.compile(
392
+ "|".join([re.escape(k) for k in sorted(replacements, key=len, reverse=True)]),
393
+ flags=re.DOTALL,
394
+ )
395
+ return pattern.sub(lambda x: replacements[x.group(0)], string)
396
+
397
+
398
+ def match_template_variables(
399
+ string: str,
400
+ ) -> tuple[str | None, str | None]:
401
+ """Try to match {{XXX}} in input string.
402
+
403
+ Args:
404
+ string: String in which to look for template
405
+
406
+ Returns:
407
+ (Matched string with brackets, matched string without brackets)
408
+ """
409
+ match = TEMPLATE_VARIABLES.search(string)
410
+ if match:
411
+ template_string = match.group()
412
+ return template_string, template_string[2:-2]
413
+ return None, None
414
+
415
+
416
+ def earliest_index(string_to_search: str, strings_to_try: Sequence[str]) -> int | None:
417
+ """Search a string for each of a list of strings and return the earliest
418
+ index.
419
+
420
+ Args:
421
+ string_to_search: String to search
422
+ strings_to_try: Strings to try
423
+
424
+ Returns:
425
+ Earliest index of the strings to try in string to search or None
426
+ """
427
+ after_string = len(string_to_search) + 1
428
+ indices = []
429
+ for string_to_try in strings_to_try:
430
+ try:
431
+ index = string_to_search.index(string_to_try)
432
+ indices.append(index)
433
+ except ValueError:
434
+ indices.append(after_string)
435
+ earliest_index = sorted(indices)[0]
436
+ if earliest_index == after_string:
437
+ return None
438
+ else:
439
+ return earliest_index
440
+
441
+
442
+ def get_matching_text_in_strs(
443
+ a: str,
444
+ b: str,
445
+ match_min_size: int = 30,
446
+ ignore: str = "",
447
+ end_characters: str = "",
448
+ ) -> list[str]:
449
+ """Returns a list of matching blocks of text in a and b.
450
+
451
+ Args:
452
+ a: First string to match
453
+ b: Second string to match
454
+ match_min_size: Minimum block size to match on. Defaults to 30.
455
+ ignore: Any characters to ignore in matching. Defaults to ''.
456
+ end_characters: End characters to look for. Defaults to ''.
457
+
458
+ Returns:
459
+ List of matching blocks of text
460
+ """
461
+ compare = difflib.SequenceMatcher(lambda x: x in ignore)
462
+ compare.set_seqs(a=a, b=b)
463
+ matching_text = []
464
+
465
+ for match in compare.get_matching_blocks():
466
+ start = match.a
467
+ text = a[start : start + match.size]
468
+ if end_characters:
469
+ prev_text = text
470
+ while len(text) != 0 and text[0] in end_characters:
471
+ text = text[1:]
472
+ while len(text) != 0 and text[-1] not in end_characters:
473
+ text = text[:-1]
474
+ if len(text) == 0:
475
+ text = prev_text
476
+ if len(text) >= match_min_size:
477
+ matching_text.append(text)
478
+ return matching_text
479
+
480
+
481
+ def get_matching_text(
482
+ string_list: list[str],
483
+ match_min_size: int = 30,
484
+ ignore: str = "",
485
+ end_characters: str = ".!\r\n",
486
+ ) -> str:
487
+ """Returns a string containing matching blocks of text in a list of strings
488
+ followed by non-matching.
489
+
490
+ Args:
491
+ string_list: List of strings to match
492
+ match_min_size: Minimum block size to match on. Defaults to 30.
493
+ ignore: Any characters to ignore in matching. Defaults to ''.
494
+ end_characters: End characters to look for. Defaults to '.\r\n'.
495
+
496
+ Returns:
497
+ String containing matching blocks of text followed by non-matching
498
+ """
499
+ a = string_list[0]
500
+ for i in range(1, len(string_list)):
501
+ b = string_list[i]
502
+ result = get_matching_text_in_strs(
503
+ a,
504
+ b,
505
+ match_min_size=match_min_size,
506
+ ignore=ignore,
507
+ end_characters=end_characters,
508
+ )
509
+ a = "".join(result)
510
+ return a
511
+
512
+
513
+ def get_matching_then_nonmatching_text(
514
+ string_list: list[str],
515
+ separator: str = "",
516
+ match_min_size: int = 30,
517
+ ignore: str = "",
518
+ end_characters: str = ".!\r\n",
519
+ ) -> str:
520
+ """Returns a string containing matching blocks of text in a list of strings
521
+ followed by non-matching.
522
+
523
+ Args:
524
+ string_list: List of strings to match
525
+ separator: Separator to add between blocks of text. Defaults to ''.
526
+ match_min_size: Minimum block size to match on. Defaults to 30.
527
+ ignore: Any characters to ignore in matching. Defaults to ''.
528
+ end_characters: End characters to look for. Defaults to '.\r\n'.
529
+
530
+ Returns:
531
+ String containing matching blocks of text followed by non-matching
532
+ """
533
+
534
+ def add_separator_if_needed(text_list):
535
+ if (
536
+ separator
537
+ and len(text_list) > 0
538
+ and text_list[-1][-len(separator) :] != separator
539
+ ):
540
+ text_list.append(separator)
541
+
542
+ a = string_list[0]
543
+ for i in range(1, len(string_list)):
544
+ b = string_list[i]
545
+ combined_len = len(a) + len(b)
546
+ result = get_matching_text_in_strs(
547
+ a,
548
+ b,
549
+ match_min_size=match_min_size,
550
+ ignore=ignore,
551
+ end_characters=end_characters,
552
+ )
553
+ new_a = a
554
+ new_b = b
555
+ for text in result:
556
+ new_a = new_a.replace(text, "")
557
+ new_b = new_b.replace(text, "")
558
+ if new_a and new_a in a:
559
+ pos_a = a.index(new_a)
560
+ else:
561
+ pos_a = combined_len
562
+ if new_b and new_b in b:
563
+ pos_b = b.index(new_b)
564
+ else:
565
+ pos_b = combined_len
566
+ if pos_b > pos_a:
567
+ text_1 = new_b
568
+ pos_1 = pos_b
569
+ text_2 = new_a
570
+ pos_2 = pos_a
571
+ else:
572
+ text_1 = new_a
573
+ pos_1 = pos_a
574
+ text_2 = new_b
575
+ pos_2 = pos_b
576
+ output = []
577
+ pos = 0
578
+ for text in result:
579
+ output.append(text)
580
+ pos += len(text)
581
+ if text_1 and pos >= pos_1:
582
+ add_separator_if_needed(output)
583
+ output.append(text_1)
584
+ pos += len(text_1)
585
+ text_1 = None
586
+ if text_2 and pos >= pos_2:
587
+ add_separator_if_needed(output)
588
+ output.append(text_2)
589
+ pos += len(text_2)
590
+ text_2 = None
591
+ if text_1 and pos_1 == combined_len:
592
+ add_separator_if_needed(output)
593
+ output.append(text_1)
594
+ if text_2 and pos_2 == combined_len:
595
+ add_separator_if_needed(output)
596
+ output.append(text_2)
597
+ a = "".join(output)
598
+ return a