• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

OCHA-DAP / hdx-python-country / 23871587941

01 Apr 2026 09:24PM UTC coverage: 96.782% (-0.2%) from 97.023%
23871587941

push

github

mcarans
Update test to include version column in pcodes data

1203 of 1243 relevant lines covered (96.78%)

0.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.1
/src/hdx/location/adminlevel.py
1
import logging
1✔
2
import re
1✔
3
from collections.abc import Iterable, Sequence
1✔
4
from pathlib import Path
1✔
5
from typing import Any
1✔
6

7
from hdx.utilities.dictandlist import dict_of_sets_add
1✔
8
from hdx.utilities.matching import Phonetics, multiple_replace
1✔
9
from hdx.utilities.retriever import Retrieve
1✔
10
from hdx.utilities.text import normalise
1✔
11

12
from hdx.location.country import Country
1✔
13

14
logger = logging.getLogger(__name__)
1✔
15

16

17
class AdminLevel:
1✔
18
    """AdminLevel class which takes in p-codes and then maps names to those
19
    p-codes with fuzzy matching if necessary.
20

21
    The dictionary admin_config, which defaults to an empty dictionary, can
22
    have the following optional keys:
23
    countries_fuzzy_try are countries (iso3 codes) for which to try fuzzy
24
    matching. Default is all countries.
25
    admin_name_mappings is a dictionary of mappings from name to p-code (for
26
    where fuzzy matching fails)
27
    admin_name_replacements is a dictionary of textual replacements to try when
28
    fuzzy matching
29
    admin_fuzzy_dont is a list of names for which fuzzy matching should not be
30
    tried
31

32
    The admin_level_overrides parameter allows manually overriding the returned
33
    admin level for given countries. It is a dictionary with iso3s as keys and
34
    admin level numbers as values.
35

36
    The retriever parameter accepts an object of type Retrieve (or inherited
37
    classes). It is used to allow either that admin data from urls is saved
38
    to files or to enable already saved files to be used instead of downloading
39
    from urls.
40

41
    Args:
42
        admin_config: Configuration dictionary. Defaults to {}.
43
        admin_level: Admin level. Defaults to 1.
44
        admin_level_overrides: Countries at other admin levels.
45
        retriever: Retriever object to use for loading/saving files.
46
    """
47

48
    pcode_regex = re.compile(r"^([a-zA-Z]{2,3})(\d+)$")
1✔
49
    _admin_url_default = "https://data.humdata.org/dataset/cb963915-d7d1-4ffa-90dc-31277e24406f/resource/abc9bb55-c389-4221-9fa8-25b1d21df415/download/global_pcodes_adm_1_2.csv"
1✔
50
    admin_url = _admin_url_default
1✔
51
    admin_all_pcodes_url = "https://data.humdata.org/dataset/cb963915-d7d1-4ffa-90dc-31277e24406f/resource/71a63c2f-ba2f-4fef-8bf9-e4259dc41610/download/global_pcodes.csv"
1✔
52
    _formats_url_default = "https://data.humdata.org/dataset/cb963915-d7d1-4ffa-90dc-31277e24406f/resource/67d09390-c5f8-40b7-9eef-704b189f3e5a/download/global_pcode_lengths.csv"
1✔
53
    formats_url = _formats_url_default
1✔
54

55
    def __init__(
1✔
56
        self,
57
        admin_config: dict | None = None,
58
        admin_level: int = 1,
59
        admin_level_overrides: dict | None = None,
60
        retriever: Retrieve | None = None,
61
    ) -> None:
62
        self._admin_level_overrides = admin_level_overrides or {}
1✔
63
        self._retriever: Retrieve | None = retriever
1✔
64
        admin_config = admin_config or {}
1✔
65
        self._countries_fuzzy_try = admin_config.get("countries_fuzzy_try")
1✔
66
        self._admin_name_mappings = admin_config.get("admin_name_mappings", {})
1✔
67
        self._admin_name_replacements = admin_config.get("admin_name_replacements", {})
1✔
68
        self._admin_fuzzy_dont = admin_config.get("admin_fuzzy_dont", list())
1✔
69
        self._use_parent = False
1✔
70
        self._zeroes = {}
1✔
71
        self._parent_admins = []
1✔
72
        self._phonetics = Phonetics()
1✔
73
        self.init_matches_errors()
1✔
74
        self.admin_level = admin_level
1✔
75
        self.pcodes = []
1✔
76
        self.pcode_lengths = {}
