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))