opensheaf-py the tools of OpenSheaf, a literate program chunks · source

Collections across archives

An OpenSheaf archive lists its own documents in its main collection, which the chapters on the core build automatically from the documents deposited (osheaf collect). This chapter is about collections that a curator composes by hand: a reading list, the papers of a group, the issue of a journal. Their members may live in this archive or in any other, and each is fixed by a digest, so that a collection states exactly what it contains even when the members belong to archives the curator does not control. The module is src/opensheaf/collections.py.

Two kinds of copies complete the picture, each in a chapter of its own: mirrors, whole copies of another archive's object, and accepted copies, a copy of the one version of a document that a journal or a series has vetted. The website shows the members of the main collection, whatever archive they come from.

What a collection is

A collection is an OCFL object like a document, with three files in each version: datacite.json (its description: title, curator, licence), object.json (its identity, with kind collection), and members.json, the list of members (section 10.2 of the specification). A member names an object by the pair of its archive identifier and its object identifier, and gives the address of the archive when the collection was made.

Collections are of two kinds (section 10.1), and members.json says which in its type. An archive collection keeps objects whole and is impartial about their versions: each member pins the head of its object by the SHA-512 digest of that version's inventory, and may follow the object as it grows. A selection vets particular revisions (a journal its accepted version, a reading list the version it recommends): each member pins a revision set, the document versions the curator vetted, each by the object version that held it and the digest of its inventory, and nothing else of the document is part of the selection:

{
  "format": "opensheaf-collection/0.1",
  "type": "archive",
  "order": "manual",
  "members": [
    {"kind": "document", "archive": "tag:opensheaf.org,2026:mgubi",
     "archiveUrl": "https://mgubi.org/papers/", "id": "fractional-phi43",
     "version": "v4", "inventorySha512": "8f2c…", "follow": "latest",
     "cite": {"title": "…", "creators": ["Gubinelli, Massimiliano"], "year": "2024"}}
  ]
}
{
  "format": "opensheaf-collection/0.1",
  "type": "selection",
  "order": "manual",
  "members": [
    {"kind": "document", "archive": "tag:opensheaf.org,2026:mgubi",
     "archiveUrl": "https://mgubi.org/papers/", "id": "fractional-phi43",
     "revisions": [{"documentVersion": 2, "version": "v2", "inventorySha512": "4f1e…"},
                   {"documentVersion": 3, "version": "v4", "inventorySha512": "c07a…"}],
     "cite": {"title": "…", "creators": ["Gubinelli, Massimiliano"], "year": "2024"}}
  ]
}

The pin is what makes the collection trustworthy across archives. The inventory of an object version lists the digest of every file of that version and of all the versions before it; its digest therefore fixes the whole object up to that version. A collection version, which is itself an object version with an inventory, fixes all its members in turn: a collection is a hash tree, and whoever reads it can check every byte without trusting the archives it was fetched from (section 6.4: "integrity comes from the pin, not from the place").

follow: latest, in an archive collection, is a setting for the curator's tools, not a floating reference: the member is pinned all the same, and osheaf collection update writes a new collection version when the followed object has a new head, whatever the head holds. A selection has no such setting: tools never change its revision sets on their own (section 10.2), and update only reports the members that have versions the selection has not vetted. cite is a cached citation, so that the collection can be shown and ordered without fetching anything, and stays readable if a member's archive disappears; it is never authoritative.

The module

The module starts with its documentation, which is the user's documentation of the input file collection.yaml (next section), and then defines, in this order, the reading of archives, the resolution of a member, the reading of collection.yaml, the writing of a collection version, the two commands and the checks of members and accepted copies against other archives.

⟨the documentation of collections.py⟩

import hashlib
import json
import sys
import tempfile
import shutil
import urllib.parse
import urllib.request
from pathlib import Path

from . import core as P

UA = {"User-Agent": "opensheaf-py (https://github.com/opensheaf/opensheaf-py)"}
KINDS = ("archive", "selection")


⟨reading another archive⟩


⟨resolving a member of a collection⟩


⟨reading collection.yaml⟩


⟨writing a collection version⟩


⟨the command collection sync⟩


⟨the command collection update⟩


⟨checking the members of other archives⟩


⟨checking accepted copies against their sources⟩

UA is the User-Agent header of every request: archives are static websites, and a server that sees which program reads it can tell its owner, or block it, without guessing. KINDS are the two values of type (section 10.2).

The input: collection.yaml