1✔
77
        self.name_to_pcode = {}
1✔
78
        self.name_parent_to_pcode = {}
1✔
79
        self.pcode_to_name = {}
1✔
80
        self.pcode_to_iso3 = {}
1✔
81
        self.pcode_to_parent = {}
1✔
82
        self.pcode_formats = {}
1✔
83

84
    @classmethod
1✔
85
    def looks_like_pcode(cls, string: str) -> bool:
1✔
86
        """Check if a string looks like a p-code using regex matching of format.
87
        Checks for 2 or 3 letter country iso code at start and then numbers.
88

89
        Args:
90
            string: String to check
91

92
        Returns:
93
            Whether string looks like a p-code
94
        """
95
        if cls.pcode_regex.match(string):
1✔
96
            return True
1✔
97
        return False
1✔
98

99
    @classmethod
1✔
100
    def set_default_admin_url(cls, admin_url: str | None = None) -> None:
1✔
101
        """
102
        Set default admin URL from which to retrieve admin data
103

104
        Args:
105
            admin_url: Admin URL from which to retrieve admin data. Defaults to internal value.
106

107
        Returns:
108
            None
109
        """
110
        if admin_url is None:
1✔
111
            admin_url = cls._admin_url_default
1✔
112
        cls.admin_url = admin_url
1✔
113

114
    def setup_from_iterable(
1✔
115
        self,
116
        iterable: Iterable[dict],
117
        countryiso3s: Sequence[str] | None = None,
118
    ) -> None:
119
        """
120
        Setup p-codes from an Iterable such as an Iterator or Sequence
121

122
        Args:
123
            iterable: Iterable such as an Iterator or Sequence
124
            countryiso3s: Countries to read. Defaults to None (all).
125

126
        Returns:
127
            None
128
        """
129
        if countryiso3s:
1✔
130
            countryiso3s = [countryiso3.upper() for countryiso3 in countryiso3s]
1✔
131
        iterator = iter(iterable)
1✔
132
        row = next(iterator, None)
1✔
133
        if not row:
1✔
134
            return
×
135

136
        def process_row(row):
1✔
137
            admin_level = row.get("Admin Level")
1✔
138
            if admin_level and int(admin_level) != self.admin_level:
1✔
139
                return
1✔
140
            countryiso3 = row["Location"].upper()
1✔
141
            if countryiso3s and countryiso3 not in countryiso3s:
1✔
142
                return
1✔
143
            pcode = row["P-Code"].upper()
1✔
144
            adm_name = row["Name"]
1✔
145
            self.pcode_lengths[countryiso3] = len(pcode)
1✔
146
            self.pcodes.append(pcode)
1✔
147
            if adm_name is None:
1✔
148
                adm_name = ""
×
149
            self.pcode_to_name[pcode] = adm_name
1✔
150
            self.pcode_to_iso3[pcode] = countryiso3
1✔
151
            if not adm_name:
1✔
152
                logger.error(f"Admin name is blank for pcode {pcode} of {countryiso3}!")
×
153
                return
×
154

155
            adm_name = normalise(adm_name)
1✔
156
            name_to_pcode = self.name_to_pcode.get(countryiso3, {})
1✔
157
            name_to_pcode[adm_name] = pcode
1✔
158
            self.name_to_pcode[countryiso3] = name_to_pcode
1✔
159

160
            if self._use_parent:
1✔
161
                name_parent_to_pcode = self.name_parent_to_pcode.get(countryiso3, {})
1✔
162
                parent = row["Parent P-Code"]
1✔
163
                name_to_pcode = name_parent_to_pcode.get(parent, {})
1✔
164
                name_to_pcode[adm_name] = pcode
1✔
165
                name_parent_to_pcode[parent] = name_to_pcode
1✔
166
                self.name_parent_to_pcode[countryiso3] = name_parent_to_pcode
1✔
167
                self.pcode_to_parent[pcode] = parent
1✔
168

169
        self._use_parent = "Parent P-Code" in row
1✔
170
        process_row(row)
1✔
171
        for row in iterator:
1✔
172
            process_row(row)
1✔
173

174
    def setup_from_url(
1✔
175
        self,
176
        admin_url: Path | str | None = None,
177
        countryiso3s: Sequence[str] | None = None,
178
    ) -> None:
