Functional Weave
Code in Python

validation.iban@1.0.0

impl/python.py

3,949 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from typing import List, Optional

from .validation_iban_data import IBAN_LENGTHS  ← this capability’s own data, compiled from data/iban-lengths.json into the same file by fune build

#: ISO 13616 allows 34 characters at most; Norway's 15 is the shortest issued.
_MAX_IBAN = 34
_MIN_IBAN = 15


def _is_digit(ch: str) -> bool:
    return "0" <= ch <= "9"


def _is_upper_letter(ch: str) -> bool:
    return "A" <= ch <= "Z"


def _compact(value: str) -> str:
    """Strip spaces and fold to upper case, ASCII only.

    ``str.upper()`` is Unicode-aware and would disagree with the Rust sibling
    on inputs like "ß". Nothing in an IBAN is non-ASCII.
    """
    out: List[str] = []
    for ch in value:
        # Only the ASCII space is stripped. IBANs are printed in groups of four
        # and pasted that way; hyphens and other punctuation are not a printing
        # convention, they are a sign the value came from somewhere unexpected.
        if ch == " ":
            continue
        out.append(chr(ord(ch) - 32) if "a" <= ch <= "z" else ch)
    return "".join(out)


def iban_length(country: str) -> int:
    """The registered IBAN length for a country, or -1 if the country has none."""
    code = _compact(country)
    for row in IBAN_LENGTHS:
        if row.country == code:
            return row.length
    return -1


def is_iban(value: str) -> bool:
    """Is this a structurally valid IBAN?

    Two checks, both necessary. The ISO 13616 mod-97-10 checksum catches
    mistyped and transposed characters, but on its own it would accept a
    correctly-checksummed string of any length; the country's registered
    length is what catches a truncated or padded account number that still
    happens to check out.

    Neither check proves the account exists. Only the bank can say that, and
    only a payment (or a confirmation-of-payee service) proves it belongs to
    the person you think it does.
    """
    if not isinstance(value, str):
        return False

    iban = _compact(value)
    if len(iban) < _MIN_IBAN or len(iban) > _MAX_IBAN:
        return False

    # Positions 1-2 are the country, 3-4 the check digits. Testing this before
    # the table lookup means a lower-case or punctuated value fails here rather
    # than being reported as an unknown country.
    if not _is_upper_letter(iban[0]) or not _is_upper_letter(iban[1]):
        return False
    if not _is_digit(iban[2]) or not _is_digit(iban[3]):
        return False

    expected = iban_length(iban[0:2])
    if expected < 0 or len(iban) != expected:
        return False

    for ch in iban[4:]:
        if not _is_digit(ch) and not _is_upper_letter(ch):
            return False

    return _mod97(iban) == 1


def _mod97(iban: str) -> int:
    """ISO 13616 mod-97-10: move the first four characters to the end, replace
    each letter with its position in the alphabet plus 9 (A=10 ... Z=35), and
    take the whole thing modulo 97.

    Python would happily hold the 68-digit integer, but the remainder is
    carried forward one character at a time so that this implementation is the
    same algorithm as the Rust one, where a 68-digit integer is not an option.
    A letter contributes two digits, so it multiplies the running remainder by
    100; the largest intermediate is 96 * 100 + 35 = 9635.
    """
    remainder = 0
    n = len(iban)
    for i in range(n):
        # Rotation without building a second string: read from position 4
        # onwards, then wrap round to the first four characters.
        ch = iban[(i + 4) % n]
        if _is_digit(ch):
            remainder = (remainder * 10 + (ord(ch) - 48)) % 97
        else:
            remainder = (remainder * 100 + (ord(ch) - 55)) % 97
    return remainder


def format_iban(value: str) -> Optional[str]:
    """The IBAN in its printed form, groups of four separated by single spaces,
    or None if it does not validate.
    """
    if not is_iban(value):
        return None
    iban = _compact(value)
    return " ".join(iban[i : i + 4] for i in range(0, len(iban), 4))