Skip to content

Client & Response

AlmaAPIClient

The main HTTP client for the Alma REST API. Construct it for a given environment, then hand it to a domain class.

AlmaAPIClient

AlmaAPIClient(
    environment: str = "SANDBOX",
    *,
    api_key: Optional[str] = None,
    max_retries: int = DEFAULT_RETRY_TOTAL,
    backoff_factor: float = DEFAULT_RETRY_BACKOFF_FACTOR,
    retry: Optional[Retry] = None,
    timeout: Optional[float] = None,
    region: str = DEFAULT_REGION,
    host: Optional[str] = None
)

General abstract gateway to the Alma API.

This class provides: - Environment management (SANDBOX/PRODUCTION) - Core HTTP methods (GET, POST, PUT, DELETE) - Authentication handling - Connection testing - Foundation for pluggable domain-specific classes

This class is designed to be inherited or composed by specific domain classes (BiblioGraphicRecords, Users, Admin, etc.)

Initialize the API client.

Parameters:

Name Type Description Default
environment str

'SANDBOX' or 'PRODUCTION'.

'SANDBOX'
api_key Optional[str]

Explicit API key. When given it is used verbatim and takes precedence over the environment variable. When None (the default) the key is read from the environment-variable fallback for environment (ALMA_SB_API_KEY for SANDBOX, ALMA_PROD_API_KEY for PRODUCTION). Mirrors the OpenAI(api_key=...) / Anthropic(api_key=...) pattern and lets callers inject keys from a secrets manager, keyring, or CLI arg, or run two clients with different keys in one process (issue #143).

None
max_retries int

Total number of retries the mounted HTTPAdapter should attempt on retryable responses (429 and 5xx). Must be an int >= 0. Ignored when retry is given.

DEFAULT_RETRY_TOTAL
backoff_factor float

Exponential backoff multiplier passed to urllib3.util.Retry. With backoff_factor=1 the wait schedule between attempts is roughly 1s, 2s, 4s, ... Must be a non-negative number. Ignored when retry is given.

DEFAULT_RETRY_BACKOFF_FACTOR
retry Optional[Retry]

Optional fully-built urllib3.util.Retry instance. When supplied, it is used verbatim and max_retries / backoff_factor are ignored — this is the escape hatch for advanced callers who need to tune fields not exposed by the simple kwargs (allowed_methods, raise_on_status, etc.).

None
timeout Optional[float]

Default per-request timeout in seconds. None falls back to DEFAULT_REQUEST_TIMEOUT (60s). Must be a positive number when supplied. Per-call _request(..., timeout=...) overrides this on a request-by-request basis.

None
region str

Alma hosting region key. Must be one of the keys in REGION_HOSTS (EU, NA, AP, APS, CA, CN). Defaults to "EU" for backward compatibility with all pre-#7 callers. Ignored when host is set.

DEFAULT_REGION
host Optional[str]

Override the resolved base URL with an arbitrary string. When non-None this beats region entirely — useful for staging proxies, on-prem mirrors, and tests. The value is stored verbatim on self.base_url.

None

Raises:

Type Description
AlmaValidationError

If max_retries is negative or not an int, if backoff_factor is negative or not numeric, if timeout is non-positive or non-numeric, or if region is not a known key and host is not given.

Pattern source: GitHub issue #5 (HTTP: retry with exponential backoff for 429/5xx), issue #6 (HTTP: make timeout configurable; lower default from 300s to 60s), and issue #7 (HTTP: make region/host configurable).

Source code in src/almaapitk/client/AlmaAPIClient.py
def __init__(
    self,
    environment: str = 'SANDBOX',
    *,
    api_key: Optional[str] = None,
    max_retries: int = DEFAULT_RETRY_TOTAL,
    backoff_factor: float = DEFAULT_RETRY_BACKOFF_FACTOR,
    retry: Optional[Retry] = None,
    timeout: Optional[float] = None,
    region: str = DEFAULT_REGION,
    host: Optional[str] = None,
):
    """Initialize the API client.

    Args:
        environment: 'SANDBOX' or 'PRODUCTION'.
        api_key: Explicit API key. When given it is used verbatim and
            takes precedence over the environment variable. When
            ``None`` (the default) the key is read from the
            environment-variable fallback for ``environment``
            (``ALMA_SB_API_KEY`` for SANDBOX, ``ALMA_PROD_API_KEY``
            for PRODUCTION). Mirrors the ``OpenAI(api_key=...)`` /
            ``Anthropic(api_key=...)`` pattern and lets callers inject
            keys from a secrets manager, keyring, or CLI arg, or run
            two clients with different keys in one process (issue #143).
        max_retries: Total number of retries the mounted HTTPAdapter
            should attempt on retryable responses (429 and 5xx).
            Must be an ``int >= 0``. Ignored when ``retry`` is given.
        backoff_factor: Exponential backoff multiplier passed to
            ``urllib3.util.Retry``. With ``backoff_factor=1`` the wait
            schedule between attempts is roughly 1s, 2s, 4s, ...
            Must be a non-negative number. Ignored when ``retry`` is
            given.
        retry: Optional fully-built ``urllib3.util.Retry`` instance.
            When supplied, it is used verbatim and ``max_retries`` /
            ``backoff_factor`` are ignored — this is the escape hatch
            for advanced callers who need to tune fields not exposed
            by the simple kwargs (allowed_methods, raise_on_status,
            etc.).
        timeout: Default per-request timeout in seconds. ``None``
            falls back to ``DEFAULT_REQUEST_TIMEOUT`` (60s). Must be
            a positive number when supplied. Per-call
            ``_request(..., timeout=...)`` overrides this on a
            request-by-request basis.
        region: Alma hosting region key. Must be one of the keys in
            ``REGION_HOSTS`` (``EU``, ``NA``, ``AP``, ``APS``, ``CA``,
            ``CN``). Defaults to ``"EU"`` for backward compatibility
            with all pre-#7 callers. Ignored when ``host`` is set.
        host: Override the resolved base URL with an arbitrary
            string. When non-``None`` this beats ``region`` entirely
            — useful for staging proxies, on-prem mirrors, and
            tests. The value is stored verbatim on
            ``self.base_url``.

    Raises:
        AlmaValidationError: If ``max_retries`` is negative or not an
            int, if ``backoff_factor`` is negative or not numeric,
            if ``timeout`` is non-positive or non-numeric, or if
            ``region`` is not a known key and ``host`` is not given.

    Pattern source: GitHub issue #5 (HTTP: retry with exponential
    backoff for 429/5xx), issue #6 (HTTP: make timeout configurable;
    lower default from 300s to 60s), and issue #7 (HTTP: make
    region/host configurable).
    """
    self.environment = environment.upper()
    # Explicit key injection wins over the env-var fallback; resolved
    # in ``_load_configuration`` (issue #143).
    self._api_key_arg = api_key
    # Default per-request timeout. Per-call ``timeout=`` kwargs in
    # ``_request`` override this on a request-by-request basis. The
    # constructor kwarg lets callers opt into a longer ceiling for
    # workloads dominated by paged/long-running endpoints.
    self._validate_timeout(timeout)
    self.timeout = (
        timeout if timeout is not None else DEFAULT_REQUEST_TIMEOUT
    )
    # Validate retry knobs early so misconfiguration surfaces at
    # construction time rather than on the first network failure.
    if retry is None:
        self._validate_retry_kwargs(max_retries, backoff_factor)
    self._max_retries = max_retries
    self._backoff_factor = backoff_factor
    self._retry_override = retry
    # Region/host are stashed before ``_load_configuration`` so the
    # base-URL resolver can pick them up (issue #7). ``switch_environment``
    # also re-runs ``_load_configuration`` and must therefore see the
    # same values on ``self``.
    self._region = region
    self._host_override = host
    # Logger is set up first so ``_load_configuration`` can use
    # ``self.logger`` instead of ``print()``. Only ``self.environment``
    # (set above) is required for logger setup.
    self._setup_logger()
    self._load_configuration()
    self._setup_headers()
    self._setup_session()

get

get(
    endpoint: str,
    params: Optional[Dict] = None,
    custom_headers: Optional[Dict] = None,
) -> AlmaResponse

Make a GET request.

Parameters:

Name Type Description Default
endpoint str

API endpoint (e.g., 'almaws/v1/bibs/123456')

required
params Optional[Dict]

Query parameters

None
custom_headers Optional[Dict]

Additional headers

None

Returns:

Type Description
AlmaResponse

AlmaResponse object

Source code in src/almaapitk/client/AlmaAPIClient.py
def get(self, endpoint: str, params: Optional[Dict] = None,
        custom_headers: Optional[Dict] = None) -> AlmaResponse:
    """
    Make a GET request.

    Args:
        endpoint: API endpoint (e.g., 'almaws/v1/bibs/123456')
        params: Query parameters
        custom_headers: Additional headers

    Returns:
        AlmaResponse object
    """
    return self._request(
        'GET', endpoint, params=params, custom_headers=custom_headers
    )

post

post(
    endpoint: str,
    data: Any = None,
    params: Optional[Dict] = None,
    content_type: Optional[str] = None,
    custom_headers: Optional[Dict] = None,
) -> Response

Make a POST request.

Parameters:

Name Type Description Default
endpoint str

API endpoint

required
data Any

Request body (dict for JSON, str for XML)

None
params Optional[Dict]

Query parameters

None
content_type Optional[str]

Override content type ('application/xml', etc.)

None
custom_headers Optional[Dict]

Additional headers

None

Returns:

Type Description
Response

AlmaResponse object

Source code in src/almaapitk/client/AlmaAPIClient.py
def post(self, endpoint: str, data: Any = None, params: Optional[Dict] = None,
         content_type: Optional[str] = None,
         custom_headers: Optional[Dict] = None) -> requests.Response:
    """
    Make a POST request.

    Args:
        endpoint: API endpoint
        data: Request body (dict for JSON, str for XML)
        params: Query parameters
        content_type: Override content type ('application/xml', etc.)
        custom_headers: Additional headers

    Returns:
        AlmaResponse object
    """
    return self._request(
        'POST', endpoint,
        params=params, data=data,
        content_type=content_type, custom_headers=custom_headers,
    )

put

put(
    endpoint: str,
    data: Any = None,
    params: Optional[Dict] = None,
    content_type: Optional[str] = None,
    custom_headers: Optional[Dict] = None,
) -> AlmaResponse

Make a PUT request.

Parameters:

Name Type Description Default
endpoint str

API endpoint

required
data Any

Request body (dict for JSON, str for XML)

None
params Optional[Dict]

Query parameters

None
content_type Optional[str]

Override content type ('application/xml', etc.)

None
custom_headers Optional[Dict]

Additional headers

None

Returns:

Type Description
AlmaResponse

AlmaResponse object

Source code in src/almaapitk/client/AlmaAPIClient.py
def put(self, endpoint: str, data: Any = None, params: Optional[Dict] = None,
        content_type: Optional[str] = None,
        custom_headers: Optional[Dict] = None) -> AlmaResponse:
    """
    Make a PUT request.

    Args:
        endpoint: API endpoint
        data: Request body (dict for JSON, str for XML)
        params: Query parameters
        content_type: Override content type ('application/xml', etc.)
        custom_headers: Additional headers

    Returns:
        AlmaResponse object
    """
    return self._request(
        'PUT', endpoint,
        params=params, data=data,
        content_type=content_type, custom_headers=custom_headers,
    )

delete

delete(
    endpoint: str,
    params: Optional[Dict] = None,
    custom_headers: Optional[Dict] = None,
) -> AlmaResponse

Make a DELETE request.

Parameters:

Name Type Description Default
endpoint str

API endpoint

required
params Optional[Dict]

Query parameters

None
custom_headers Optional[Dict]

Additional headers

None

Returns:

Type Description
AlmaResponse

AlmaResponse object

Source code in src/almaapitk/client/AlmaAPIClient.py
def delete(self, endpoint: str, params: Optional[Dict] = None,
           custom_headers: Optional[Dict] = None)  -> AlmaResponse:
    """
    Make a DELETE request.

    Args:
        endpoint: API endpoint
        params: Query parameters
        custom_headers: Additional headers

    Returns:
        AlmaResponse object
    """
    return self._request(
        'DELETE', endpoint,
        params=params, custom_headers=custom_headers,
    )

iter_paged

iter_paged(
    endpoint: str,
    *,
    params: Optional[Dict[str, Any]] = None,
    page_size: int = 100,
    record_key: Optional[str] = None,
    max_records: Optional[int] = None
) -> Iterator[Dict[str, Any]]

Yield records one at a time, fetching pages on demand.

Walks any Alma "list/search" endpoint that uses the standard limit / offset pagination contract and exposes a total_record_count field plus a record array under a well-known key (invoice, pol, user, bib, ...). Centralises the offset bookkeeping that previously lived inline in every domain method that walked paged results, so callers no longer have to re-derive the loop or remember to stop on the total_record_count boundary.

The method is a generator: pages are fetched on demand, so a caller that breaks out early after the first match never pays for pages it does not need. Callers that want a list use list(client.iter_paged(...)).

Parameters:

Name Type Description Default
endpoint str

API endpoint (e.g., 'almaws/v1/acq/invoices'). Must be non-empty.

required
params Optional[Dict[str, Any]]

Caller-supplied query parameters. Merged with the paginator's limit / offset on each request; the paginator's values always win on key collision so the walk cannot be derailed by a stray caller-supplied offset.

None
page_size int

Records to request per page. Must be a positive int. Defaults to 100 (the most common Alma per-endpoint cap). Some Alma endpoints cap below this; callers walking those should pass an explicit page_size matching the endpoint's documented limit.

100
record_key Optional[str]

Top-level key in the response body under which the record array lives (e.g., 'invoice', 'pol', 'user', 'bib'). When None (the default), the first page is fetched but no records are yielded — useful only for total-count probes; almost every real caller will pass a string here.

None
max_records Optional[int]

Hard cap on the number of records yielded. None (the default) means yield until the endpoint is exhausted. Must be None or a non-negative int when supplied.

None

Yields:

Type Description
Dict[str, Any]

Each record dict in turn, in the order Alma returns them.

Raises:

Type Description
AlmaValidationError

If endpoint is empty, page_size is non-positive / non-int, or max_records is negative / non-int.

AlmaAPIError

Surfaces verbatim from the underlying self.get call when a page fetch fails.

Pattern source: GitHub issue #11 (API: add iter_paged() generator at the client level).

Source code in src/almaapitk/client/AlmaAPIClient.py
def iter_paged(
    self,
    endpoint: str,
    *,
    params: Optional[Dict[str, Any]] = None,
    page_size: int = 100,
    record_key: Optional[str] = None,
    max_records: Optional[int] = None,
) -> Iterator[Dict[str, Any]]:
    """Yield records one at a time, fetching pages on demand.

    Walks any Alma "list/search" endpoint that uses the standard
    ``limit`` / ``offset`` pagination contract and exposes a
    ``total_record_count`` field plus a record array under a
    well-known key (``invoice``, ``pol``, ``user``, ``bib``, ...).
    Centralises the offset bookkeeping that previously lived inline
    in every domain method that walked paged results, so callers no
    longer have to re-derive the loop or remember to stop on the
    ``total_record_count`` boundary.

    The method is a *generator*: pages are fetched on demand, so a
    caller that breaks out early after the first match never pays
    for pages it does not need. Callers that want a list use
    ``list(client.iter_paged(...))``.

    Args:
        endpoint: API endpoint (e.g., ``'almaws/v1/acq/invoices'``).
            Must be non-empty.
        params: Caller-supplied query parameters. Merged with the
            paginator's ``limit`` / ``offset`` on each request; the
            paginator's values always win on key collision so the
            walk cannot be derailed by a stray caller-supplied
            offset.
        page_size: Records to request per page. Must be a positive
            ``int``. Defaults to ``100`` (the most common Alma
            per-endpoint cap). Some Alma endpoints cap below this;
            callers walking those should pass an explicit
            ``page_size`` matching the endpoint's documented limit.
        record_key: Top-level key in the response body under which
            the record array lives (e.g., ``'invoice'``,
            ``'pol'``, ``'user'``, ``'bib'``). When ``None`` (the
            default), the first page is fetched but no records are
            yielded — useful only for total-count probes; almost
            every real caller will pass a string here.
        max_records: Hard cap on the number of records yielded.
            ``None`` (the default) means yield until the endpoint
            is exhausted. Must be ``None`` or a non-negative
            ``int`` when supplied.

    Yields:
        Each record dict in turn, in the order Alma returns them.

    Raises:
        AlmaValidationError: If ``endpoint`` is empty, ``page_size``
            is non-positive / non-int, or ``max_records`` is
            negative / non-int.
        AlmaAPIError: Surfaces verbatim from the underlying
            ``self.get`` call when a page fetch fails.

    Pattern source: GitHub issue #11 (API: add iter_paged()
    generator at the client level).
    """
    # Input validation. ``bool`` is an ``int`` subclass in Python,
    # so reject it explicitly for the same reason
    # ``_validate_retry_kwargs`` does -- callers passing ``True``
    # almost certainly mean to enable a feature, not to set a
    # numeric size.
    if not endpoint:
        raise AlmaValidationError("endpoint is required")
    if (
        isinstance(page_size, bool)
        or not isinstance(page_size, int)
        or page_size <= 0
    ):
        raise AlmaValidationError(
            f"page_size must be a positive int, got {page_size!r}"
        )
    if max_records is not None:
        if (
            isinstance(max_records, bool)
            or not isinstance(max_records, int)
            or max_records < 0
        ):
            raise AlmaValidationError(
                "max_records must be a non-negative int or None, "
                f"got {max_records!r}"
            )

    self.logger.info(
        "Paginating Alma endpoint",
        endpoint=endpoint,
        page_size=page_size,
        record_key=record_key,
        max_records=max_records,
    )

    offset = 0
    yielded = 0
    # Snapshot the caller-supplied params once; per-page kwargs are
    # built by overlaying ``limit``/``offset`` so pagination state
    # cannot leak back into the caller's dict between iterations.
    base_params = dict(params or {})

    while True:
        page_params = {
            **base_params,
            "limit": page_size,
            "offset": offset,
        }
        body = self.get(endpoint, params=page_params).json()

        if record_key:
            items = body.get(record_key, []) or []
        else:
            items = []

        for item in items:
            if max_records is not None and yielded >= max_records:
                return
            yield item
            yielded += 1

        # ``max_records`` cap may also fire mid-page; check at the
        # top of the next iteration too so we don't over-fetch.
        if max_records is not None and yielded >= max_records:
            return

        total = body.get("total_record_count", 0) or 0
        offset += page_size
        # Stop conditions:
        # 1. We've walked past the reported total_record_count.
        # 2. The page came back empty (defensive: handles endpoints
        #    that omit total_record_count or report it as 0 even
        #    when records are present on the last page).
        if offset >= total or not items:
            return

test_connection

test_connection() -> bool

Test if the API connection works.

Returns:

Type Description
bool

True if connection successful, False otherwise

Source code in src/almaapitk/client/AlmaAPIClient.py
def test_connection(self) -> bool:
    """
    Test if the API connection works.

    Returns:
        True if connection successful, False otherwise
    """
    try:
        response = self.get('almaws/v1/conf/libraries')
        success = response.status_code == 200
        if success:
            self.logger.info(
                "Successfully connected to Alma API",
                environment=self.environment,
            )
        else:
            # Issue #154 F-002: response.text used to be interpolated
            # into the message string, bypassing redact_sensitive_data
            # (which only walks structured kwargs). Pass status and
            # body through as redactable extra fields instead.
            self.logger.error(
                "Connection failed",
                status_code=response.status_code,
                body_preview=(response.text or "")[:200],
            )
        return success
    except Exception:
        # Issue #154 F-003: do NOT interpolate ``{e}`` into the message
        # — logger.exception already attaches the traceback via
        # exc_info, and the interpolation would defeat the redactor.
        self.logger.exception("Connection error")
        return False

switch_environment

switch_environment(new_environment: str) -> None

Switch between SANDBOX and PRODUCTION environments.

Parameters:

Name Type Description Default
new_environment str

'SANDBOX' or 'PRODUCTION'.

required

Raises:

Type Description
AlmaAPIError

When the client has already been closed (see :meth:close). A closed client signals the caller's intent to release the underlying requests.Session; silently re-creating it during a switch_environment call would mask programmer errors (e.g., reusing a with-block client outside the block). Construct a new AlmaAPIClient instead.

Exception

Any error raised by _load_configuration or _setup_headers while applying the new environment is re-raised after rolling back to old_env.

Pattern source: this method mirrors the closed-state guard used by :meth:_request (issue #13). Behaviour was previously undefined post-close() (issue #138, finding F-012).

Source code in src/almaapitk/client/AlmaAPIClient.py
def switch_environment(self, new_environment: str) -> None:
    """Switch between SANDBOX and PRODUCTION environments.

    Args:
        new_environment: ``'SANDBOX'`` or ``'PRODUCTION'``.

    Raises:
        AlmaAPIError: When the client has already been closed (see
            :meth:`close`). A closed client signals the caller's
            intent to release the underlying ``requests.Session``;
            silently re-creating it during a ``switch_environment``
            call would mask programmer errors (e.g., reusing a
            ``with``-block client outside the block). Construct a
            new ``AlmaAPIClient`` instead.
        Exception: Any error raised by ``_load_configuration`` or
            ``_setup_headers`` while applying the new environment is
            re-raised after rolling back to ``old_env``.

    Pattern source: this method mirrors the closed-state guard used
    by :meth:`_request` (issue #13). Behaviour was previously
    undefined post-``close()`` (issue #138, finding F-012).
    """
    # Issue #138: refuse to switch environments after ``close()``.
    # Aligns with the HTTP-verb guard in ``_request``: a ``None``
    # session is the documented sentinel for "this client has been
    # closed". The defensive ``hasattr(self, '_session') and
    # self._session is not None`` blocks below remain in place for
    # the in-flight failure path (where ``__init__`` aborted before
    # ``_setup_session`` ran), but the close-then-switch caller now
    # gets a clear error instead of landing in an unusable state.
    if getattr(self, "_session", None) is None:
        raise AlmaAPIError(
            "AlmaAPIClient has been closed; construct a new client "
            "instance to switch environments."
        )

    old_env = self.environment
    self.environment = new_environment.upper()
    try:
        self._load_configuration()
        self._setup_headers()
        # Keep the session's headers in sync with the new environment
        # so the persistent session sends the correct apikey going
        # forward (issue #3).
        if hasattr(self, '_session') and self._session is not None:
            self._session.headers.update(self.default_headers)
        self.logger.info(f"Switched from {old_env} to {self.environment}")
    except Exception as e:
        # Revert to old environment if switch fails
        self.environment = old_env
        self._load_configuration()
        self._setup_headers()
        if hasattr(self, '_session') and self._session is not None:
            self._session.headers.update(self.default_headers)
        raise e

get_environment

get_environment() -> str

Get current environment.

Source code in src/almaapitk/client/AlmaAPIClient.py
def get_environment(self) -> str:
    """Get current environment."""
    return self.environment

get_base_url

get_base_url() -> str

Get base URL.

Source code in src/almaapitk/client/AlmaAPIClient.py
def get_base_url(self) -> str:
    """Get base URL."""
    return self.base_url

close

close() -> None

Close the persistent requests.Session and release pooled connections.

Idempotent: calling close more than once is safe and is a no-op after the first call. After the session is closed, the client transitions to a "closed" state and any subsequent HTTP verb call (get/post/put/delete/_request) will raise :class:AlmaAPIError. Re-creating a session implicitly on next use was deliberately rejected: a closed client signals the caller's intent to release the resource, and silently rebuilding it would mask programmer errors (e.g., reusing a with-block client outside the block).

If the caller still needs to make calls after closing, they should construct a new AlmaAPIClient.

Note

close raises nothing. The requests.Session.close call is best-effort: any exception while closing is swallowed and logged at WARNING level so teardown in __exit__ (the typical caller) never masks an in-flight exception from the with body.

Pattern source: GitHub issue #13.

Source code in src/almaapitk/client/AlmaAPIClient.py
def close(self) -> None:
    """Close the persistent ``requests.Session`` and release pooled connections.

    Idempotent: calling ``close`` more than once is safe and is a
    no-op after the first call. After the session is closed, the
    client transitions to a "closed" state and any subsequent HTTP
    verb call (``get``/``post``/``put``/``delete``/``_request``) will
    raise :class:`AlmaAPIError`. Re-creating a session implicitly on
    next use was deliberately rejected: a closed client signals the
    caller's intent to release the resource, and silently rebuilding
    it would mask programmer errors (e.g., reusing a ``with``-block
    client outside the block).

    If the caller still needs to make calls after closing, they
    should construct a new ``AlmaAPIClient``.

    Note:
        ``close`` raises nothing. The ``requests.Session.close`` call is
        best-effort: any exception while closing is swallowed and logged
        at WARNING level so teardown in ``__exit__`` (the typical caller)
        never masks an in-flight exception from the ``with`` body.

    Pattern source: GitHub issue #13.
    """
    session = getattr(self, "_session", None)
    if session is None:
        return
    try:
        session.close()
    except Exception as exc:  # noqa: BLE001 — defensive teardown only
        # Never let a teardown failure mask the user's primary
        # exception. Log and move on; the session reference is still
        # cleared so the client lands in the closed state.
        self.logger.warning(
            "Error while closing AlmaAPIClient session", error=str(exc)
        )
    finally:
        self._session = None

AlmaResponse

The wrapper returned by most calls. Use .data / .json() for the parsed body and .success to check the outcome.

AlmaResponse

AlmaResponse(response)

Response wrapper to maintain compatibility with existing domain classes.

The parsed JSON body is cached on first access (issue #16): .data and .json() and the client's debug-body-logging path all share a single response.json() call. Repeated access — common in idioms like if r.data and r.data.get("foo"): — no longer re-parses the body on every read, which is measurable on large analytics payloads.

Source code in src/almaapitk/client/AlmaAPIClient.py
def __init__(self, response):
    self._response = response
    self.status_code = response.status_code
    self.success = response.status_code < 400
    # Sentinel-based cache: ``None`` is a legitimate parsed body
    # value (e.g. content-type is non-JSON, or the body is empty), so
    # we cannot use ``None`` itself as the "not yet parsed" marker.
    self._cached_body: Any = _UNSET

data property

data: Dict[str, Any]

Cached parsed JSON body (alias for json()).

First access parses; subsequent accesses return the cached object, so idioms like if r.data and r.data.get('x'): only pay the parse cost once (issue #16). Shares its cache with json() and the internal _safe_body() debug-logging path -- self._response.json() is called at most once per response across all three.

Returns:

Type Description
Dict[str, Any]

Parsed JSON body of the response.

Raises:

Type Description
ValueError

When the response body is not valid JSON.

json

json() -> Dict[str, Any]

Return the parsed JSON body of the response.

Cached on first successful parse; subsequent calls return the cached value without touching self._response.json() again (issue #16). Exception behaviour matches the pre-#16 contract -- callers that previously got a ValueError on a malformed body still do.

Returns:

Type Description
Dict[str, Any]

Parsed JSON body of the response.

Raises:

Type Description
ValueError

When the response body is not valid JSON.

Source code in src/almaapitk/client/AlmaAPIClient.py
def json(self) -> Dict[str, Any]:
    """Return the parsed JSON body of the response.

    Cached on first successful parse; subsequent calls return the
    cached value without touching ``self._response.json()`` again
    (issue #16). Exception behaviour matches the pre-#16 contract
    -- callers that previously got a ``ValueError`` on a malformed
    body still do.

    Returns:
        Parsed JSON body of the response.

    Raises:
        ValueError: When the response body is not valid JSON.
    """
    if self._cached_body is _UNSET:
        # ``self._response.json()`` raises ``ValueError`` (or its
        # ``requests.exceptions.JSONDecodeError`` subclass) on
        # malformed bodies; let that propagate so existing callers
        # see the same exception shape they used to.
        self._cached_body = self._response.json()
    return self._cached_body

text

text() -> str

Return text data from response.

Source code in src/almaapitk/client/AlmaAPIClient.py
def text(self) -> str:
    """Return text data from response."""
    return self._response.text