A curator writes the collection as a short YAML file and runs osheaf collection sync --from collection.yaml. Each member is given by the address of its archive and its object identifier; the tool does the rest: it finds the head or the revisions, computes the pins, and caches the citation. A member of a selection lists its revisions by their document versions, the numbers readers see (revisions: [2, 3]); the tool finds the object version holding each. A document version may be held by several object versions (a description added later holds the same files), and the one taken is the entry of the history, the last object version holding it, which is what the document's archive presents; a curator who vetted another one names it by its object version instead (v2).

"""Curated collections (section 10 of the specification): members from this
archive or from other archives, each pinned by the SHA-512 digest of the
inventory of an object version.

    osheaf collection sync [ID] --from collection.yaml
    osheaf collection update [ID]

collection.yaml (read with every value as text):

    id: collection-main            # optional: the collection object (default: the main collection)
    type: archive                  # archive (the default) or selection (section 10.1)
    title: Stochastic quantisation
    description: Papers on the stochastic quantisation of field theories.
    order: manual                  # manual (default: the order below) or issued-desc / issued-asc
    members:
      - archive: https://mgubi.org/papers/        # an archive's base URL (or "." for this archive)
        id: fractional-phi43                      # the object
        # version: v3                             # a fixed object version (default: the head)
        # follow: no                              # default: latest (`update` follows the head)
      - {archive: https://mgubi.org/papers/, id: paracontrolled-distributions}

In a selection each member gives the revisions vetted instead:

    type: selection
    members:
      - archive: https://authors.example.org/
        id: network-discrimination
        revisions: [2, 3]          # document versions (or object versions: [v2, v4])
      - {archive: ., id: accepted-paper, revisions: [1]}

An archive collection pins the head of each object (section 10.2): `sync`
reads the member archive's discovery document and the object's inventory,
checks the pin against the checksum file the archive publishes, and caches a
citation of the current version. `update` moves members with follow: latest to
the new head of their object, whatever it holds (a withdrawal too), and brings
up to date the mirrors of these members held here. A selection pins each
revision by the object version holding that document version (the entry of
its history); a member without `revisions` keeps the revisions of the
previous version of the selection, or vets the current version. Revisions
already pinned keep their pins. `update` never changes a selection: it
reports the members with document versions not vetted. A new collection
version is written when the list or a pin changes. Archive addresses may be
https:// or file:// URLs, or local folders.
"""

Two keys of an entry are not in this summary: mirror: yes asks for a copy of the member in this archive (mirrors), and licence-checked: yes goes with it when the member's licence is not an open one.

The file is read with YAML's BaseLoader, which takes every value as a string. The ordinary loader would turn follow: no into False, a version 1.0 into a number and a date into a datetime.date; with every value a string, the code decides itself what no, yes or fixed mean, and a value is never silently changed. A file ending in .json is read as JSON instead. The module yaml is imported only here, so that the rest of the package works without it.

A file without type describes an archive collection, as every collection did before the specification distinguished the two kinds; the tool says so, so that the curator can write the type and decide. Any other value than the two kinds is an error.

def load_spec(path):
    import yaml
    text = Path(path).read_text(encoding="utf-8")
    try:
        spec = json.loads(text) if str(path).endswith(".json") else yaml.load(text, Loader=yaml.BaseLoader)
    except (yaml.YAMLError, ValueError) as e:
        sys.exit(f"{path}: not valid: {e}")
    if not isinstance(spec, dict) or not isinstance(spec.get("members", []), list):
        sys.exit(f"{path}: expected a mapping with members: [...]")
    if "type" not in spec:
        print(f"{path}: no type: an archive collection (write `type: archive`, or `type: selection` "
              "for a collection vetting revisions, section 10.1)")
        spec["type"] = "archive"
    if spec["type"] not in KINDS:
        sys.exit(f"{path}: type must be archive or selection, not {spec['type']}")
    ⟨check the keys of the members of each kind⟩
    return spec

The keys of the two kinds of members exclude each other (section 10.2): a member of a selection has revisions and neither a version nor follow, since following is not vetting; a member of an archive collection has no revisions. A mistake here is better reported before anything is fetched.

for e in spec.get("members", []):
    if not isinstance(e, dict) or not e.get("id"):
        sys.exit(f"{path}: every member needs an id")
    if spec["type"] == "selection" and (e.get("version") or e.get("follow")):
        sys.exit(f"{path}: {e['id']}: a member of a selection gives its revisions, not a version or follow")
    if spec["type"] == "archive" and e.get("revisions"):
        sys.exit(f"{path}: {e['id']}: revisions belong to selections (type: selection)")

Reading archives

An archive is read through its base URL, as section 6.4 prescribes: the discovery document opensheaf.json at that address names the storage root, and an object version is then found at <storageRoot><id>/<version>/inventory.json, with its files at the content paths the inventory gives. Everything is a plain GET of a static file, so the same code reads an archive served over HTTPS and one in a folder of the local disk, through a file:// URL. The tests use the latter, and a curator may use it for archives kept side by side.

