Functional Weave
Code in TypeScript

validation.isbn@1.0.0

impl/python.py

2,894 bytes · the Python implementation · view raw

from typing import List, Optional

from .validation_isbn_types import IsbnCheck


def _invalid(reason: str) -> IsbnCheck:
    return IsbnCheck(valid=False, normalised=None, isbn10=None, reason=reason)


def _ean_check(twelve: str) -> str:
    """The EAN-13 check digit for twelve digits: weights 1, 3, 1, 3 ... from the left."""
    total = sum((ord(twelve[i]) - 48) * (1 if i % 2 == 0 else 3) for i in range(12))
    return str((10 - total % 10) % 10)


def _isbn10_check(nine: str) -> str:
    """The ISBN-10 check character for nine digits: weights 10 down to 2, mod 11, 10 written X."""
    total = sum((ord(nine[i]) - 48) * (10 - i) for i in range(9))
    check = (11 - total % 11) % 11
    return "X" if check == 10 else str(check)


def validate_isbn(value: str) -> IsbnCheck:
    """Check an ISBN-10 or ISBN-13 and return it as an ISBN-13, with its
    ISBN-10 where one exists.

    The two forms use different check digits, so conversion recomputes rather
    than copies. Validators answer rather than raise.
    """
    if not isinstance(value, str):
        return _invalid("empty")

    # Hyphen positions vary by publisher, so separators are ignored wholesale.
    compact = "".join(ch for ch in value if ch != " " and ch != "-")
    # An optional "ISBN" label, any case, then an optional colon. Folded by
    # hand: str.upper() is Unicode-aware and the Rust sibling is not.
    if len(compact) >= 4:
        label = "".join(chr(ord(c) - 32) if "a" <= c <= "z" else c for c in compact[:4])
        if label == "ISBN":
            compact = compact[4:]
    if compact.startswith(":"):
        compact = compact[1:]
    if not compact:
        return _invalid("empty")

    out: List[str] = []
    for ch in compact:
        if "0" <= ch <= "9":
            out.append(ch)
        elif ch == "X" or ch == "x":
            out.append("X")
        else:
            return _invalid("bad-character")
    isbn = "".join(out)
    if len(isbn) != 10 and len(isbn) != 13:
        return _invalid("bad-length")
    # X means ten, so it can only be an ISBN-10's check digit.
    x_at = isbn.find("X")
    if x_at != -1 and (len(isbn) == 13 or x_at != 9):
        return _invalid("bad-format")

    if len(isbn) == 13:
        prefix = isbn[:3]
        if prefix != "978" and prefix != "979":
            return _invalid("bad-prefix")
        if _ean_check(isbn) != isbn[12]:
            return _invalid("bad-check-digit")
        # 979 ISBNs were never ISBN-10s, so they have no ISBN-10 to give back.
        isbn10: Optional[str] = isbn[3:12] + _isbn10_check(isbn[3:12]) if prefix == "978" else None
        return IsbnCheck(valid=True, normalised=isbn, isbn10=isbn10, reason=None)

    if _isbn10_check(isbn) != isbn[9]:
        return _invalid("bad-check-digit")
    body = "978" + isbn[:9]
    return IsbnCheck(valid=True, normalised=body + _ean_check(body), isbn10=isbn, reason=None)