Readers, transformers and writers
A harvest is an invenio-vocabularies datastream built from the harvester definition (see tasks):
flowchart TD
reader["reader<br/><code>harvester.loader</code>"]
transformers["transformers<br/><code>harvester.transformers</code>, in order"]
writers["writers<br/><code>harvester.writers</code>"]
reader --> transformers --> writers
The defaults cover a plain OAI-PMH source whose metadata format the target model can import:
- reader - invenio's
oai-pmhreader; - transformer -
oai-import, which converts the metadata with one of the model's imports; - writer -
oai-service, which creates, updates and deletes the local records and keeps a harvest record for every harvested OAI identifier.
A source that is not an OAI-PMH endpoint (e.g. a JSON API) needs a custom reader; metadata the model can not import, or data that need adjusting, need a custom transformer; additional actions with the harvested records need a custom writer. This page describes what each of them receives, what it has to produce and how errors are handled.
Registration and options
Readers, transformers and writers are looked up by name in the
VOCABULARIES_DATASTREAM_READERS / _TRANSFORMERS / _WRITERS config (name → class).
The oai-import transformer and the oai-service writer are added by this library;
register your own in invenio.cfg:
from invenio_vocabularies.config import (
VOCABULARIES_DATASTREAM_READERS as _READERS,
VOCABULARIES_DATASTREAM_TRANSFORMERS as _TRANSFORMERS,
VOCABULARIES_DATASTREAM_WRITERS as _WRITERS,
)
from my_repository.oai import DoiToIdTransformer, MyApiReader, MyApiTransformer, NotifyWriter
VOCABULARIES_DATASTREAM_READERS = {**_READERS, "my-api": MyApiReader}
VOCABULARIES_DATASTREAM_TRANSFORMERS = {
**_TRANSFORMERS,
"my-api": MyApiTransformer,
"doi-to-id": DoiToIdTransformer,
}
VOCABULARIES_DATASTREAM_WRITERS = {**_WRITERS, "notify": NotifyWriter}
With oarepo-config, config.configure_datastreams(readers=..., transformers=...,
writers=...) does the same and also accepts import strings
("my_repository.oai:MyApiReader").
The harvester refers to them as name{options}, the options being a JSON5 object - for
example in the oai_harvesters.yaml fixture:
- id: my-source
name: My source
base-url: https://api.example.org
metadata-prefix: json
loader: my-api{page_size:100}
transformer:
- my-api
- doi-to-id{prefix:"ext-"}
writer:
- oai-service{model:"datasets"}
- notify{url:"https://example.org/hooks/harvest"}
Each class is instantiated for every harvest, with the options as keyword arguments, plus:
harvester_id- always, for every reader, transformer and writer;- for the reader, unless set in the options:
base_url,metadata_prefix,set(the harvester'ssetspec),from_date(incremental harvests - only records changed since then) andidentifiers(re-harvest of the given records only).
Accept **kwargs, so that options added in the future do not break your class.
Note
invenio reports any KeyError raised in the constructor as
FactoryError: Transformer <name> not configured (or Reader / Writer). If you
get it for a name you have registered, check the constructor.
Readers
A reader subclasses invenio_vocabularies.datastreams.readers.BaseReader and implements
read(item=None, *args, **kwargs), a generator yielding one item per harvested record.
The item is an OAI record - any object with these attributes:
| Attribute | Meaning |
|---|---|
header.identifier |
unique identifier of the record at the source - the key of its harvest record |
header.datestamp |
last change at the source, an ISO 8601 string or a datetime (UTC when without timezone); a record with an unchanged datestamp is skipped |
header.deleted |
True if the record was deleted at the source - the local record gets deleted |
raw |
the original payload as a string, stored on the harvest record |
It may be yielded as is or as {"record": <OAI record>}, as invenio's oai-pmh reader
does (with an oaipmh_scythe Record).
Honour from_date - otherwise every scheduled harvest reads the whole source - and
identifiers, used to re-harvest selected records from the administration or the
invenio oai harvest <harvester> <identifiers> command.
Errors in readers stop the harvest
An exception raised by a reader ends the harvest: there is no record to attach it to yet, and a generator can not continue after raising. Keep readers simple - fetch, wrap into an OAI record and yield - and do any parsing or conversion of the payload in a transformer, where a failure affects only that record. Let network errors propagate: the harvest fails and the next run picks the records up again (scheduled runs harvest from the last successful run).
Example - a reader of a paginated JSON API:
import dataclasses
import json
import requests
from invenio_vocabularies.datastreams.readers import BaseReader
@dataclasses.dataclass
class Header:
identifier: str
datestamp: str
deleted: bool = False
@dataclasses.dataclass
class ApiRecord:
header: Header
raw: str
json: dict
class MyApiReader(BaseReader):
def __init__(self, *args, base_url=None, from_date=None, identifiers=None, page_size=50, **kwargs):
super().__init__(origin=base_url)
self.from_date = from_date
self.identifiers = set(identifiers or [])
self.page_size = page_size
def read(self, item=None, *args, **kwargs):
url = f"{self._origin}/records"
params = {"size": self.page_size}
if self.from_date:
params["updated_since"] = self.from_date
while url:
response = requests.get(url, params=params, timeout=60)
response.raise_for_status() # stops the harvest, the next run tries again
body = response.json()
for hit in body["hits"]:
identifier = f"oai:my-api:{hit['id']}"
if self.identifiers and identifier not in self.identifiers:
continue
yield ApiRecord(
header=Header(identifier=identifier, datestamp=hit["updated"], deleted=hit.get("deleted", False)),
raw=json.dumps(hit),
json=hit,
)
url, params = body["links"].get("next"), None
Transformers
A transformer subclasses invenio_vocabularies.datastreams.transformers.BaseTransformer
and implements apply(stream_entry, *args, **kwargs). It gets a StreamEntry and
returns it - the returned entry goes to the next transformer. stream_entry.entry is
the reader's item for the first transformer and the previous transformer's output for
the others.
After the last transformer, the entry must be:
{
"record": {...}, # data for the model's service (create / update), None if deleted at the source
"oai_record": oai_record, # the reader's OAI record, unchanged
}
record is what you would send to the model's REST API. The oai-service writer also
understands:
- the record's pid - for models whose pid provider reads the pid from the record data
(
get_pid_value_from_record, e.g. external pids inid), a record harvested for the first time takes over the local record with the same pid instead of creating a new one; parent.communities.ids- slugs or ids of communities to add the record to, see Communities;- no
files/media_files- the sections are written as{"enabled": False}.
A record deleted at the source (header.deleted) has no metadata: produce
"record": None, and let the following transformers pass such entries through.
Using oai-import
oai-import{model:"<model>"} takes the
element inside the OAI <metadata> and converts it with the model's import whose
oai_name is the element's (namespace, local name). For example, a model that can
import DataCite XML, harvested with the oai_datacite metadata prefix:
from oarepo_model.customizations import AddMetadataImport
AddMetadataImport(
code="datacite-xml",
name=_("DataCite XML"),
mimetype="application/vnd.datacite.datacite+xml",
deserializer=DataCiteXMLDeserializer(),
oai_name=("http://datacite.org/schema/kernel-4", "resource"), # (namespace, local name)
)
The deserializer gets the XML element (deserialize_xml) if it has that method, the
element serialized to a string (deserialize) otherwise, and returns the record data.
If it raises - e.g. a marshmallow ValidationError for data it can not convert - the
record fails, see Errors. Add further transformers after oai-import to
adjust its output.
Example
Converting the MyApiReader records above, and a second transformer taking the
record's pid from its DOI:
from invenio_vocabularies.datastreams.errors import TransformerError
from invenio_vocabularies.datastreams.transformers import BaseTransformer
class MyApiTransformer(BaseTransformer):
def __init__(self, *args, **kwargs):
pass
def apply(self, stream_entry, *args, **kwargs):
oai_record = stream_entry.entry
if oai_record.header.deleted:
stream_entry.entry = {"record": None, "oai_record": oai_record}
return stream_entry
hit = oai_record.json
stream_entry.entry = {
"record": {
"metadata": {"title": hit["title"], "publication_date": hit["published"]},
"parent": {"communities": {"ids": [hit["department"]]}},
},
"oai_record": oai_record,
}
return stream_entry
class DoiToIdTransformer(BaseTransformer):
def __init__(self, *args, prefix="", **kwargs):
self.prefix = prefix
def apply(self, stream_entry, *args, **kwargs):
record = stream_entry.entry["record"]
if record is None: # deleted at the source
return stream_entry
dois = [i["identifier"] for i in record["metadata"].get("identifiers", []) if i["scheme"] == "doi"]
if not dois:
raise TransformerError("The record has no DOI, can not create its identifier.")
record["id"] = self.prefix + dois[0]
return stream_entry
The oai-service writer
oai-service{model:"<model>", ...}
brings the local record to the state reported by the source - creates, updates, deletes,
restores - through the model's service, and stores the harvest record. Its options and
the rules it follows are described in the
module documentation. For example, a
writer adding every record to the cesnet community and updating published records
without a draft round trip:
Communities
Written records are added to the writer's community and to the communities listed in
the record's parent.communities.ids, so a transformer can decide the membership per
record. They are added directly (RDM bulk_add), without a community inclusion request;
a subcommunity's parent is added as well, and the community becomes the default one if
the record has none yet. Communities the record is already in are skipped, and records
are never removed from a community. An unknown community makes the record fail.
The harvest record
Every harvested OAI identifier has a harvest record (OAIHarvestedRecord, Administration → OAI-PMH Harvester Records):
| Field | Content |
|---|---|
oai_identifier |
oai_record.header.identifier |
harvester_id |
the harvester that harvested the record |
datestamp |
oai_record.header.datestamp, in UTC |
deleted |
oai_record.header.deleted |
harvested_at |
when the record was processed |
original_data |
{"oai_payload": oai_record.raw} - the payload as received |
transformed_data |
record as produced by the transformers (values that are not JSON as strings); empty if the record failed in a transformer or was deleted at the source |
record_pid, record_type |
pid and model of the local record; kept from the previous harvest when writing fails |
errors |
list of {"type", "location", "message"}, see below |
has_errors |
errors is not empty |
has_warnings |
always False - warnings are not tracked |
errors contain:
- validation errors reported by the model's service -
typeisvalidation_error,locationthe field andmessagethe message; - validation exceptions raised by a transformer or while writing (marshmallow
ValidationError, e.g. from a deserializer or from publishing) - one error per message,typeis the exception class,messagethe message andlocationthe field path (metadata.terms_of_use.license); - other exceptions raised by a transformer or while writing -
typeis the exception class,messageits text (<class> (no message)if empty) andlocationa trimmed traceback with the local variables of each frame; - errors reported by the datastream as strings - only
message.
A record with has_errors is written again by the next harvest even if its datestamp did
not change. A record without errors whose datestamp did not change is skipped, unless
the writer has update_all.
Custom writers
To do something more with the harvested records - notify another system, collect
statistics - add a writer after oai-service (see the fixture above). A writer
subclasses invenio_vocabularies.datastreams.writers.BaseWriter and implements
write(stream_entry, *args, **kwargs) and write_many(stream_entries, *args, **kwargs).
- All writers get the same entry object and the datastream ignores what they return - change the entry in place if a later writer should see it.
- After
oai-service,stream_entry.entry["record"]is the local record as returned by the service (Noneif it was deleted or nothing was written) andstream_entry.op_typethe Operation performed. A record skipped as up to date hasop_typeNoneandrecordset to the current local record. - Check
stream_entry.errors/stream_entry.excfirst - a record that failed in a transformer or inoai-servicehas them set and was not written. - An exception raised by a writer does not stop the harvest (see Errors), but
it is not stored on the harvest record -
oai-servicehas committed that (and the local record) already. Make the writer tolerate failures of its own target.
Example - notifying another system about written records:
import requests
from flask import current_app
from invenio_vocabularies.datastreams.writers import BaseWriter
class NotifyWriter(BaseWriter):
def __init__(self, *args, url, **kwargs):
super().__init__()
self.url = url
def write(self, stream_entry, *args, **kwargs):
if stream_entry.errors or stream_entry.exc is not None or stream_entry.op_type is None:
return stream_entry # failed, or unchanged since the last harvest
record = stream_entry.entry["record"]
try:
requests.post(
self.url,
json={"operation": str(stream_entry.op_type), "id": record["id"] if record else None},
timeout=10,
)
except requests.RequestException:
current_app.logger.warning("Could not notify %s", self.url, exc_info=True)
return stream_entry
def write_many(self, stream_entries, *args, **kwargs):
return [self.write(stream_entry) for stream_entry in stream_entries]
A writer replacing oai-service has to keep the harvest records itself - incremental
harvests, deletions and the administration of harvested records depend on them.
Subclass OAIServiceWriter
instead and override what needs to be different.
Errors
A single record never stops the harvest - transformers and writers are wrapped by create_datastream. Only reader errors (see Readers) and configuration errors (an unknown reader, transformer or writer name) end it.
A transformer signals that a record can not be imported by raising - preferably a
TransformerError with a message that tells the repository manager what is wrong, but
any exception (a KeyError on unexpected data, a marshmallow ValidationError, ...) is
handled the same way. Appending messages to stream_entry.errors and returning the entry
works too; they are reported as one TransformerError. Then:
- the following transformers are skipped and the record is not written - the local record, if any, stays as it is;
- the harvest record gets
has_errorsand the error (type, message, traceback with local variables); itstransformed_datais empty; - the harvest logs
Entry <identifier>: failed: <type>: <message>and goes on with the next record; - the next harvest processes the record again, even if its datestamp did not change.
Errors while writing - validation errors reported by the model's service, exceptions in
the service, an unknown community - are handled by oai-service the same way: the whole
write is rolled back, the errors are stored on the harvest record, the writer logs
OAI identifier <identifier>: <n> error(s) stored on the harvest record: ... and the
harvest logs the record as failed.
There are no warnings - a record is either written or fails. If a problem should not prevent the import, fix the data in the transformer (and log it) instead.