register --live accepts the Institute's own Zenodo community inclusion for the archive copy it publishes; never fatal, reported in the curator ping, retried on a re-run
Browse files- crossref_deposit.py +26 -0
- intake/scripts/test_zenodo_community.py +116 -0
- repository_deposit.py +32 -0
crossref_deposit.py
CHANGED
|
@@ -875,6 +875,17 @@ def register(sub_dir: Path, *, live: bool = False, override_window: bool = False
|
|
| 875 |
_write_json_atomic(state_p, state)
|
| 876 |
outcome["archive"] = ck.get("archive_url")
|
| 877 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 878 |
# 5. author notice, DRAFTED
|
| 879 |
if not ck.get("author_drafted_at"):
|
| 880 |
ok = _notify_author(sub_dir, submission, doi, state.get("publications_slug"),
|
|
@@ -894,12 +905,27 @@ def register(sub_dir: Path, *, live: bool = False, override_window: bool = False
|
|
| 894 |
_ping(f"DOI REGISTERED for {sub_dir.name}: https://doi.org/{doi}\n"
|
| 895 |
f"Landing: {outcome.get('landing') or '(publications not configured)'}\n"
|
| 896 |
f"Zenodo archive: {outcome.get('archive')}\n"
|
|
|
|
| 897 |
f"Author 'published' email: {outcome['author_draft']}"
|
| 898 |
+ (" (Gmail Drafts -- review and send by hand)" if ck.get("author_drafted_at") else "")
|
| 899 |
+ ("\n(resumed from an earlier partial run)" if outcome["resumed"] else ""))
|
| 900 |
return {**outcome, **(ck.get("crossref_result") or {})}
|
| 901 |
|
| 902 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
def _publish_zenodo_archive(sub_dir: Path, doi: str, *, log) -> Optional[str]:
|
| 904 |
"""Publish the Zenodo draft staged at accept. PREFLIGHT first (audit 2026-09-27 item 3): the
|
| 905 |
remote draft must carry OUR DOI as its external DOI, or we refuse -- publishing
|
|
|
|
| 875 |
_write_json_atomic(state_p, state)
|
| 876 |
outcome["archive"] = ck.get("archive_url")
|
| 877 |
|
| 878 |
+
# 4b. the Institute's Zenodo community (his yes, 2026-09-29): publishing opens an
|
| 879 |
+
# inclusion request the community must accept, and --live is his approval, so it
|
| 880 |
+
# is accepted here. Never fatal: a failure is reported and a re-run tries again.
|
| 881 |
+
if not ck.get("zenodo_community_at") and ck.get("archive_url") not in (None, "none"):
|
| 882 |
+
result = _accept_zenodo_community(state.get("deposit_record_id"), log=log)
|
| 883 |
+
ck["zenodo_community"] = result
|
| 884 |
+
if result in ("accepted", "already"):
|
| 885 |
+
ck["zenodo_community_at"] = _now_iso()
|
| 886 |
+
_save_staged()
|
| 887 |
+
outcome["community"] = ck.get("zenodo_community")
|
| 888 |
+
|
| 889 |
# 5. author notice, DRAFTED
|
| 890 |
if not ck.get("author_drafted_at"):
|
| 891 |
ok = _notify_author(sub_dir, submission, doi, state.get("publications_slug"),
|
|
|
|
| 905 |
_ping(f"DOI REGISTERED for {sub_dir.name}: https://doi.org/{doi}\n"
|
| 906 |
f"Landing: {outcome.get('landing') or '(publications not configured)'}\n"
|
| 907 |
f"Zenodo archive: {outcome.get('archive')}\n"
|
| 908 |
+
f"ICSAC Zenodo community: {outcome.get('community') or 'not attempted'}\n"
|
| 909 |
f"Author 'published' email: {outcome['author_draft']}"
|
| 910 |
+ (" (Gmail Drafts -- review and send by hand)" if ck.get("author_drafted_at") else "")
|
| 911 |
+ ("\n(resumed from an earlier partial run)" if outcome["resumed"] else ""))
|
| 912 |
return {**outcome, **(ck.get("crossref_result") or {})}
|
| 913 |
|
| 914 |
|
| 915 |
+
def _accept_zenodo_community(record_id, *, log) -> str:
|
| 916 |
+
"""Accept the ICSAC community inclusion for the published archive copy; never raises."""
|
| 917 |
+
if not record_id:
|
| 918 |
+
return "none"
|
| 919 |
+
try:
|
| 920 |
+
import repository_deposit
|
| 921 |
+
result = repository_deposit.accept_community_inclusion(str(record_id))
|
| 922 |
+
log(f" crossref: ICSAC Zenodo community for record {record_id}: {result}")
|
| 923 |
+
return result
|
| 924 |
+
except Exception as exc:
|
| 925 |
+
log(f" crossref: ICSAC Zenodo community for record {record_id} FAILED: {str(exc)[:200]}")
|
| 926 |
+
return f"failed: {str(exc)[:120]}"
|
| 927 |
+
|
| 928 |
+
|
| 929 |
def _publish_zenodo_archive(sub_dir: Path, doi: str, *, log) -> Optional[str]:
|
| 930 |
"""Publish the Zenodo draft staged at accept. PREFLIGHT first (audit 2026-09-27 item 3): the
|
| 931 |
remote draft must carry OUR DOI as its external DOI, or we refuse -- publishing
|
intake/scripts/test_zenodo_community.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Checks for the ICSAC Zenodo community step of register --live (his yes,
|
| 3 |
+
2026-09-29): the inclusion request that publishing opens is accepted as part
|
| 4 |
+
of the approved publish; a record already in the community is left alone; a
|
| 5 |
+
failure never stops the run and a re-run tries again. Offline: the Zenodo API
|
| 6 |
+
and every other network step are stubbed; writes land in one temporary
|
| 7 |
+
directory that is removed at the end, pass or fail. Run from the repo root:
|
| 8 |
+
.venv/bin/python intake/scripts/test_zenodo_community.py
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import shutil
|
| 14 |
+
import sys
|
| 15 |
+
import tempfile
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 19 |
+
sys.path.insert(0, str(ROOT))
|
| 20 |
+
|
| 21 |
+
failures: list[str] = []
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def check(cond: bool, name: str) -> None:
|
| 25 |
+
print((" ok " if cond else " FAIL ") + name)
|
| 26 |
+
if not cond:
|
| 27 |
+
failures.append(name)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
tmp = Path(tempfile.mkdtemp(prefix="zenodo-community-"))
|
| 31 |
+
try:
|
| 32 |
+
import repository_deposit as rd
|
| 33 |
+
|
| 34 |
+
COMM = "130c51d8-icsac"
|
| 35 |
+
calls: list = []
|
| 36 |
+
|
| 37 |
+
def fake(members=(), requests=(), accept_link=True):
|
| 38 |
+
def _req(method, url, *, token, body=None, **k):
|
| 39 |
+
calls.append((method, url))
|
| 40 |
+
if url.endswith("/communities/icsac"):
|
| 41 |
+
return {"id": COMM}
|
| 42 |
+
if url.endswith("/communities"):
|
| 43 |
+
return {"hits": {"hits": [{"id": c} for c in members]}}
|
| 44 |
+
if url.endswith("/requests"):
|
| 45 |
+
hits = []
|
| 46 |
+
for rid, status in requests:
|
| 47 |
+
links = {"actions": {"accept": f"https://zenodo.org/api/requests/{rid}/actions/accept"}} if accept_link else {}
|
| 48 |
+
hits.append({"id": rid, "type": "community-inclusion", "status": status,
|
| 49 |
+
"receiver": {"community": COMM}, "links": links})
|
| 50 |
+
return {"hits": {"hits": hits}}
|
| 51 |
+
if "/actions/accept" in url:
|
| 52 |
+
return {"status": "accepted"}
|
| 53 |
+
raise AssertionError(url)
|
| 54 |
+
return _req
|
| 55 |
+
|
| 56 |
+
print("1. the helper")
|
| 57 |
+
calls.clear(); rd._request_json = fake(requests=[("r1", "submitted")])
|
| 58 |
+
check(rd.accept_community_inclusion("9") == "accepted"
|
| 59 |
+
and ("POST", "https://zenodo.org/api/requests/r1/actions/accept") in calls,
|
| 60 |
+
"a pending request is accepted")
|
| 61 |
+
calls.clear(); rd._request_json = fake(members=[COMM])
|
| 62 |
+
check(rd.accept_community_inclusion("9") == "already" and not any(m == "POST" for m, _ in calls),
|
| 63 |
+
"a record already in the community is left alone")
|
| 64 |
+
calls.clear(); rd._request_json = fake(requests=[("r2", "accepted")])
|
| 65 |
+
check(rd.accept_community_inclusion("9") == "none" and not any(m == "POST" for m, _ in calls),
|
| 66 |
+
"no pending request, nothing done")
|
| 67 |
+
calls.clear(); rd._request_json = fake(requests=[("r3", "submitted")], accept_link=False)
|
| 68 |
+
check(rd.accept_community_inclusion("9") == "not-permitted", "a token that may not accept says so")
|
| 69 |
+
|
| 70 |
+
print("2. the live run")
|
| 71 |
+
import crossref_deposit as cd
|
| 72 |
+
from intake import author_approval
|
| 73 |
+
sub = tmp / "ICSAC-SUB-09998"
|
| 74 |
+
(sub / "crossref").mkdir(parents=True)
|
| 75 |
+
(sub / "submission.json").write_text(json.dumps({"sub_id": sub.name, "title": "T", "source": "upload"}))
|
| 76 |
+
(sub / "state.json").write_text(json.dumps({"decision": "accept", "state": "completed", "deposit_record_id": "555"}))
|
| 77 |
+
(sub / "crossref/deposit.xml").write_bytes(b"<x/>")
|
| 78 |
+
(sub / "crossref/staged.json").write_text(json.dumps({"doi": "10.67697/icsac.2026.998", "registered": False}))
|
| 79 |
+
cd.stage = lambda *a, **k: None
|
| 80 |
+
cd.validate_xml = lambda b: None
|
| 81 |
+
cd.deposit = lambda *a, **k: "batch-1"
|
| 82 |
+
cd.poll_result = lambda *a, **k: {"status": "completed", "success": 1, "failure": 0}
|
| 83 |
+
pings: list = []
|
| 84 |
+
cd._ping = lambda msg: pings.append(msg)
|
| 85 |
+
cd._push_publications = lambda *a, **k: {"slug": "t"}
|
| 86 |
+
cd._publish_zenodo_archive = lambda *a, **k: "https://zenodo.org/record/555"
|
| 87 |
+
cd._notify_author = lambda *a, **k: True
|
| 88 |
+
author_approval.gate = lambda *a, **k: (True, "approved (stub)")
|
| 89 |
+
seen: list = []
|
| 90 |
+
|
| 91 |
+
def flaky(record_id, *, log):
|
| 92 |
+
seen.append(record_id)
|
| 93 |
+
return "failed: HTTP 503" if len(seen) == 1 else "accepted"
|
| 94 |
+
cd._accept_zenodo_community = flaky
|
| 95 |
+
out = cd.register(sub, live=True, log=lambda m: None)
|
| 96 |
+
ck = json.loads((sub / "crossref/staged.json").read_text())["checkpoints"]
|
| 97 |
+
check(seen == ["555"] and out.get("community", "").startswith("failed")
|
| 98 |
+
and "zenodo_community_at" not in ck, "a failure is reported, not recorded as done")
|
| 99 |
+
check(bool(ck.get("author_drafted_at")) and "ICSAC Zenodo community: failed" in pings[-1],
|
| 100 |
+
"the run still finishes, and the ping says what happened")
|
| 101 |
+
out = cd.register(sub, live=True, log=lambda m: None)
|
| 102 |
+
ck = json.loads((sub / "crossref/staged.json").read_text())["checkpoints"]
|
| 103 |
+
check(seen == ["555", "555"] and ck.get("zenodo_community") == "accepted" and ck.get("zenodo_community_at"),
|
| 104 |
+
"a re-run tries again and records the accept")
|
| 105 |
+
out = cd.register(sub, live=True, log=lambda m: None)
|
| 106 |
+
check(seen == ["555", "555"], "once accepted, a later run leaves it alone")
|
| 107 |
+
finally:
|
| 108 |
+
shutil.rmtree(tmp, ignore_errors=True)
|
| 109 |
+
|
| 110 |
+
print()
|
| 111 |
+
if failures:
|
| 112 |
+
print(f"FAILED: {len(failures)}")
|
| 113 |
+
for f in failures:
|
| 114 |
+
print(" - " + f)
|
| 115 |
+
sys.exit(1)
|
| 116 |
+
print("ALL GREEN")
|
repository_deposit.py
CHANGED
|
@@ -303,6 +303,38 @@ def stage_deposit_draft(submission: dict, paper_pdf_path: Path,
|
|
| 303 |
return {"record_id": record_id, "draft_url": draft_url}
|
| 304 |
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
def publish_draft(record_id: str, *, log=None) -> dict:
|
| 307 |
"""Publish a previously-staged draft deposit. Mints the DOI, makes the
|
| 308 |
record live, triggers icsac-community membership.
|
|
|
|
| 303 |
return {"record_id": record_id, "draft_url": draft_url}
|
| 304 |
|
| 305 |
|
| 306 |
+
def accept_community_inclusion(record_id: str, *, community: str | None = None) -> str:
|
| 307 |
+
"""Accept the inclusion request that publishing opened for the Institute's own
|
| 308 |
+
Zenodo community (the community reviews every submission, the Institute's too).
|
| 309 |
+
Called only from register-doi.sh --live, whose typed confirmation is the
|
| 310 |
+
curator's approval (his yes, 2026-09-29). Never used for an author's own records:
|
| 311 |
+
ICSAC links to authors' code and data, it does not curate them.
|
| 312 |
+
|
| 313 |
+
Returns "accepted", "already" (the record is in the community), "none" (no
|
| 314 |
+
pending request to act on) or "not-permitted" (the token may not accept).
|
| 315 |
+
Raises DepositFailed on an HTTP error.
|
| 316 |
+
"""
|
| 317 |
+
api = config.ZENODO_API
|
| 318 |
+
token = config.ZENODO_TOKEN
|
| 319 |
+
slug = community or config.COMMUNITY_ID
|
| 320 |
+
comm_id = _request_json("GET", f"{api}/communities/{slug}", token=token).get("id")
|
| 321 |
+
if not comm_id:
|
| 322 |
+
return "none"
|
| 323 |
+
members = _request_json("GET", f"{api}/records/{record_id}/communities", token=token)
|
| 324 |
+
if any(h.get("id") == comm_id for h in (members.get("hits") or {}).get("hits") or []):
|
| 325 |
+
return "already"
|
| 326 |
+
reqs = _request_json("GET", f"{api}/records/{record_id}/requests", token=token)
|
| 327 |
+
for r in (reqs.get("hits") or {}).get("hits") or []:
|
| 328 |
+
if (r.get("type") == "community-inclusion" and r.get("status") == "submitted"
|
| 329 |
+
and (r.get("receiver") or {}).get("community") == comm_id):
|
| 330 |
+
accept = ((r.get("links") or {}).get("actions") or {}).get("accept")
|
| 331 |
+
if not accept:
|
| 332 |
+
return "not-permitted"
|
| 333 |
+
_request_json("POST", accept, token=token, body={})
|
| 334 |
+
return "accepted"
|
| 335 |
+
return "none"
|
| 336 |
+
|
| 337 |
+
|
| 338 |
def publish_draft(record_id: str, *, log=None) -> dict:
|
| 339 |
"""Publish a previously-staged draft deposit. Mints the DOI, makes the
|
| 340 |
record live, triggers icsac-community membership.
|