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

Mirrors

A collection refers to the members of other archives without copying them (collections). A reference needs nobody's permission, but it depends on the other archive staying online. A mirror removes that dependence: it is a copy, in this archive's storage root, of another archive's object, byte for byte and with all its versions (section 10.4 of the specification). This chapter describes src/opensheaf/mirror.py, which makes and updates mirrors; it uses the reading of other archives from the chapter on collections, and its licence check is used again by accepted copies.

What a mirror is

A mirror is the same object as the original, not a new one. It keeps the original identifier, and its object.json files keep naming the original archive: nothing in it is rewritten, so every inventory and every digest stays valid, and a pin made on the original is confirmed by the copy. This is how the rest of the tools recognise a mirror: an object of the storage root whose head names another archive (mirror_of, in the chapter on the main collection). The discovery document lists it with mirrorOf, the identifier of that archive (section 6.1), the main collection leaves it out, a collection lists the copy in a member's mirrors, and the verification of a collection falls back on the copy when the original archive fails.

Since OCFL objects only grow, bringing a mirror up to date means copying the versions it does not have yet, and nothing else; a mirror is never changed otherwise. Two rules limit what may be mirrored. The identifier must be free: an object of this archive with the same identifier rules the mirror out (identifiers are never reused, section 6.3). And copying is redistribution, which the licence of every version copied must allow.

The module

The documentation states the command and the rule on licences. The module reuses Remote and get from the collections module, so that mirrors read other archives exactly as collections do.

"""Mirrors (section 10.4 of the specification): a copy, in this archive's
storage root, of another archive's object, byte for byte and with all its
versions, kept under its own identifier.

    osheaf mirror ARCHIVE-URL ID [--licence-checked]

A mirror is allowed only if the licence of the copied content allows
redistribution: the tool checks the rightsList of every version it copies and
refuses unless each is an open licence (CC BY, CC BY-SA, CC0, GPL), or the
curator states with --licence-checked that redistribution is allowed (for
instance by the authors' permission). A reference (a member pinned in a
collection, without a copy) needs no permission. Every copied file is checked
against the digest of the source's inventory; a mirror that exists already
gets the new versions only, which is how it is brought up to date (running the
command again, or `osheaf collection update` for the members of a collection).
"""

import hashlib
import json
import shutil
import sys
import tempfile
from pathlib import Path

from . import core as P
from .collections import Remote, get

⟨the licences known to be open⟩

Open licences

A licence is recognised as open in either of the two ways datacite.json records it (section 8): by its SPDX identifier, written in lower case, or by the URL of the licence. The list is short on purpose: licences that certainly allow anyone to redistribute the work unchanged. arXiv's default licence, for instance, gives arXiv alone the right to distribute, and is not among them.

OPEN = {"cc-by-4.0", "cc-by-sa-4.0", "cc0-1.0", "gpl-3.0-only", "gpl-3.0-or-later", "cc-by-3.0"}
OPEN_URIS = ("creativecommons.org/licenses/by/", "creativecommons.org/licenses/by-sa/",
             "creativecommons.org/publicdomain/zero/", "gnu.org/licenses/gpl")

A URL matches by its stem, so that any version of a Creative Commons licence, and its national ports, is recognised. The stems of CC BY and CC BY-SA do not match the NonCommercial and NoDerivatives variants anyway (licenses/by-nc/ does not contain licenses/by/), and the test for -nc and -nd makes the exclusion explicit. One open entry in the rightsList suffices: a work offered under several licences may be redistributed under any of them.

def is_open(rights):
    for r in rights or []:
        if (r.get("rightsIdentifier") or "").lower() in OPEN:
            return True
        uri = (r.get("rightsUri") or "").lower()
        if any(u in uri for u in OPEN_URIS) and "-nc" not in uri and "-nd" not in uri:
            return True
    return False

When the licence is not open, redistribution may still be allowed, for instance by the authors' permission. The tool cannot know that, and asks the curator to say so with --licence-checked.

Mirroring an object

mirror both creates a mirror and brings it up to date. It reads the original archive, refuses to mirror an object of this very archive, and then applies the two rules above before copying anything.

def mirror(url, obj_id, licence_checked=False):
    """Copy (or bring up to date) object OBJ_ID of the archive at URL."""
    r = Remote(url)
    if r.archive_id == P.ARCHIVE["archiveId"]:
        sys.exit(f"{obj_id} is an object of this archive")
    inv = r.inventory(obj_id)
    ⟨refuse to mirror versions without an open licence⟩
    ⟨refuse to mirror over an object of this archive⟩
    tmp = Path(tempfile.mkdtemp(dir=P.OCFL))
    try:
        ⟨copy the new versions of a mirrored object into a staging folder⟩
    finally:
        shutil.rmtree(tmp, ignore_errors=True)
    print(f"{obj_id}: mirrored from {r.archive_id} ({len(new)} new version(s), head {inv['head']})")
    return r