179
        """
180
        Setup p-codes from a URL. Defaults to global p-codes dataset on HDX.
181

182
        Args:
183
            admin_url: URL from which to load data. Defaults to global p-codes dataset.
184
            countryiso3s: Countries to read. Defaults to None (all).
185

186
        Returns:
187
            None
188
        """
189
        if not self._retriever:
1✔
190
            raise ValueError(
×
191
                "AdminLevel must be constructed with a valid Retrieve object to use this method!"
192
            )
193

194
        if not admin_url:
1✔
195
            admin_url = self.admin_url
1✔
196
        _, iterator = self._retriever.get_tabular_rows(admin_url, dict_form=True)
1✔
197
        self.setup_from_iterable(iterator, countryiso3s)
1✔
198

199
    def load_pcode_formats_from_iterable(self, iterable: Iterable) -> None:
1✔
200
        """
201
        Load p-code formats from an Iterable such as an Iterator or Sequence
202

203
        Args:
204
            iterable: Iterable such as an Iterator or Sequence
205

206
        Returns:
207
            None
208
        """
209
        for row in iterable:
1✔
210
            pcode_format = [int(row["Country Length"])]
1✔
211
            for admin_no in range(1, 4):
1✔
212
                length = row[f"Admin {admin_no} Length"]
1✔
213
                if not length or "|" in length:
1✔
214
                    break
1✔
215
                pcode_format.append(int(length))
1✔
216
            self.pcode_formats[row["Location"]] = pcode_format
1✔
217

218
        for pcode in self.pcodes:
1✔
219
            countryiso3 = self.pcode_to_iso3[pcode]
1✔
220
            for x in re.finditer("0", pcode):
1✔
221
                dict_of_sets_add(self._zeroes, countryiso3, x.start())
1✔
222

223
    def load_pcode_formats(self, formats_url: Path | str | None = None) -> None:
1✔
224
        """
225
        Load p-code formats from a URL. Defaults to global p-codes dataset on HDX.
226

227
        Args:
228
            formats_url: URL from which to load data. Defaults to global p-codes dataset.
229

230
        Returns:
231
            None
232
        """
233
        if not self._retriever:
1✔
234
            raise ValueError(
×
235
                "AdminLevel must be constructed with a valid Retrieve object to use this method!"
236
            )
237
        if not formats_url:
1✔
238
            formats_url = self.formats_url
×
239
        _, iterator = self._retriever.get_tabular_rows(formats_url, dict_form=True)
1✔
240
        self.load_pcode_formats_from_iterable(iterator)
1✔
241

242
    def set_parent_admins(self, parent_admins: list[list]) -> None:
1✔
243
        """
244
        Set parent admins
245

246
        Args:
247
            parent_admins: List of P-codes per parent admin
248

249
        Returns:
250
            None
251
        """
252
        self._parent_admins = parent_admins
1✔
253

254
    def set_parent_admins_from_adminlevels(
1✔
255
        self, adminlevels: list["AdminLevel"]
256
    ) -> None:
257
        """
258
        Set parent admins from AdminLevel objects
259

260
        Args:
261
            adminlevels: List of parent AdminLevel objects
262

263
        Returns:
264
            None
265
        """
266
        self._parent_admins = [adminlevel.pcodes for adminlevel in adminlevels]
1✔
267

268
    def get_pcode_list(self) -> list[str]:
1✔
269
        """Get list of all pcodes
270

271
        Returns:
272
            List of pcodes
273
        """
274
        return self.pcodes
1✔
275

276
    def get_admin_level(self, countryiso3: str) -> int:
1✔
277
        """Get admin level for country
278

279
        Args:
280
            countryiso3: ISO3 country code
281

282
        Returns:
283
            Admin level
284
        """
285
        admin_level = self._admin_level_overrides.get(countryiso3)
1✔
286
        if admin_level:
1✔
287
            return admin_level
1✔
288
        return self.admin_level
1✔
289

290
    def get_pcode_length(self, countryiso3: str) -> int | None:
1✔
291
        """Get pcode length for country
292

293
        Args:
294
            countryiso3: ISO3 country code
295

296
        Returns:
297
            Country's pcode length or None
298
        """
299
        return self.pcode_lengths.get(countryiso3)
1✔
300

301
    def init_matches_errors(self) -> None:
1✔
302
        """Initialise storage of fuzzy matches, ignored and errors for logging purposes
303

304
        Returns:
305
            None
306
        """
307
        self._matches = set()
1✔
308
        self._ignored = set()
1✔
309
        self._errors = set()
1✔
310

