Functional Weave
Code in Python

collections.paginate

Slice a list into a 1-based page and report the totals an API response needs.

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

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

What it does

Page numbers are 1-based, because every pagination UI, every query string and every API contract in the wild is 1-based. Page 0 is therefore a caller bug and raises.

A page past the end is NOT an error. It returns an empty items list with the real totals, because a client asking for page 9 of a collection that shrank to 2 pages between requests is a race, not a mistake, and it should render an empty page rather than a 500.

For example

  • paginate(items ×7, 1, 3) → items ×3, page 1, per page 3, total 7, total pages 3, has next true, has prev false first page of seven items, three at a time
  • paginate(items ×7, 2, 3) → items ×3, page 2, per page 3, total 7, total pages 3, has next true, has prev true a middle page has a page either side of it
  • paginate(items ×7, 3, 3) → items ×1, page 3, per page 3, total 7, total pages 3, has next false, has prev true the last page is short and has no next

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 paginate(items: Sequence[T], page: int, per_page: int) -> Page[T]
itemsT[]the whole collection, already filtered and sorted
pageint1-based page number; 1 is the first page
per_pageintpage size, 1 or greater
returnsPage<T>

The type it declares, generated into your project

@dataclass(frozen=True)
class Page(Generic[T]):
    """One page of a collection, plus everything a client needs to draw a pager."""

    items: List[T]
    page: int
    per_page: int
    total: int
    total_pages: int
    has_next: bool
    has_prev: bool

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

from fune.collections.paginate import paginate  # collections.paginate@^1
impl/python.py · 65 lines · open · raw
from typing import Any, Sequence, TypeVar

from .collections_paginate_types import Page


T = TypeVar("T")


def _whole(value: Any) -> bool:
    # bool is a subclass of int in Python, so True would otherwise pass as 1
    # and silently paginate. Every language rejects it, so reject it here too.
    return isinstance(value, int) and not isinstance(value, bool)


def paginate(items: Sequence[T], page: int, per_page: int) -> Page[T]:
    """Take the 1-based page ``page`` of ``items``, ``per_page`` items at a time.

    Page numbers are 1-based because every pagination UI, query string and API
    contract in the wild is 1-based; page 0 can only come from a caller that has
    the contract wrong, so it raises.

    A page past the end is deliberately not an error. Between two requests a
    collection can shrink under a client that is already on page 9, and that
    client should render an empty page, not a 500.
    """
    if isinstance(items, (str, bytes)) or not isinstance(items, (list, tuple)):
        raise TypeError("paginate needs a list of items")
    if not _whole(page):
        raise TypeError("page must be a whole number, received %r" % (page,))
    if not _whole(per_page):
        raise TypeError("per_page must be a whole number, received %r" % (per_page,))
    # A page size of zero cannot mean "no limit": that would quietly hand back
    # the entire collection, which is the outage pagination exists to prevent.
    if per_page < 1:
        raise ValueError("per_page must be at least 1, received %d" % (per_page,))
    if page < 1:
        raise ValueError("page must be 1 or greater, received %d" % (page,))

    total = len(items)
    # Zero items is zero pages, not one empty page, so `page <= total_pages` is
    # the one correct test for "does this page exist".
    total_pages = -(-total // per_page)
    start = (page - 1) * per_page

    return Page(
        # Slicing already clamps, so a start past the end yields [] rather than
        # raising - the documented behaviour for a page past the end.
        items=list(items[start : start + per_page]),
        page=page,
        per_page=per_page,
        total=total,
        total_pages=total_pages,
        has_next=page < total_pages,
        # On page 9 of a 2-page collection there really are earlier pages.
        has_prev=page > 1,
    )


def page_of_index(index: int, per_page: int) -> int:
    """The 1-based page number a zero-based item index falls on."""
    if not _whole(index) or index < 0:
        raise ValueError("index must be a whole number of 0 or greater, received %r" % (index,))
    if not _whole(per_page) or per_page < 1:
        raise ValueError("per_page must be at least 1, received %r" % (per_page,))
    return index // per_page + 1

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 collections.paginate
Download for Python collections.paginate-1.0.0-python.fune · 11,279 bytes sha256 ea12c1f49e7a1d8733c64d71293db3113c05a7d2f33869e51e7827e306166301

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

The whole function, every language, is one file too: collections.paginate-1.0.0.fune, 17,341 bytes, sha256 8929c0b7700311f6c92da9535e16d1b59c1b2c009d632583ebe0718405baa03b. 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 collections.paginate

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

# fune: after collections.paginate

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 collections.paginate --steps.

# fune: step collections.paginate 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
first page of seven items, three at a time items ×7, 1, 3 → items ×3, page 1, per page 3, total 7, total pages 3, has next true, has prev false
a middle page has a page either side of it items ×7, 2, 3 → items ×3, page 2, per page 3, total 7, total pages 3, has next true, has prev true
the last page is short and has no next items ×7, 3, 3 → items ×1, page 3, per page 3, total 7, total pages 3, has next false, has prev true
a page past the end is empty, not an error, and still reports the real totals items ×7, 9, 3 → items , page 9, per page 3, total 7, total pages 3, has next false, has prev true
the page immediately after the last one is empty items ×7, 4, 3 → items , page 4, per page 3, total 7, total pages 3, has next false, has prev true
a page size that divides exactly leaves no short page items ×6, 2, 3 → items ×3, page 2, per page 3, total 6, total pages 2, has next false, has prev true
one item per page makes as many pages as there are items items ×7, 4, 1 → items ×1, page 4, per page 1, total 7, total pages 7, has next true, has prev true
a single item is a single full page with nothing either side items ×1, 1, 10 → items ×1, page 1, per page 10, total 1, total pages 1, has next false, has prev false
an empty collection is zero pages, not one empty page , 1, 10 → items , page 1, per page 10, total 0, total pages 0, has next false, has prev false
page two of an empty collection still says there is something behind it , 2, 10 → items , page 2, per page 10, total 0, total pages 0, has next false, has prev true
Show the other 8 tests
CaseArgumentsExpected
a page size bigger than the collection returns all of it items ×2, 1, 50 → items ×2, page 1, per page 50, total 2, total pages 1, has next false, has prev false
records are passed through untouched, however uneven their shapes items ×3, 1, 2 → items ×2, page 1, per page 2, total 3, total pages 2, has next true, has prev false
a page size of zero is an error, never the whole collection items ×1, 1, 0 → error: must be at least 1
a negative page size is an error items ×1, 1, -5 → error: must be at least 1
page zero is a caller bug because pages are 1-based items ×1, 0, 10 → error: page must be 1 or greater
a negative page is a caller bug items ×1, -2, 10 → error: page must be 1 or greater
a fractional page number is a caller bug items ×1, 1.5, 10 → error: page must be a whole number
a fractional page size is a caller bug items ×1, 1, 2.5 → error: must be a whole number

More from the author

perPage of 0 or less is an error. There is no honest page of zero items: an unbounded page would silently return the whole collection, which is exactly the outage pagination exists to prevent. Ask for the whole list explicitly instead.

totalPages is 0 for an empty collection, not 1. Zero items really is zero pages, and it makes `page <= totalPages` the single correct test for whether a page exists.

hasPrev is `page > 1`, not `total > 0`: on page 9 of a 2-page collection there genuinely are earlier pages to go back to.

Files

PathBytes
README.md979
impl/python.py2,762
impl/rust.rs3,422
impl/typescript.ts2,415
vectors.json4,580