Skip to content

oarepo_oaipmh_harvester.writers

oarepo_oaipmh_harvester.writers

Writers for OAI-PMH harvested records.

This package ships the oai-service writer, which brings the local records to the harvested state through the model's service and keeps a harvest record for every harvested OAI identifier.

What the harvest record contains, how errors are stored and how to write additional writers is described in Readers, transformers and writers.

Modules:

Name Description
oai_service

oai-service writer - synchronizes local records with harvested ones.

OAIServiceWriter

Bases: BaseWriter

Writer for OAI-PMH harvested records.

This writer uses a service to write records harvested via OAI-PMH. It also stores an OAIRecord model instance to keep track of the harvesting status.

Source code in oarepo_oaipmh_harvester/writers/oai_service.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
class OAIServiceWriter(BaseWriter):
    """Writer for OAI-PMH harvested records.

    This writer uses a service to write records harvested via OAI-PMH. It also stores
    an OAIRecord model instance to keep track of the harvesting status.
    """

    def __init__(  # noqa PLR0913,PLR0917
        self,
        model: str,
        *args: Any,
        identity: Identity | None = None,
        update_all: bool = False,
        harvester_id: str | None = None,
        pid_field: str = "id",
        publish: bool = True,
        direct_update: bool = False,
        community: str | None = None,
        **kwargs: Any,
    ):
        """Initialize the OAI service writer.

        Args:
            model: Name of the target model (`current_runtime.models`).
            identity: Identity used for the service calls, defaults to the system identity.
            update_all: Write every record, even if it is up to date.
            harvester_id: Harvester the harvest records belong to (set by the harvest task).
            pid_field: Path of the pid value in the service result.
            publish: Publish created / updated drafts. Required (`ValueError` otherwise) for
                rdm models; `False` only for models without drafts.
            direct_update: Update published records via `service.update` instead of edit -
                update draft - publish. Only for services that allow updating published
                records (drafts services do not by default).
            community: Slug (or id) of a community every written record is added to. Only
                for rdm models.

        """
        self._model = model
        self._update_all = update_all
        self._harvester_id = harvester_id
        self._pid_field = pid_field
        self._identity = identity or system_identity
        self._publish = publish
        self._direct_update = direct_update
        self._community = community

        super().__init__(
            *args,
            **kwargs,
        )

        is_rdm = hasattr(self.service, "delete_record")
        if not publish and is_rdm:
            # rdm records can not be updated directly, only via edit - update draft - publish
            raise ValueError(f"Model '{model}' uses rdm records service, the oai-service writer needs publish=True.")
        if community and not is_rdm:
            raise ValueError(f"Model '{model}' does not use rdm records service, it can not be added to communities.")

    @property
    def service(self) -> RecordService:
        """Get the service for the specified model."""
        return current_runtime.models[self._model].service

    @override
    def write(  # too complex
        self,
        stream_entry: StreamEntry,
        *args: Any,
        **kwargs: Any,
    ) -> StreamEntry:
        """Write the input entry using a given service."""
        current_app.logger.debug("Writing entry: %s", stream_entry.entry)
        harvested_at = dt.datetime.now(dt.UTC)
        original_data = {"oai_payload": stream_entry.entry["oai_record"].raw}
        transformed_data = stream_entry.entry["record"]

        # 0. extract the record oai identifier
        oai_identifier = stream_entry.entry["oai_record"].header.identifier
        oai_datestamp = _utc_datestamp(stream_entry.entry["oai_record"].header.datestamp)
        oai_deleted = stream_entry.entry["oai_record"].header.deleted

        # 1. check if there is an OAI record already
        oai_record = self._get_oai_record(oai_identifier)

        current_app.logger.debug(
            "Processing OAI identifier %s: existing OAI record: %s",
            oai_identifier,
            oai_record,
        )

        written_data = None
        validation_errors: list[dict] = []
        exception_raised = None
        op_type = None

        try:
            if stream_entry.errors or stream_entry.exc is not None:
                # reading / transformation failed - only the errors are stored on the oai
                # record, the local record is left as it is
                current_app.logger.debug("Not writing OAI identifier %s, it has errors", oai_identifier)
            else:
                pid_value = oai_record.record_pid if oai_record else None
                if not pid_value and not oai_deleted:
                    pid_value = self._get_pid_value_from_record(transformed_data)

                # 2. find out the state of the local record
                local = LocalRecord.resolve(self.service, pid_value, with_drafts=self._publish)

                # 3. decide on operation type
                op_type = local.operation(oai_deleted)

                # 4. if exists, check if the record is already up to date
                if self._check_up_to_date(stream_entry, oai_record, oai_datestamp, oai_deleted, local):
                    return stream_entry
                current_app.logger.debug(
                    "Determined operation type '%s' for OAI identifier %s (pid_value=%s, local state=%s)",
                    op_type,
                    oai_identifier,
                    pid_value,
                    local.state,
                )

                written_data, validation_errors = self._save_and_publish_record(
                    transformed_data,
                    local,
                    op_type,
                    oai_identifier,
                )
        except Exception as e:  # noqa BLE001 ok to catch here
            # we can't know the state of the db connection, rollback is a safe bet
            db.session.rollback()
            exception_raised = e

        # In case of exception, the session has been rolled back in the lines
        # above, so we can safely proceed to store the OAI record
        stored_errors = self._store_oai_record(
            oai_record,
            oai_identifier,
            oai_datestamp,
            oai_deleted,
            harvested_at,
            original_data,
            transformed_data,
            stream_entry,
            validation_errors,
            exception_raised,
            written_data,
        )
        if stored_errors:
            current_app.logger.info(
                "OAI identifier %s: %s error(s) stored on the harvest record: %s",
                oai_identifier,
                len(stored_errors),
                "; ".join(_short_error(error) for error in stored_errors),
            )
        if exception_raised is not None and stream_entry.exc is None:
            # report the failure to the datastream as well, not only on the oai record
            stream_entry.exc = exception_raised

        # the datastream api expects error messages; structured errors are on the oai record
        stream_entry.errors = [
            *(stream_entry.errors or []),
            *(f"{error.get('location', '')}: {error['message']}" for error in validation_errors),
        ]
        stream_entry.entry = {
            "record": written_data,
            "oai_record": stream_entry.entry["oai_record"],
        }
        stream_entry.op_type = op_type
        return stream_entry

    def _save_and_publish_record(
        self,
        transformed_data: dict,
        local: LocalRecord,
        op_type: Operation,
        oai_identifier: str,
    ) -> tuple[dict | None, list[dict]]:
        """Save and optionally publish a record within a unit of work.

        Args:
            transformed_data: The transformed record data to be saved.
            local: The local record (state, pid value) the operation is applied to.
            op_type: The operation type (create, update, delete, etc.).
            oai_identifier: The OAI identifier of the record.

        Returns:
            written_data: The data of the written record, or None if not applicable.
            validation_errors: Errors reported by the service (`type`, `location`,
                `message`); the transaction is rolled back if there are any.

        """
        # we use unit of work here to avoid side effects
        # (such as created draft record) in case of failure
        with UnitOfWork() as uow:
            # resolve lazy strings before writing
            transformed_data = json.loads(json.dumps(transformed_data, default=str))
            # parent communities are not writable via the service, they are added after publishing
            communities = self._pop_communities(transformed_data)
            self._add_files_defaults(transformed_data)
            try:
                written_data = self._write(transformed_data, local, op_type, oai_identifier, uow)
            except Exception:
                current_app.logger.exception(
                    "Error during '%s' operation for OAI identifier %s",
                    op_type,
                    oai_identifier,
                )
                raise
            commit_transaction = True
            validation_errors: list[dict] = []
            if written_data:
                # ensure the written data is also transformed (e.g. to resolve lazy strings)
                written_data = json.loads(json.dumps(written_data, default=str))

                current_app.logger.debug(
                    "Written (%s) %s",
                    op_type,
                    json.dumps(written_data, indent=2, ensure_ascii=False),
                )

                if written_data.get("errors"):
                    # the service reported errors during creation/update

                    validation_errors = [x for err in written_data["errors"] for x in self._convert_to_oai_error(err)]
                    commit_transaction = False
                elif self._publish and (
                    op_type in (Operation.CREATE, Operation.UPDATE_DRAFT)
                    or (
                        # with direct update the published record is updated, no draft to publish
                        not self._direct_update and op_type in (Operation.UPDATE, Operation.RESTORE, Operation.REVIVE)
                    )
                ):
                    draft_service = cast("DraftRecordService", self.service)
                    draft_service.publish(
                        self._identity,
                        dict_lookup(written_data, self._pid_field),
                        uow=uow,
                    )
                if commit_transaction:
                    self._add_to_communities(dict_lookup(written_data, self._pid_field), communities, uow)
            if commit_transaction:
                uow.commit()
            else:
                uow.rollback()
            return written_data, validation_errors

    def _add_files_defaults(self, record_data: dict | None) -> None:
        """Disable files / media files of records whose data do not mention them.

        Only for the sections the model supports - a model without media files would reject
        the `media_files` key.
        """
        if record_data is None:
            # deleted at the source
            return
        model = current_runtime.models[self._model]
        if model.file_service is not None:
            record_data.setdefault("files", {"enabled": False})
        if model.media_file_service is not None:
            record_data.setdefault("media_files", {"enabled": False})

    def _pop_communities(self, record_data: dict | None) -> list[str]:
        """Remove `parent.communities` from the record data, return the writer's and the listed communities."""
        # no record data for records deleted at the source
        parent = (record_data or {}).get("parent") or {}
        listed = (parent.pop("communities", None) or {}).get("ids") or []
        # dict.fromkeys - remove duplicates, keep the order
        return list(dict.fromkeys([*([self._community] if self._community else []), *listed]))

    def _add_to_communities(self, pid_value: str, communities: list[str], uow: UnitOfWork) -> None:
        """Add the published record to the communities (slugs or ids) it is not in yet.

        `bulk_add` adds the record directly, without an inclusion request (allowed for system
        processes only), also adds a subcommunity's parent and makes the community the default
        one when the record has none.
        """
        for community in communities:
            # resolved for each community - adding a subcommunity also adds its parent
            record = cast("RDMRecord", self.service.record_cls.pid.resolve(pid_value))
            if current_communities.service.record_cls.pid.resolve(community).id in record.parent.communities:
                continue
            errors = current_rdm_records.record_communities_service.bulk_add(
                self._identity, community, [pid_value], uow=uow
            )
            if errors:
                raise ValueError(f"Can not add record {pid_value} to community {community}: {errors[0]['message']}")

    def _get_oai_record(self, oai_identifier: str) -> OAIHarvestedRecord | None:
        """Get the OAIHarvestedRecord instance by its OAI identifier."""
        return db.session.query(OAIHarvestedRecord).filter_by(oai_identifier=oai_identifier).one_or_none()

    def _check_up_to_date(
        self,
        stream_entry: StreamEntry,
        oai_record: OAIHarvestedRecord | None,
        oai_datestamp: dt.datetime,
        oai_deleted: bool,
        local: LocalRecord,
    ) -> bool:
        """Check if the local record already reflects the harvested one, so writing can be skipped.

        Besides unchanged oai metadata, the local record must be in the harvested state -
        a record deleted or restored locally since the last harvest is written again.
        """
        if not oai_record:
            return False

        if (
            oai_datestamp == oai_record.datestamp
            and oai_deleted == oai_record.deleted
            and not self._update_all
            and oai_record.has_errors is False
            and oai_record.has_warnings is False
        ):
            op_type = local.operation(oai_deleted)
            if oai_deleted:
                # up to date only if the local record is deleted (or absent) as well
                if op_type is Operation.NOOP:
                    stream_entry.entry["record"] = None
                    return True
                return False
            if op_type is not Operation.UPDATE or local.draft is not None:
                # local record is not live (deleted, draft only, missing) or has a local
                # edit draft - write it (the draft gets overwritten and published)
                return False
            # try to read the existing record
            try:
                fetched_record = self.service.read(system_identity, oai_record.record_pid)
                stream_entry.entry["record"] = fetched_record.to_dict()
            except Exception:  # noqa BLE001 ok to catch here
                current_app.logger.warning(
                    "Failed to read existing record %s for OAI identifier %s. Re-writing the record.",
                    oai_record.record_pid,
                    oai_record.oai_identifier,
                )
                # proceed to re-write the record
            else:
                return True
        return False

    def _get_pid_value_from_record(self, record_data: dict) -> str | None:
        """Read the pid value from the transformed data via the pid provider, if it supports it."""
        pid_field = getattr(self.service.record_cls.pid, "field", None)
        pid_provider = getattr(pid_field, "_provider", None) if pid_field else None
        get_pid_value_from_record = getattr(pid_provider, "get_pid_value_from_record", None) if pid_provider else None
        pid_type = getattr(pid_field, "_pid_type", None) if pid_field else None
        if get_pid_value_from_record and pid_type:
            # try to get pid value from transformed data
            return get_pid_value_from_record(record_data)
        return None

    def _store_oai_record(  # noqa PLR0913,PLR0917
        self,
        oai_record: OAIHarvestedRecord | None,
        oai_identifier: str,
        oai_datestamp: dt.datetime,
        oai_deleted: bool,
        harvested_at: dt.datetime,
        original_data: dict,
        transformed_data: dict | None,
        stream_entry: StreamEntry,
        validation_errors: list[dict],
        exception_raised: Exception | None,
        written_data: dict | None,
    ) -> list[dict]:
        """Create or update the OAIHarvestedRecord instance, return the stored errors."""
        if not oai_record:
            oai_record = OAIHarvestedRecord(
                oai_identifier=oai_identifier,
                harvester_id=self._harvester_id,
            )
        oai_record.datestamp = oai_datestamp
        oai_record.deleted = oai_deleted
        # store errors and warnings
        # build the list before assigning - in-place append on a JSON column is not persisted
        errors = [x for e in (stream_entry.errors or []) for x in self._convert_stream_error(e)]
        errors.extend(validation_errors)
        if exception_raised is not None:
            errors.extend(self._convert_exception_to_errors(exception_raised))
        if stream_entry.exc:
            errors.extend(self._convert_exception_to_errors(stream_entry.exc))
        oai_record.errors = json.loads(json.dumps(errors, default=str))
        oai_record.harvested_at = harvested_at
        oai_record.has_errors = bool(oai_record.errors)
        # no warnings tracking for now
        oai_record.has_warnings = False

        # store the internal identifier
        # written_entry.entry is a RecordItem from the service, not plain record
        if written_data is not None:
            pid = dict_lookup(written_data, self._pid_field)
            oai_record.record_pid = pid
            oai_record.record_type = self._model

        # store the original and transformed data; values that are not json serializable
        # (dates, lazy strings, ...) would make the commit fail
        oai_record.original_data = original_data
        oai_record.transformed_data = json.loads(json.dumps(transformed_data or {}, default=str))

        db.session.add(oai_record)
        db.session.commit()

        # indexed right away - the bulk queue is processed only by a running celery beat,
        # so the administration would show the state of a previous harvest until then
        try:
            indexer = current_oai_record_service.indexer
            indexer.index(OAIRecordAggregate.from_model(oai_record))
        except Exception:
            current_app.logger.exception(
                "Could not index harvest record %s, queued for bulk indexing", oai_record.oai_identifier
            )
            current_oai_record_service.indexer.bulk_index([oai_record.oai_identifier])
        return oai_record.errors

    def _write(
        self,
        record_data: dict,
        local: LocalRecord,
        op_type: Operation,
        oai_identifier: str,
        uow: UnitOfWork,
    ) -> dict | None:
        """Write the record data using the service.

        Operations other than `NOOP` and `CREATE` always have a pid value - a local
        record without a pid is classified as `ABSENT` (see
        [LocalRecord.resolve][oarepo_oaipmh_harvester.writers.oai_service.LocalRecord.resolve]).
        """
        match op_type:
            case Operation.NOOP:
                return None
            case Operation.CREATE:
                return cast("dict", self.service.create(self._identity, record_data, uow=uow).to_dict())
            case Operation.UPDATE_DRAFT:
                draft_service = cast("DraftRecordService", self.service)
                return cast(
                    "dict",
                    draft_service.update_draft(
                        self._identity, cast("str", local.pid_value), record_data, uow=uow
                    ).to_dict(),
                )
            case Operation.UPDATE | Operation.RESTORE | Operation.REVIVE:
                self._bring_back(local, op_type, uow)
                return self._update(record_data, local, uow)
            case Operation.DELETE | Operation.DELETE_RECORD | Operation.DELETE_DRAFT:
                self._delete(local, op_type, oai_identifier, uow)
                return None
        raise ValueError(f"Unknown operation type: {op_type}")

    def _bring_back(self, local: LocalRecord, op_type: Operation, uow: UnitOfWork) -> None:
        """Make a locally deleted record live again before it is updated (no-op for `UPDATE`)."""
        pid_value = cast("str", local.pid_value)
        if op_type is Operation.RESTORE:
            rdm_service = cast("RDMRecordService", self.service)
            if getattr(local.record, "deletion_status", None) == RecordDeletionStatusEnum.MARKED:
                rdm_service.unmark_record_for_purge(self._identity, pid_value, uow=uow)
            rdm_service.restore_record(self._identity, pid_value, uow=uow)
        elif op_type is Operation.REVIVE:
            self._revive(local)

    def _delete(self, local: LocalRecord, op_type: Operation, oai_identifier: str, uow: UnitOfWork) -> None:
        """Delete the local record (`DELETE` / `DELETE_RECORD`) and its local draft."""
        pid_value = cast("str", local.pid_value)
        if op_type is Operation.DELETE:
            self.service.delete(self._identity, pid_value, uow=uow)
        elif op_type is Operation.DELETE_RECORD:
            cast("RDMRecordService", self.service).delete_record(
                self._identity,
                pid_value,
                {"note": f"Deleted at the OAI-PMH source ({oai_identifier})", "is_visible": True},
                uow=uow,
            )
        self._delete_local_draft(local, uow)

    def _delete_local_draft(self, local: LocalRecord, uow: UnitOfWork) -> None:
        """Delete a local edit draft of a record deleted at the source.

        `delete_record` (now or when the record was tombstoned locally) removes the draft
        from the index but keeps it in the db, while `delete_draft` fails if the draft is
        not indexed - so index it first (the ops run in order on commit).
        """
        if local.draft is not None:
            draft_service = cast("DraftRecordService", self.service)
            uow.register(RecordIndexOp(local.draft, indexer=draft_service.indexer))
            draft_service.delete_draft(self._identity, cast("str", local.pid_value), uow=uow)

    def _revive(self, local: LocalRecord) -> None:
        """Bring a hard deleted record back with its original pid.

        Hard delete sets `model.json` to `None`, but the previous revision still has
        the full json - reverting to it undeletes the record (`is_deleted` is
        `json is None`). The harvested data is applied by the following update.
        """
        record = cast("Record", local.record)
        pid = cast("PersistentIdentifier", local.deleted_pid)
        last_revision_id = next(
            (r.revision_id for r in reversed(record.revisions) if r.model is not None and r.model.json is not None),
            None,
        )
        if last_revision_id is None:
            raise ValueError(f"Hard deleted record {local.pid_value} has no revision to revive from.")
        pid.sync_status(PIDStatus.REGISTERED)
        record.revert(last_revision_id)

    def _update(self, record_data: dict, local: LocalRecord, uow: UnitOfWork) -> dict:
        """Update a live record; draft services do not allow direct update, so go through a draft."""
        pid_value = cast("str", local.pid_value)
        if not self._publish or self._direct_update:
            written = self.service.update(self._identity, pid_value, record_data, uow=uow).to_dict()
            # a local edit draft would otherwise survive (and overwrite the harvested data when published)
            self._delete_local_draft(local, uow)
            return cast("dict", written)
        draft_service = cast("DraftRecordService", self.service)
        # edit returns the existing draft if there is one - it gets overwritten by the harvested data
        draft_service.edit(self._identity, pid_value, uow=uow)
        return cast(
            "dict",
            draft_service.update_draft(self._identity, pid_value, record_data, uow=uow).to_dict(),
        )

    def write_many(self, stream_entries: list[StreamEntry], *args: Any, **kwargs: Any) -> list[StreamEntry]:
        """Write the input entries using a given service."""
        # For now, just call write() for each entry
        return [self.write(stream_entry, *args, **kwargs) for stream_entry in stream_entries]

    def _convert_exception_to_errors(self, exception: Exception) -> list[dict]:
        """Turn an exception into error dicts (type, message, location).

        Validation exceptions (marshmallow `ValidationError`, rdm
        `ValidationErrorWithMessageAsList`) carry structured `messages` - their `str()` is
        the repr of a list or dict. They give one error per message with the field path as
        the location, like the validation errors returned by the service. Other exceptions
        give a single error with the message and a trimmed traceback with the locals of each
        frame as the location.
        """
        messages = getattr(exception, "messages", None)
        if messages:
            if isinstance(exception, ma.ValidationError):
                messages = exception.normalized_messages()
            return [
                {"type": type(exception).__name__, "message": message, "location": field}
                for field, message in _flatten_messages(messages)
            ]
        return [self._convert_exception_to_error_dict(exception)]

    def _convert_exception_to_error_dict(self, exception: Exception) -> dict:
        """Turn an exception into an error dict with a trimmed traceback and its locals."""
        tb = exception.__traceback__
        formatted_tb = []
        while tb:
            frame = tb.tb_frame
            fn = (frame.f_code.co_filename or "").split("site-packages/")[-1]
            formatted_tb.append(f"{frame.f_code.co_name}@{fn}:{tb.tb_lineno}")
            for local_name, local_value in frame.f_locals.items():
                try:
                    local_value_str = repr(local_value)
                except Exception:  # noqa: BLE001 a broken repr must not prevent storing the error
                    local_value_str = f"<{type(local_value).__name__}, repr failed>"
                if len(local_value_str) > MAX_TRACEBACK_VARIABLE_LENGTH:
                    local_value_str = local_value_str[: MAX_TRACEBACK_VARIABLE_LENGTH - 3] + "..."
                formatted_tb.append(f"    {local_name} = {local_value_str}")
            tb = tb.tb_next

        return {
            "type": type(exception).__name__,
            # e.g. KeyError() or AssertionError without a message
            "message": str(exception) or f"{type(exception).__name__} (no message)",
            "location": "\n".join(formatted_tb),
        }

    def _convert_stream_error(self, error: Any) -> list[dict]:
        """Normalize a stream error (dict, exception or other) into error dicts."""
        if isinstance(error, dict):
            return [error]
        if isinstance(error, Exception):
            return self._convert_exception_to_errors(error)
        return [{"message": str(error) or "Unknown error (no message)"}]

    def _convert_to_oai_error(self, error: Any) -> Generator[dict]:
        """Convert a service error to an OAI-PMH error dict (type, location, message)."""
        if not isinstance(error, dict):
            yield {"message": str(error)}
        else:
            for field, message in _flatten_messages(error.get("messages") or [], error.get("field", "unknown")):
                yield {
                    "type": "validation_error",
                    "location": field,
                    "message": message,
                }

