1616
1717from errors import (
1818 DisseminationError ,
19+ InternetArchiveConsistencyError ,
1920 InternetArchiveDesiredStateError ,
2021 InternetArchiveIdentifierCollisionError ,
2122 InternetArchiveImmutableMetadataError ,
@@ -553,19 +554,29 @@ def _assert_restricted_metadata_current(self, item, desired):
553554
554555 def apply_archive_repairs (
555556 self , item , desired , inspection = None , access_key = None ,
556- secret_key = None , progress = None ):
557- """Apply only the file and metadata differences found by inspection."""
557+ secret_key = None , progress = None , defer_json_upload = False ):
558+ """Apply only the file and metadata differences found by inspection.
559+
560+ When ``defer_json_upload`` is set the JSON original is neither uploaded
561+ nor verified here: the caller is going to mutate the Thoth location
562+ (which changes the ``json::thoth`` export) and will upload the final,
563+ post-location sidecar separately via :meth:`upload_json_sidecar`. In
564+ that mode only the PDF original and managed metadata are uploaded and
565+ strictly verified, so the location is only created once the archive item
566+ and its PDF are known-good. The PDF verified MD5 is still returned.
567+ """
558568 inspection = inspection or self .inspect_item (item , desired )
559569 if inspection ['ownership' ] == 'collision' :
560570 self ._raise_item_collision (inspection ['ownership_reason' ])
561571 self ._assert_restricted_metadata_current (item , desired )
562572
573+ pdf_name = '{}.pdf' .format (desired .identifier )
574+ json_name = '{}.json' .format (desired .identifier )
575+ managed_names = (
576+ (pdf_name ,) if defer_json_upload else (pdf_name , json_name )
577+ )
563578 files_to_upload = [
564- name
565- for name in (
566- '{}.pdf' .format (desired .identifier ),
567- '{}.json' .format (desired .identifier ),
568- )
579+ name for name in managed_names
569580 if not inspection ['files' ][name ]['current' ]
570581 ]
571582 creating_item = not inspection ['exists' ]
@@ -589,11 +600,7 @@ def apply_archive_repairs(
589600 item , desired , ownership = current_ownership )
590601 self ._assert_restricted_metadata_current (item , desired )
591602 files_to_upload = [
592- name
593- for name in (
594- '{}.pdf' .format (desired .identifier ),
595- '{}.json' .format (desired .identifier ),
596- )
603+ name for name in managed_names
597604 if not inspection ['files' ][name ]['current' ]
598605 ]
599606 creating_item = not inspection ['exists' ]
@@ -645,13 +652,109 @@ def apply_archive_repairs(
645652 if progress is not None :
646653 progress ('update_archive_metadata' , 'completed' )
647654
655+ verification_md5s = (
656+ {pdf_name : desired .expected_md5s [pdf_name ]}
657+ if defer_json_upload else desired .expected_md5s
658+ )
648659 return self ._verify_final_state (
660+ item ,
661+ verification_md5s ,
662+ desired .metadata ,
663+ desired .absent_metadata_fields ,
664+ uploaded_file_names = frozenset (uploaded_file_names ),
665+ )
666+
667+ def upload_json_sidecar (
668+ self , item , desired , access_key = None , secret_key = None ,
669+ progress = None ):
670+ """Upload the post-location JSON original exactly once and verify it.
671+
672+ Called after a Thoth location mutation has changed the ``json::thoth``
673+ export, so ``desired`` must be a freshly rebuilt state reflecting the new
674+ location. The mutation target is revalidated immediately before any
675+ mutation: the item must still exist, still be safely Thoth-owned (or an
676+ accepted legacy Thoth item), and still satisfy the initial-only and
677+ admin-only metadata invariants. These are the same safety checks the
678+ primary mutation path performs immediately before writing, so a JSON-only
679+ stage can never create or hijack an item that changed after stage-one
680+ PDF/metadata verification. If the item disappeared it is never recreated
681+ here (no ``metadata=None`` upload to a missing identifier).
682+
683+ The JSON original is uploaded only if the remote copy does not already
684+ match, then the final remote MD5s and managed metadata are strictly
685+ verified (the PDF and metadata are re-verified for safety). A synchronous
686+ rejection is never retried; an accepted-but-pending original goes through
687+ the existing bounded propagation verification without any re-upload.
688+
689+ Returns a mutation summary ``{'verified': ..., 'uploaded': bool}`` so the
690+ caller can report the JSON action truthfully: ``uploaded`` is ``True``
691+ only when a JSON upload was actually performed, and ``False`` when the
692+ rebuilt sidecar already matched the remote original (a no-op that must
693+ not be reported as an attempted or applied action).
694+ """
695+ json_name = '{}.json' .format (desired .identifier )
696+ try :
697+ item .refresh ()
698+ except req_except .RequestException as error :
699+ raise DisseminationError (
700+ 'Unable to refresh Internet Archive item {} before uploading '
701+ 'the post-location JSON sidecar: {}' .format (
702+ desired .identifier , error )
703+ ) from error
704+
705+ # Revalidate the mutation target before comparing, loading credentials,
706+ # reporting the upload as attempted, or writing anything. The item may
707+ # have disappeared, changed ownership, or drifted on restricted metadata
708+ # between stage-one verification and this deferred stage.
709+ ownership = self .classify_item_ownership (item )
710+ if ownership ['status' ] == 'missing' or not item .exists :
711+ raise InternetArchiveConsistencyError (
712+ 'Internet Archive item {} disappeared after PDF verification; '
713+ 'refusing to create or modify it through the deferred JSON '
714+ 'sidecar stage' .format (desired .identifier ))
715+ # Reject collisions and mismatched thoth-work-id; permit owned and
716+ # accepted legacy items exactly as the primary mutation path does.
717+ # ``missing`` is already handled above, so this never tolerates a
718+ # disappeared item the way the bare helper would on the create path.
719+ self ._assert_item_owned_by_thoth (
720+ item , ownership = ownership , warn_legacy = False )
721+ # Re-check the initial-only (e.g. mediatype) and admin-only (collection)
722+ # invariants before mutating.
723+ self ._assert_restricted_metadata_current (item , desired )
724+
725+ comparison = self .compare_original_files (
726+ self ._original_files (item .files ),
727+ {json_name : desired .expected_md5s [json_name ]},
728+ )
729+ uploaded = False
730+ uploaded_file_names = set ()
731+ if not comparison [json_name ]['current' ]:
732+ access_key = access_key or self .get_variable_from_env (
733+ 'ia_s3_access' , 'Internet Archive' )
734+ secret_key = secret_key or self .get_variable_from_env (
735+ 'ia_s3_secret' , 'Internet Archive' )
736+ if progress is not None :
737+ progress ('upload_json_original' , 'attempted' )
738+ self ._upload_files (
739+ desired .identifier ,
740+ {json_name : BytesIO (desired .file_bytes [json_name ])},
741+ None ,
742+ access_key ,
743+ secret_key ,
744+ )
745+ uploaded = True
746+ uploaded_file_names .add (json_name )
747+ if progress is not None :
748+ progress ('upload_json_original' , 'completed' )
749+
750+ verified = self ._verify_final_state (
649751 item ,
650752 desired .expected_md5s ,
651753 desired .metadata ,
652754 desired .absent_metadata_fields ,
653755 uploaded_file_names = frozenset (uploaded_file_names ),
654756 )
757+ return {'verified' : verified , 'uploaded' : uploaded }
655758
656759 def _assert_item_owned_by_thoth (
657760 self , item , ownership = None , warn_legacy = True ):
@@ -862,6 +965,21 @@ def _metadata_values_equal(cls, field, current_value, desired_value):
862965 return cls ._clean_metadata_value (current_value ) \
863966 == cls ._clean_metadata_value (desired_value )
864967
968+ @staticmethod
969+ def _canonicalise_ia_string (value ):
970+ """Normalise line endings the way Internet Archive stores them.
971+
972+ Internet Archive canonicalises metadata string line endings, collapsing
973+ ``\\ r\\ n`` and bare ``\\ r`` to ``\\ n``. We apply the same canonicalisation
974+ to every metadata string we build, compare, patch, and verify so the
975+ representation we send always equals the representation IA returns; this
976+ prevents a value that differs only by line ending from looking like a
977+ perpetual metadata discrepancy. No other whitespace is collapsed and no
978+ meaningful leading/trailing whitespace is stripped here (the existing
979+ blank-value handling in :meth:`_clean_metadata_value` is unchanged).
980+ """
981+ return value .replace ('\r \n ' , '\n ' ).replace ('\r ' , '\n ' )
982+
865983 @classmethod
866984 def _as_metadata_list (cls , value ):
867985 if isinstance (value , (list , tuple , set )):
@@ -870,26 +988,36 @@ def _as_metadata_list(cls, value):
870988 values = []
871989 else :
872990 values = [value ]
873- return [
874- cleaned for cleaned in (
875- cls ._clean_metadata_value (entry ) for entry in values )
876- if cleaned is not None
877- ]
991+ # Canonicalise each value and drop exact duplicates that collapse to the
992+ # same stored representation (e.g. a ``\\r\\n`` and a ``\\n`` variant of
993+ # the same subject), preserving first-occurrence order. IA stores only
994+ # the distinct canonical values, so a comparison that kept the duplicate
995+ # would never converge.
996+ deduplicated = []
997+ for entry in values :
998+ cleaned = cls ._clean_metadata_value (entry )
999+ if cleaned is not None and cleaned not in deduplicated :
1000+ deduplicated .append (cleaned )
1001+ return deduplicated
8781002
8791003 @classmethod
8801004 def _clean_metadata_value (cls , value ):
8811005 if value is None :
8821006 return None
8831007 if isinstance (value , (list , tuple , set )):
884- cleaned_values = [
885- cleaned for cleaned in (
886- cls ._clean_metadata_value (entry ) for entry in value )
887- if cleaned is not None
888- ]
1008+ # Canonicalise then drop exact post-canonicalisation duplicates,
1009+ # preserving first-occurrence order, so a repeatable field we build
1010+ # or send carries only the distinct values IA can store.
1011+ cleaned_values = []
1012+ for entry in value :
1013+ cleaned = cls ._clean_metadata_value (entry )
1014+ if cleaned is not None and cleaned not in cleaned_values :
1015+ cleaned_values .append (cleaned )
8891016 return cleaned_values or None
890- if isinstance (value , str ) \
891- and (not value .strip () or value == 'None' ):
892- return None
1017+ if isinstance (value , str ):
1018+ value = cls ._canonicalise_ia_string (value )
1019+ if not value .strip () or value == 'None' :
1020+ return None
8931021 return value
8941022
8951023 @classmethod
0 commit comments