Skip to content

Commit 7a16edc

Browse files
committed
Merge branch 'release/v1.6.4'
2 parents 62743ab + 248279f commit 7a16edc

7 files changed

Lines changed: 1915 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
## [[1.6.4]](https://github.com/thoth-pub/thoth-dissemination/releases/tag/v1.6.4) - 2026-07-28
10+
### Fixed
11+
- Canonicalise Internet Archive managed metadata line endings (collapsing `\r\n` and bare `\r` to `\n`) consistently across desired metadata, the current-state comparison, patches, and final verification, and deduplicate repeatable values (`collection`, `creator`, `isbn`, `subject`, `language`, `issn`) that collapse to the same stored string while preserving first-occurrence order, so a value differing only by line ending (e.g. a subject supplied as both `Ancient\r\nGreek Thought` and `Ancient\nGreek Thought`) is no longer treated as a perpetual metadata discrepancy that blocks convergence
12+
- Defer the Internet Archive JSON sidecar upload during reconciliation until after the Thoth location has been created or updated: because the `json::thoth` export embeds the publication's locations, a sidecar built and uploaded before the location mutation is immediately stale and needed a second apply to converge. Reconciliation now uploads and strictly verifies the PDF and managed metadata, mutates the Thoth location, rebuilds the desired state from a fresh post-location export (confirming the PDF MD5 is unchanged), then uploads the sidecar exactly once and verifies its final remote MD5; the dry-run transparently predicts the post-location `upload_json_original`, so a first-time create now converges in a single apply across a fresh invocation with no hidden mutation and no duplicate JSON upload. Partial-failure boundaries are preserved: a PDF/metadata verification failure prevents the location mutation and JSON upload, a location mutation failure prevents the JSON upload, a post-location rebuild failure or PDF-source drift is reported with the location applied and the JSON not uploaded, and an accepted-but-pending JSON original uses the existing bounded propagation verification without re-uploading
13+
914
## [[1.6.3]](https://github.com/thoth-pub/thoth-dissemination/releases/tag/v1.6.3) - 2026-07-27
1015
### Fixed
1116
- Normalise the Internet Archive JSON sidecar into a deterministic canonical representation before upload and checksum calculation, stripping only the volatile top-level `jsonGeneratedAt` generation timestamp, so semantically unchanged Thoth metadata no longer produces a different expected JSON MD5 on every run and managed JSON originals can converge to `current`

errors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ class InternetArchiveVerificationError(DisseminationError):
2828
"""Report an Internet Archive upload which could not be verified in time."""
2929

3030

31+
class InternetArchiveConsistencyError(DisseminationError):
32+
"""Report an Archive item that changed inconsistently mid-reconciliation.
33+
34+
Raised when a target item that passed an earlier stage of a multi-stage
35+
reconciliation (for example the PDF/metadata verification that precedes the
36+
deferred JSON sidecar upload) has since disappeared. The item is never
37+
recreated from the later stage.
38+
"""
39+
40+
3141
class InternetArchiveDesiredStateError(DisseminationError):
3242
"""Report which source failed while constructing Archive desired state."""
3343

iauploader.py

Lines changed: 153 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from 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

Comments
 (0)