hdx-python-country 3.7.1__tar.gz → 3.7.3__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 (37) hide show
  1. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/.config/pre-commit-config.yaml +4 -4
  2. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/.github/workflows/publish.yaml +2 -1
  3. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/.github/workflows/run-python-tests.yaml +4 -6
  4. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/PKG-INFO +3 -3
  5. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/documentation/main.md +13 -4
  6. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/pyproject.toml +14 -18
  7. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/requirements.txt +21 -25
  8. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/src/hdx/location/_version.py +2 -2
  9. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/src/hdx/location/adminlevel.py +115 -25
  10. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/src/hdx/location/names.py +3 -1
  11. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/src/hdx/location/phonetics.py +10 -10
  12. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/tests/fixtures/adminlevel.yaml +1 -0
  13. hdx_python_country-3.7.3/tests/fixtures/adminlevelparent.yaml +26 -0
  14. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/tests/hdx/location/test_adminlevel.py +159 -5
  15. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/.config/coveragerc +0 -0
  16. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/.config/pytest.ini +0 -0
  17. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/.config/ruff.toml +0 -0
  18. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/.gitignore +0 -0
  19. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/CONTRIBUTING.md +0 -0
  20. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/LICENSE +0 -0
  21. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/README.md +0 -0
  22. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/documentation/.readthedocs.yaml +0 -0
  23. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/documentation/pydoc-markdown.yaml +0 -0
  24. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/src/hdx/location/Countries & Territories Taxonomy MVP - C&T Taxonomy with HXL Tags.csv +0 -0
  25. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/src/hdx/location/__init__.py +0 -0
  26. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/src/hdx/location/country.py +0 -0
  27. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/src/hdx/location/currency.py +0 -0
  28. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/src/hdx/location/wfp_exchangerates.py +0 -0
  29. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/tests/fixtures/global_pcode_lengths.csv +0 -0
  30. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/tests/fixtures/global_pcodes_adm_1_2.csv +0 -0
  31. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/tests/fixtures/secondary_historic_rates.csv +0 -0
  32. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/tests/fixtures/secondary_rates.json +0 -0
  33. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/tests/hdx/location/Countries_UZB_Deleted.csv +0 -0
  34. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/tests/hdx/location/__init__.py +0 -0
  35. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/tests/hdx/location/test_country.py +0 -0
  36. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/tests/hdx/location/test_currency.py +0 -0
  37. {hdx_python_country-3.7.1 → hdx_python_country-3.7.3}/tests/hdx/location/test_wfp_exchangerates.py +0 -0
@@ -1,14 +1,14 @@
1
1
  default_language_version:
2
- python: python3.11
2
+ python: python3.12
3
3
  repos:
4
4
  - repo: https://github.com/pre-commit/pre-commit-hooks
5
- rev: v4.5.0
5
+ rev: v4.6.0
6
6
  hooks:
7
7
  - id: trailing-whitespace
8
8
  - id: end-of-file-fixer
9
9
  - id: check-ast
10
10
  - repo: https://github.com/astral-sh/ruff-pre-commit
11
- rev: v0.4.4
11
+ rev: v0.4.8
12
12
  hooks:
13
13
  # Run the linter.
14
14
  - id: ruff
@@ -17,7 +17,7 @@ repos:
17
17
  - id: ruff-format
18
18
  args: [--config, .config/ruff.toml]
19
19
  - repo: https://github.com/astral-sh/uv-pre-commit
20
- rev: 0.1.45
20
+ rev: 0.2.10
21
21
  hooks:
22
22
  # Run the pip compile
23
23
  - id: pip-compile
@@ -21,7 +21,8 @@ jobs:
21
21
  - name: Install dependencies
22
22
  run: |
23
23
  python -m pip install --upgrade pip
24
- pip install --upgrade hatch
24
+ - name: Install Hatch
25
+ uses: pypa/hatch@install
25
26
  - name: Build with hatch
26
27
  run: |
27
28
  hatch build
@@ -26,17 +26,15 @@ jobs:
26
26
  - name: Install dependencies
27
27
  run: |
28
28
  python -m pip install --upgrade pip
29
- pip install --upgrade hatch
29
+ - name: Install Hatch
30
+ uses: pypa/hatch@install
30
31
  - name: Test with hatch/pytest
31
- env:
32
- WFP_KEY: ${{ secrets.WFP_KEY }}
33
- WFP_SECRET: ${{ secrets.WFP_SECRET }}
34
32
  run: |
35
- hatch run test:test
33
+ hatch test
36
34
  - name: Check styling
37
35
  if: always()
38
36
  run: |
39
- hatch run lint:style
37
+ hatch fmt --check
40
38
  - name: Publish Unit Test Results
41
39
  uses: EnricoMi/publish-unit-test-result-action@v2
42
40
  if: always()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: hdx-python-country
3
- Version: 3.7.1
3
+ Version: 3.7.3
4
4
  Summary: HDX Python country code and exchange rate (fx) utilities
5
5
  Project-URL: Homepage, https://github.com/OCHA-DAP/hdx-python-country
6
6
  Author: Michael Rans
@@ -25,9 +25,9 @@ Classifier: Programming Language :: Python :: 3.11
25
25
  Classifier: Programming Language :: Python :: 3.12
