Coverage for changes_metadata_manager / patch / creator_names.py: 90%

138 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-07-17 16:43 +0000

1# SPDX-FileCopyrightText: 2025-2026 Arcangelo Massari <arcangelo.massari@unibo.it> 

2# 

3# SPDX-License-Identifier: ISC 

4 

5import argparse 

6import json 

7import re 

8import time 

9from collections import Counter 

10from copy import deepcopy 

11from pathlib import Path 

12 

13import requests 

14from rdflib import Graph 

15from rich.console import Console 

16from rich.progress import ( 

17 BarColumn, 

18 MofNCompleteColumn, 

19 Progress, 

20 SpinnerColumn, 

21 TextColumn, 

22) 

23 

24from changes_metadata_manager.folder_metadata_builder import ( 

25 BASE_URI, 

26 load_kg, 

27) 

28from changes_metadata_manager.zenodo_api import ( 

29 ZenodoRecordCache, 

30 fetch_record, 

31) 

32from changes_metadata_manager.zenodo_metadata import ( 

33 ZenodoUpdatePayload, 

34 build_zenodo_update_payload, 

35 extract_stage, 

36 zenodo_payload_differences, 

37) 

38from changes_metadata_manager.zenodo_upload import ( 

39 CREATORS_LOOKUP_PATH, 

40 _atomic_write_json, 

41 build_creators_for_entity_stage, 

42 build_metadata_creators, 

43 load_creators_lookup, 

44 merge_creators, 

45) 

46from changes_metadata_manager.patch.prepared_updates import ( 

47 CACHE_FILENAME, 

48 MANIFEST_FILENAME, 

49 REQUEST_DELAY, 

50 apply_prepared_updates, 

51 extract_package_entity_ids, 

52 prepare_output_directory, 

53 write_payload, 

54) 

55 

56console = Console() 

57ACTIVITY_ENTITY_PATTERN = re.compile(rf"^{re.escape(BASE_URI)}/act/([^/]+)/") 

58 

59 

60def _creator_orcid(creator: dict) -> str: 

61 identifiers = creator["person_or_org"]["identifiers"] 

62 orcids = [ 

63 identifier["identifier"] 

64 for identifier in identifiers 

65 if identifier["scheme"] == "orcid" 

66 ] 

67 if len(orcids) != 1: 

68 raise ValueError(f"Expected one ORCID for creator: {creator}") 

69 return orcids[0] 

70 

71 

72def _creator_signature(creator: dict) -> tuple[str, str, str, str, tuple[str, ...]]: 

73 person = creator["person_or_org"] 

74 affiliations = tuple(sorted(item["name"] for item in creator["affiliations"])) 

75 return ( 

76 person["type"], 

77 person["family_name"], 

78 person["given_name"], 

79 creator["role"]["id"], 

80 affiliations, 

81 ) 

82 

83 

84def _creators_by_orcid( 

85 creators: list[dict], 

86) -> dict[str, tuple[str, str, str, str, tuple[str, ...]]]: 

87 creators_by_orcid = { 

88 _creator_orcid(creator): _creator_signature(creator) for creator in creators 

89 } 

90 if len(creators_by_orcid) != len(creators): 

91 raise ValueError("Duplicate creator ORCID") 

92 return creators_by_orcid 

93 

94 

95def _classify_creators( 

96 current_creators: list[dict], 

97 expected_creators: list[dict], 

98) -> tuple[str, set[str]]: 

99 current = _creators_by_orcid(current_creators) 

100 expected = _creators_by_orcid(expected_creators) 

101 missing_orcids = expected.keys() - current.keys() 

102 

103 if current == expected: 

104 return "already_correct", set() 

105 

106 expected_without_missing = { 

107 orcid: signature 

108 for orcid, signature in expected.items() 

109 if orcid not in missing_orcids 

110 } 

111 if missing_orcids and current == expected_without_missing: 

112 return "would_patch", missing_orcids 

113 

114 return "blocked", missing_orcids 

