4,249 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 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. """ifnot 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) < 5or 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]
ifnot _is_inward(inward) ornot _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."""ifnot _is_digit(inward[0]):
returnFalsefor ch in inward[1:3]:
ifnot _is_letter(ch) or ch in _INWARD_EXCLUDED:
returnFalsereturnTruedef _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. """ifnot _is_letter(outward[0]) or outward[0] in _AREA_FIRST_EXCLUDED:
returnFalseif len(outward) == 2:
# A9return _is_digit(outward[1])
if len(outward) == 3:
if _is_letter(outward[1]):
# AA9return outward[1] notin _AREA_SECOND_EXCLUDED and _is_digit(outward[2])
# A99 or A9Aifnot _is_digit(outward[1]):
returnFalsereturn _is_digit(outward[2]) or outward[2] in _THIRD_POSITION_ALLOWED
if len(outward) == 4:
# AA99 or AA9Aifnot _is_letter(outward[1]) or outward[1] in _AREA_SECOND_EXCLUDED:
returnFalseifnot _is_digit(outward[2]):
returnFalsereturn _is_digit(outward[3]) or outward[3] in _FOURTH_POSITION_ALLOWED
returnFalsedef is_uk_postcode(value: str) -> bool:
"""Shorthand for callers that only need the yes or no."""return uk_postcode(value).valid