26
26
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
27
27
  Requires-Python: >=3.8
28
- Requires-Dist: hdx-python-utilities>=3.6.8
28
+ Requires-Dist: hdx-python-utilities>=3.7.0
29
29
  Requires-Dist: libhxl>=5.2.1
30
- Requires-Dist: pyphonetics
30
+ Requires-Dist: rapidfuzz
31
31
  Provides-Extra: dev
32
32
  Requires-Dist: pre-commit; extra == 'dev'
33
33
  Provides-Extra: test
@@ -113,10 +113,19 @@ The administration level mappings takes input configuration dictionary,
113
113
 
114
114
  *admin_config* can have the following optional keys:
115
115
 
116
- *countries_fuzzy_try* are countries (iso3 codes) for which to try fuzzy matching. Default is all countries.
117
- *admin_name_mappings* is a dictionary of mappings from name to pcode (for where fuzzy matching fails)
118
- *admin_name_replacements* is a dictionary of textual replacements to try when fuzzy matching
119
- *admin_fuzzy_dont* is a list of names for which fuzzy matching should not be tried
116
+ *countries_fuzzy_try* are countries (iso3 codes) for which to try fuzzy
117
+ matching. Default is all countries.
118
+ *admin_name_mappings* is a dictionary of mappings from name to pcode. These can
119
+ be global or they can be restricted by country or parent (if the AdminLevel
120
+ object has been set up with parents). Keys take the form "MAPPING",
121
+ "AFG|MAPPING" or "AF01|MAPPING".
122
+ *admin_name_replacements* is a dictionary of textual replacements to try when
123
+ fuzzy matching. It maps from string to string replacement. The replacements can
124
+ be global or they can be restricted by country or parent (if the AdminLevel
125
+ object has been set up with parents). Keys take the form "STRING_TO_REPLACE",
126
+ "AFG|STRING_TO_REPLACE" or "AF01|STRING_TO_REPLACE".
127
+ *admin_fuzzy_dont* is a list of names for which fuzzy matching should not be
128
+ tried
120
129
 
121
130
  Once an AdminLevel object is constructed, one of three setup methods must be
122
131
  called: *setup_from_admin_info*, *setup_from_libhxl_dataset* or