service: RecordService property

Get the service for the specified model.

__init__(model: str, *args: Any, identity: Identity | None = None, update_all: bool = False, harvester_id: str | None = None, pid_field: str = 'id', publish: bool = True, direct_update: bool = False, community: str | None = None, **kwargs: Any)

Initialize the OAI service writer.

Parameters:

Name Type Description Default
model str

Name of the target model (current_runtime.models).

required
identity Identity | None

Identity used for the service calls, defaults to the system identity.

None
update_all bool

Write every record, even if it is up to date.

False
harvester_id str | None

Harvester the harvest records belong to (set by the harvest task).

None
pid_field str

Path of the pid value in the service result.

'id'
publish bool

Publish created / updated drafts. Required (ValueError otherwise) for rdm models; False only for models without drafts.

True
direct_update bool

Update published records via service.update instead of edit - update draft - publish. Only for services that allow updating published records (drafts services do not by default).

False
community str | None

Slug (or id) of a community every written record is added to. Only for rdm models.

None
Source code in oarepo_oaipmh_harvester/writers/oai_service.py
def __init__(  # noqa PLR0913,PLR0917
    self,
    model: str,
    *args: Any,
    identity: Identity | None = None,
    update_all: bool = False,
    harvester_id: str | None = None,
    pid_field: str = "id",
    publish: bool = True,
    direct_update: bool = False,
    community: str | None = None,
    **kwargs: Any,
):
    """Initialize the OAI service writer.

    Args:
        model: Name of the target model (`current_runtime.models`).
        identity: Identity used for the service calls, defaults to the system identity.
        update_all: Write every record, even if it is up to date.
        harvester_id: Harvester the harvest records belong to (set by the harvest task).
        pid_field: Path of the pid value in the service result.
        publish: Publish created / updated drafts. Required (`ValueError` otherwise) for
            rdm models; `False` only for models without drafts.
        direct_update: Update published records via `service.update` instead of edit -
            update draft - publish. Only for services that allow updating published
            records (drafts services do not by default).
        community: Slug (or id) of a community every written record is added to. Only
            for rdm models.

    """
    self._model = model
    self._update_all = update_all
    self._harvester_id = harvester_id
    self._pid_field = pid_field
    self._identity = identity or system_identity
    self._publish = publish
    self._direct_update = direct_update
    self._community = community

    super().__init__(
        *args,
        **kwargs,
    )

    is_rdm = hasattr(self.service, "delete_record")
    if not publish and is_rdm:
        # rdm records can not be updated directly, only via edit - update draft - publish
        raise ValueError(f"Model '{model}' uses rdm records service, the oai-service writer needs publish=True.")
    if community and not is_rdm:
        raise ValueError(f"Model '{model}' does not use rdm records service, it can not be added to communities.")

