Functional Weave
Code in Python

validation.isbn

Check an ISBN-10 or ISBN-13 and convert between them; the normalised form is always the ISBN-13.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 19 tests, run in TypeScript, Python and Rust.

What it does

Checks an International Standard Book Number in either form and hands back both. The answer is always normalised to the ISBN-13, because that is the form every ISBN has had since 2007 and the one to store and compare: the ISBN-10 0-306-40615-2 and the ISBN-13 978-0-306-40615-7 are the same book.

TWO CHECK DIGITS, TWO ALGORITHMS:

For example

  • validate_isbn(0-306-40615-2) → valid true, normalised 9780306406157, isbn10 0306406152, reason — an ISBN-10, converted to its ISBN-13
  • validate_isbn(978-0-306-40615-7) → valid true, normalised 9780306406157, isbn10 0306406152, reason — the same book as an ISBN-13
  • validate_isbn(0-8044-2957-X) → valid true, normalised 9780804429573, isbn10 080442957X, reason — an ISBN-10 whose check digit is X (ten)

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def validate_isbn(value: str) -> IsbnCheck
valuestringan ISBN-10 or ISBN-13, hyphenated or spaced, optionally prefixed "ISBN"
returnsIsbnCheck

The type it declares, generated into your project

@dataclass(frozen=True)
class IsbnCheck:
    """The result of checking an ISBN. Every string is null when valid is false; reason is null when it is true."""

    valid: bool
    #: the ISBN-13, digits only
    normalised: Optional[str]
    #: the ISBN-10 equivalent; null for 979 ISBNs, which have none
    isbn10: Optional[str]
    #: empty, bad-character, bad-length, bad-format, bad-prefix or bad-check-digit
    reason: Optional[str]

Your code names it in one line, in the file that uses it

from fune.validation.isbn import validate_isbn  # validation.isbn@^1
impl/python.py · 75 lines · open · 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)

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add validation.isbn
Download for Python validation.isbn-1.0.0-python.fune · 11,611 bytes sha256 6a63e0a88527ac54edf148dac18c575c1b5c359c9acff06968db298546934d10

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./validation.isbn-1.0.0-python.fune, or fetch it from a terminal with fune pull validation.isbn@1.0.0:python.

The whole function, every language, is one file too: validation.isbn-1.0.0.fune, 19,171 bytes, sha256 8172c558a9fcbd41bc83843d50404321a07e6af6e93416743fbf2dbf57cced2f. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before validation.isbn

after — your function gets the result and the arguments, and returns the final result.

# fune: after validation.isbn

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show validation.isbn --steps.

# fune: step validation.isbn after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
an ISBN-10, converted to its ISBN-13 0-306-40615-2 → valid true, normalised 9780306406157, isbn10 0306406152, reason —
the same book as an ISBN-13 978-0-306-40615-7 → valid true, normalised 9780306406157, isbn10 0306406152, reason —
an ISBN-10 whose check digit is X (ten) 0-8044-2957-X → valid true, normalised 9780804429573, isbn10 080442957X, reason —
a lower-case x is the same check digit 080442957x → valid true, normalised 9780804429573, isbn10 080442957X, reason —
the ISBN-13 of an X book converts back to the X form 9780804429573 → valid true, normalised 9780804429573, isbn10 080442957X, reason —
a 979 ISBN has no ISBN-10 979-10-90636-07-1 → valid true, normalised 9791090636071, isbn10 —, reason —
an ISBN prefix and spaces are accepted ISBN 978 0 306 40615 7 → valid true, normalised 9780306406157, isbn10 0306406152, reason —
a lower-case prefix with a colon isbn:0306406152 → valid true, normalised 9780306406157, isbn10 0306406152, reason —
copying the ISBN-10 check digit onto the ISBN-13 is wrong 9780306406152 → valid false, normalised —, isbn10 —, reason bad-check-digit
an ISBN-10 with its last digit off by one 0306406153 → valid false, normalised —, isbn10 —, reason bad-check-digit
Show the other 9 tests
CaseArgumentsExpected
an ISBN-10 with two digits transposed 0306046152 → valid false, normalised —, isbn10 —, reason bad-check-digit
a valid EAN-13 outside Bookland is not an ISBN 4006381333931 → valid false, normalised —, isbn10 —, reason bad-prefix
X anywhere but the end is a bad format 03064X6152 → valid false, normalised —, isbn10 —, reason bad-format
X is never part of an ISBN-13 978030640615X → valid false, normalised —, isbn10 —, reason bad-format
twelve digits is neither length 978030640615 → valid false, normalised —, isbn10 —, reason bad-length
a letter other than X is a bad character 0-306-4O615-2 → valid false, normalised —, isbn10 —, reason bad-character
the prefix alone is empty ISBN → valid false, normalised —, isbn10 —, reason empty
the empty string → valid false, normalised —, isbn10 —, reason empty
a non-string is empty, not an exception 42 → valid false, normalised —, isbn10 —, reason empty

More from the author

- ISBN-10: weight the ten characters 10, 9, 8 ... 1 and the sum must be a multiple of 11. The check digit can therefore be 10, written `X` (or `x`), and `X` is allowed only in that last position. - ISBN-13: the GS1/EAN-13 rule - weights 1, 3, 1, 3 ... from the left, and the check digit brings the sum to a multiple of 10. The first three digits must be 978 or 979 ("Bookland"); any other EAN-13 is a barcode, not an ISBN.

CONVERSION is where naive code goes wrong: the two check digits are computed differently, so converting means recomputing, never copying the old check digit across. An ISBN-10 becomes `978` + its first nine digits + a fresh EAN check digit. An ISBN-13 has an ISBN-10 only when it starts with 978; `979` ISBNs (issued since the 978 range began running out) have none, and `isbn10` is null for them.

ACCEPTED INPUT: ASCII digits and `X`, with ASCII spaces and hyphens ignored anywhere (hyphen positions depend on the registration group and publisher, so they are not checked), and an optional leading `ISBN` in any case, optionally followed by a colon: `ISBN 978-0-306-40615-7`, `isbn:0306406152`. Forms such as `ISBN-13:` are not recognised.

| reason | meaning | |-------------------|------------------------------------------------------------------| | `empty` | nothing left after removing separators and the prefix, or not a string | | `bad-character` | a character other than a digit, `X`, space or hyphen | | `bad-length` | not 10 or 13 characters | | `bad-format` | an `X` anywhere but the last place of an ISBN-10 | | `bad-prefix` | a 13-digit number that does not start with 978 or 979 | | `bad-check-digit` | the check digit does not match |

The checks run in that order; the first failure is the reason. Validators answer rather than throw.

A valid ISBN is well formed, not necessarily assigned: only the ISBN agency's records say whether a book carries it. Hyphenation (splitting into group, publisher, title and check) needs the International ISBN Agency's range table and is out of scope.

Source: International ISBN Agency, ISBN Users' Manual, 7th edition, sections 5 and 9 (https://www.isbn-international.org/content/isbn-users-manual).

Files

PathBytes
README.md2,748
impl/python.py2,894
impl/rust.rs4,274
impl/typescript.ts2,957
vectors.json3,301