@@ -34,9 +34,9 @@ classifiers = [
34
34
  requires-python = ">=3.8"
35
35
 
36
36
  dependencies = [
37
- "hdx-python-utilities>=3.6.8",
37
+ "hdx-python-utilities>=3.7.0",
38
38
  "libhxl>=5.2.1",
39
- "pyphonetics",
39
+ "rapidfuzz",
40
40
  ]
41
41
  dynamic = ["version"]
42
42
 
@@ -79,25 +79,21 @@ version_scheme = "python-simplified-semver"
79
79
 
80
80
  # Tests
81
81
 
82
- [tool.hatch.envs.test]
83
- features = ["test"]#, "wfp"]
82
+ [tool.hatch.envs.hatch-test]
83
+ features = ["test"]
84
84
 
85
- [tool.hatch.envs.test.scripts]
86
- test = """
85
+ [[tool.hatch.envs.hatch-test.matrix]]
86
+ python = ["3.12"]
87
+
88
+ [tool.hatch.envs.hatch-test.scripts]
89
+ run = """
87
90
  pytest -c .config/pytest.ini --rootdir=. --junitxml=test-results.xml \
88
91
  --cov --cov-config=.config/coveragerc --no-cov-on-fail \
89
92
  --cov-report=lcov --cov-report=term-missing
90
93
  """
91
94
 
92
- [[tool.hatch.envs.test.matrix]]
93
- python = ["3.12"]
94
-
95
- [tool.hatch.envs.lint]
96
- detached = true
97
- dependencies = ["ruff"]
98
-
99
- [tool.hatch.envs.lint.scripts]
100
- style = [
101
- "ruff check --config .config/ruff.toml --diff {args:.}",
102
- "ruff format --config .config/ruff.toml --diff {args:.}",
103
- ]
95
+ [tool.hatch.envs.hatch-static-analysis.scripts]
96
+ format-check = ["ruff format --config .config/ruff.toml --check --diff {args:.}",]
97
+ format-fix = ["ruff format --config .config/ruff.toml {args:.}",]
98
+ lint-check = ["ruff check --config .config/ruff.toml {args:.}",]
99
+ lint-fix = ["ruff check --config .config/ruff.toml --fix {args:.}",]
@@ -8,7 +8,7 @@ attrs==23.2.0
8
8
  # jsonlines
9
9
  # jsonschema
10
10
  # referencing
11
- certifi==2024.2.2
11
+ certifi==2024.6.2
12
12
  # via requests
13
13
  cfgv==3.4.0
14
14
  # via pre-commit
@@ -18,17 +18,17 @@ charset-normalizer==3.3.2
18
18
  # via requests
19
19
  click==8.1.7
20
20
  # via typer
21
- coverage==7.5.1
21
+ coverage==7.5.3
22
22
  # via pytest-cov
23
23
  distlib==0.3.8
24
24
  # via virtualenv
25
25
  et-xmlfile==1.1.0
26
26
  # via openpyxl
27
- filelock==3.14.0
27
+ filelock==3.15.3
28
28
  # via virtualenv
29
29
  frictionless==5.17.0
30
30
  # via hdx-python-utilities
31
- hdx-python-utilities==3.6.8
31
+ hdx-python-utilities==3.7.0
32
32
  # via hdx-python-country (pyproject.toml)
33
33
  humanize==4.9.0
34
34
  # via frictionless
@@ -36,7 +36,7 @@ identify==2.5.36
36
36
  # via pre-commit
37
37
  idna==3.7
38
38
  # via requests
39
- ijson==3.2.3
39
+ ijson==3.3.0
40
40
  # via hdx-python-utilities
41
41
  iniconfig==2.0.0
42
42
  # via pytest
@@ -60,17 +60,17 @@ loguru==0.7.2
60
60
  # via hdx-python-utilities
61
61
  markdown-it-py==3.0.0
62
62
  # via rich
63
- marko==2.0.3
63
+ marko==2.1.1
64
64
  # via frictionless
65
65
  markupsafe==2.1.5
66
66
  # via jinja2
67
67
  mdurl==0.1.2
68
68
  # via markdown-it-py
69
- nodeenv==1.8.0
69
+ nodeenv==1.9.1
70
70
  # via pre-commit
71
- openpyxl==3.1.2
71
+ openpyxl==3.1.4
72
72
  # via hdx-python-utilities
73
- packaging==24.0
73
+ packaging==24.1
74
74
  # via pytest
75
75
  petl==1.7.15
76
76
  # via frictionless
@@ -84,21 +84,19 @@ ply==3.11
84
84
  # libhxl
85
85
  pre-commit==3.7.1
86
86
  # via hdx-python-country (pyproject.toml)
87
- pydantic==2.7.1
87
+ pydantic==2.7.4
88
88
  # via frictionless
89
- pydantic-core==2.18.2
89
+ pydantic-core==2.18.4
90
90
  # via pydantic
91
91
  pygments==2.18.0
92
92
  # via rich
93
- pyphonetics==0.5.3
94
- # via hdx-python-country (pyproject.toml)
95
- pytest==8.2.1
93
+ pytest==8.2.2
96
94
  # via
97
95
  # hdx-python-country (pyproject.toml)
98
96
  # pytest-cov
99
97
  pytest-cov==5.0.0
100
98
  # via hdx-python-country (pyproject.toml)
101
- python-dateutil==2.8.2
99
+ python-dateutil==2.9.0.post0
102
100
  # via
103
101
  # frictionless
104
102
  # hdx-python-utilities
@@ -112,13 +110,15 @@ pyyaml==6.0.1
112
110
  # frictionless
113
111
  # pre-commit
114
112
  # tableschema-to-template
113
+ rapidfuzz==3.9.3
114
+ # via hdx-python-country (pyproject.toml)
115
115
  ratelimit==2.2.1
116
116
  # via hdx-python-utilities
117
117
  referencing==0.35.1
118
118
  # via
119
119
  # jsonschema
120
120
  # jsonschema-specifications
121
- requests==2.32.2
121
+ requests==2.32.3
122
122
  # via
123
123
  # frictionless
124
124
  # libhxl
@@ -137,8 +137,6 @@ ruamel-yaml==0.18.6
137
137
  # via hdx-python-utilities
138
138
  ruamel-yaml-clib==0.2.8
139
139
  # via ruamel-yaml
140
- setuptools==70.0.0
141
- # via nodeenv
142
140
  shellingham==1.5.4
143
141
  # via typer
144
142
  simpleeval==0.9.13
@@ -149,7 +147,7 @@ six==1.16.0
149
147
  # python-dateutil
150
148
  stringcase==1.2.0
151
149
  # via frictionless
152
- structlog==24.1.0
150
+ structlog==24.2.0
153
151
  # via libhxl
154
152
  tableschema-to-template==0.0.13
155
153
  # via hdx-python-utilities
@@ -159,21 +157,19 @@ text-unidecode==1.3
159
157
  # via python-slugify
160
158
  typer==0.12.3
161
159
  # via frictionless
162
- typing-extensions==4.11.0
160
+ typing-extensions==4.12.2
163
161
  # via
164
162
  # frictionless
165
163
  # pydantic
166
164
  # pydantic-core
167
165
  # typer
168
166
  unidecode==1.3.8
169
- # via
170
- # libhxl
171
- # pyphonetics
172
- urllib3==2.2.1
167
+ # via libhxl
168
+ urllib3==2.2.2
173
169
  # via
174
170
  # libhxl
175
171
  # requests
176
- validators==0.28.1
172
+ validators==0.28.3
177
173
  # via frictionless
178
174
  virtualenv==20.26.2
179
175
  # via pre-commit
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '3.7.1'
16
- __version_tuple__ = version_tuple = (3, 7, 1)
15
+ __version__ = version = '3.7.3'
16
+ __version_tuple__ = version_tuple = (3, 7, 3)
@@ -5,7 +5,6 @@ from typing import Any, Dict, List, Optional, Tuple
5
5
  import hxl
6
6
  from hxl import InputOptions
7
7
  from hxl.input import HXLIOException
8
- from unidecode import unidecode
9
8
 
10
9
  from hdx.location.country import Country
11
10
  from hdx.location.names import clean_name
@@ -147,7 +146,7 @@ class AdminLevel:
147
146
  self.pcode_to_name[pcode] = adm_name
148
147
 
149
148
  name_to_pcode = self.name_to_pcode.get(countryiso3, {})
150
- name_to_pcode[unidecode(adm_name).lower()] = pcode
149
+ name_to_pcode[clean_name(adm_name)] = pcode
151
150
  self.name_to_pcode[countryiso3] = name_to_pcode
152
151
  self.pcode_to_iso3[pcode] = countryiso3
153
152
  self.pcode_to_iso3[pcode] = countryiso3
@@ -157,7 +156,7 @@ class AdminLevel:
157
156
  countryiso3, {}
158
157
  )
159
158
  name_to_pcode = name_parent_to_pcode.get(parent, {})
160
- name_to_pcode[unidecode(adm_name).lower()] = pcode
159
+ name_to_pcode[clean_name(adm_name)] = pcode
161
160
  name_parent_to_pcode[parent] = name_to_pcode
162
161
  self.name_parent_to_pcode[countryiso3] = name_parent_to_pcode
163
162
  self.pcode_to_parent[pcode] = parent
@@ -306,7 +305,7 @@ class AdminLevel:
306
305
  """Get admin level for country
307
306
 
308
307
  Args:
309
- countryiso3 (str): Iso3 country code
308
+ countryiso3 (str): ISO3 country code
310
309
 
311
310
  Returns:
312
311
  int: Admin level
@@ -320,7 +319,7 @@ class AdminLevel:
320
319
  """Get pcode length for country
321
320
 
322
321
  Args:
323
- countryiso3 (str): Iso3 country code
322
+ countryiso3 (str): ISO3 country code
324
323
 
325
324
  Returns:
326
325
  Optional[int]: Country's pcode length or None
@@ -517,6 +516,41 @@ class AdminLevel:
517
516
  return pcode
518
517
  return None
519
518
 
519
+ def get_admin_name_replacements(
520
+ self, countryiso3: str, parent: Optional[str]
521
+ ) -> Dict[str, str]:
522
+ """Get relevant admin name replacements from admin name replacements
523
+ which is a dictionary of mappings from string to string replacement.
524
+ These can be global or they can be restricted by
525
+ country or parent (if the AdminLevel object has been set up with
526
+ parents). Keys take the form "STRING_TO_REPLACE",
527
+ "AFG|STRING_TO_REPLACE" or "AF01|STRING_TO_REPLACE".
528
+
529
+ Args:
530
+ countryiso3 (str): ISO3 country code
531
+ parent (Optional[str]): Parent admin code
532
+
533
+ Returns:
534
+ Dict[str, str]: Relevant admin name replacements
535
+ """
536
+ relevant_name_replacements = {}
537
+ for key, value in self.admin_name_replacements.items():
538
+ if "|" not in key:
539
+ if key not in relevant_name_replacements:
540
+ relevant_name_replacements[key] = value
541
+ continue
542
+ prefix, name = key.split("|")
543
+ if parent:
544
+ if prefix == parent:
545
+ if name not in relevant_name_replacements:
546
+ relevant_name_replacements[name] = value
547
+ continue
548
+ if prefix == countryiso3:
549
+ if name not in relevant_name_replacements:
550
+ relevant_name_replacements[name] = value
551
+ continue
552
+ return relevant_name_replacements
553
+
520
554
  def fuzzy_pcode(
521
555
  self,
522
556
  countryiso3: str,
@@ -526,7 +560,7 @@ class AdminLevel:
526
560
  """Fuzzy match name to pcode
527
561
 
528
562
  Args:
529
- countryiso3 (str): Iso3 country code
563
+ countryiso3 (str): ISO3 country code
530
564
  name (str): Name to match
531
565
  **kwargs:
532
566
  parent (Optional[str]): Parent admin code
@@ -543,8 +577,17 @@ class AdminLevel:
543
577
  if logname:
544
578
  self.ignored.add((logname, countryiso3))
545
579
  return None
546
- if self.use_parent and "parent" in kwargs:
547
- parent = kwargs["parent"]
580
+ if self.use_parent:
581
+ parent = kwargs.get("parent")
582
+ else:
583
+ parent = None
584
+ if parent is None:
585
+ name_to_pcode = self.name_to_pcode.get(countryiso3)
586
+ if not name_to_pcode:
587
+ if logname:
588
+ self.errors.add((logname, countryiso3))
589
+ return None
590
+ else:
548
591
  name_parent_to_pcode = self.name_parent_to_pcode.get(countryiso3)
549
592
  if not name_parent_to_pcode:
550
593
  if logname:
@@ -555,15 +598,10 @@ class AdminLevel:
555
598
  if logname:
556
599
  self.errors.add((logname, countryiso3, parent))
557
600
  return None
558
- else:
559
- name_to_pcode = self.name_to_pcode.get(countryiso3)
560
- if not name_to_pcode:
561
- if logname:
562
- self.errors.add((logname, countryiso3))
563
- return None
564
601
  adm_name_lookup = clean_name(name)
565
602
  adm_name_lookup2 = multiple_replace(
566
- adm_name_lookup, self.admin_name_replacements
603
+ adm_name_lookup,
604
+ self.get_admin_name_replacements(countryiso3, parent),
567
605
  )
568
606
  pcode = name_to_pcode.get(
569
607
  adm_name_lookup, name_to_pcode.get(adm_name_lookup2)
@@ -603,22 +641,25 @@ class AdminLevel:
603
641
  break
604
642
  if not pcode:
605
643
  map_names = list(name_to_pcode.keys())
606
- lower_mapnames = [x.lower() for x in map_names]
607
644
 
608
645
  def al_transform_1(name):
609
- if name[:3] == "al ":
646
+ prefix = name[:3]
647
+ if prefix == "al ":
610
648
  return f"ad {name[3:]}"
649
+ elif prefix == "ad ":
650
+ return f"al {name[3:]}"
611
651
  else:
612
652
  return None
613
653
 
614
654
  def al_transform_2(name):
615
- if name[:3] == "al ":
655
+ prefix = name[:3]
656
+ if prefix == "al " or prefix == "ad ":
616
657
  return name[3:]
617
658
  else:
618
659
  return None
619
660
 
620
661
  matching_index = self.phonetics.match(
621
- lower_mapnames,
662
+ map_names,
622
663
  adm_name_lookup,
623
664
  alternative_name=adm_name_lookup2,
624
665
  transform_possible_names=[al_transform_1, al_transform_2],
@@ -643,19 +684,48 @@ class AdminLevel:
643
684
  )
644
685
  return pcode
645
686
 
687
+ def get_name_mapped_pcode(
688
+ self, countryiso3: str, name: str, parent: Optional[str]
689
+ ) -> Optional[str]:
690
+ """Get pcode from admin name mappings which is a dictionary of mappings
691
+ from name to pcode. These can be global or they can be restricted by
692
+ country or parent (if the AdminLevel object has been set up with
693
+ parents). Keys take the form "MAPPING", "AFG|MAPPING" or
694
+ "AF01|MAPPING".
695
+
696
+ Args:
697
+ countryiso3 (str): ISO3 country code
698
+ name (str): Name to match
699
+ parent (Optional[str]): Parent admin code
700
+
701
+ Returns:
702
+ Optional[str]: P code match from admin name mappings or None if no match
703
+ """
704
+ if parent:
705
+ pcode = self.admin_name_mappings.get(f"{parent}|{name}")
706
+ if pcode is None:
707
+ pcode = self.admin_name_mappings.get(f"{countryiso3}|{name}")
708
+ else:
709
+ pcode = self.admin_name_mappings.get(f"{countryiso3}|{name}")
710
+ if pcode is None:
711
+ pcode = self.admin_name_mappings.get(name)
712
+ return pcode
713
+
646
714
  def get_pcode(
647
715
  self,
648
716
  countryiso3: str,
649
717
  name: str,
650
718
  fuzzy_match: bool = True,
719
+ fuzzy_length: int = 4,
651
720
  **kwargs: Any,
652
721
  ) -> Tuple[Optional[str], bool]:
653
722
  """Get pcode for a given name
654
723
 
655
724
  Args:
656
- countryiso3 (str): Iso3 country code
725
+ countryiso3 (str): ISO3 country code
657
726
  name (str): Name to match
658
727
  fuzzy_match (bool): Whether to try fuzzy matching. Defaults to True.
728
+ fuzzy_length (int): Minimum length for fuzzy matching. Defaults to 4.
659
729
  **kwargs:
660
730
  parent (Optional[str]): Parent admin code
661
731
  logname (str): Log using this identifying name. Defaults to not logging.
@@ -663,9 +733,17 @@ class AdminLevel:
663
733
  Returns:
664
734
  Tuple[Optional[str], bool]: (Matched P code or None if no match, True if exact match or False if not)
665
735
  """
666
- pcode = self.admin_name_mappings.get(name)
736
+ if self.use_parent:
737
+ parent = kwargs.get("parent")
738
+ else:
739
+ parent = None
740
+ pcode = self.get_name_mapped_pcode(countryiso3, name, parent)
667
741
  if pcode and self.pcode_to_iso3[pcode] == countryiso3:
668
- return pcode, True
742
+ if parent:
743
+ if self.pcode_to_parent[pcode] == parent:
744
+ return pcode, True
745
+ else:
746
+ return pcode, True
669
747
  if self.looks_like_pcode(name):
670
748
  pcode = name.upper()
671
749
  if pcode in self.pcodes: # name is a p-code
@@ -677,8 +755,7 @@ class AdminLevel:
677
755
  )
678
756
  return pcode, True
679
757
  else:
680
- if self.use_parent and "parent" in kwargs:
681
- parent = kwargs["parent"]
758
+ if parent:
682
759
  name_parent_to_pcode = self.name_parent_to_pcode.get(
683
760
  countryiso3
684
761
  )
@@ -694,7 +771,7 @@ class AdminLevel:
694
771
  pcode = name_to_pcode.get(name.lower())
695
772
  if pcode:
696
773
  return pcode, True
697
- if not fuzzy_match:
774
+ if not fuzzy_match or len(name) < fuzzy_length:
698
775
  return None, True
699
776
  pcode = self.fuzzy_pcode(countryiso3, name, **kwargs)
700
777
  return pcode, False
@@ -756,3 +833,16 @@ class AdminLevel:
756
833
  logger.info(line)
757
834
  output.append(line)
758
835
  return output
836
+
837
+ def output_admin_name_replacements(self) -> List[str]:
838
+ """Output log of name replacements
839
+
840
+ Returns:
841
+ List[str]: List of name replacements
842
+ """
843
+ output = []
844
+ for name, replacement in self.admin_name_replacements.items():
845
+ line = f"{name}: {replacement}"
846
+ logger.info(line)
847
+ output.append(line)
848
+ return output
@@ -3,7 +3,7 @@ import unicodedata
3
3
 
4
4
  from unidecode import unidecode
5
5
 
6
- non_ascii = "([^\x00-\x7f])+"
6
+ non_ascii = r"([^\x00-\x7f])+"
7
7
 
8
8
 
9
9
  def clean_name(name: str) -> str:
@@ -25,5 +25,7 @@ def clean_name(name: str) -> str:
25
25
  )