311
    def convert_admin_pcode_length(
1✔
312
        self, countryiso3: str, pcode: str, **kwargs: Any
313
    ) -> str | None:
314
        """Standardise pcode length by country and match to an internal pcode.
315
        Requires that p-code formats be loaded (eg. using load_pcode_formats)
316

317
        Args:
318
            countryiso3: ISO3 country code
319
            pcode: P code to match
320
            **kwargs: See below
321
            parent (str): Parent admin code
322
            logname (str): Log using this identifying name. Defaults to not logging.
323

324
        Returns:
325
            Matched P code or None if no match
326
        """
327
        logname = kwargs.get("logname")
1✔
328
        match = self.pcode_regex.match(pcode)
1✔
329
        if not match:
1✔
330
            return None
1✔
331
        pcode_format = self.pcode_formats.get(countryiso3)
1✔
332
        if not pcode_format:
1✔
333
            if self.get_admin_level(countryiso3) == 1:
1✔
334
                return self.convert_admin1_pcode_length(countryiso3, pcode, logname)
1✔
335
            return None
1✔
336
        countryiso, digits = match.groups()
1✔
337
        countryiso_length = len(countryiso)
1✔
338
        if countryiso_length > pcode_format[0]:
1✔
339
            countryiso2 = Country.get_iso2_from_iso3(countryiso3)
1✔
340
            pcode_parts = [countryiso2, digits]
1✔
341
        elif countryiso_length < pcode_format[0]:
1✔
342
            pcode_parts = [countryiso3, digits]
×
343
        else:
344
            pcode_parts = [countryiso, digits]
1✔
345
        new_pcode = "".join(pcode_parts)
1✔
346
        if new_pcode in self.pcodes:
1✔
347
            if logname:
1✔
348
                self._matches.add(
1✔
349
                    (
350
                        logname,
351
                        countryiso3,
352
                        new_pcode,
353
                        self.pcode_to_name[new_pcode],
354
                        "pcode length conversion-country",
355
                    )
356
                )
357
            return new_pcode
1✔
358
        total_length = sum(pcode_format[: self.admin_level + 1])
1✔
359
        admin_changes = []
1✔
360
        for admin_no in range(1, self.admin_level + 1):
1✔
361
            len_new_pcode = len(new_pcode)
1✔
362
            if len_new_pcode == total_length:
1✔
363
                break
1✔
364
            admin_length = pcode_format[admin_no]
1✔
365
            pcode_part = pcode_parts[admin_no]
1✔
366
            part_length = len(pcode_part)
1✔
367
            if part_length == admin_length:
1✔
368
                break
1✔
369
            pos = sum(pcode_format[:admin_no])
1✔
370
            if part_length < admin_length:
1✔
371
                if pos in self._zeroes[countryiso3]:
1✔
372
                    pcode_parts[admin_no] = f"0{pcode_part}"
1✔
373
                    admin_changes.append(str(admin_no))
1✔
374
                    new_pcode = "".join(pcode_parts)
1✔
375
                break
1✔
376
            elif part_length > admin_length and admin_no == self.admin_level:
1✔
377
                if pcode_part[0] == "0":
1✔
378
                    pcode_parts[admin_no] = pcode_part[1:]
1✔
379
                    admin_changes.append(str(admin_no))
1✔
380
                    new_pcode = "".join(pcode_parts)
1✔
381
                    break
1✔
382
            if len_new_pcode < total_length:
1✔
383
                if admin_length > 2 and pos in self._zeroes[countryiso3]:
1✔
384
                    pcode_part = f"0{pcode_part}"
1✔
385
                    if self._parent_admins and admin_no < self.admin_level:
1✔
386
                        parent_pcode = [pcode_parts[i] for i in range(admin_no)]
1✔
387
                        parent_pcode.append(pcode_part[:admin_length])
1✔
388
                        parent_pcode = "".join(parent_pcode)
1✔
389
                        if parent_pcode not in self._parent_admins[admin_no - 1]:
1✔
390
                            pcode_part = pcode_part[1:]
1✔
391
                        else:
392
                            admin_changes.append(str(admin_no))
1✔
393
                    else:
394
                        admin_changes.append(str(admin_no))
1✔
395
            elif len_new_pcode > total_length:
1✔
396
                if admin_length <= 2 and pcode_part[0] == "0":
1✔
397
                    pcode_part = pcode_part[1:]
1✔
398
                    if self._parent_admins and admin_no < self.admin_level:
1✔
399
                        parent_pcode = [pcode_parts[i] for i in range(admin_no)]
1✔
400
                        parent_pcode.append(pcode_part[:admin_length])
1✔
401
                        parent_pcode = "".join(parent_pcode)
1✔
402
                        if parent_pcode not in self._parent_admins[admin_no - 1]:
1✔
403
                            pcode_part = f"0{pcode_part}"
1✔
404
                        else:
405
                            admin_changes.append(str(admin_no))
1✔
406
                    else:
407
                        admin_changes.append(str(admin_no))
1✔
408
            pcode_parts[admin_no] = pcode_part[:admin_length]
1✔
409
            pcode_parts.append(pcode_part[admin_length:])
1✔
410
            new_pcode = "".join(pcode_parts)
1✔
411
        if new_pcode in self.pcodes:
1✔
412
            if logname:
1✔
413
                admin_changes_str = ",".join(admin_changes)
1✔
414
                self._matches.add(
1✔
415
                    (
416
                        logname,
417
                        countryiso3,
418
                        new_pcode,
419
                        self.pcode_to_name[new_pcode],
420
                        f"pcode length conversion-admins {admin_changes_str}",
421
                    )
422
                )
423
            return new_pcode
1✔
424
        return None
1✔
425

426
    def convert_admin1_pcode_length(
1✔
427
        self, countryiso3: str, pcode: str, logname: str | None = None
428
    ) -> str | None:
429
        """Standardise pcode length by country and match to an internal pcode.
430
        Only works for admin1 pcodes.
431

432
        Args:
433
            countryiso3: ISO3 country code
434
            pcode: P code for admin one to match
435
            logname: Identifying name to use when logging. Defaults to None (don't log).
436

437
        Returns:
438
            Matched P code or None if no match
439
        """
440
        pcode_length = len(pcode)
1✔
441
        country_pcodelength = self.pcode_lengths.get(countryiso3)
1✔
442
        if not country_pcodelength:
1✔
443
            return None
1✔
444
        if pcode_length == country_pcodelength or pcode_length < 4 or pcode_length > 6:
1✔
445
            return None
1✔
446
        if country_pcodelength == 4:
1✔
447
            pcode = f"{Country.get_iso2_from_iso3(pcode[:3])}{pcode[-2:]}"
1✔
448
        elif country_pcodelength == 5:
1✔
449
            if pcode_length == 4:
1✔
450
                pcode = f"{pcode[:2]}0{pcode[-2:]}"
1✔
451
            else:
452
                pcode = f"{Country.get_iso2_from_iso3(pcode[:3])}{pcode[-3:]}"
1✔
453
        elif country_pcodelength == 6:
1✔
454
            if pcode_length == 4:
1✔
455
                pcode = f"{Country.get_iso3_from_iso2(pcode[:2])}0{pcode[-2:]}"
1✔
456
            else:
457
                pcode = f"{Country.get_iso3_from_iso2(pcode[:2])}{pcode[-3:]}"
1✔
458
        else:
459
            pcode = None
1✔
460
        if pcode in self.pcodes:
1✔
461
            if logname:
1✔
462
                self._matches.add(
1✔
463
                    (
464
                        logname,
465
                        countryiso3,
466
                        pcode,
467
                        self.pcode_to_name[pcode],
468
                        "pcode length conversion",
469
                    )
470
                )
471
            return pcode
1✔
472
        return None
1✔
473

474
    def get_admin_name_replacements(
1✔
475
        self, countryiso3: str, parent: str | None
476
    ) -> dict[str, str]:
477
        """Get relevant admin name replacements from admin name replacements
478
        which is a dictionary of mappings from string to string replacement.
479
        These can be global or they can be restricted by
480
        country or parent (if the AdminLevel object has been set up with
481
        parents). Keys take the form "STRING_TO_REPLACE",
482
        "AFG|STRING_TO_REPLACE" or "AF01|STRING_TO_REPLACE".
483

484
        Args:
485
            countryiso3: ISO3 country code
486
            parent: Parent admin code
487

488
        Returns:
489
            Relevant admin name replacements
490
        """
491
        relevant_name_replacements = {}
1✔
492
        for key, value in self._admin_name_replacements.items():
1✔
493
            if "|" not in key:
1✔
494
                if key not in relevant_name_replacements:
1✔
495
                    relevant_name_replacements[key] = value
1✔
496
                continue
1✔
497
            prefix, name = key.split("|")
