from typing import List, Optional from .validation_iban_data import IBAN_LENGTHS #: 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))