26
26
  # Remove all non-ASCII characters
27
27
  clean_name = re.sub(non_ascii, " ", clean_name)
28
+ clean_name = clean_name.replace("'", "")
29
+ clean_name = re.sub(r"[\W_]", " ", clean_name)
28
30
  clean_name = unidecode(clean_name)
29
31
  return clean_name.strip().lower()
@@ -1,18 +1,18 @@
1
1
  from typing import Callable, Optional
2
2
 
3
- import pyphonetics
3
+ from rapidfuzz import fuzz
4
4
 
5
5
  from hdx.utilities.typehint import ListTuple
6
6
 
7
7
 
8
- class Phonetics(pyphonetics.RefinedSoundex):
8
+ class Phonetics:
9
9
  def match(
10
10
  self,
11
11
  possible_names: ListTuple,
12
12
  name: str,
13
13
  alternative_name: Optional[str] = None,
14
14
  transform_possible_names: ListTuple[Callable] = [],
15
- threshold: int = 2,
15
+ threshold: float = 60,
16
16
  ) -> Optional[int]:
17
17
  """
18
18
  Match name to one of the given possible names. Returns None if no match
@@ -23,22 +23,22 @@ class Phonetics(pyphonetics.RefinedSoundex):
23
23
  name (str): Name to match
24
24
  alternative_name (str): Alternative name to match. Defaults to None.
25
25
  transform_possible_names (ListTuple[Callable]): Functions to transform possible names.
26
- threshold: Match threshold. Defaults to 2.
26
+ threshold (float): Match threshold. Value is 0-100. Defaults to 60.
27
27
 
28
28
  Returns:
29
29
  Optional[int]: Index of matching name from possible names or None
30
30
  """