115 

116 

117def _entity_ids(kg: Graph) -> set[str]: 

118 entity_ids: set[str] = set() 

119 for subject in kg.subjects(): 

120 match = ACTIVITY_ENTITY_PATTERN.match(str(subject)) 

121 if match: 

122 entity_ids.add(match.group(1)) 

123 return entity_ids 

124 

125 

126def _payload_with_creators( 

127 record: dict, expected_creators: list[dict] 

128) -> ZenodoUpdatePayload: 

129 payload = build_zenodo_update_payload(record) 

130 payload["metadata"]["creators"] = deepcopy(expected_creators) 

131 return payload 

132 

133 

134def prepare_creator_name_patches( 

135 drafts_path: Path, 

136 kg_path: Path, 

137 output_dir: Path, 

138 *, 

139 creators_lookup_path: Path = CREATORS_LOOKUP_PATH, 

140) -> Path: 

141 prepare_output_directory(output_dir) 

142 console.print(f"Loading KG from {kg_path}...") 

143 kg = load_kg(kg_path) 

144 kg_entity_ids = _entity_ids(kg) 

145 creators_lookup = load_creators_lookup(creators_lookup_path) 

146 with open(drafts_path) as file: 

147 drafts = json.load(file) 

148 

149 records = [draft for draft in drafts if draft["status"] != "failed"] 

150 manifest_path = output_dir / MANIFEST_FILENAME 

151 manifest: list[dict] = [] 

152 stats: Counter[str] = Counter() 

153 _atomic_write_json(manifest_path, manifest) 

154 cache_path = drafts_path.parent / CACHE_FILENAME 

155 

156 console.print(f"Checking {len(records)} records...") 

157 with ( 

158 ZenodoRecordCache(cache_path) as cache, 

159 Progress( 

160 SpinnerColumn(), 

161 TextColumn("[progress.description]{task.description}"), 

162 BarColumn(), 

163 MofNCompleteColumn(), 

164 ) as progress, 

165 ): 

166 task = progress.add_task("Auditing", total=len(records)) 

167 for draft in records: 

168 record_id = str(draft["draft_id"]) 

169 log_entry = {"record_id": draft["draft_id"]} 

170 progress.update(task, description=f"Record {record_id}") 

171 

172 if draft["status"] != "published": 

173 log_entry["status"] = "blocked" 

174 log_entry["reason"] = f"Unsupported record status: {draft['status']}" 

175 manifest.append(log_entry) 

176 stats["blocked"] += 1 

177 _atomic_write_json(manifest_path, manifest) 

178 progress.advance(task) 

179 continue 

180 

181 cache_hit = False 

182 try: 

183 zenodo_url = draft["zenodo_url"].rstrip("/") 

184 cached_record = cache.get(zenodo_url, record_id) 

185 if cached_record is None: 

186 record, has_edit_draft = fetch_record( 

187 zenodo_url, 

188 record_id, 

189 draft["access_token"], 

190 draft["user_agent"], 

191 ) 

192 cache.set(zenodo_url, record_id, record, has_edit_draft) 

193 else: 

194 record, has_edit_draft = cached_record 

195 cache_hit = True 

196 stage = extract_stage(record) 

197 entity_ids = extract_package_entity_ids(Path(draft["config_file"])) 

198 missing_entity_ids = set(entity_ids).difference(kg_entity_ids) 

199 if missing_entity_ids: 

200 missing_ids = ", ".join(sorted(missing_entity_ids)) 

201 raise ValueError( 

202 f"No KG activities found for entities: {missing_ids}" 

203 ) 

204 expected_creators = merge_creators( 

205 build_creators_for_entity_stage( 

206 kg, entity_ids, stage, creators_lookup 

207 ), 

208 build_metadata_creators(kg, entity_ids, creators_lookup), 

209 ) 

210 remote_status, missing_orcids = _classify_creators( 

211 record["metadata"]["creators"], expected_creators 

212 ) 