An address is normalised to a URL ending with a slash, so that relative paths can be appended; a local folder becomes a file:// URL.

def norm_url(url):
    if url.startswith(("http://", "https://", "file://")):
        return url if url.endswith("/") else url + "/"
    return Path(url).resolve().as_uri() + "/"

urllib reads all three kinds of URLs. The timeout is generous: a PDF of a long paper over a slow connection takes time.

def get(url):
    with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=120) as r:
        return r.read()

Most files read are JSON.

def get_json(url):
    return json.loads(get(url))

An archive is read through four operations: the inventory of an object (at its root, the head, or in a version's folder), a file of a version through its logical path, as in the chapters on the core, the pin of an object version, and its archive identifier. Two classes give them: a Remote, read over the network or from a file:// URL, and Here, this archive, read from the disk. On top of them, a common base class reads the history and the document versions of an object the same way for both, so that a member is resolved by the same code wherever it lives.

A Remote is an archive opened at an address: its discovery document is read once, and gives the storage root (by default ocfl/; a relative address is relative to the base URL, an absolute one is taken as it is) and the archive identifier, which is what names the archive in collections.

class Remote(Archive):
    """An OpenSheaf archive read over HTTP(S) or from a file:// URL."""

    def __init__(self, url):
        self.url = norm_url(url)
        self.disc = get_json(self.url + "opensheaf.json")
        self.root = urllib.parse.urljoin(self.url, self.disc.get("storageRoot", "ocfl/"))
        self.archive_id = self.disc["archiveId"]

    def inventory(self, obj_id, ver=None):
        return get_json(self.root + obj_id + "/" + (f"{ver}/" if ver else "") + "inventory.json")

    def file(self, obj_id, inv, ver, logical):
        cp = P.content_path(inv, ver, logical)
        if cp is None:
            raise KeyError(f"{obj_id} {ver}: no {logical}")
        return get(self.root + obj_id + "/" + cp)

    ⟨the pin of a remote object version⟩

A pin is the SHA-512 of the bytes of an inventory. OCFL keeps next to each inventory a sidecar, inventory.json.sha512, with its digest. Before pinning, the two are compared: a collection that pinned a damaged or half-uploaded inventory would fix the damage for ever, and a mismatch is cheap to report now and expensive to discover later.

def pin(self, obj_id, ver):
    data = get(self.root + f"{obj_id}/{ver}/inventory.json")
    dig = hashlib.sha512(data).hexdigest()
    side = get(self.root + f"{obj_id}/{ver}/inventory.json.sha512").decode().split()[0]
    if side != dig:
        raise ValueError(f"{obj_id} {ver}: the inventory does not match its checksum file")
    return dig

This archive is read from its storage root on the disk, with the same operations. Its pins need no comparison with the sidecar, which the verification of the archive checks anyway. A missing object or version raises an exception, as a missing file of a remote archive does.

class Here(Archive):
    """This archive, read from the disk with the operations of a Remote."""

    def __init__(self):
        self.url = P.ARCHIVE["baseUrl"]
        self.disc = {"name": P.ARCHIVE["name"], "archiveId": P.ARCHIVE["archiveId"]}
        self.archive_id = P.ARCHIVE["archiveId"]

    def inventory(self, obj_id, ver=None):
        p = P.OCFL / obj_id / (ver or "") / "inventory.json"
        if not p.exists():
            raise FileNotFoundError(f"this archive has no {obj_id} {ver or ''}".strip())
        return json.loads(p.read_text(encoding="utf-8"))

    def file(self, obj_id, inv, ver, logical):
        cp = P.content_path(inv, ver, logical)
        if cp is None:
            raise KeyError(f"{obj_id} {ver}: no {logical}")
        return (P.OCFL / obj_id / cp).read_bytes()

    def pin(self, obj_id, ver):
        return P.digest(P.OCFL / obj_id / ver / "inventory.json")

The document version of an object version is in its object.json. The history is that of section 7.3, as in the chapter on document versions: the history of object.json, read from the head or from an earlier object version at, and otherwise the last object version holding each document version, among those up to at, which needs the object.json of every one of them. The current version is the last entry. A selection uses the history to find the object version holding each vetted document version, and to keep its revisions in the order of the document's history (section 10.2); an archive collection, to cache the citation of the current version.

def doc_version(self, obj_id, inv, ver):
    return json.loads(self.file(obj_id, inv, ver, P.OBJ))["documentVersion"]

def history(self, obj_id, at=None):
    """(the history as of object version AT (default: the head), the root inventory)."""
    inv = self.inventory(obj_id)
    at = at or inv["head"]
    obj = json.loads(self.file(obj_id, inv, at, P.OBJ))
    if obj.get("history"):
        return obj["history"], inv
    last = {}
    for ver in inv["versions"]:
        if int(ver[1:]) <= int(at[1:]):
            last[self.doc_version(obj_id, inv, ver)] = ver
    return [last[n] for n in sorted(last)], inv

def current(self, obj_id, at=None):
    """The object version holding the current document version as of AT."""
    return self.history(obj_id, at)[0][-1]

Resolving a member

Resolving turns an entry of collection.yaml into a member of members.json, in a collection of the given kind. It reads the object, here or in its archive, pins it (an archive collection) or its revisions (a selection), and builds the member record from the object.json and datacite.json of the version presented: the current version of an archive collection's member, the last revision of a selection's. old is the member as the previous version of the collection has it, if any, so that revisions already vetted keep their pins; it is ignored if it names another archive's object of the same identifier.

def resolve(entry, kind="archive", old=None):
    """The member for an entry of collection.yaml in a collection of KIND
    (mirror: yes copies the object into this archive first, and lists the
    copy as a mirror); OLD is the member in the previous collection version."""
    url = entry.get("archive", ".")
    obj_id = entry["id"]
    ⟨mirror a member first, if its entry asks⟩
    local = url in (".", "", P.ARCHIVE["baseUrl"]) or norm_url(url) == norm_url(str(P.ROOT))
    src = Here() if local else Remote(url)
    try:
        inv = src.inventory(obj_id)
    except Exception as e:  # noqa: BLE001 - reported
        sys.exit(f"{obj_id}: not found in {src.archive_id}: {e}")
    if old and old.get("archive") != src.archive_id:
        old = None
    if kind == "selection":
        ⟨the revisions of a member of a selection⟩
    else:
        ⟨the pinned version of a member of an archive collection⟩
    dc = json.loads(src.file(obj_id, inv, shown, "datacite.json"))
    obj = json.loads(src.file(obj_id, inv, shown, P.OBJ))
    ⟨the member record of a resolved entry⟩

With mirror: yes the object is first copied into this archive, or brought up to date if it was copied before; the copy is then listed in the member's mirrors. The values are strings (BaseLoader), hence the comparison with the usual spellings of yes.

mirrored = str(entry.get("mirror", "")).lower() in ("yes", "true", "1")
if mirrored:
    from . import mirror
    mirror.mirror(url, obj_id, str(entry.get("licence-checked", "")).lower() in ("yes", "true", "1"))

A member is of this archive when its address is . (or empty), the base URL of this archive, or a path or URL leading to its root folder. Such a member is read from the disk; any other through a Remote, whose archive identifier is the one its discovery document states, whatever address was written in the entry.

A member of an archive collection

A member of an archive collection pins the head of its object (section 10.2), and presents its current version, read from the history of the head. An entry may fix another version: the member then does not follow (below), and keeps that pin. The specification calls the pinned version the head of the object when the collection version was made, which describes the pin of a member that follows; a fixed earlier version pins the object as it was then, every version up to it. The curator is told, since vetting one version of a document is what a selection does.

ver = entry.get("version") or inv["head"]
if ver not in inv["versions"]:
    sys.exit(f"{obj_id} has no object version {ver} (versions: {', '.join(inv['versions'])})")
if ver != inv["head"]:
    print(f"  {obj_id}: pinned at {ver}, not at its head {inv['head']}: the object as it was at "
          f"{ver} (to vet one version, make a selection, section 10.1)")
pin = src.pin(obj_id, ver)
shown = src.current(obj_id, ver)

The revisions of a member of a selection

A member of a selection lists the revisions vetted, as document versions or as object versions. A document version is resolved to the entry of the document's history, the last object version holding it; an object version is taken as given, with the document version it holds. A revision of the previous version of the selection for the same document version is kept as it is, pin included, unless the entry names another object version: running sync again with the same file changes nothing, even if the document's archive has described the version again since, which the selection has not vetted (section 10.2).

Without revisions, the member keeps the revisions of the previous version of the selection or, if it is new, vets the current version of the document, and the curator is told which. Adding or removing a revision is a change of the file, an act of the curator, as the specification requires; the tool never decides it.

hist, _ = src.history(obj_id)
docs = {h: src.doc_version(obj_id, inv, h) for h in hist}
before = {r["documentVersion"]: r for r in (old or {}).get("revisions", [])}
given = entry.get("revisions")
if isinstance(given, str):
    given = [given]
if not given and before:
    given = [str(n) for n in before]
elif not given:
    given = [str(docs[hist[-1]])]
    print(f"  {obj_id}: no revisions given: vetting its current version {given[0]}")
revisions = []
for g in given:
    ⟨one revision of a member of a selection⟩
⟨the revisions in the order of the history⟩
shown = revisions[-1]["version"]

A revision given as an object version (v4) must exist, and holds the document version its object.json states; one given as a number must be a document version of the document, and is the revision pinned before or else the entry of the history.

g = str(g).strip()
if g.startswith("v"):
    if g not in inv["versions"]:
        sys.exit(f"{obj_id} has no object version {g}")
    ver, n = g, src.doc_version(obj_id, inv, g)
else:
    n = int(g)
    ver = before[n]["version"] if n in before else next((h for h in hist if docs[h] == n), None)
    if ver is None:
        sys.exit(f"{obj_id} has no document version {n}")
if n in before and before[n]["version"] == ver:
    revisions.append(before[n])
else:
    revisions.append({"documentVersion": n, "version": ver, "inventorySha512": src.pin(obj_id, ver)})

The revisions are listed in the order of the document's history (section 10.2), whatever the order of the file, and the last is the one presented. Two revisions of the same document version would make the set ambiguous, and section 14 asks for distinct document versions.

order = [docs[h] for h in hist]
revisions.sort(key=lambda r: order.index(r["documentVersion"]) if r["documentVersion"] in order else -1)
if len({r["documentVersion"] for r in revisions}) != len(revisions):
    sys.exit(f"{obj_id}: a document version is vetted twice")

The member record

The member record has the fields of section 10.2. The address recorded is the one written, if it is an HTTP address, so that a collection published on the web records the address its readers know; for a local folder, the file:// URL is all there is, and for this archive its base URL. The cached citation, from the version presented, holds the title, the creators as display names, the year and, when known, the Issued date, by which collections in date order are sorted. A member of an archive collection has its pin, a member of a selection its revisions.

archive_url = url if not local and url.startswith(("http://", "https://")) else src.url
cite = {"title": dc["titles"][0]["title"],
        "creators": [P.display_name(c) for c in dc["creators"]],
        "year": dc.get("publicationYear")}
if P.date_of(dc, "Issued"):
    cite["issued"] = P.date_of(dc, "Issued")
m = {"kind": obj["kind"], "archive": src.archive_id, "archiveUrl": norm_url(archive_url), "id": obj_id}
if kind == "selection":
    m["revisions"] = revisions
else:
    m["version"], m["inventorySha512"] = ver, pin
if dc.get("doi"):
    m["doi"] = dc["doi"]
⟨whether a resolved member follows its object⟩
⟨whether this archive mirrors a resolved member⟩
m["cite"] = cite
return m

The citation is added last, so that it stands at the end of the member in members.json, after the fields that matter.

A member of an archive collection follows its object unless the entry says otherwise (follow: no, false, none or fixed) or fixes a version: a fixed version that followed its object would not stay fixed. A member of a selection never follows (section 10.2).

if kind == "archive" and str(entry.get("follow", "latest")).lower() not in ("no", "false", "none", "fixed") \
        and not entry.get("version"):
    m["follow"] = "latest"

The member's mirrors lists this archive when it holds a copy of every version the member pins: a mirror made just now or earlier, recognised as an object of the same identifier whose head names the member's archive. A mirror that has not been brought up to date to the pinned version is not listed, since a reader resolving the pin there would not find it.

d = P.OCFL / obj_id
have = P.load_inventory(d) if not local else None
if have and P.mirror_of(d, have) == src.archive_id \
        and all(v in have["versions"] for v, _, _ in P.member_pins(m)):
    m["mirrors"] = [P.ARCHIVE["baseUrl"]]

Writing a collection version

A collection version is written only if something changed: the kind, the member list (members, pins, order) or the title. A change of the description alone does not make a new version. The document version of a collection counts the changes of its member list (section 7.4), and its Issued date is that of its first version.

def write(coll_id, members, title, description, order, message, kind="archive"):
    """A new version of collection COLL_ID, if its member list changed."""
    coll_dir = P.OCFL / coll_id
    inv = P.load_inventory(coll_dir)
    version, issued = 1, P.TODAY
    doc = {"format": "opensheaf-collection/0.1", "type": kind, "order": order, "members": members}
    ⟨stop if the collection is unchanged, or number its next version⟩
    cur = P.ARCHIVE["curator"]
    tmp = Path(tempfile.mkdtemp())
    P.write_json(tmp / "members.json", doc)
    ⟨the description of a collection version⟩
    ⟨the object record of a collection version⟩
    inv = P.add_version(coll_id, {n: tmp / n for n in ("members.json", "datacite.json", P.OBJ)},
                        message or f"Collection version {version}: {len(members)} members")
    shutil.rmtree(tmp, ignore_errors=True)
    print(f"{coll_id}: {inv['head']} ({len(members)} members)")
    return inv

Comparing the whole of members.json also compares the cached citations: a member whose title was corrected in its archive gives a new collection version at the next sync, which is what one wants, since the collection shows that title. A collection written before members.json had a type gets a new version, which states it.

if inv:
    old = P.read_json(coll_dir, inv, "members.json")
    old_dc = P.read_json(coll_dir, inv, "datacite.json")
    if old == doc and old_dc["titles"][0]["title"] == title:
        print(f"{coll_id}: unchanged ({inv['head']})")
        return inv
    version = P.read_json(coll_dir, inv, P.OBJ)["documentVersion"] + 1
    issued = P.date_of(old_dc, "Issued") or P.TODAY

The curator of the archive (from osheaf.json) is the creator of the collection, and the archive its publisher. The member list is placed under CC0 (section 10.6): a list of references is metadata, and its licence never extends to the members, which keep their own.

P.write_json(tmp / "datacite.json", {
    "titles": [{"title": title}],
    "creators": [{"name": cur["name"], "nameType": "Personal", "givenName": cur.get("givenName", ""),
                  "familyName": cur.get("familyName", cur["name"]),
                  "affiliation": [{"name": cur["affiliation"]}] if cur.get("affiliation") else []}],
    "publisher": {"name": P.ARCHIVE["name"]},
    "publicationYear": int(issued[:4]),
    "types": {"resourceTypeGeneral": "Collection"},
    "version": str(version),
    "url": P.ARCHIVE["baseUrl"],
    "dates": [{"date": issued, "dateType": "Issued"}, {"date": P.TODAY, "dateType": "Available"}],
    "descriptions": [{"description": description, "descriptionType": "Abstract"}],
    "rightsList": [dict(P.RIGHTS["cc0-1.0"])],
})

The object.json of a collection has only the fields common to all objects: none of those of documents (sources, renditions, history, notices) applies.

P.write_json(tmp / P.OBJ, {
    "format": P.OBJ_FORMAT, "specification": P.SPEC, "kind": "collection",
    "archiveId": P.ARCHIVE["archiveId"], "objectId": coll_id, "documentVersion": version,
    "status": "active"})

The chapters on the core also use write: osheaf init --curated creates an empty main collection with order manual, an archive collection until the curator's first sync says otherwise, which osheaf collect then leaves alone, so that the curator's list is never replaced by the archive's own documents.

The commands

collection sync

sync resolves every entry of the file, in order, printing the pins as it goes, and writes the collection (by default the main collection of the archive). The members of the previous version of the collection that have revisions are given to resolve by identifier, so that their revisions keep their pins. The order manual keeps the order of the file; the orders by date use the sorting of the main collection, from the chapters on the core (section 10.2: by Issued, then Available, ties broken by the object identifier). That function reads its direction from the module variable ORDER of the core, which is therefore set first. Curated members have no Available date in their citation, so they are sorted by Issued alone, those without a date last.

def sync(args):
    P.init_storage_root()
    spec = load_spec(args.from_file)
    coll_id = args.collection or spec.get("id") or P.ARCHIVE["mainCollection"]
    kind, order = spec["type"], spec.get("order", "manual")
    before = {}
    cinv = P.load_inventory(P.OCFL / coll_id)
    if cinv:
        before = {m["id"]: m for m in P.read_json(P.OCFL / coll_id, cinv, "members.json")["members"]
                  if "revisions" in m}
    members = []
    for e in spec.get("members", []):
        m = resolve(e, kind, before.get(e["id"]))
        pins = ", ".join(f"{v} {d[:16]}..." for v, d, _ in P.member_pins(m))
        print(f"  {m['id']} ({m['archive']}): {pins}")
        members.append(m)
    if order != "manual":
        P.ORDER = order
        members = P.member_order(members)
    write(coll_id, members, spec.get("title", P.ARCHIVE["name"]),
          spec.get("description", "A collection of documents."), order, args.message, kind)

collection update

update needs no input file: it reads the last version of the collection and acts according to its kind. A collection written before the specification had kinds has no type: it is taken as an archive collection, and its next version says so.

def update(args):
    """Members with follow: latest move to the head of their object; a selection
    is never changed, its members' versions not vetted are reported."""
    coll_id = args.collection or P.ARCHIVE["mainCollection"]
    d = P.OCFL / coll_id
    inv = P.load_inventory(d)
    if inv is None:
        sys.exit(f"no collection {coll_id}")
    doc = P.read_json(d, inv, "members.json")
    dc = P.read_json(d, inv, "datacite.json")
    kind = doc.get("type")
    if kind is None:
        print(f"{coll_id}: no type: an archive collection (section 10.1)")
        kind = "archive"
    if kind == "selection":
        report_unvetted(coll_id, doc["members"])
        return
    members = []
    for m in doc["members"]:
        if m.get("follow") == "latest":
            ⟨move a followed member to the head of its object⟩
        members.append(m)
    desc = next((x["description"] for x in dc.get("descriptions", [])), "")
    write(coll_id, members, dc["titles"][0]["title"], desc, doc.get("order", "manual"), args.message)

A followed member is resolved again from its archive's address as recorded in the collection (or . for a member of this archive), which pins the head of its object, whatever the head holds (section 10.2): a new document version, a description, a notice, or a withdrawal, which the curator is told about and presentations show. A mirror of the member held here is brought up to date first, so that the copy holds the new head and stays listed in the member's mirrors. A member whose archive cannot be read keeps its pin, and the curator is told.

local = m["archive"] == P.ARCHIVE["archiveId"]
if not local:
    refresh_mirror(m)
try:
    new = resolve({"archive": "." if local else m["archiveUrl"], "id": m["id"]})
except Exception as e:  # noqa: BLE001 - reported, the pin is kept
    print(f"  {m['id']}: its archive cannot be read ({str(e)[:120]}); the pin {m['version']} is kept")
    members.append(m)
    continue
if new["version"] != m["version"]:
    print(f"  {m['id']}: {m['version']} -> {new['version']}")
    if head_status(new, local) == "withdrawn":
        print(f"  {m['id']}: withdrawn in its archive; followed all the same (section 10.2)")
m = new

A mirror is recognised as in the rest of the tools: an object of this storage root, of the member's identifier, whose head names the member's archive. It is brought up to date by the mirror module, which copies the new versions only, checking their licences and files. A refusal (a new version without an open licence) or a failure leaves the mirror as it was, a valid copy of the earlier versions, and is reported; the member is then no longer listed with this archive among its mirrors, since the copy does not hold the new pin.

def refresh_mirror(m):
    """Bring a mirror of member M held here up to date (section 10.4)."""
    d = P.OCFL / m["id"]
    inv = P.load_inventory(d)
    if not inv or P.mirror_of(d, inv) != m["archive"]:
        return
    from . import mirror
    try:
        mirror.mirror(m["archiveUrl"], m["id"])
    except (SystemExit, Exception) as e:  # noqa: BLE001 - reported, the mirror is kept
        print(f"  {m['id']}: the mirror here was not brought up to date: {str(e)[:200]}")

The status of an object belongs to the object (section 11.1) and is read from its head, here or in its archive.

def head_status(m, local):
    """The status of a member's object, read from its head."""
    src = Here() if local else Remote(m["archiveUrl"])
    inv = src.inventory(m["id"])
    return json.loads(src.file(m["id"], inv, inv["head"], P.OBJ)).get("status")

A selection is never changed by update: tools must not change revision sets on their own (section 10.2). What a curator needs to know is which members have document versions that the selection has not vetted, later in the document's history than its last revision, and which were withdrawn; the curator decides, and records the decision with sync.

def report_unvetted(coll_id, members):
    """Report the document versions of a selection's members that it has not vetted."""
    print(f"{coll_id}: a selection: its revisions change only by the curator's sync (section 10.2)")
    for m in members:
        local = m["archive"] == P.ARCHIVE["archiveId"]
        try:
            src = Here() if local else Remote(m["archiveUrl"])
            hist, inv = src.history(m["id"])
            docs = [src.doc_version(m["id"], inv, h) for h in hist]
            status = json.loads(src.file(m["id"], inv, inv["head"], P.OBJ)).get("status")
        except Exception as e:  # noqa: BLE001 - reported
            print(f"  {m['id']}: its archive cannot be read ({str(e)[:120]})")
            continue
        vetted = [r["documentVersion"] for r in m["revisions"]]
        last = max((docs.index(n) for n in vetted if n in docs), default=-1)
        newer = docs[last + 1:]
        if newer:
            print(f"  {m['id']}: document version(s) {', '.join(map(str, newer))} not vetted "
                  f"(the selection has {', '.join(map(str, vetted))})")
        if status == "withdrawn":
            print(f"  {m['id']}: withdrawn in its archive")

Checking the members of other archives

osheaf verify --remote (in the chapters on the core) checks the pins of the members of every collection. Members of this archive are checked with the rest of the archive; this function checks the others. Each pin is checked: the pin of a member of an archive collection, and the pin of every revision of a member of a selection, whose object version must also hold the document version the revision states (section 14).

def check_remote(members):
    """Errors for members of other archives whose pins cannot be confirmed."""
    errors = []
    for m in members:
        if m.get("archive") == P.ARCHIVE["archiveId"]:
            continue
        for ver, pin, n in P.member_pins(m):
            problem = confirm(m, ver, pin, n)
            if problem:
                errors.append(f"member {m['id']} {ver}: {problem}")
    return errors

A pin is resolved as section 6.4 says: first at the archive's recorded address, then at the mirrors listed in the member, then at those listed in the discovery document of the member's archive, when that document can be read (the archive's storage may have failed while its discovery document did not, or an object was lost). The first source whose inventory matches the pin settles the question; the pin is in error only if every source fails, and the error lists what went wrong at each. External copies (a DOI record, a software archive snapshot), which a client may also use, are not tried.

def confirm(m, ver, pin, n):
    """None if the pin VER/PIN of member M (holding document version N, if
    given) is confirmed at a source; else what went wrong at each."""
    problems = []
    sources = [m["archiveUrl"]] + [u for u in m.get("mirrors", []) if u != m["archiveUrl"]]
    for src in sources:      # the list grows with the archive's own mirrors
        try:
            ⟨confirm a pin at one source⟩
        except Exception as e:  # noqa: BLE001 - reported
            problems.append(f"{src}: unavailable: {str(e)[:120]}")
    return "; ".join(problems)

Four cases need care:

Any exception (a network failure, a missing file, an inventory whose sidecar disagrees) makes the source unavailable, not the member wrong: only bytes that are found and differ from the pin are a mismatch.

r = Here() if src == P.ARCHIVE["baseUrl"] else Remote(src)
if src == m["archiveUrl"]:
    if r.archive_id != m["archive"]:
        problems.append(f"{src} is now the archive {r.archive_id}")
        continue
    sources += [u for u in r.disc.get("mirrors", []) if u not in sources]
    if any(t.get("id") == m["id"] for t in r.disc.get("tombstones", [])):
        return "removed (a tombstone in its archive)"
if r.pin(m["id"], ver) != pin:
    problems.append(f"{src}: the pin does not match")
    continue
if n is not None and r.doc_version(m["id"], r.inventory(m["id"], ver), ver) != n:
    problems.append(f"{src}: {ver} does not hold document version {n}")
    continue
if src != m["archiveUrl"]:
    print(f"  {m['id']} {ver}: its archive failed; checked on the mirror {src}")
return None

Checking accepted copies

Section 14 also asks a verifier to check each accepted copy against its source, when the source is available: its first document version must hold exactly the files of the source version named in external. An accepted copy is a document of this archive whose first object version records a source with service OpenSheaf; the record is the address of the source object version, and the digest under files its pin. The source's inventory of that version is fetched and checked against the pin, and then the two states are compared: the digests of every logical path but the two descriptions. Since inventories record SHA-512 digests, no file needs to be fetched. A source that cannot be read is reported, and not counted as an error: the copy exists precisely so as not to depend on it.

def check_accepted_copies():
    """Errors for accepted copies whose first document version does not hold
    exactly the files of their source version (section 14)."""
    errors = []
    for obj_id, d, inv in P.objects():
        first = next(iter(inv["versions"]))
        obj = P.read_json(d, inv, P.OBJ, first)
        src = next((e for e in obj.get("external", []) if e.get("role") == "source"
                    and e.get("service") == "OpenSheaf"), None)
        if obj.get("kind") != "document" or P.mirror_of(d, inv) or not src:
            continue
        ⟨compare an accepted copy with its source⟩
    return errors

The state of a version maps digests to logical paths; turned around, it gives the digest of each path, which is what is compared.

pin = next((f.get("sha512") for f in src.get("files", []) if f.get("name") == "inventory.json"), None)
ver = src["record"].rstrip("/").rsplit("/", 1)[1]
try:
    data = get(src["record"].rstrip("/") + "/inventory.json")
except Exception as e:  # noqa: BLE001 - the source is unavailable: not checked
    print(f"  {obj_id}: the source of the accepted copy is unavailable ({str(e)[:120]}); not checked")
    continue
if hashlib.sha512(data).hexdigest() != pin:
    errors.append(f"{obj_id}: accepted copy: the source {src.get('label')} does not match its pin")
    continue
files = lambda state: {p: dg for dg, ps in state.items() for p in ps if p not in ("datacite.json", P.OBJ)}
if files(json.loads(data)["versions"][ver]["state"]) != files(inv["versions"][first]["state"]):
    errors.append(f"{obj_id}: accepted copy: its first version does not hold exactly the files of "
                  f"{src.get('label')}")