31
- mindistance = None
31
+ max_similarity = 0
32
32
  matching_index = None
33
33
 
34
34
  transform_possible_names.insert(0, lambda x: x)
35
35
 
36
36
  def check_name(name, possible_name):
37
- nonlocal mindistance, matching_index # noqa: E999
37
+ nonlocal max_similarity, matching_index # noqa: E999
38
38
 
39
- distance = self.distance(name, possible_name)
40
- if mindistance is None or distance < mindistance:
41
- mindistance = distance
39
+ similarity = fuzz.token_sort_ratio(name, possible_name)
40
+ if similarity > max_similarity:
41
+ max_similarity = similarity
42
42
  matching_index = i
43
43
 
44
44
  for i, possible_name in enumerate(possible_names):
@@ -51,6 +51,6 @@ class Phonetics(pyphonetics.RefinedSoundex):
51
51
  check_name(name, transformed_possible_name)
52
52
  if alternative_name:
53
53
  check_name(alternative_name, transformed_possible_name)
54
- if mindistance is None or mindistance > threshold:
54
+ if max_similarity < threshold:
55
55
  return None
56
56
  return matching_index
@@ -434,6 +434,7 @@ admin_info:
434
434
  - {pcode: XYZ123456, name: Random, iso3: XYZ}
435
435
 
