Functional Weave
Code in Rust

validation.uk-postcode@2.0.0

impl/python.py

4,267 bytes · the Python implementation · view raw

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 validate_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 validate_uk_postcode(value).valid