Skip to content

Bibliographic Records

build_alma_bib_xml

build_alma_bib_xml(
    spec: Dict[str, Any], require_245: bool = False
) -> str

Build Alma's non-namespaced <bib><record> MARCXML from a spec.

Pure, network-free helper (the XML-assembly shape mirrors BibliographicRecords._build_updated_marc_xml but is spec-driven and escapes exactly once). The spec is plain JSON-serialisable data so it is usable from non-Python callers (e.g. Power Automate)::

spec = {
    "leader": "     nam a22     3i 4500",   # optional; default if omitted
    "fields": [
        {"tag": "008", "data": "..."},                       # control field
        {"tag": "245", "ind1": "1", "ind2": "0",
         "subfields": [["a", "Data Reduction Methods"]]},    # data field
        {"tag": "650", "ind1": " ", "ind2": "0",
         "subfields": [["a", "Data reduction"]]},
        {"tag": "650", "ind1": " ", "ind2": "0",
         "subfields": [["a", "Data science"]]},
    ],
}

Beyond spec shape, the builder enforces MARC content designation (issue #187) so it is not more permissive than the editing/reading paths: tags must be three digits, control vs data is decided by the tag (00X is a control field carrying data; 010-999 is a data field carrying subfields — a data-range tag with data is rejected), subfield codes are a single lowercase letter or digit, and indicators are a blank / digit / lowercase letter.

Parameters:

Name Type Description Default
spec Dict[str, Any]

Mapping with an optional leader string and a non-empty fields list. Each field needs a 3-digit string tag; control fields (00X) carry data, data fields carry ind1 / ind2 (default blank) and a non-empty subfields list of [code, value] pairs. Field and subfield order is preserved and repeated tags/subfields are supported.

required
require_245 bool

When True, assert client-side that the record contains exactly one 245 Title Statement (mandatory, non-repeatable in MARC 21) before building — a cheap pre-flight that beats a network round-trip to Alma's validator (issue #189). Default False delegates completeness to Alma's validate=true.

False

Returns:

Type Description
str

A <bib><record>...</record></bib> XML string ready for

str

meth:BibliographicRecords.create_record.

Raises:

Type Description
AlmaValidationError

If the spec is malformed (not a dict, missing or empty fields, a field without a valid 3-digit tag, a control field without data, a data-range tag carrying data, a data field without subfields, an invalid subfield code or indicator), or — when require_245 is set — if exactly one 245 is not present.

Source code in src/almaapitk/domains/bibs.py
def build_alma_bib_xml(spec: Dict[str, Any], require_245: bool = False) -> str:
    """Build Alma's non-namespaced ``<bib><record>`` MARCXML from a spec.

    Pure, network-free helper (the XML-assembly shape mirrors
    ``BibliographicRecords._build_updated_marc_xml`` but is spec-driven and
    escapes exactly once). The ``spec`` is plain JSON-serialisable data so it
    is usable from non-Python callers (e.g. Power Automate)::

        spec = {
            "leader": "     nam a22     3i 4500",   # optional; default if omitted
            "fields": [
                {"tag": "008", "data": "..."},                       # control field
                {"tag": "245", "ind1": "1", "ind2": "0",
                 "subfields": [["a", "Data Reduction Methods"]]},    # data field
                {"tag": "650", "ind1": " ", "ind2": "0",
                 "subfields": [["a", "Data reduction"]]},
                {"tag": "650", "ind1": " ", "ind2": "0",
                 "subfields": [["a", "Data science"]]},
            ],
        }

    Beyond spec *shape*, the builder enforces MARC *content designation*
    (issue #187) so it is not more permissive than the editing/reading paths:
    tags must be three digits, control vs data is decided by the tag (``00X``
    is a control field carrying ``data``; ``010``-``999`` is a data field
    carrying ``subfields`` — a data-range tag with ``data`` is rejected),
    subfield codes are a single lowercase letter or digit, and indicators are a
    blank / digit / lowercase letter.

    Args:
        spec: Mapping with an optional ``leader`` string and a non-empty
            ``fields`` list. Each field needs a 3-digit string ``tag``; control
            fields (``00X``) carry ``data``, data fields carry ``ind1`` /
            ``ind2`` (default blank) and a non-empty ``subfields`` list of
            ``[code, value]`` pairs. Field and subfield order is preserved and
            repeated tags/subfields are supported.
        require_245: When ``True``, assert client-side that the record contains
            exactly one ``245`` Title Statement (mandatory, non-repeatable in
            MARC 21) before building — a cheap pre-flight that beats a network
            round-trip to Alma's validator (issue #189). Default ``False``
            delegates completeness to Alma's ``validate=true``.

    Returns:
        A ``<bib><record>...</record></bib>`` XML string ready for
        :meth:`BibliographicRecords.create_record`.

    Raises:
        AlmaValidationError: If the spec is malformed (not a dict, missing or
            empty ``fields``, a field without a valid 3-digit ``tag``, a control
            field without ``data``, a data-range tag carrying ``data``, a data
            field without ``subfields``, an invalid subfield code or indicator),
            or — when ``require_245`` is set — if exactly one ``245`` is not
            present.
    """
    if not isinstance(spec, dict):
        raise AlmaValidationError("spec must be a dict")

    fields = spec.get("fields")
    if not isinstance(fields, list) or not fields:
        raise AlmaValidationError("spec must include a non-empty 'fields' list")

    # #189: optional client-side completeness gate. 245 (Title Statement) is
    # mandatory and non-repeatable in MARC 21, so exactly one must be present.
    if require_245:
        n245 = sum(
            1 for f in fields if isinstance(f, dict) and f.get("tag") == "245"
        )
        if n245 != 1:
            raise AlmaValidationError(
                "MARC completeness check (require_245): a bibliographic record "
                "must contain exactly one 245 Title Statement, found "
                f"{n245} (pass require_245=False to delegate this to Alma's "
                "validator)"
            )

    leader_text = spec.get("leader", DEFAULT_LEADER)
    if not isinstance(leader_text, str):
        raise AlmaValidationError("'leader' must be a string when provided")

    bib_elem = ET.Element("bib")
    record_elem = ET.SubElement(bib_elem, "record")

    leader_elem = ET.SubElement(record_elem, "leader")
    leader_elem.text = _strip_illegal_xml_chars(leader_text)

    for index, field in enumerate(fields):
        if not isinstance(field, dict):
            raise AlmaValidationError(f"field at index {index} must be a dict")

        tag = field.get("tag")
        if not tag or not isinstance(tag, str):
            raise AlmaValidationError(
                f"field at index {index} is missing a string 'tag'"
            )
        # #187: MARC tags are exactly three numeric characters. Mirrors the
        # check update_marc_field / get_marc_subfield already enforce, so the
        # builder is not more permissive than the editing/reading paths.
        if len(tag) != 3 or not tag.isdigit():
            raise AlmaValidationError(
                f"MARC tag must be exactly 3 digits, got {tag!r} "
                f"(field at index {index})"
            )

        # #187: control vs data is decided by the TAG (00X = control field),
        # NOT by the presence of a 'data' key. A data-range tag (010-999)
        # carrying 'data' would emit a <controlfield> for a data tag —
        # structurally invalid MARC — so it is rejected here.
        is_control = tag.startswith("00")
        if is_control:
            if "data" not in field:
                raise AlmaValidationError(
                    f"control field {tag} requires a 'data' value"
                )
            control_elem = ET.SubElement(record_elem, "controlfield")
            control_elem.set("tag", tag)
            # Assign to .text and let ET escape at serialisation (no pre-escape).
            control_elem.text = _strip_illegal_xml_chars(str(field["data"]))
            continue

        if "data" in field:
            raise AlmaValidationError(
                f"data field {tag} must not carry control-field 'data'; only "
                "00X control fields use 'data'. Provide 'subfields' instead"
            )

        subfields = field.get("subfields")
        if not subfields:
            raise AlmaValidationError(
                f"data field {tag} requires a non-empty 'subfields' list"
            )
        if not isinstance(subfields, list):
            raise AlmaValidationError(
                f"field {tag} 'subfields' must be a list of [code, value] pairs"
            )

        data_elem = ET.SubElement(record_elem, "datafield")
        data_elem.set("tag", tag)
        data_elem.set("ind1", _normalize_indicator(field.get("ind1", " ")))
        data_elem.set("ind2", _normalize_indicator(field.get("ind2", " ")))

        for sf_index, subfield in enumerate(subfields):
            if not isinstance(subfield, (list, tuple)) or len(subfield) != 2:
                raise AlmaValidationError(
                    f"subfield {sf_index} of field {tag} must be a "
                    "[code, value] pair"
                )
            code, value = subfield
            if not code or not isinstance(code, str):
                raise AlmaValidationError(
                    f"subfield {sf_index} of field {tag} needs a string code"
                )
            # #187: subfield codes are a single lowercase letter or digit.
            if len(code) != 1 or not (("a" <= code <= "z") or code.isdigit()):
                raise AlmaValidationError(
                    f"subfield code {code!r} in field {tag} must be a single "
                    "lowercase letter (a-z) or digit (0-9)"
                )
            subfield_elem = ET.SubElement(data_elem, "subfield")
            subfield_elem.set("code", code)
            subfield_elem.text = _strip_illegal_xml_chars(
                "" if value is None else str(value)
            )

    # ``encoding='unicode'`` returns a str; ET escapes &, <, > exactly once.
    return ET.tostring(bib_elem, encoding="unicode")

BibliographicRecords

BibliographicRecords(client: AlmaAPIClient)

Domain class for handling Alma Bibliographic Records API operations. Builds upon and improves the existing bib record functionality.

Source code in src/almaapitk/domains/bibs.py
def __init__(self, client: AlmaAPIClient):
    self.client = client
    self.logger = client.logger

get_record

get_record(
    mms_id: str, view: str = "full", expand: str = None
) -> AlmaResponse

Retrieve a bibliographic record by MMS ID.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the bibliographic record

required
view str

Level of detail (brief, full)

'full'
expand str

Additional data to include (p_avail, e_avail, d_avail)

None

Returns:

Type Description
AlmaResponse

AlmaResponse containing the bibliographic record

Source code in src/almaapitk/domains/bibs.py
def get_record(self, mms_id: str, view: str = "full", expand: str = None) -> AlmaResponse:
    """
    Retrieve a bibliographic record by MMS ID.

    Args:
        mms_id: The MMS ID of the bibliographic record
        view: Level of detail (brief, full)
        expand: Additional data to include (p_avail, e_avail, d_avail)

    Returns:
        AlmaResponse containing the bibliographic record
    """
    if not mms_id:
        raise AlmaValidationError("MMS ID is required")

    params = {"view": view}
    if expand:
        params["expand"] = expand

    endpoint = f"almaws/v1/bibs/{mms_id}"
    response = self.client.get(endpoint, params=params)

    self.logger.info(f"Retrieved bib record {mms_id}")
    return response

create_record

create_record(
    marc_xml: str,
    validate: bool = True,
    override_warning: bool = False,
) -> AlmaResponse

Create a new bibliographic record.

Parameters:

Name Type Description Default
marc_xml str

MARC XML data for the record

required
validate bool

Whether to validate the record

True
override_warning bool

Whether to override validation warnings

False

Returns:

Type Description
AlmaResponse

AlmaResponse containing the created record

Source code in src/almaapitk/domains/bibs.py
def create_record(self, marc_xml: str, validate: bool = True,
                 override_warning: bool = False) -> AlmaResponse:
    """
    Create a new bibliographic record.

    Args:
        marc_xml: MARC XML data for the record
        validate: Whether to validate the record
        override_warning: Whether to override validation warnings

    Returns:
        AlmaResponse containing the created record
    """
    if not marc_xml:
        raise AlmaValidationError("MARC XML data is required")

    # Validate XML structure
    try:
        ET.fromstring(marc_xml)
    except ET.ParseError as e:
        raise AlmaValidationError(f"Invalid XML structure: {e}")

    params = {
        "validate": "true" if validate else "false",
        "override_warning": "true" if override_warning else "false"
    }

    endpoint = "almaws/v1/bibs"
    response = self.client.post(endpoint, data=marc_xml,
                              content_type='application/xml', params=params)

    self.logger.info("Created new bib record")
    return response

create_record_from_fields

create_record_from_fields(
    spec: Dict[str, Any],
    validate: bool = True,
    override_warning: bool = False,
    require_245: bool = True,
) -> AlmaResponse

Create a new bibliographic record from a native, structure-driven spec.

Builds Alma's non-namespaced <bib><record> XML from spec via :func:build_alma_bib_xml and funnels it into :meth:create_record, so callers never hand-assemble MARCXML. Pattern source: thin builder-then-create_record wrapper (issue #179).

Parameters:

Name Type Description Default
spec Dict[str, Any]

JSON-serialisable field structure (see :func:build_alma_bib_xml).

required
validate bool

Whether Alma should validate the record.

True
override_warning bool

Whether to override validation warnings.

False
require_245 bool

Assert client-side that the record has exactly one 245 before the network round-trip (issue #189). Defaults to True for the create path — a real bib needs a title; pass False to delegate the check to Alma.

True

Returns:

Type Description
AlmaResponse

AlmaResponse containing the created record.

Raises:

Type Description
AlmaValidationError

If spec is malformed or (by default) has no single 245.

Source code in src/almaapitk/domains/bibs.py
def create_record_from_fields(self, spec: Dict[str, Any], validate: bool = True,
                              override_warning: bool = False,
                              require_245: bool = True) -> AlmaResponse:
    """
    Create a new bibliographic record from a native, structure-driven spec.

    Builds Alma's non-namespaced ``<bib><record>`` XML from ``spec`` via
    :func:`build_alma_bib_xml` and funnels it into :meth:`create_record`,
    so callers never hand-assemble MARCXML. Pattern source: thin
    builder-then-``create_record`` wrapper (issue #179).

    Args:
        spec: JSON-serialisable field structure (see
            :func:`build_alma_bib_xml`).
        validate: Whether Alma should validate the record.
        override_warning: Whether to override validation warnings.
        require_245: Assert client-side that the record has exactly one
            ``245`` before the network round-trip (issue #189). Defaults to
            ``True`` for the create path — a real bib needs a title; pass
            ``False`` to delegate the check to Alma.

    Returns:
        AlmaResponse containing the created record.

    Raises:
        AlmaValidationError: If ``spec`` is malformed or (by default) has no
            single ``245``.
    """
    marc_xml = build_alma_bib_xml(spec, require_245=require_245)

    self.logger.info("Creating bib record from field spec")
    return self.create_record(
        marc_xml, validate=validate, override_warning=override_warning
    )

create_record_from_pymarc

create_record_from_pymarc(
    record: Any,
    validate: bool = True,
    override_warning: bool = False,
    require_245: bool = True,
) -> AlmaResponse

Create a new bibliographic record from a pymarc.Record.

Converts the record to a native spec and delegates to :meth:create_record_from_fields. pymarc is an optional extra (pip install almaapitk[pymarc]) imported lazily here so the core install stays dependency-light (issue #179).

Parameters:

Name Type Description Default
record Any

A pymarc.Record instance.

required
validate bool

Whether Alma should validate the record.

True
override_warning bool

Whether to override validation warnings.

False
require_245 bool

Assert client-side that the record has exactly one 245 before creating (issue #189); default True.

True

Returns:

Type Description
AlmaResponse

AlmaResponse containing the created record.

Raises:

Type Description
AlmaValidationError

If pymarc is not installed or record is not a pymarc.Record instance.

Source code in src/almaapitk/domains/bibs.py
def create_record_from_pymarc(self, record: Any, validate: bool = True,
                             override_warning: bool = False,
                             require_245: bool = True) -> AlmaResponse:
    """
    Create a new bibliographic record from a ``pymarc.Record``.

    Converts the record to a native spec and delegates to
    :meth:`create_record_from_fields`. ``pymarc`` is an optional extra
    (``pip install almaapitk[pymarc]``) imported lazily here so the core
    install stays dependency-light (issue #179).

    Args:
        record: A ``pymarc.Record`` instance.
        validate: Whether Alma should validate the record.
        override_warning: Whether to override validation warnings.
        require_245: Assert client-side that the record has exactly one
            ``245`` before creating (issue #189); default ``True``.

    Returns:
        AlmaResponse containing the created record.

    Raises:
        AlmaValidationError: If ``pymarc`` is not installed or ``record``
            is not a ``pymarc.Record`` instance.
    """
    try:
        import pymarc  # noqa: F401  (optional dependency, imported lazily)
    except ImportError as exc:
        raise AlmaValidationError(
            "create_record_from_pymarc requires the optional 'pymarc' "
            "extra. Install it with: pip install almaapitk[pymarc]"
        ) from exc

    if not isinstance(record, pymarc.Record):
        raise AlmaValidationError("record must be a pymarc.Record instance")

    spec = _pymarc_record_to_spec(record)

    self.logger.info("Creating bib record from pymarc record")
    return self.create_record_from_fields(
        spec, validate=validate, override_warning=override_warning,
        require_245=require_245
    )

update_record

update_record(
    mms_id: str,
    marc_xml: str,
    validate: bool = True,
    override_warning: bool = True,
    override_lock: bool = True,
    stale_version_check: bool = False,
) -> AlmaResponse

Update an existing bibliographic record.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the record to update

required
marc_xml str

Updated MARC XML data

required
validate bool

Whether to validate the record

True
override_warning bool

Whether to override validation warnings

True
override_lock bool

Whether to override record locks

True
stale_version_check bool

Whether to check for stale versions

False

Returns:

Type Description
AlmaResponse

AlmaResponse containing the updated record

Source code in src/almaapitk/domains/bibs.py
def update_record(self, mms_id: str, marc_xml: str, validate: bool = True,
                 override_warning: bool = True, override_lock: bool = True,
                 stale_version_check: bool = False) -> AlmaResponse:
    """
    Update an existing bibliographic record.

    Args:
        mms_id: The MMS ID of the record to update
        marc_xml: Updated MARC XML data
        validate: Whether to validate the record
        override_warning: Whether to override validation warnings
        override_lock: Whether to override record locks
        stale_version_check: Whether to check for stale versions

    Returns:
        AlmaResponse containing the updated record
    """
    if not mms_id:
        raise AlmaValidationError("MMS ID is required")

    if not marc_xml:
        raise AlmaValidationError("MARC XML data is required")

    # Validate XML structure
    try:
        ET.fromstring(marc_xml)
    except ET.ParseError as e:
        raise AlmaValidationError(f"Invalid XML structure: {e}")

    params = {
        "validate": "true" if validate else "false",
        "override_warning": "true" if override_warning else "false",
        "override_lock": "true" if override_lock else "false",
        "stale_version_check": "true" if stale_version_check else "false"
    }

    endpoint = f"almaws/v1/bibs/{mms_id}"
    response = self.client.put(endpoint, data=marc_xml, 
                             content_type='application/xml', params=params)

    self.logger.info(f"Updated bib record {mms_id}")
    return response

delete_record

delete_record(
    mms_id: str, override_attached_items: bool = False
) -> AlmaResponse

Delete a bibliographic record.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the record to delete

required
override_attached_items bool

Whether to delete even if items are attached (overrides Alma's deletion warnings).

False

Returns:

Type Description
AlmaResponse

AlmaResponse confirming deletion

Source code in src/almaapitk/domains/bibs.py
def delete_record(self, mms_id: str, override_attached_items: bool = False) -> AlmaResponse:
    """
    Delete a bibliographic record.

    Args:
        mms_id: The MMS ID of the record to delete
        override_attached_items: Whether to delete even if items are
            attached (overrides Alma's deletion warnings).

    Returns:
        AlmaResponse confirming deletion
    """
    if not mms_id:
        raise AlmaValidationError("MMS ID is required")

    # #193: Alma's DELETE /bibs/{mms_id} 'override' is a boolean-valued
    # string (true/false), per docs/alma-swagger/bibs.json. Sending
    # 'attached_items' is rejected ("Make sure the override parameter is
    # false or true"), so the override branch never worked. Send 'true'.
    params = {}
    if override_attached_items:
        params["override"] = "true"

    endpoint = f"almaws/v1/bibs/{mms_id}"
    response = self.client.delete(endpoint, params=params)

    self.logger.info(f"Deleted bib record {mms_id}")
    return response

update_marc_field

update_marc_field(
    mms_id: str,
    field: str,
    subfields: SubfieldsArg,
    ind1: str = " ",
    ind2: str = " ",
    mode: str = "replace_first",
) -> AlmaResponse

Update, replace, or append a MARC field in a bibliographic record.

Many MARC tags (6XX/5XX/7XX/020/490/856, etc.) are repeatable, so a record routinely carries several occurrences of the same tag (e.g. three 650 subject headings). This method only touches the occurrence(s) selected by mode and preserves every other occurrence untouched, so an update to one instance never silently drops the rest (issue #184).

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the record.

required
field str

MARC field number (e.g. "650"); must be a 3-digit tag.

required
subfields SubfieldsArg

Subfields for the new/updated field, in one of two shapes (see :data:SubfieldsArg):

  • an ordered list of [code, value] pairs, e.g. [["a", "Science"], ["x", "History"], ["x", "20th century"]] — preserves order and repeated subfield codes (many MARC subfields are Repeatable); or
  • a dict ({"a": "Science", "x": "History"}) — accepted for backward compatibility, but a dict key cannot repeat so it cannot express a repeated subfield code.
required
ind1 str

First indicator for the new/updated field.

' '
ind2 str

Second indicator for the new/updated field.

' '
mode str

How to apply the change to the target tag:

  • "replace_first" (default) — replace the first occurrence of field in place and preserve every other occurrence of the same tag. If the tag is absent it is created. This is the non-destructive default.
  • "replace_all" — remove all existing occurrences of field and add a single new one built from subfields. Use this to intentionally collapse a repeated tag to one value.
  • "append" — keep every existing occurrence of field and add one additional occurrence built from subfields.
'replace_first'

Returns:

Type Description
AlmaResponse

AlmaResponse containing the updated record.

Raises:

Type Description
AlmaValidationError

If mms_id is empty, field is not a 3-digit tag, subfields is empty or malformed (each pair must be [code, value] with a single-character code), or mode is not one of "replace_first"/"replace_all"/"append".

AlmaAPIError

If the record cannot be retrieved or has no MARC XML.

Source code in src/almaapitk/domains/bibs.py
def update_marc_field(self, mms_id: str, field: str, subfields: SubfieldsArg,
                     ind1: str = ' ', ind2: str = ' ',
                     mode: str = "replace_first") -> AlmaResponse:
    """
    Update, replace, or append a MARC field in a bibliographic record.

    Many MARC tags (6XX/5XX/7XX/020/490/856, etc.) are **repeatable**, so a
    record routinely carries several occurrences of the same tag (e.g. three
    650 subject headings). This method only touches the occurrence(s)
    selected by ``mode`` and preserves every other occurrence untouched, so
    an update to one instance never silently drops the rest (issue #184).

    Args:
        mms_id: The MMS ID of the record.
        field: MARC field number (e.g. ``"650"``); must be a 3-digit tag.
        subfields: Subfields for the new/updated field, in one of two
            shapes (see :data:`SubfieldsArg`):

            * an ordered list of ``[code, value]`` pairs, e.g.
              ``[["a", "Science"], ["x", "History"], ["x", "20th century"]]``
              — preserves order **and** repeated subfield codes (many
              MARC subfields are Repeatable); or
            * a ``dict`` (``{"a": "Science", "x": "History"}``) —
              accepted for backward compatibility, but a dict key cannot
              repeat so it cannot express a repeated subfield code.
        ind1: First indicator for the new/updated field.
        ind2: Second indicator for the new/updated field.
        mode: How to apply the change to the target tag:

            * ``"replace_first"`` (default) — replace the *first* occurrence
              of ``field`` in place and preserve every other occurrence of
              the same tag. If the tag is absent it is created. This is the
              non-destructive default.
            * ``"replace_all"`` — remove *all* existing occurrences of
              ``field`` and add a single new one built from ``subfields``.
              Use this to intentionally collapse a repeated tag to one value.
            * ``"append"`` — keep every existing occurrence of ``field`` and
              add one additional occurrence built from ``subfields``.

    Returns:
        AlmaResponse containing the updated record.

    Raises:
        AlmaValidationError: If ``mms_id`` is empty, ``field`` is not a
            3-digit tag, ``subfields`` is empty or malformed (each pair
            must be ``[code, value]`` with a single-character code), or
            ``mode`` is not one of
            ``"replace_first"``/``"replace_all"``/``"append"``.
        AlmaAPIError: If the record cannot be retrieved or has no MARC XML.
    """
    if not mms_id:
        raise AlmaValidationError("MMS ID is required")

    if not field or not field.isdigit() or len(field) != 3:
        raise AlmaValidationError("Field must be a 3-digit number")

    # Normalise (and validate) subfields into ordered (code, value)
    # pairs up front so bad input fails before any API round-trip.
    # Accepts both a dict and a list of [code, value] pairs; the list
    # form is the only one that can carry a repeated subfield code
    # (issue #185).
    subfield_pairs = self._normalize_subfields(subfields)

    valid_modes = ("replace_first", "replace_all", "append")
    if mode not in valid_modes:
        raise AlmaValidationError(
            f"mode must be one of {valid_modes}, got {mode!r}"
        )

    try:
        # Get current record
        self.logger.info(
            f"Updating MARC field {field} for record {mms_id} (mode={mode})"
        )

        response = self.get_record(mms_id)
        if not response.success:
            raise AlmaAPIError(f"Failed to retrieve record {mms_id}")

        bib_data = response.json()
        marc_xml = bib_data.get('anies', [''])[0]

        if not marc_xml:
            raise AlmaAPIError("No MARC XML found in record")

        # Parse XML
        try:
            root = ET.fromstring(marc_xml)
        except ET.ParseError as e:
            raise AlmaValidationError(f"Invalid MARC XML: {e}")

        # Build updated XML
        updated_xml = self._build_updated_marc_xml(
            root, field, subfield_pairs, ind1, ind2, mode
        )

        # Update the record
        return self.update_record(mms_id, updated_xml)

    except Exception as e:
        self.logger.error(f"Error updating MARC field {field} for record {mms_id}: {e}")
        raise

get_holdings

get_holdings(
    mms_id: str, holding_id: str = None
) -> AlmaResponse

Get holdings for a bibliographic record.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the bibliographic record

required
holding_id str

Specific holding ID, or None for all holdings

None

Returns:

Type Description
AlmaResponse

AlmaResponse containing holdings data

Source code in src/almaapitk/domains/bibs.py
def get_holdings(self, mms_id: str, holding_id: str = None) -> AlmaResponse:
    """
    Get holdings for a bibliographic record.

    Args:
        mms_id: The MMS ID of the bibliographic record
        holding_id: Specific holding ID, or None for all holdings

    Returns:
        AlmaResponse containing holdings data
    """
    if not mms_id:
        raise AlmaValidationError("MMS ID is required")

    if holding_id:
        endpoint = f"almaws/v1/bibs/{mms_id}/holdings/{holding_id}"
    else:
        endpoint = f"almaws/v1/bibs/{mms_id}/holdings"

    response = self.client.get(endpoint)
    self.logger.info(f"Retrieved holdings for bib {mms_id}")
    return response

create_holding

create_holding(
    mms_id: str, holding_data: Dict[str, Any]
) -> AlmaResponse

Create a new holding record.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the bibliographic record

required
holding_data Dict[str, Any]

Holding record data

required

Returns:

Type Description
AlmaResponse

AlmaResponse containing the created holding

Source code in src/almaapitk/domains/bibs.py
def create_holding(self, mms_id: str, holding_data: Dict[str, Any]) -> AlmaResponse:
    """
    Create a new holding record.

    Args:
        mms_id: The MMS ID of the bibliographic record
        holding_data: Holding record data

    Returns:
        AlmaResponse containing the created holding
    """
    if not mms_id:
        raise AlmaValidationError("MMS ID is required")

    endpoint = f"almaws/v1/bibs/{mms_id}/holdings"
    response = self.client.post(endpoint, data=holding_data)

    self.logger.info(f"Created holding for bib {mms_id}")
    return response

get_items

get_items(
    mms_id: str,
    holding_id: str = "ALL",
    item_id: str = None,
) -> AlmaResponse

Get items for a bibliographic record.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the bibliographic record

required
holding_id str

Holding ID or "ALL" for all holdings

'ALL'
item_id str

Specific item ID, or None for all items

None

Returns:

Type Description
AlmaResponse

AlmaResponse containing items data

Source code in src/almaapitk/domains/bibs.py
def get_items(self, mms_id: str, holding_id: str = "ALL", item_id: str = None) -> AlmaResponse:
    """
    Get items for a bibliographic record.

    Args:
        mms_id: The MMS ID of the bibliographic record
        holding_id: Holding ID or "ALL" for all holdings
        item_id: Specific item ID, or None for all items

    Returns:
        AlmaResponse containing items data
    """
    if not mms_id:
        raise AlmaValidationError("MMS ID is required")

    if item_id:
        endpoint = f"almaws/v1/bibs/{mms_id}/holdings/{holding_id}/items/{item_id}"
    else:
        endpoint = f"almaws/v1/bibs/{mms_id}/holdings/{holding_id}/items"

    response = self.client.get(endpoint)
    self.logger.info(f"Retrieved items for bib {mms_id}")
    return response

create_item

create_item(
    mms_id: str, holding_id: str, item_data: Dict[str, Any]
) -> AlmaResponse

Create a new item record.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the bibliographic record

required
holding_id str

The holding ID

required
item_data Dict[str, Any]

Item record data

required

Returns:

Type Description
AlmaResponse

AlmaResponse containing the created item

Source code in src/almaapitk/domains/bibs.py
def create_item(self, mms_id: str, holding_id: str, item_data: Dict[str, Any]) -> AlmaResponse:
    """
    Create a new item record.

    Args:
        mms_id: The MMS ID of the bibliographic record
        holding_id: The holding ID
        item_data: Item record data

    Returns:
        AlmaResponse containing the created item
    """
    if not mms_id or not holding_id:
        raise AlmaValidationError("MMS ID and holding ID are required")

    endpoint = f"almaws/v1/bibs/{mms_id}/holdings/{holding_id}/items"
    response = self.client.post(endpoint, data=item_data)

    self.logger.info(f"Created item for bib {mms_id}, holding {holding_id}")
    return response

scan_in_item

scan_in_item(
    mms_id: str,
    holding_id: str,
    item_pid: str,
    library: str,
    department: Optional[str] = None,
    circ_desk: Optional[str] = None,
    work_order_type: Optional[str] = None,
    status: Optional[str] = None,
    done: bool = False,
    confirm: bool = True,
) -> AlmaResponse

Scan in an item to a department with optional work order.

This operation simulates the UI "Scan In Items" function, which allows placing items in a work order within a department. When used after receiving an item, it prevents the item from going into Transit status and keeps it in the specified department.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the bibliographic record

required
holding_id str

The holding ID

required
item_pid str

The item PID (item identifier)

required
library str

Library code where item should be scanned in

required
department Optional[str]

Department code (provide either department or circ_desk)

None
circ_desk Optional[str]

Circulation desk code (alternative to department)

None
work_order_type Optional[str]

Work order type code (e.g., 'AcqWorkOrder')

None
status Optional[str]

Work order status (e.g., 'CopyCataloging', 'Labeling')

None
done bool

If True, completes the work order; if False, keeps item in department

False
confirm bool

Whether to bypass confirmation prompts (default: True)

True

Returns:

Type Description
AlmaResponse

AlmaResponse containing the updated item data

Raises:

Type Description
AlmaValidationError

If required parameters are missing or invalid

Example

Scan in item to acquisitions department with work order

bibs.scan_in_item( ... mms_id="99123456789", ... holding_id="22123456789", ... item_pid="23123456789", ... library="ACQ_LIB", ... department="ACQ_DEPT", ... work_order_type="AcqWorkOrder", ... status="CopyCataloging", ... done=False # Keep in department ... )

Notes
  • Either department or circ_desk must be provided
  • When done=False, item stays in department with work order
  • When done=True, work order is completed and item may move to next step
  • Work order configuration must exist in Alma (Configuration > Fulfillment > Physical Fulfillment > Work Order Types)
Source code in src/almaapitk/domains/bibs.py
def scan_in_item(self, mms_id: str, holding_id: str, item_pid: str,
                 library: str, department: Optional[str] = None,
                 circ_desk: Optional[str] = None,
                 work_order_type: Optional[str] = None,
                 status: Optional[str] = None,
                 done: bool = False,
                 confirm: bool = True) -> AlmaResponse:
    """
    Scan in an item to a department with optional work order.

    This operation simulates the UI "Scan In Items" function, which allows placing
    items in a work order within a department. When used after receiving an item,
    it prevents the item from going into Transit status and keeps it in the
    specified department.

    Args:
        mms_id: The MMS ID of the bibliographic record
        holding_id: The holding ID
        item_pid: The item PID (item identifier)
        library: Library code where item should be scanned in
        department: Department code (provide either department or circ_desk)
        circ_desk: Circulation desk code (alternative to department)
        work_order_type: Work order type code (e.g., 'AcqWorkOrder')
        status: Work order status (e.g., 'CopyCataloging', 'Labeling')
        done: If True, completes the work order; if False, keeps item in department
        confirm: Whether to bypass confirmation prompts (default: True)

    Returns:
        AlmaResponse containing the updated item data

    Raises:
        AlmaValidationError: If required parameters are missing or invalid

    Example:
        >>> # Scan in item to acquisitions department with work order
        >>> bibs.scan_in_item(
        ...     mms_id="99123456789",
        ...     holding_id="22123456789",
        ...     item_pid="23123456789",
        ...     library="ACQ_LIB",
        ...     department="ACQ_DEPT",
        ...     work_order_type="AcqWorkOrder",
        ...     status="CopyCataloging",
        ...     done=False  # Keep in department
        ... )

    Notes:
        - Either department or circ_desk must be provided
        - When done=False, item stays in department with work order
        - When done=True, work order is completed and item may move to next step
        - Work order configuration must exist in Alma (Configuration > Fulfillment >
          Physical Fulfillment > Work Order Types)
    """
    if not all([mms_id, holding_id, item_pid, library]):
        raise AlmaValidationError(
            "MMS ID, holding ID, item PID, and library are required"
        )

    if not department and not circ_desk:
        raise AlmaValidationError(
            "Either department or circ_desk must be provided"
        )

    # Build query parameters
    params = {
        "op": "scan",
        "library": library,
        "confirm": str(confirm).lower()
    }

    if department:
        params["department"] = department
    elif circ_desk:
        params["circ_desk"] = circ_desk

    if work_order_type:
        params["work_order_type"] = work_order_type

    if status:
        params["status"] = status

    if done:
        params["done"] = str(done).lower()

    endpoint = f"almaws/v1/bibs/{mms_id}/holdings/{holding_id}/items/{item_pid}"

    self.logger.info(
        f"Scanning in item {item_pid} to {department or circ_desk} "
        f"at library {library}"
        + (f" with work order {work_order_type}" if work_order_type else "")
    )

    response = self.client.post(endpoint, data={}, params=params)

    if response.success:
        self.logger.info(
            f"Successfully scanned in item {item_pid} to department"
        )

    return response

get_representations

get_representations(
    mms_id: str, representation_id: str = None
) -> AlmaResponse

Get digital representations for a bibliographic record.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the bibliographic record

required
representation_id str

Specific representation ID, or None for all

None

Returns:

Type Description
AlmaResponse

AlmaResponse containing representation data

Source code in src/almaapitk/domains/bibs.py
def get_representations(self, mms_id: str, representation_id: str = None) -> AlmaResponse:
    """
    Get digital representations for a bibliographic record.

    Args:
        mms_id: The MMS ID of the bibliographic record
        representation_id: Specific representation ID, or None for all

    Returns:
        AlmaResponse containing representation data
    """
    if not mms_id:
        raise AlmaValidationError("MMS ID is required")

    if representation_id:
        endpoint = f"almaws/v1/bibs/{mms_id}/representations/{representation_id}"
    else:
        endpoint = f"almaws/v1/bibs/{mms_id}/representations"

    response = self.client.get(endpoint)
    self.logger.info(f"Retrieved representations for bib {mms_id}")
    return response

create_representation

create_representation(
    mms_id: str,
    access_rights_value: str,
    access_rights_desc: str,
    lib_code: str,
    usage_type: str = "PRESERVATION_MASTER",
) -> AlmaResponse

Create a new digital representation. Enhanced version of your existing method.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the bibliographic record

required
access_rights_value str

Access rights policy value

required
access_rights_desc str

Access rights description

required
lib_code str

Library code

required
usage_type str

Usage type for the representation

'PRESERVATION_MASTER'

Returns:

Type Description
AlmaResponse

AlmaResponse containing the created representation

Source code in src/almaapitk/domains/bibs.py
def create_representation(self, mms_id: str, access_rights_value: str, 
                        access_rights_desc: str, lib_code: str, 
                        usage_type: str = "PRESERVATION_MASTER") -> AlmaResponse:
    """
    Create a new digital representation.
    Enhanced version of your existing method.

    Args:
        mms_id: The MMS ID of the bibliographic record
        access_rights_value: Access rights policy value
        access_rights_desc: Access rights description
        lib_code: Library code
        usage_type: Usage type for the representation

    Returns:
        AlmaResponse containing the created representation
    """

    # Check for None/null values but allow empty strings
    if mms_id is None or access_rights_value is None or lib_code is None:
        raise AlmaValidationError("MMS ID, access rights value, and library code cannot be None")

    # Check for empty strings only for critical fields (not access_rights)
    if not mms_id or not lib_code:
        raise AlmaValidationError("MMS ID and library code are required and cannot be empty")

    # access_rights_value and access_rights_desc can be empty strings (but not None)
    if not isinstance(access_rights_value, str) or not isinstance(lib_code, str) or not isinstance(mms_id, str):
        raise AlmaValidationError("MMS ID, access rights value, and library code must be strings")


    rep_data = {
        "access_rights_policy_id": {
            "value": access_rights_value,
            "desc": access_rights_desc
        },
        "is_remote": False,
        "library": {"value": lib_code},
        "usage_type": {"value": usage_type}
    }

    endpoint = f"almaws/v1/bibs/{mms_id}/representations"
    response = self.client.post(endpoint, data=rep_data)

    self.logger.info(f"Created representation for bib {mms_id}")
    return response

get_representation_files

get_representation_files(
    mms_id: str, representation_id: str, file_id: str = None
) -> AlmaResponse

Get files for a digital representation.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the bibliographic record

required
representation_id str

The representation ID

required
file_id str

Specific file ID, or None for all files

None

Returns:

Type Description
AlmaResponse

AlmaResponse containing file data

Source code in src/almaapitk/domains/bibs.py
def get_representation_files(self, mms_id: str, representation_id: str, 
                           file_id: str = None) -> AlmaResponse:
    """
    Get files for a digital representation.

    Args:
        mms_id: The MMS ID of the bibliographic record
        representation_id: The representation ID
        file_id: Specific file ID, or None for all files

    Returns:
        AlmaResponse containing file data
    """
    if not all([mms_id, representation_id]):
        raise AlmaValidationError("MMS ID and representation ID are required")

    if file_id:
        endpoint = f"almaws/v1/bibs/{mms_id}/representations/{representation_id}/files/{file_id}"
    else:
        endpoint = f"almaws/v1/bibs/{mms_id}/representations/{representation_id}/files"

    response = self.client.get(endpoint)
    self.logger.info(f"Retrieved files for representation {representation_id}")
    return response
link_file_to_representation(
    mms_id: str, representation_id: str, file_path: str
) -> AlmaResponse

Link a file to a digital representation. Enhanced version of your existing method.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the bibliographic record

required
representation_id str

The representation ID

required
file_path str

Path to the file in storage

required

Returns:

Type Description
AlmaResponse

AlmaResponse containing the linked file data

Source code in src/almaapitk/domains/bibs.py
def link_file_to_representation(self, mms_id: str, representation_id: str, 
                               file_path: str) -> AlmaResponse:
    """
    Link a file to a digital representation.
    Enhanced version of your existing method.

    Args:
        mms_id: The MMS ID of the bibliographic record
        representation_id: The representation ID
        file_path: Path to the file in storage

    Returns:
        AlmaResponse containing the linked file data
    """
    if not all([mms_id, representation_id, file_path]):
        raise AlmaValidationError("MMS ID, representation ID, and file path are required")

    file_data = {"path": file_path}

    endpoint = f"almaws/v1/bibs/{mms_id}/representations/{representation_id}/files"
    response = self.client.post(endpoint, data=file_data)

    self.logger.info(f"Linked file {file_path} to representation {representation_id}")
    return response

update_representation_file

update_representation_file(
    mms_id: str,
    representation_id: str,
    file_id: str,
    file_data: Dict[str, Any],
) -> AlmaResponse

Update a file in a digital representation. Enhanced version of your existing method.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the bibliographic record

required
representation_id str

The representation ID

required
file_id str

The file ID

required
file_data Dict[str, Any]

Updated file data

required

Returns:

Type Description
AlmaResponse

AlmaResponse containing the updated file data

Source code in src/almaapitk/domains/bibs.py
def update_representation_file(self, mms_id: str, representation_id: str, 
                             file_id: str, file_data: Dict[str, Any]) -> AlmaResponse:
    """
    Update a file in a digital representation.
    Enhanced version of your existing method.

    Args:
        mms_id: The MMS ID of the bibliographic record
        representation_id: The representation ID
        file_id: The file ID
        file_data: Updated file data

    Returns:
        AlmaResponse containing the updated file data
    """
    if not all([mms_id, representation_id, file_id]):
        raise AlmaValidationError("MMS ID, representation ID, and file ID are required")

    endpoint = f"almaws/v1/bibs/{mms_id}/representations/{representation_id}/files/{file_id}"
    response = self.client.put(endpoint, data=file_data)

    self.logger.info(f"Updated file {file_id} in representation {representation_id}")
    return response

get_collection_members

get_collection_members(
    collection_id: str, limit: int = 100, offset: int = 0
) -> AlmaResponse

Get bibliographic records that are members of a collection.

Parameters:

Name Type Description Default
collection_id str

The collection ID

required
limit int

Maximum number of records to return (default 100)

100
offset int

Starting position for pagination (default 0)

0

Returns:

Type Description
AlmaResponse

AlmaResponse containing list of bib records in the collection

Raises:

Type Description
AlmaValidationError

If collection_id is empty or None

AlmaAPIError

If the API request fails (e.g., collection not found)

Source code in src/almaapitk/domains/bibs.py
def get_collection_members(self, collection_id: str, limit: int = 100,
                           offset: int = 0) -> AlmaResponse:
    """
    Get bibliographic records that are members of a collection.

    Args:
        collection_id: The collection ID
        limit: Maximum number of records to return (default 100)
        offset: Starting position for pagination (default 0)

    Returns:
        AlmaResponse containing list of bib records in the collection

    Raises:
        AlmaValidationError: If collection_id is empty or None
        AlmaAPIError: If the API request fails (e.g., collection not found)
    """
    if not collection_id:
        raise AlmaValidationError("Collection ID is required")

    params = {
        "limit": str(limit),
        "offset": str(offset)
    }

    endpoint = f"almaws/v1/bibs/collections/{collection_id}/bibs"
    response = self.client.get(endpoint, params=params)

    self.logger.info(f"Retrieved collection members for collection {collection_id}")
    return response

add_to_collection

add_to_collection(
    collection_id: str, mms_id: str
) -> AlmaResponse

Add a bibliographic record to a collection.

Parameters:

Name Type Description Default
collection_id str

The collection ID

required
mms_id str

The MMS ID of the bibliographic record to add

required

Returns:

Type Description
AlmaResponse

AlmaResponse containing the added bib record

Raises:

Type Description
AlmaValidationError

If collection_id or mms_id is empty or None

AlmaAPIError

If the API request fails (e.g., collection/bib not found)

Source code in src/almaapitk/domains/bibs.py
def add_to_collection(self, collection_id: str, mms_id: str) -> AlmaResponse:
    """
    Add a bibliographic record to a collection.

    Args:
        collection_id: The collection ID
        mms_id: The MMS ID of the bibliographic record to add

    Returns:
        AlmaResponse containing the added bib record

    Raises:
        AlmaValidationError: If collection_id or mms_id is empty or None
        AlmaAPIError: If the API request fails (e.g., collection/bib not found)
    """
    if not collection_id:
        raise AlmaValidationError("Collection ID is required")
    if not mms_id:
        raise AlmaValidationError("MMS ID is required")

    # The API expects a bib object with mms_id
    bib_data = {
        "mms_id": mms_id
    }

    endpoint = f"almaws/v1/bibs/collections/{collection_id}/bibs"
    response = self.client.post(endpoint, data=bib_data)

    self.logger.info(f"Added bib {mms_id} to collection {collection_id}")
    return response

remove_from_collection

remove_from_collection(
    collection_id: str, mms_id: str
) -> AlmaResponse

Remove a bibliographic record from a collection.

Parameters:

Name Type Description Default
collection_id str

The collection ID

required
mms_id str

The MMS ID of the bibliographic record to remove

required

Returns:

Type Description
AlmaResponse

AlmaResponse confirming removal

Raises:

Type Description
AlmaValidationError

If collection_id or mms_id is empty or None

AlmaAPIError

If the API request fails (e.g., collection/bib not found)

Source code in src/almaapitk/domains/bibs.py
def remove_from_collection(self, collection_id: str, mms_id: str) -> AlmaResponse:
    """
    Remove a bibliographic record from a collection.

    Args:
        collection_id: The collection ID
        mms_id: The MMS ID of the bibliographic record to remove

    Returns:
        AlmaResponse confirming removal

    Raises:
        AlmaValidationError: If collection_id or mms_id is empty or None
        AlmaAPIError: If the API request fails (e.g., collection/bib not found)
    """
    if not collection_id:
        raise AlmaValidationError("Collection ID is required")
    if not mms_id:
        raise AlmaValidationError("MMS ID is required")

    endpoint = f"almaws/v1/bibs/collections/{collection_id}/bibs/{mms_id}"
    response = self.client.delete(endpoint)

    self.logger.info(f"Removed bib {mms_id} from collection {collection_id}")
    return response

get_marc_subfield

get_marc_subfield(
    mms_id: str,
    field: str,
    subfield: str,
    strict: bool = False,
) -> List[str]

Get specific MARC subfield values from a bibliographic record.

Parameters:

Name Type Description Default
mms_id str

The MMS ID of the record

required
field str

MARC data-field tag (e.g., "907"). Data fields (010-999) only — control fields (00X) have no subfields.

required
subfield str

Subfield code (e.g., "e")

required
strict bool

When True, a failed record fetch or unparseable MARC raises instead of returning []. This lets a caller tell "the field/subfield is genuinely absent" (always []) from "the fetch/parse failed" (raises). Default False keeps the batch-friendly behaviour of swallowing errors and returning [] so a bulk job continues (issue #190).

False

Returns:

Type Description
List[str]

List of subfield values; an empty list means the field/subfield

List[str]

is genuinely absent from the record.

Raises:

Type Description
AlmaValidationError

for a missing mms_id, a tag that is not a 3-digit number, a control-field (00X) tag, or a subfield that is not a single character.

AlmaAPIError / Exception

on fetch/parse failure only when strict is True.

Source code in src/almaapitk/domains/bibs.py
def get_marc_subfield(self, mms_id: str, field: str, subfield: str,
                      strict: bool = False) -> List[str]:
    """
    Get specific MARC subfield values from a bibliographic record.

    Args:
        mms_id: The MMS ID of the record
        field: MARC data-field tag (e.g., "907"). Data fields (010-999)
            only — control fields (00X) have no subfields.
        subfield: Subfield code (e.g., "e")
        strict: When ``True``, a failed record fetch or unparseable MARC
            **raises** instead of returning ``[]``. This lets a caller tell
            "the field/subfield is genuinely absent" (always ``[]``) from
            "the fetch/parse failed" (raises). Default ``False`` keeps the
            batch-friendly behaviour of swallowing errors and returning
            ``[]`` so a bulk job continues (issue #190).

    Returns:
        List of subfield values; an **empty list** means the field/subfield
        is genuinely absent from the record.

    Raises:
        AlmaValidationError: for a missing ``mms_id``, a tag that is not a
            3-digit number, a control-field (00X) tag, or a ``subfield``
            that is not a single character.
        AlmaAPIError / Exception: on fetch/parse failure **only when**
            ``strict`` is ``True``.
    """
    if not mms_id:
        raise AlmaValidationError("MMS ID is required")

    if not field or not field.isdigit() or len(field) != 3:
        raise AlmaValidationError("Field must be a 3-digit number")

    # #190: control fields (00X) are <controlfield> elements with no
    # subfields, so a subfield lookup could only ever return []. Reject the
    # nonsensical request with a clear message instead of silently yielding
    # an empty list that reads like "absent".
    if field.startswith("00"):
        raise AlmaValidationError(
            f"get_marc_subfield reads data fields (010-999); {field} is a "
            "control field (00X) with no subfields — read it via get_record"
        )

    if not subfield or len(subfield) != 1:
        raise AlmaValidationError("Subfield must be a single character")

    try:
        # Structured kwargs (never f-string interpolation) so the redactor
        # sees each value (issue #154 pattern).
        self.logger.info(
            "Getting MARC subfield",
            mms_id=mms_id, field=field, subfield=subfield,
        )

        response = self.get_record(mms_id)
        if not response.success:
            raise AlmaAPIError(f"Failed to retrieve record {mms_id}")

        bib_data = response.json()
        marc_xml = bib_data.get('anies', [''])[0]

        if not marc_xml:
            self.logger.warning("No MARC XML found in record", mms_id=mms_id)
            return []

        # Parse MARC XML and extract subfield values
        values = self._extract_marc_subfield_values(marc_xml, field, subfield)

        self.logger.info(
            "Found MARC subfield values",
            mms_id=mms_id, field=field, subfield=subfield, count=len(values),
        )
        return values

    except Exception as e:
        # #190: surface the failure in strict mode so callers can tell it
        # apart from a genuine "absent"; otherwise swallow and return [] so
        # batch processing continues. Structured kwargs keep the redactor
        # in the loop.
        self.logger.error(
            "Error getting MARC subfield",
            mms_id=mms_id, field=field, subfield=subfield, error=str(e),
        )
        if strict:
            raise
        return []