436
436
  countries_fuzzy_try:
437
+ - AFG
437
438
  - NER
438
439
  - NGA
439
440
  - UKR
@@ -0,0 +1,26 @@
1
+ admin_info_with_parent:
2
+ - {pcode: AF0101, name: Kabul, iso3: AFG, parent: AF01}
3
+ - {pcode: AF0102, name: Paghman, iso3: AFG, parent: AF01}
4
+ - {pcode: AF0201, name: Kabul, iso3: AFG, parent: AF02} # testing purposes
5
+ - {pcode: AF0301, name: Charikar, iso3: AFG, parent: AF03}
6
+ - {pcode: AF0401, name: Maydan Shahr, iso3: AFG, parent: AF04}
7
+ - {pcode: AF0501, name: Pul-e-Alam, iso3: AFG, parent: AF05}
8
+ - {pcode: AF0501, name: Pul-e-Alam, iso3: AFG, parent: AF05}
9
+ - {pcode: CD2013, name: Mbanza-Ngungu, iso3: COD, parent: CD20}
10
+ - {pcode: CD3102, name: Kenge, iso3: COD, parent: CD31}
11
+ - {pcode: MW305, name: Blantyre, iso3: MWI, parent: MW3}
12
+
13
+ admin_name_mappings:
14
+ "MyMapping": "AF0301"
15
+ "AFG|MyMapping2": "AF0401"
16
+ "AF05|MyMapping3": "AF0501"
17
+
18
+ admin_name_replacements:
19
+ "wx": "an"
20
+
21
+ alt1_admin_name_replacements:
22
+ "COD|wx": "an"
23
+
24
+ alt2_admin_name_replacements:
25
+ "CD20|wx": "an"
26
+ "CD31|wx": "en"
@@ -14,6 +14,10 @@ class TestAdminLevel:
14
14
  def config(self):
