from typing import List from .validation_uk_postcode_types import UkPostcode #: Letters excluded from the two final characters of the inward code. They are #: the ones most easily misread in handwriting and on a sorting machine, so #: Royal Mail never issued them there. _INWARD_EXCLUDED = "CIKMOV" #: Q, V and X never appear as the first letter of a postcode area. _AREA_FIRST_EXCLUDED = "QVX" #: I, J and Z never appear as the second letter of a two-letter area. _AREA_SECOND_EXCLUDED = "IJZ" #: The only letters used in the third position of the A9A district form. _THIRD_POSITION_ALLOWED = "ABCDEFGHJKPSTUW" #: The only letters used in the fourth position of the AA9A district form. _FOURTH_POSITION_ALLOWED = "ABEHMNPRVWXY" #: The one postcode that obeys no rule: Girobank's, kept in service since 1968 #: and still valid on an address label. _GIROBANK = "GIR0AA" _INVALID = UkPostcode(valid=False, normalised=None, outward=None, inward=None) def _is_digit(ch: str) -> bool: return "0" <= ch <= "9" def _is_letter(ch: str) -> bool: return "A" <= ch <= "Z" def uk_postcode(value: str) -> UkPostcode: """Parse and normalise a UK postcode. Returns a result rather than raising, and rather than returning a bare boolean: the caller nearly always wants the canonical form to store, and splitting outward from inward is the difference between "can we deliver here" and "which delivery office is this". A malformed value comes back as valid=False with Nones, so ``result.normalised or original`` is safe. Normalisation is upper case with exactly one space before the final three characters: "sw1a1aa" and " SW1A 1AA " both become "SW1A 1AA". That is the form Royal Mail prints and the form two records should be compared in. """ if not isinstance(value, str): return _INVALID # Only the ASCII space is stripped, and it is stripped everywhere: people # type the space in the wrong place far more often than they omit it, so # position carries no information worth preserving. chars: List[str] = [] for ch in value: if ch == " ": continue chars.append(chr(ord(ch) - 32) if "a" <= ch <= "z" else ch) code = "".join(chars) # Five is "M1 1AE", seven is "DN55 1PT"; nothing shorter or longer exists. if len(code) < 5 or len(code) > 7: return _INVALID if code == _GIROBANK: return UkPostcode(valid=True, normalised="GIR 0AA", outward="GIR", inward="0AA") inward = code[-3:] outward = code[:-3] if not _is_inward(inward) or not _is_outward(outward): return _INVALID return UkPostcode(valid=True, normalised=outward + " " + inward, outward=outward, inward=inward) def _is_inward(inward: str) -> bool: """The inward code is always a digit followed by two letters.""" if not _is_digit(inward[0]): return False for ch in inward[1:3]: if not _is_letter(ch) or ch in _INWARD_EXCLUDED: return False return True def _is_outward(outward: str) -> bool: """The outward code is one of six shapes: A9, A99, A9A, AA9, AA99, AA9A. The letter restrictions below are not cosmetic - they are what stops a plausible looking string such as "QW1A" from being accepted. """ if not _is_letter(outward[0]) or outward[0] in _AREA_FIRST_EXCLUDED: return False if len(outward) == 2: # A9 return _is_digit(outward[1]) if len(outward) == 3: if _is_letter(outward[1]): # AA9 return outward[1] not in _AREA_SECOND_EXCLUDED and _is_digit(outward[2]) # A99 or A9A if not _is_digit(outward[1]): return False return _is_digit(outward[2]) or outward[2] in _THIRD_POSITION_ALLOWED if len(outward) == 4: # AA99 or AA9A if not _is_letter(outward[1]) or outward[1] in _AREA_SECOND_EXCLUDED: return False if not _is_digit(outward[2]): return False return _is_digit(outward[3]) or outward[3] in _FOURTH_POSITION_ALLOWED return False def is_uk_postcode(value: str) -> bool: """Shorthand for callers that only need the yes or no.""" return uk_postcode(value).valid