1✔
498
            if parent:
1✔
499
                if prefix == parent:
1✔
500
                    if name not in relevant_name_replacements:
1✔
501
                        relevant_name_replacements[name] = value
1✔
502
                    continue
1✔
503
            if prefix == countryiso3:
1✔
504
                if name not in relevant_name_replacements:
1✔
505
                    relevant_name_replacements[name] = value
1✔
506
                continue
1✔
507
        return relevant_name_replacements
1✔
508

509
    def get_admin_fuzzy_dont(self, countryiso3: str, parent: str | None) -> list[str]:
1✔
510
        """Get relevant admin names that should not be fuzzy matched from
511
        admin fuzzy dont which is a list of strings. These can be global
512
        or they can be restricted by country or parent. Keys take the form
513
        "DONT_MATCH", "AFG|DONT_MATCH", or "AF01|DONT_MATCH".
514

515
        Args:
516
            countryiso3: ISO3 country code
517
            parent: Parent admin code
518

519
        Returns:
520
            Relevant admin names that should not be fuzzy matched
521
        """
522
        relevant_admin_fuzzy_dont = []
1✔
523
        for value in self._admin_fuzzy_dont:
1✔
524
            if "|" not in value:
1✔
525
                if value not in relevant_admin_fuzzy_dont:
1✔
526
                    relevant_admin_fuzzy_dont.append(value)
1✔
527
                continue
1✔
528
            prefix, name = value.split("|")
1✔
529
            if parent:
1✔
530
                if prefix == parent:
1✔
531
                    if name not in relevant_admin_fuzzy_dont:
1✔
532
                        relevant_admin_fuzzy_dont.append(name)
1✔
533
            if prefix == countryiso3:
1✔
534
                if name not in relevant_admin_fuzzy_dont:
1✔
535
                    relevant_admin_fuzzy_dont.append(name)
1✔
536
                continue
1✔
537
        return relevant_admin_fuzzy_dont
1✔
538

539
    def fuzzy_pcode(
1✔
540
        self,
541
        countryiso3: str,
542
        name: str,
543
        normalised_name: str,
544
        **kwargs: Any,
545
    ) -> str | None:
546
        """Fuzzy match name to pcode
547

548
        Args:
549
            countryiso3: ISO3 country code
550
            name: Name to match
551
            normalised_name: Normalised name
552
            **kwargs: See below
553
            parent (str): Parent admin code
554
            logname (str): Log using this identifying name. Defaults to not logging.
555

556
        Returns:
557
            Matched P code or None if no match
558
        """
559
        logname = kwargs.get("logname")
1✔
560
        if (
1✔
561
            self._countries_fuzzy_try is not None
562
            and countryiso3 not in self._countries_fuzzy_try
563
        ):
564
            if logname:
1✔
565
                self._ignored.add((logname, countryiso3))
1✔
566
            return None
1✔
567
        if self._use_parent:
1✔
568
            parent = kwargs.get("parent")
1✔
569
        else:
570
            parent = None
1✔
571
        if parent is None:
1✔
572
            name_to_pcode = self.name_to_pcode.get(countryiso3)
1✔
573
            if not name_to_pcode:
1✔
574
                if logname:
1✔
575
                    self._errors.add((logname, countryiso3))
1✔
576
                return None
1✔
577
        else:
578
            name_parent_to_pcode = self.name_parent_to_pcode.get(countryiso3)
1✔
579
            if not name_parent_to_pcode:
1✔
580
                if logname:
1✔
581
                    self._errors.add((logname, countryiso3))
1✔
582
                return None
1✔
583
            name_to_pcode = name_parent_to_pcode.get(parent)
1✔
584
            if not name_to_pcode:
1✔
585
                if logname:
1✔
586
                    self._errors.add((logname, countryiso3, parent))
1✔
587
                return None
1✔
588
        alt_normalised_name = multiple_replace(
1✔
589
            normalised_name,
590
            self.get_admin_name_replacements(countryiso3, parent),
591
        )
592
        pcode = name_to_pcode.get(
1✔
593
            normalised_name, name_to_pcode.get(alt_normalised_name)
594
        )
595
        if not pcode and name.lower() in self.get_admin_fuzzy_dont(countryiso3, parent):
1✔
596
            if logname:
1✔
597
                self._ignored.add((logname, countryiso3, name))
1✔
598
            return None
1✔
599
        if not pcode:
1✔
600
            for map_name in name_to_pcode:
1✔
601
                if normalised_name in map_name:
1✔
602
                    pcode = name_to_pcode[map_name]
1✔
603
                    if logname:
1✔
604
                        self._matches.add(
1✔
605
                            (
606
                                logname,
607
                                countryiso3,
608
                                name,
609
                                self.pcode_to_name[pcode],
610
                                "substring",
611
                            )
612
                        )
613
                    break
1✔
614
            for map_name in name_to_pcode:
1✔
615
                if alt_normalised_name in map_name:
1✔
616
                    pcode = name_to_pcode[map_name]
1✔
617
                    if logname:
1✔
618
                        self._matches.add(
1✔
619
                            (
620
                                logname,
621
                                countryiso3,
622
                                name,
623
                                self.pcode_to_name[pcode],
624
                                "substring",
625
                            )
626
                        )
627
                    break
1✔
628
        if not pcode:
1✔
629
            map_names = list(name_to_pcode.keys())
1✔
630

631
            def al_transform_1(name):
1✔
632
                prefix = name[:3]
1✔
633
                if prefix == "al ":
1✔
634
                    return f"ad {name[3:]}"
1✔
635
                elif prefix == "ad ":
1✔
636
                    return f"al {name[3:]}"
1✔
637
                else:
638
                    return None
1✔
639

640
            def al_transform_2(name):
1✔
641
                prefix = name[:3]
1✔
642
                if prefix == "al " or prefix == "ad ":
1✔
643
                    return name[3:]
1✔
644
                else:
645
                    return None
1✔
646

647
            matching_index = self._phonetics.match(
1✔
648
                map_names,
649
                normalised_name,
650
                alternative_name=alt_normalised_name,
651
                transform_possible_names=[al_transform_1, al_transform_2],
652
            )
653

654
            if matching_index is None:
1✔
655
                if logname:
1✔
656
                    self._errors.add((logname, countryiso3, name))
1✔
657
                return None
1✔
658

659
            map_name = map_names[matching_index]
1✔
660
            pcode = name_to_pcode[map_name]
1✔
661
            if logname:
1✔
662
                self._matches.add(
1✔
663
                    (
664
                        logname,
665
                        countryiso3,
666
                        name,
667
                        self.pcode_to_name[pcode],
668
                        "fuzzy",
669
                    )
670
                )
671
        return pcode
1✔
672

673
    def get_name_mapped_pcode(
1✔
674
        self, countryiso3: str, name: str, parent: str | None
675
    ) -> str | None:
676
        """Get pcode from admin name mappings which is a dictionary of mappings
677
        from name to pcode. These can be global or they can be restricted by
678
        country or parent (if the AdminLevel object has been set up with
679
        parents). Keys take the form "MAPPING", "AFG|MAPPING" or
680
        "AF01|MAPPING".
681

682
        Args:
683
            countryiso3: ISO3 country code
684
            name: Name to match
685
            parent: Parent admin code
686

687
        Returns:
688
            P code match from admin name mappings or None if no match
689
        """
690
        if parent:
1✔
691
            pcode = self._admin_name_mappings.get(f"{parent}|{name}")
1✔
692
            if pcode is None:
1✔
693
                pcode = self._admin_name_mappings.get(f"{countryiso3}|{name}")
1✔
694
        else:
695
            pcode = self._admin_name_mappings.get(f"{countryiso3}|{name}")
1✔
696
        if pcode is None:
1✔
697
            pcode = self._admin_name_mappings.get(name)
1✔
698
        return pcode
1✔
699

700
    def get_pcode(
1✔
701
        self,
702
        countryiso3: str,
703
        name: str,
704
        fuzzy_match: bool = True,
705
        fuzzy_length: int = 4,
706
        **kwargs: Any,
707
    ) -> tuple[str | None, bool]:
708
        """Get pcode for a given name
709

710
        Args:
711
            countryiso3: ISO3 country code
712
            name: Name to match
713
            fuzzy_match: Whether to try fuzzy matching. Defaults to True.
714
            fuzzy_length: Minimum length for fuzzy matching. Defaults to 4.
715
            **kwargs: See below
716
            parent (str): Parent admin code
717
            logname (str): Log using this identifying name. Defaults to not logging.
718

719
        Returns:
720
            (Matched P code or None if no match, True if exact match or False if not)
721
        """
722
        if self._use_parent:
1✔
723
            parent = kwargs.get("parent")
1✔
724
        else:
725
            parent = None