15
15
  return load_yaml(join("tests", "fixtures", "adminlevel.yaml"))
16
16
 
17
+ @pytest.fixture(scope="function")
18
+ def config_parent(self):
19
+ return load_yaml(join("tests", "fixtures", "adminlevelparent.yaml"))
20
+
17
21
  @pytest.fixture(scope="function")
18
22
  def url(self):
19
23
  return "https://raw.githubusercontent.com/OCHA-DAP/hdx-python-country/main/tests/fixtures/global_pcodes_adm_1_2.csv"
@@ -161,9 +165,27 @@ class TestAdminLevel:
161
165
  assert output[31] == "Juba Dhexe: Middle Juba (SO27)"
162
166
  assert output[61] == "CU Niamey: Niamey (NER008)"
163
167
 
168
+ output = adminone.output_admin_name_replacements()
169
+ assert output == [
170
+ " urban: ",
171
+ "sud: south",
172
+ "ouest: west",
173
+ "est: east",
174
+ "nord: north",
175
+ "': ",
176
+ "/: ",
177
+ ".: ",
178
+ " region: ",
179
+ " oblast: ",
180
+ ]
181
+
164
182
  def test_adminlevel_fuzzy(self, config):
165
183
  adminone = AdminLevel(config)
166
184
  adminone.setup_from_admin_info(config["admin_info"])
185
+ assert adminone.get_pcode("YEM", "Al_Dhale'a", logname="test") == (
186
+ "YE30",
187
+ False,
188
+ )
167
189
  assert adminone.get_pcode("YEM", "Al Dali", logname="test") == (
168
190
  "YE30",
169
191
  False,
@@ -174,20 +196,30 @@ class TestAdminLevel:
174
196
  "YE30",
175
197
  False,
176
198
  )
199
+ assert adminone.get_pcode("YEM", "Ad Jauf", logname="test") == (
200
+ "YE16",
201
+ False,
202
+ )
203
+ assert adminone.get_pcode("AFG", "Sar-E-Pul", logname="test") == (
204
+ "AF22",
205
+ False,
206
+ )
177
207
  assert adminone.get_pcode("SOM", "Bay", logname="test") == (
178
208
  "SO24",
179
209
  True,
180
210
  )
181
211
  output = adminone.output_matches()
182
212
  assert output == [
213
+ "test - YEM: Matching (fuzzy) Ad Jauf to Al Jawf on map",
183
214
  "test - YEM: Matching (fuzzy) Al Dali to Ad Dali on map",
184
215
  "test - YEM: Matching (fuzzy) Al Dhale'e / الضالع to Ad Dali on map",
216
+ "test - YEM: Matching (fuzzy) Al_Dhale'a to Ad Dali on map",
185
217
  ]
186
218
 
187
- def test_adminlevel_parent(self, config):
188
- admintwo = AdminLevel(config)
219
+ def test_adminlevel_parent(self, config_parent):
220
+ admintwo = AdminLevel(config_parent)
189
221
  admintwo.countries_fuzzy_try = None
190
- admintwo.setup_from_admin_info(config["admin_info_with_parent"])
222
+ admintwo.setup_from_admin_info(config_parent["admin_info_with_parent"])
191
223
  assert admintwo.use_parent is True
192
224
  assert admintwo.pcode_to_parent["AF0101"] == "AF01"
193
225
  assert admintwo.get_pcode("AFG", "AF0101", logname="test") == (
@@ -223,6 +255,129 @@ class TestAdminLevel:
223
255
  "ABC", "Kabull", parent="AF02", logname="test"
224
256
  ) == (None, False)
225
257
 