213 missing_creators = [ 

214 { 

215 "name": ( 

216 f"{creator['person_or_org']['given_name']} " 

217 f"{creator['person_or_org']['family_name']}" 

218 ), 

219 "orcid": _creator_orcid(creator), 

220 } 

221 for creator in expected_creators 

222 if _creator_orcid(creator) in missing_orcids 

223 ] 

224 log_entry.update( 

225 { 

226 "entity_ids": entity_ids, 

227 "stage": stage, 

228 "missing_creators": missing_creators, 

229 } 

230 ) 

231 

232 if has_edit_draft: 

233 log_entry["status"] = "blocked" 

234 log_entry["reason"] = "An edit draft already exists" 

235 elif remote_status == "blocked": 

236 log_entry["status"] = "blocked" 

237 log_entry["reason"] = ( 

238 "Creator differences are not limited to missing creators" 

239 ) 

240 elif remote_status == "already_correct": 

241 log_entry["status"] = "already_correct" 

242 else: 

243 payload = _payload_with_creators(record, expected_creators) 

244 payload_differences = zenodo_payload_differences( 

245 payload, record, {"creators"} 

246 ) 

247 if payload_differences: 

248 log_entry["status"] = "blocked" 

249 log_entry["reason"] = ( 

250 "Remote metadata cannot be preserved in an update payload" 

251 ) 

252 log_entry["differences"] = payload_differences 

253 else: 

254 payload_file = f"{record_id}.yaml" 

255 write_payload(output_dir / payload_file, payload) 

256 log_entry["status"] = "would_patch" 

257 log_entry["payload_file"] = payload_file 

258 except (requests.RequestException, ValueError) as exc: 

259 log_entry["status"] = "error" 

260 log_entry["error"] = str(exc) 

261 console.print(f"\n[red][FAILED][/red] Record {record_id}: {exc}") 

262 

263 manifest.append(log_entry) 

264 stats[log_entry["status"]] += 1 

265 _atomic_write_json(manifest_path, manifest) 

266 progress.advance(task) 

267 if not cache_hit: 

268 time.sleep(REQUEST_DELAY) 

269 

270 console.print() 

271 console.print("[bold]Results:[/bold]") 

272 for status in ("would_patch", "already_correct", "blocked", "error"): 

273 console.print(f" {status}: {stats[status]}") 

274 console.print(f" Manifest: {manifest_path}") 

275 return manifest_path 

276 

277 

278def apply_creator_name_patches(drafts_path: Path, output_dir: Path) -> Path: 

279 return apply_prepared_updates(drafts_path, output_dir) 

280 

281 

282if __name__ == "__main__": # pragma: no cover 

283 parser = argparse.ArgumentParser( 

284 description="Patch missing creators on affected Zenodo records" 

285 ) 

286 subparsers = parser.add_subparsers(dest="command", required=True) 

287 

288 prepare_parser = subparsers.add_parser( 

289 "prepare", help="Audit records and prepare update payloads" 

290 ) 

291 prepare_parser.add_argument("drafts_json", type=Path, help="Path to drafts.json") 

292 prepare_parser.add_argument( 

293 "kg_path", type=Path, help="Path to knowledge graph (kg.ttl)" 

294 ) 

295 prepare_parser.add_argument( 

296 "output_dir", type=Path, help="Directory for prepared payloads" 

297 ) 

298 

299 apply_parser = subparsers.add_parser( 

300 "apply", help="Apply and publish prepared update payloads" 

301 ) 

302 apply_parser.add_argument("drafts_json", type=Path, help="Path to drafts.json") 

303 apply_parser.add_argument( 

304 "output_dir", type=Path, help="Directory containing prepared payloads" 

305 ) 

306 

307 args = parser.parse_args() 

308 if args.command == "prepare": 

309 prepare_creator_name_patches(args.drafts_json, args.kg_path, args.output_dir) 

310 else: 

311 apply_creator_name_patches(args.drafts_json, args.output_dir)