write(stream_entry: StreamEntry, *args: Any, **kwargs: Any) -> StreamEntry

Write the input entry using a given service.

Source code in oarepo_oaipmh_harvester/writers/oai_service.py
@override
def write(  # too complex
    self,
    stream_entry: StreamEntry,
    *args: Any,
    **kwargs: Any,
) -> StreamEntry:
    """Write the input entry using a given service."""
    current_app.logger.debug("Writing entry: %s", stream_entry.entry)
    harvested_at = dt.datetime.now(dt.UTC)
    original_data = {"oai_payload": stream_entry.entry["oai_record"].raw}
    transformed_data = stream_entry.entry["record"]

    # 0. extract the record oai identifier
    oai_identifier = stream_entry.entry["oai_record"].header.identifier
    oai_datestamp = _utc_datestamp(stream_entry.entry["oai_record"].header.datestamp)
    oai_deleted = stream_entry.entry["oai_record"].header.deleted

    # 1. check if there is an OAI record already
    oai_record = self._get_oai_record(oai_identifier)

    current_app.logger.debug(
        "Processing OAI identifier %s: existing OAI record: %s",
        oai_identifier,
        oai_record,
    )

    written_data = None
    validation_errors: list[dict] = []
    exception_raised = None
    op_type = None

    try:
        if stream_entry.errors or stream_entry.exc is not None:
            # reading / transformation failed - only the errors are stored on the oai
            # record, the local record is left as it is
            current_app.logger.debug("Not writing OAI identifier %s, it has errors", oai_identifier)
        else:
            pid_value = oai_record.record_pid if oai_record else None
            if not pid_value and not oai_deleted:
                pid_value = self._get_pid_value_from_record(transformed_data)

            # 2. find out the state of the local record
            local = LocalRecord.resolve(self.service, pid_value, with_drafts=self._publish)

            # 3. decide on operation type
            op_type = local.operation(oai_deleted)

            # 4. if exists, check if the record is already up to date
            if self._check_up_to_date(stream_entry, oai_record, oai_datestamp, oai_deleted, local):
                return stream_entry
            current_app.logger.debug(
                "Determined operation type '%s' for OAI identifier %s (pid_value=%s, local state=%s)",
                op_type,
                oai_identifier,
                pid_value,
                local.state,
            )

            written_data, validation_errors = self._save_and_publish_record(
                transformed_data,
                local,
                op_type,
                oai_identifier,
            )
    except Exception as e:  # noqa BLE001 ok to catch here
        # we can't know the state of the db connection, rollback is a safe bet
        db.session.rollback()
        exception_raised = e

    # In case of exception, the session has been rolled back in the lines
    # above, so we can safely proceed to store the OAI record
    stored_errors = self._store_oai_record(
        oai_record,
        oai_identifier,
        oai_datestamp,
        oai_deleted,
        harvested_at,
        original_data,
        transformed_data,
        stream_entry,
        validation_errors,
        exception_raised,
        written_data,
    )
    if stored_errors:
        current_app.logger.info(
            "OAI identifier %s: %s error(s) stored on the harvest record: %s",
            oai_identifier,
            len(stored_errors),
            "; ".join(_short_error(error) for error in stored_errors),
        )
    if exception_raised is not None and stream_entry.exc is None:
        # report the failure to the datastream as well, not only on the oai record
        stream_entry.exc = exception_raised

    # the datastream api expects error messages; structured errors are on the oai record
    stream_entry.errors = [
        *(stream_entry.errors or []),
        *(f"{error.get('location', '')}: {error['message']}" for error in validation_errors),
    ]
    stream_entry.entry = {
        "record": written_data,
        "oai_record": stream_entry.entry["oai_record"],
    }
    stream_entry.op_type = op_type
    return stream_entry

write_many(stream_entries: list[StreamEntry], *args: Any, **kwargs: Any) -> list[StreamEntry]

Write the input entries using a given service.

Source code in oarepo_oaipmh_harvester/writers/oai_service.py
def write_many(self, stream_entries: list[StreamEntry], *args: Any, **kwargs: Any) -> list[StreamEntry]:
    """Write the input entries using a given service."""
    # For now, just call write() for each entry
    return [self.write(stream_entry, *args, **kwargs) for stream_entry in stream_entries]