258
+ output = admintwo.output_admin_name_mappings()
259
+ assert output == [
260
+ "MyMapping: Charikar (AF0301)",
261
+ "AFG|MyMapping2: Maydan Shahr (AF0401)",
262
+ "AF05|MyMapping3: Pul-e-Alam (AF0501)",
263
+ ]
264
+ assert admintwo.get_pcode("AFG", "MyMapping", logname="test") == (
265
+ "AF0301",
266
+ True,
267
+ )
268
+ assert admintwo.get_pcode(
269
+ "AFG", "MyMapping", parent="AF03", logname="test"
270
+ ) == ("AF0301", True)
271
+ assert admintwo.get_pcode(
272
+ "AFG", "MyMapping", parent="AF04", logname="test"
273
+ ) == (None, False)
274
+
275
+ assert admintwo.get_pcode("AFG", "MyMapping2", logname="test") == (
276
+ "AF0401",
277
+ True,
278
+ )
279
+ assert admintwo.get_pcode(
280
+ "AFG", "MyMapping2", parent="AF04", logname="test"
281
+ ) == ("AF0401", True)
282
+ assert admintwo.get_pcode(
283
+ "AFG", "MyMapping2", parent="AF05", logname="test"
284
+ ) == (None, False)
285
+
286
+ assert admintwo.get_pcode("AFG", "MyMapping3", logname="test") == (
287
+ None,
288
+ False,
289
+ )
290
+ assert admintwo.get_pcode(
291
+ "AFG", "MyMapping3", parent="AF05", logname="test"
292
+ ) == ("AF0501", True)
293
+ assert admintwo.get_pcode(
294
+ "AFG", "MyMapping3", parent="AF04", logname="test"
295
+ ) == (None, False)
296
+
297
+ output = admintwo.output_admin_name_replacements()
298
+ assert output == ["wx: an"]
299
+ assert admintwo.get_pcode(
300
+ "COD", "City of Mbwxza-Nanga", logname="test"
301
+ ) == ("CD2013", False)
302
+ assert admintwo.get_pcode(
303
+ "COD", "City of Mbwxza-Nanga", parent="CD20", logname="test"
304
+ ) == ("CD2013", False)
305
+ assert admintwo.get_pcode(
306
+ "COD", "City of Mbwxza-Nanga", parent="CD19", logname="test"
307
+ ) == (None, False)
308
+ assert admintwo.get_pcode(
309
+ "MWI", "City of Blwxtyre", logname="test"
310
+ ) == (
311
+ "MW305",
312
+ False,
313
+ )
314
+
315
+ admintwo.admin_name_replacements = config_parent[
316
+ "alt1_admin_name_replacements"
317
+ ]
318
+ output = admintwo.output_admin_name_replacements()
319
+ assert output == ["COD|wx: an"]
320
+ assert admintwo.get_pcode(
321
+ "COD", "City of Mbwxza-Nanga", logname="test"
322
+ ) == ("CD2013", False)
323
+ assert admintwo.get_pcode(
324
+ "COD", "City of Mbwxza-Nanga", parent="CD20", logname="test"
325
+ ) == ("CD2013", False)
326
+ assert admintwo.get_pcode(
327
+ "COD", "City of Mbwxza-Nanga", parent="CD19", logname="test"
328
+ ) == (None, False)
329
+ assert admintwo.get_pcode("COD", "Kenge City", logname="test") == (
330
+ "CD3102",
331
+ False,
332
+ )
333
+ assert admintwo.get_pcode("COD", "Kwxge City", logname="test") == (
334
+ None,
335
+ False,
336
+ )
337
+ assert admintwo.get_pcode(
338
+ "COD", "Kwxge City", parent="CD31", logname="test"
339
+ ) == (None, False)
340
+ assert admintwo.get_pcode(
341
+ "MWI", "City of Blwxtyre", logname="test"
342
+ ) == (
343
+ None,
344
+ False,
345
+ )
346
+ assert admintwo.get_pcode(
347
+ "MWI", "City of Blwxtyre", parent="MW3", logname="test"
348
+ ) == (None, False)
349
+
350
+ admintwo.admin_name_replacements = config_parent[
351
+ "alt2_admin_name_replacements"
352
+ ]
353
+ output = admintwo.output_admin_name_replacements()
354
+ assert output == ["CD20|wx: an", "CD31|wx: en"]
355
+ assert admintwo.get_pcode(
356
+ "COD", "City of Mbwxza-Nanga", logname="test"
357
+ ) == (None, False)
358
+ assert admintwo.get_pcode(
359
+ "COD", "City of Mbwxza-Nanga", parent="CD20", logname="test"
360
+ ) == ("CD2013", False)
361
+ assert admintwo.get_pcode(
362
+ "COD", "City of Mbwxza-Nanga", parent="CD19", logname="test"
363
+ ) == (None, False)
364
+ assert admintwo.get_pcode("COD", "Kwxge City", logname="test") == (
365
+ None,
366
+ False,
367
+ )
368
+ assert admintwo.get_pcode(
369
+ "COD", "Kwxge City", parent="CD31", logname="test"
370
+ ) == ("CD3102", False)
371
+ assert admintwo.get_pcode(
372
+ "MWI", "City of Blwxtyre", logname="test"
373
+ ) == (
374
+ None,
375
+ False,
376
+ )
377
+ assert admintwo.get_pcode(
378
+ "MWI", "City of Blwxtyre", parent="MW3", logname="test"
379
+ ) == (None, False)
380
+
226
381
  def test_adminlevel_with_url(self, config, url):
227
382
  adminone = AdminLevel(config)
228
383
  with pytest.raises(HXLIOException):
@@ -322,7 +477,7 @@ class TestAdminLevel:
322
477
  )
323
478
  assert adminone.get_pcode("YEM", "Ad Dali", logname="test") == (
324
479
  "YE30",
325
- False,
480
+ True,
326
481
  )
327
482
  assert adminone.get_pcode("YEM", "Ad Dal", logname="test") == (
328
483
  "YE30",
@@ -365,7 +520,6 @@ class TestAdminLevel:
365
520
  "test - NGA: Matching (pcode length conversion) NG015 to Federal Capital Territory on map",
366
521
  "test - UKR: Matching (substring) Chernihiv Oblast to Chernihivska on map",
367
522
  "test - YEM: Matching (substring) Ad Dal to Ad Dali' on map",
368
- "test - YEM: Matching (substring) Ad Dali to Ad Dali' on map",
369
523
  "test - YEM: Matching (pcode length conversion) YE30 to Ad Dali' on map",
370
524
  ]
371
525
  output = adminone.output_ignored()