The licence is checked on every version about to be copied. The versions already mirrored were checked, or vouched for with --licence-checked, when they were copied, and cannot have changed since, since an object version is fixed: checking only the new ones lets a mirror made with --licence-checked be brought up to date with new, openly licensed versions without the curator vouching again for the old ones. The message names the closed versions with their licence, and suggests the alternatives: a reference, which needs no permission, or --licence-checked.

closed = []
here = P.load_inventory(P.OCFL / obj_id) or {"versions": {}}
for ver in [v for v in inv["versions"] if v not in here["versions"]]:
    dc = json.loads(r.file(obj_id, inv, ver, "datacite.json"))
    if not is_open(dc.get("rightsList")):
        closed.append(f"{ver} ({(dc.get('rightsList') or [{}])[0].get('rights', 'no licence')})")
if closed and not licence_checked:
    sys.exit(f"{obj_id}: not mirrored: these versions are not under an open licence: "
             f"{', '.join(closed)}. Pin it in a collection instead (a reference needs no "
             "permission), or mirror with --licence-checked if redistribution is allowed.")

An object of the same identifier that is already here must be a mirror of the same archive; anything else (a document of this archive, or a mirror of another archive's object that happens to share the identifier) is left alone.

d = P.OCFL / obj_id
have = P.load_inventory(d)
if have:
    here = P.read_json(d, have, P.OBJ)
    if here.get("archiveId") != r.archive_id:
        sys.exit(f"this archive has an object {obj_id} already (section 10.4: a mirror keeps its "
                 "identifier only if no object of this archive has it)")

Copying through a staging folder

The copy is made in a staging folder and moved into place only when every file has been checked, so that a failure half-way (a network error, a file that does not match its digest) leaves the storage root as it was. The staging folder is made inside the storage root, so that the final move is a rename on the same file system; the finally above removes it whatever happens, also after sys.exit, which raises an exception. A mirror being updated is first copied whole into the staging folder, and the versions whose folder is not there yet are the new ones.

stage = tmp / obj_id
if have:
    shutil.copytree(d, stage)
else:
    stage.mkdir()
new = [v for v in inv["versions"] if not (stage / v).exists()]
base = r.root + obj_id + "/"
for ver in new:
    ⟨copy one version of a mirrored object, checking every file⟩
⟨copy the root of a mirrored object and check its inventory⟩
⟨put the staged mirror in place⟩

A version folder of an OCFL object holds the inventory of that version, with its sidecar, and the content files added in that version: those whose content path starts with the version's name. The files of earlier versions are not copied again, since OCFL stores each content once and later versions refer to it. Each file is checked against the digest by which the manifest of the head lists it, before it is written.

for name in ("inventory.json", "inventory.json.sha512"):
    (stage / ver).mkdir(parents=True, exist_ok=True)
    (stage / ver / name).write_bytes(get(base + f"{ver}/{name}"))
for dig, paths in inv["manifest"].items():
    for cp in paths:
        if cp.startswith(ver + "/"):
            data = get(base + cp)
            if hashlib.sha512(data).hexdigest() != dig:
                sys.exit(f"{obj_id}: {cp} does not match its digest; nothing mirrored")
            f = stage / cp
            f.parent.mkdir(parents=True, exist_ok=True)
            f.write_bytes(data)

The root of the object holds the OCFL declaration file and the inventory of the head, with its sidecar. The digests of the files were checked against this inventory, as read at the start; the inventory now copied is checked against its own sidecar.

for name in ("0=ocfl_object_1.1", "inventory.json", "inventory.json.sha512"):
    (stage / name).write_bytes(get(base + name))
if hashlib.sha512((stage / "inventory.json").read_bytes()).hexdigest() != \
        (stage / "inventory.json.sha512").read_text().split()[0]:
    sys.exit(f"{obj_id}: the inventory does not match its checksum file; nothing mirrored")

The old mirror, if any, is replaced by the staged one. Between the two operations the object is briefly absent, but never incomplete.

if d.exists():
    shutil.rmtree(d)
shutil.move(str(stage), str(d))

The command

osheaf mirror ARCHIVE-URL ID [--licence-checked] creates the storage root if needed and mirrors the object, or brings an existing mirror up to date. A mirror can also be made while composing a collection, with mirror: yes in the member's entry of collection.yaml, and osheaf collection update brings up to date the mirrors of the members it follows (collections). An archive does not record where a mirror was copied from (its object.json files name the original archive, not an address), so a mirror listed in no collection is brought up to date only by running osheaf mirror again with the address.

def main(args):
    P.init_storage_root()
    mirror(args.url, args.id, args.licence_checked)