1✔
726
        pcode = self.get_name_mapped_pcode(countryiso3, name, parent)
1✔
727
        if pcode and self.pcode_to_iso3[pcode] == countryiso3:
1✔
728
            if parent:
1✔
729
                if self.pcode_to_parent[pcode] == parent:
1✔
730
                    return pcode, True
1✔
731
            else:
732
                return pcode, True
1✔
733
        if self.looks_like_pcode(name):
1✔
734
            pcode = name.upper()
1✔
735
            if pcode in self.pcodes:  # name is a p-code
1✔
736
                return name, True
1✔
737
            # name looks like a p-code, but doesn't match p-codes
738
            # so try adjusting p-code length
739
            pcode = self.convert_admin_pcode_length(countryiso3, pcode, **kwargs)
1✔
740
            return pcode, True
1✔
741
        else:
742
            normalised_name = normalise(name)
1✔
743
            if parent:
1✔
744
                name_parent_to_pcode = self.name_parent_to_pcode.get(countryiso3)
1✔
745
                if name_parent_to_pcode:
1✔
746
                    name_to_pcode = name_parent_to_pcode.get(parent)
1✔
747
                    if name_to_pcode is not None:
1✔
748
                        pcode = name_to_pcode.get(normalised_name)
1✔
749
                        if pcode:
1✔
750
                            return pcode, True
1✔
751
            else:
752
                name_to_pcode = self.name_to_pcode.get(countryiso3)
1✔
753
                if name_to_pcode is not None:
1✔
754
                    pcode = name_to_pcode.get(normalised_name)
1✔
755
                    if pcode:
1✔
756
                        return pcode, True
1✔
757
            if not fuzzy_match or len(normalised_name) < fuzzy_length:
1✔
758
                return None, True
1✔
759
            pcode = self.fuzzy_pcode(countryiso3, name, normalised_name, **kwargs)
1✔
760
            return pcode, False
1✔
761

762
    def output_matches(self) -> list[str]:
1✔
763
        """Output log of matches
764

765
        Returns:
766
            List of matches
767
        """
768
        output = []
1✔
769
        for match in sorted(self._matches):
1✔
770
            line = f"{match[0]} - {match[1]}: Matching ({match[4]}) {match[2]} to {match[3]} on map"
1✔
771
            logger.info(line)
1✔
772
            output.append(line)
1✔
773
        return output
1✔
774

775
    def output_ignored(self) -> list[str]:
1✔
776
        """Output log of ignored
777

778
        Returns:
779
            List of ignored
780
        """
781
        output = []
1✔
782
        for ignored in sorted(self._ignored):
1✔
783
            if len(ignored) == 2:
1✔
784
                line = f"{ignored[0]} - Ignored {ignored[1]}!"
1✔
785
            else:
786
                line = f"{ignored[0]} - {ignored[1]}: Ignored {ignored[2]}!"
1✔
787
            logger.info(line)
1✔
788
            output.append(line)
1✔
789
        return output
1✔
790

791
    def output_errors(self) -> list[str]:
1✔
792
        """Output log of errors
793

794
        Returns:
795
            List of errors
796
        """
797
        output = []
1✔
798
        for error in sorted(self._errors):
1✔
799
            if len(error) == 2:
1✔
800
                line = f"{error[0]} - Could not find {error[1]} in map names!"
1✔
801
            else:
802
                line = (
1✔
803
                    f"{error[0]} - {error[1]}: Could not find {error[2]} in map names!"
804
                )
805
            logger.error(line)
1✔
806
            output.append(line)
1✔
807
        return output
1✔
808

809
    def output_admin_name_mappings(self) -> list[str]:
1✔
810
        """Output log of name mappings
811

812
        Returns:
813
            List of mappings
814
        """
815
        output = []
1✔
816
        for name, pcode in self._admin_name_mappings.items():
1✔
817
            line = f"{name}: {self.pcode_to_name[pcode]} ({pcode})"
1✔
818
            logger.info(line)
1✔
819
            output.append(line)
1✔
820
        return output
1✔
821

822
    def output_admin_name_replacements(self) -> list[str]:
1✔
823
        """Output log of name replacements
824

825
        Returns:
826
            List of name replacements
827
        """
828
        output = []
1✔
829
        for name, replacement in self._admin_name_replacements.items():
1✔
830
            line = f"{name}: {replacement}"
1✔
831
            logger.info(line)
1✔
832
            output.append(line)
1✔
833
        return output
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc