Skip to main content

almapy

Checked with mypy Linting: Ruff

Introduction

This is a wrapper library for the Alma API. The design goal is to smooth off some of the rough edges of the APIs to make them easier to use.

The library is async, using niquests under the hood (HTTP/2 and HTTP/3 are disabled — Alma's gateway does not benefit from them and they complicate connection reuse under load).

Notable QoL:

  • Takes care of rate-limiting.
  • Retries server errors (including rate limit errors) automatically, for reads. Writes are only replayed on failures that prove Alma never processed the request — a 429 rejection or a connect timeout — so a lost response cannot turn into a duplicate loan, request or PO line. Pass retry=True to a write to opt back into full retries.
  • Handles some weird edge cases like incorrect response types.
  • Adds more informative exceptions than just HTTP status codes, based on Ex Libris' own error codes.

Convenient methods, namespaced by functional area, are available for a lot of common endpoints, but these are not comprehensive and are added as needed. Said methods don't try to do anything fancy with parameters or responses as of yet.

Response types

Most methods return JSON wrapped in a Box, which makes Ex Libris' rather XMLish JSON less verbose to work with — resp.bib_data.title rather than resp["bib_data"]["title"].

Seven methods return the raw response body as a str, because they deal in MARC XML records that would be mangled by a round-trip through JSON:

Method Returns
bibs.get_bib, bibs.create_bib, bibs.update_bib MARC XML record
bibs.get_holding, bibs.create_holding, bibs.update_holding MARC XML holding
analytics.get_raw_report Analytics report XML

Everything else, letters included, returns a Box.

Box is a pragmatic default, not the only option: pass model= to any Box-returning method and you get a validated instance of that type back instead — see Typed requests and responses.

Getting started

An API key

Alma API keys come from the Ex Libris Developer Network, not from Alma itself. Sign in with your institutional account, create an application, then add the API areas you need (Bibs, Users, Acquisitions, Configuration, Analytics) to it. Each area is granted Read-only or Read/write separately — grant read-only unless you specifically need writes.

Keys are bound to one environment. A sandbox key will not work against production and vice versa.

Choosing a region

Alma is served from five regional gateways, and a key only works against its own region. location defaults to "Europe", so institutions outside Europe must set it explicitly:

location Gateway
"America" api-na.hosted.exlibrisgroup.com
"Europe" (default) api-eu.hosted.exlibrisgroup.com
"Asia Pacific" api-ap.hosted.exlibrisgroup.com
"Canada" api-ca.hosted.exlibrisgroup.com
"China" api-cn.hosted.exlibrisgroup.com

Install

pip install almapy
# or
uv add almapy

Quickstart

import asyncio

from almapy import AlmaClient
from almapy.exceptions import APIClientError, APIServerError

BARCODES = ["98279242", "24569754", "345782365"]


async def main() -> None:
    async with AlmaClient(apikey="KEY", location="Europe", rate_limit=10) as client:
        tasks = [client.bibs.get_item(barcode) for barcode in BARCODES]

        # Collect everything, keeping failures as exception objects rather than
        # letting the first one cancel the rest.
        for barcode, result in zip(
            BARCODES, await asyncio.gather(*tasks, return_exceptions=True), strict=True
        ):
            match result:
                case APIServerError():
                    print(f"{barcode}: server error: {result}")
                case APIClientError():
                    print(f"{barcode}: client error: {result}")
                case _:
                    print(f"{barcode}: {result.bib_data.title}")


if __name__ == "__main__":
    asyncio.run(main())

Use async with (or call await client.aclose() yourself). The client owns a connection pool; abandoning it without closing leaks connections.

To handle results as they arrive rather than waiting for the slowest:

async def main() -> None:
    async with AlmaClient(apikey="KEY") as client:
        tasks = [client.bibs.get_item(barcode) for barcode in BARCODES]
        for coro in asyncio.as_completed(tasks):
            try:
                resp = await coro
            except APIClientError as exc:
                print(f"Client error: {exc}")
            else:
                print(resp.bib_data.title)

Typed requests and responses

almapy adds no modelling dependency of its own. Both directions are duck-typed, so any class exposing the right method works — pydantic, attrs, msgspec or hand-rolled.

Responses

All Box-returning methods accept an optional model= keyword argument. When supplied, the raw Box response is passed to model.model_validate() and the validated instance is returned. Any class with a model_validate classmethod works.

The seven raw-str methods are the exception: they hand back a MARC XML document rather than a Box, so there is nothing to validate and they take no model= argument.

from pydantic import BaseModel
from almapy import AlmaClient


class BibData(BaseModel):
    mms_id: str
    title: str | None = None


async def main():
    async with AlmaClient(apikey="KEY") as client:
        # Returns BibData instead of Box
        bib: BibData = await client.bibs.get_item("98279242", model=BibData)
        print(bib.title)

        # Default behaviour unchanged — still returns Box
        raw = await client.bibs.get_item("98279242")
        print(raw.bib_data.title)

Request bodies

Write methods take a plain dict, or any object exposing either model_dump(mode="json") or dump(mode="json"). model_dump is pydantic v2's own API, so a BaseModel works directly — no shim needed, and still no pydantic dependency on almapy's side. Both checks are runtime_checkable Protocols, so they are purely structural — nothing needs to import from almapy or inherit from it:

class UserUpdate(BaseModel):
    first_name: str


async def main():
    async with AlmaClient(apikey="KEY") as client:
        await client.users.update_user("jsmith", {"first_name": "Jane"})
        await client.users.update_user("jsmith", UserUpdate(first_name="Jane"))

almapy serialises once, before the retry loop, and sends the result as the JSON body. If an object exposes both methods dump wins, on the grounds that a model carrying a custom dump is expressing a deliberate wire shape that should not be bypassed.

This applies to JSON writes only. The MARC XML methods take a str.

Logging

almapy uses stdlib logging and follows library best practice: a NullHandler is registered on the almapy root logger so no output is produced unless the caller configures handlers.

Four semantic loggers are available:

Logger Level Event Extra fields
almapy.http DEBUG Request sent method, url
almapy.http DEBUG Response received method, url, status_code, elapsed_ms
almapy.retry WARNING Retry attempt attempt, max_attempts, exc_type
almapy.throttle DEBUG TokenBucket wait wait_secs, tokens_available
almapy.throttle WARNING Rate cut (AIMD backoff) old_rate, new_rate
almapy.throttle INFO Rate recovery old_rate, new_rate
almapy.error WARNING Alma error before raising status_code, alma_code (where applicable)

Every log record also carries a req_id field — a uuid4().hex correlation ID set at the start of each execute() call and reset in finally. Use it to correlate retries, throttle events, and errors for a single request.

To enable logging in your application:

import logging

# Show all almapy debug output
logging.getLogger("almapy").setLevel(logging.DEBUG)
logging.getLogger("almapy").addHandler(logging.StreamHandler())

# Or filter to a specific area — e.g. only retry warnings
logging.getLogger("almapy.retry").setLevel(logging.WARNING)
logging.getLogger("almapy.retry").addHandler(logging.StreamHandler())

To include the correlation ID in your formatter:

handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(levelname)s %(name)s [%(req_id)s] %(message)s"))
logging.getLogger("almapy").addHandler(handler)

structlog integration

All extra fields flow automatically into structlog via ExtraAdder():

import logging
import structlog

structlog.configure(
    processors=[
        structlog.stdlib.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
    ],
    logger_factory=structlog.stdlib.LoggerFactory(),
)

formatter = structlog.stdlib.ProcessorFormatter(
    foreign_pre_chain=[
        structlog.stdlib.ExtraAdder(),  # pulls req_id, status_code, elapsed_ms, etc.
        structlog.stdlib.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
    ],
    processors=[
        structlog.stdlib.ProcessorFormatter.remove_processors_meta,
        structlog.processors.JSONRenderer(),
    ],
)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logging.getLogger("almapy").addHandler(handler)
logging.getLogger("almapy").setLevel(logging.DEBUG)

Release files for almapy 9.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for almapy 9.1.1
File Size Uploaded
almapy-9.1.1.tar.gz 64.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for almapy 9.1.1
File Interpreter ABI Platform
almapy-9.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 102.1 kB

Release files / almapy-9.1.1.tar.gz

Download URL almapy-9.1.1.tar.gz
Size 64.8 kB
Tags Source
SHA-256 checksum
How to use checksums
81c36db9ac2fa98b3a6d37118eddffbf614ac60c47454ed3695e99846e9c699b
BLAKE2b-256 checksum
How to use checksums
26a4b654971b261ee0d2385a43140f341ba4dad4e59decd35854b80e438021dc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 29, 2026.

Transparency log

Release files / almapy-9.1.1-py3-none-any.whl

Download URL almapy-9.1.1-py3-none-any.whl
Size 37.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5416a8a2ba9d982e9a5a7c69ea768013e190ed7f9fb3afdea377403d537cc5ee
BLAKE2b-256 checksum
How to use checksums
fb4c534bf19b7c1e8d689a5b5de3f6fde39a02c148bb75893cc5a6ea44d34e9a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 29, 2026.

Transparency log

Release history Release notifications | RSS feed

9.3.1

2 release files

9.3.0

2 release files

9.2.1

2 release files

9.2.0

2 release files

9.1.3

2 release files

9.1.2

2 release files

This release

9.1.1 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page