Coverage for oc_meta / run / merge / check_merged_brs_results.py: 30%

304 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-07-25 10:39 +0000

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

2# 

3# SPDX-License-Identifier: ISC 

4 

5import argparse 

6import csv 

7import os 

8import re 

9import zipfile 

10from functools import partial 

11import multiprocessing 

12 

13import yaml 

14from rdflib import RDF, Dataset, Literal, Namespace, URIRef 

15from rich_argparse import RichHelpFormatter 

16from oc_meta.lib.sparql import execute_sparql 

17from tqdm import tqdm 

18 

19from oc_meta.core.editor import MetaEditor 

20from oc_meta.lib.file_manager import find_rdf_file 

21from oc_meta.run.merge.check_utils import has_next_chain_issues 

22from oc_meta.run.merge.csv_utils import parse_merged_entities 

23 

24DATACITE = "http://purl.org/spar/datacite/" 

25FABIO = "http://purl.org/spar/fabio/" 

26PROV = Namespace("http://www.w3.org/ns/prov#") 

27PRO = Namespace("http://purl.org/spar/pro/") 

28DCTERMS = Namespace("http://purl.org/dc/terms/") 

29FRBR = Namespace("http://purl.org/vocab/frbr/core#") 

30PRISM = Namespace("http://prismstandard.org/namespaces/basic/2.0/") 

31 

32 

33def read_csv(csv_file): 

34 with open(csv_file, "r") as f: 

35 reader = csv.DictReader(f) 

36 return list(reader) 

37 

38 

39def check_br_constraints(g: Dataset, entity): 

40 issues = [] 

41 

42 # Check types 

43 types = list(g.objects(entity, RDF.type, unique=True)) 

44 if not types: 

45 issues.append(f"Entity {entity} has no type") 

46 elif len(types) > 2: 

47 issues.append(f"Entity {entity} has more than two types") 

48 elif URIRef(FABIO + "Expression") not in types: 

49 issues.append(f"Entity {entity} is not a fabio:Expression") 

50 

51 # Check if entity is a journal issue or volume 

52 is_journal_issue = URIRef(FABIO + "JournalIssue") in types 

53 is_journal_volume = URIRef(FABIO + "JournalVolume") in types 

54 

55 # Check identifiers 

56 identifiers = list( 

57 g.objects(entity, URIRef(DATACITE + "hasIdentifier"), unique=True) 

58 ) 

59 if not identifiers: 

60 issues.append(f"Entity {entity} has no datacite:hasIdentifier") 

61 

62 # Check title (zero or one) 

63 titles = list(g.objects(entity, DCTERMS.title, unique=True)) 

64 if len(titles) > 1: 

65 issues.append(f"Entity {entity} has multiple titles") 

66 

67 # Check part of (zero or one) 

68 part_of = list(g.objects(entity, FRBR.partOf, unique=True)) 

69 if len(part_of) > 1: 

70 issues.append(f"Entity {entity} has multiple partOf relations") 

71 

72 # Check publication date (zero or one) 

73 pub_dates = list(g.objects(entity, PRISM.publicationDate, unique=True)) 

74 if len(pub_dates) > 1: 

75 issues.append(f"Entity {entity} has multiple publication dates") 

76 

77 # Check sequence identifier (zero or one) 

78 seq_ids = list( 

79 g.objects(entity, URIRef(FABIO + "hasSequenceIdentifier"), unique=True) 

80 ) 

81 if len(seq_ids) > 1: 

82 issues.append(f"Entity {entity} has multiple sequence identifiers") 

83 elif seq_ids and not (is_journal_issue or is_journal_volume): 

84 issues.append( 

85 f"Entity {entity} has sequence identifier but is not a journal issue or volume" 

86 ) 

87 

88 return issues 

89 

90 

91def check_entity_sparql(endpoint: str, entity_uri, is_surviving): 

92 has_issues = False 

93 

94 exists_query = f""" 

95 ASK {{ 

96 <{entity_uri}> ?p ?o . 

97 }} 

98 """ 

99 exists_results = execute_sparql( 

100 endpoint, exists_query, max_retries=3, backoff_factor=1 

101 ) 

102 

103 exists = exists_results["boolean"] 

104 if exists: 

105 if not is_surviving: 

106 tqdm.write(f"Error in SPARQL: Merged entity {entity_uri} still exists") 

107 has_issues = True 

108 elif is_surviving: 

109 tqdm.write(f"Error in SPARQL: Surviving entity {entity_uri} does not exist") 

110 has_issues = True 

111 return has_issues 

112 

113 if not is_surviving: 

114 referenced_query = f""" 

115 ASK {{ 

116 ?s ?p <{entity_uri}> . 

117 }} 

118 """ 

119 referenced_results = execute_sparql( 

120 endpoint, referenced_query, max_retries=3, backoff_factor=1 

121 ) 

122 

123 if referenced_results["boolean"]: 

124 tqdm.write( 

125 f"Error in SPARQL: Merged entity {entity_uri} is still referenced by other entities" 

126 ) 

127 has_issues = True 

128 

129 if not exists: 

130 return has_issues 

131 

132 if not exists: 

133 return has_issues 

134 

135 types_query = f""" 

136 SELECT ?type WHERE {{ 

137 <{entity_uri}> a ?type . 

138 }} 

139 """ 

140 types_results = execute_sparql( 

141 endpoint, types_query, max_retries=3, backoff_factor=1 

142 ) 

143 

144 types = [result["type"]["value"] for result in types_results["results"]["bindings"]] 

145 if not types: 

146 tqdm.write(f"Error in SPARQL: Entity {entity_uri} has no type") 

147 has_issues = True 

148 elif len(types) > 2: 

149 tqdm.write(f"Error in SPARQL: Entity {entity_uri} has more than two types") 

150 has_issues = True 

151 elif FABIO + "Expression" not in types: 

152 tqdm.write(f"Error in SPARQL: Entity {entity_uri} is not a fabio:Expression") 

153 has_issues = True 

154 

155 identifiers_query = f""" 

156 SELECT ?identifier WHERE {{ 

157 <{entity_uri}> <{DATACITE}hasIdentifier> ?identifier . 

158 }} 

159 """ 

160 identifiers_results = execute_sparql( 

161 endpoint, identifiers_query, max_retries=3, backoff_factor=1 

162 ) 

163 

164 identifiers = [ 

165 result["identifier"]["value"] 

166 for result in identifiers_results["results"]["bindings"] 

167 ] 

168 if not identifiers: 

169 tqdm.write( 

170 f"Error in SPARQL: Entity {entity_uri} has no datacite:hasIdentifier" 

171 ) 

172 has_issues = True 

173 

174 if is_surviving and check_duplicate_contributor_roles(endpoint, entity_uri): 

175 has_issues = True 

176 

177 if is_surviving and check_duplicate_identifiers(endpoint, entity_uri): 

178 has_issues = True 

179 

180 if is_surviving and check_has_next_integrity(endpoint, entity_uri): 

181 has_issues = True 

182 

183 return has_issues 

184 

185 

186def check_duplicate_identifiers(endpoint: str, entity_uri): 

187 query = f""" 

188 PREFIX datacite: <{DATACITE}> 

189 PREFIX literal: <http://www.essepuntato.it/2010/06/literalreification/> 

190 SELECT ?scheme ?value (COUNT(DISTINCT ?identifier) AS ?count) WHERE {{ 

191 <{entity_uri}> datacite:hasIdentifier ?identifier . 

192 ?identifier datacite:usesIdentifierScheme ?scheme . 

193 ?identifier literal:hasLiteralValue ?value . 

194 }} 

195 GROUP BY ?scheme ?value 

196 HAVING (COUNT(DISTINCT ?identifier) > 1) 

197 """ 

198 results = execute_sparql(endpoint, query, max_retries=3, backoff_factor=1) 

199 

200 has_issues = False 

201 for result in results["results"]["bindings"]: 

202 has_issues = True 

203 scheme = result["scheme"]["value"].split("/")[-1] 

204 tqdm.write( 

205 f"Error in SPARQL: Entity {entity_uri} has {result['count']['value']} " 

206 f"duplicate identifiers with scheme {scheme} and value {result['value']['value']}" 

207 ) 

208 return has_issues 

209 

210 

211def check_duplicate_contributor_roles(endpoint: str, entity_uri): 

212 query = f""" 

213 PREFIX pro: <{PRO}> 

214 PREFIX frbr: <{FRBR}> 

215 SELECT ?container ?roleType ?agent (COUNT(DISTINCT ?ar) AS ?count) WHERE {{ 

216 {{ BIND(<{entity_uri}> AS ?container) }} 

217 UNION 

218 {{ <{entity_uri}> frbr:partOf+ ?container }} 

219 ?container pro:isDocumentContextFor ?ar . 

220 ?ar pro:withRole ?roleType . 

221 ?ar pro:isHeldBy ?agent . 

222 }} 

223 GROUP BY ?container ?roleType ?agent 

224 HAVING (COUNT(DISTINCT ?ar) > 1) 

225 """ 

226 results = execute_sparql(endpoint, query, max_retries=3, backoff_factor=1) 

227 

228 has_issues = False 

229 for result in results["results"]["bindings"]: 

230 has_issues = True 

231 role_type = result["roleType"]["value"].split("/")[-1] 

232 tqdm.write( 

233 f"Error in SPARQL: Entity {result['container']['value']} has " 

234 f"{result['count']['value']} duplicate {role_type} roles held by " 

235 f"{result['agent']['value']}" 

236 ) 

237 return has_issues 

238 

239 

240def check_has_next_integrity(endpoint: str, entity_uri): 

241 issues = has_next_chain_issues(endpoint, f"BIND(<{entity_uri}> AS ?br)") 

242 for issue in issues: 

243 tqdm.write(f"Error in SPARQL: {issue}") 

244 return bool(issues) 

245 

246 

247def get_entity_triples(endpoint: str, entity_uri): 

248 query = f""" 

249 SELECT ?g ?s ?p ?o 

250 WHERE {{ 

251 GRAPH ?g {{ 

252 {{ 

253 <{entity_uri}> ?p ?o . 

254 BIND(<{entity_uri}> AS ?s) 

255 }} 

256 UNION 

257 {{ 

258 ?s ?p <{entity_uri}> . 

259 BIND(<{entity_uri}> AS ?o) 

260 }} 

261 }} 

262 }} 

263 """ 

264 results = execute_sparql(endpoint, query, max_retries=3, backoff_factor=1) 

265 

266 triples = [] 

267 for result in results["results"]["bindings"]: 

268 graph = result["g"]["value"] 

269 subject = URIRef(result["s"]["value"]) 

270 predicate = URIRef(result["p"]["value"]) 

271 

272 obj_data = result["o"] 

273 if obj_data["type"] == "uri": 

274 obj = URIRef(obj_data["value"]) 

275 else: 

276 datatype = obj_data.get("datatype") 

277 obj = Literal( 

278 obj_data["value"], datatype=URIRef(datatype) if datatype else None 

279 ) 

280 

281 triples.append((graph, subject, predicate, obj)) 

282 

283 return triples 

284 

285 

286def generate_update_query(merged_entity, surviving_entity, triples): 

287 delete_query = "DELETE DATA {\n" 

288 insert_query = "INSERT DATA {\n" 

289 

290 for graph, subject, predicate, obj in triples: 

291 if subject == URIRef(merged_entity): 

292 delete_query += ( 

293 f" GRAPH <{graph}> {{ <{subject}> <{predicate}> {obj.n3()} }}\n" 

294 ) 

295 elif obj == URIRef(merged_entity): 

296 delete_query += ( 

297 f" GRAPH <{graph}> {{ <{subject}> <{predicate}> <{obj}> }}\n" 

298 ) 

299 insert_query += f" GRAPH <{graph}> {{ <{subject}> <{predicate}> <{surviving_entity}> }}\n" 

300 

301 delete_query += "}\n" 

302 insert_query += "}\n" 

303 

304 combined_query = delete_query + "\n" + insert_query 

305 return combined_query 

306 

307 

308def process_csv(args, csv_file): 

309 csv_path, rdf_dir, meta_config_path, sparql_endpoint, query_output_dir = args 

310 csv_path = os.path.join(csv_path, csv_file) 

311 data = read_csv(csv_path) 

312 tasks = [] 

313 

314 meta_editor = MetaEditor(meta_config_path, "") 

315 

316 for row in data: 

317 if "Done" not in row or row["Done"] != "True": 

318 continue 

319 

320 surviving_entity = row["surviving_entity"] 

321 merged_entities = parse_merged_entities(row["merged_entities"]) 

322 all_entities = [surviving_entity] + merged_entities 

323 

324 for entity in all_entities: 

325 file_path = find_rdf_file( 

326 entity, 

327 rdf_dir, 

328 meta_editor.dir_split, 

329 meta_editor.n_file_item, 

330 zip_output=True, 

331 ) 

332 tasks.append( 

333 ( 

334 entity, 

335 entity == surviving_entity, 

336 file_path, 

337 rdf_dir, 

338 meta_config_path, 

339 sparql_endpoint, 

340 query_output_dir, 

341 surviving_entity, 

342 ) 

343 ) 

344 

345 return tasks 

346 

347 

348def process_file_group(args): 

349 file_path, entities, sparql_endpoint, query_output_dir = args 

350 

351 if file_path is None: 

352 for entity, is_surviving, surviving_entity in entities: 

353 tqdm.write(f"Error: Could not find file for entity {entity}") 

354 has_issues = check_entity_sparql(sparql_endpoint, entity, is_surviving) 

355 if has_issues and not is_surviving: 

356 triples = get_entity_triples(sparql_endpoint, entity) 

357 combined_query = generate_update_query( 

358 entity, surviving_entity, triples 

359 ) 

360 query_file_path = os.path.join( 

361 query_output_dir, f"update_{entity.split('/')[-1]}.sparql" 

362 ) 

363 with open(query_file_path, "w") as f: 

364 f.write(combined_query) 

365 return 

366 

367 try: 

368 with zipfile.ZipFile(file_path, "r") as zip_ref: 

369 g = Dataset(default_union=True) 

370 for filename in zip_ref.namelist(): 

371 with zip_ref.open(filename) as file: 

372 g.parse(file, format="json-ld") 

373 except FileNotFoundError: 

374 for entity, is_surviving, surviving_entity in entities: 

375 tqdm.write(f"Error: File not found for entity {entity}") 

376 has_issues = check_entity_sparql(sparql_endpoint, entity, is_surviving) 

377 if has_issues and not is_surviving: 

378 triples = get_entity_triples(sparql_endpoint, entity) 

379 combined_query = generate_update_query( 

380 entity, surviving_entity, triples 

381 ) 

382 query_file_path = os.path.join( 

383 query_output_dir, f"update_{entity.split('/')[-1]}.sparql" 

384 ) 

385 with open(query_file_path, "w") as f: 

386 f.write(combined_query) 

387 return 

388 

389 prov_file_path = file_path.replace(".zip", "") + "/prov/se.zip" 

390 prov_graph = None 

391 try: 

392 with zipfile.ZipFile(prov_file_path, "r") as zip_ref: 

393 prov_graph = Dataset(default_union=True) 

394 for filename in zip_ref.namelist(): 

395 with zip_ref.open(filename) as file: 

396 prov_graph.parse(file, format="json-ld") 

397 except FileNotFoundError: 

398 prov_graph = None 

399 except zipfile.BadZipFile: 

400 prov_graph = "badzip" 

401 

402 for entity, is_surviving, surviving_entity in entities: 

403 if (URIRef(entity), None, None) not in g: 

404 if is_surviving: 

405 tqdm.write( 

406 f"Error in file {file_path}: Surviving entity {entity} does not exist" 

407 ) 

408 else: 

409 if not is_surviving: 

410 tqdm.write( 

411 f"Error in file {file_path}: Merged entity {entity} still exists" 

412 ) 

413 else: 

414 br_issues = check_br_constraints(g, URIRef(entity)) 

415 for issue in br_issues: 

416 tqdm.write(f"Error in file {file_path}: {issue}") 

417 

418 if prov_graph is None: 

419 tqdm.write(f"Error: Provenance file not found for entity {entity}") 

420 elif prov_graph == "badzip": 

421 tqdm.write(f"Error: Invalid zip file for provenance of entity {entity}") 

422 else: 

423 check_entity_provenance( 

424 URIRef(entity), is_surviving, prov_graph, prov_file_path 

425 ) 

426 

427 has_issues = check_entity_sparql(sparql_endpoint, entity, is_surviving) 

428 

429 if has_issues and not is_surviving: 

430 triples = get_entity_triples(sparql_endpoint, entity) 

431 combined_query = generate_update_query(entity, surviving_entity, triples) 

432 query_file_path = os.path.join( 

433 query_output_dir, f"update_{entity.split('/')[-1]}.sparql" 

434 ) 

435 with open(query_file_path, "w") as f: 

436 f.write(combined_query) 

437 

438 

439def check_entity_provenance(entity_uri, is_surviving, prov_graph, prov_file_path): 

440 def extract_snapshot_number(snapshot_uri): 

441 match = re.search(r"/prov/se/(\d+)$", str(snapshot_uri)) 

442 if match: 

443 return int(match.group(1)) 

444 return 0 

445 

446 snapshots = list( 

447 prov_graph.subjects(PROV.specializationOf, entity_uri, unique=True) 

448 ) 

449 if len(snapshots) <= 1: 

450 tqdm.write( 

451 f"Error in provenance file {prov_file_path}: Less than two snapshots found for entity {entity_uri}" 

452 ) 

453 return 

454 

455 snapshots.sort(key=extract_snapshot_number) 

456 for i, snapshot in enumerate(snapshots): 

457 snapshot_number = extract_snapshot_number(snapshot) 

458 if snapshot_number != i + 1: 

459 tqdm.write( 

460 f"Error in provenance file {prov_file_path}: Snapshot {snapshot} has unexpected number {snapshot_number}, expected {i + 1}" 

461 ) 

462 

463 gen_time = prov_graph.value(snapshot, PROV.generatedAtTime) 

464 if gen_time is None: 

465 tqdm.write( 

466 f"Error in provenance file {prov_file_path}: Snapshot {snapshot} has no generation time" 

467 ) 

468 

469 if i < len(snapshots) - 1 or not is_surviving: 

470 invalidation_time = prov_graph.value(snapshot, PROV.invalidatedAtTime) 

471 if invalidation_time is None: 

472 tqdm.write( 

473 f"Error in provenance file {prov_file_path}: Non-last snapshot {snapshot} has no invalidation time" 

474 ) 

475 elif ( 

476 is_surviving 

477 and prov_graph.value(snapshot, PROV.invalidatedAtTime) is not None 

478 ): 

479 tqdm.write( 

480 f"Error in provenance file {prov_file_path}: Last snapshot of surviving entity {snapshot} should not have invalidation time" 

481 ) 

482 

483 description = prov_graph.value(snapshot, DCTERMS.description) 

484 is_merge_snapshot = description and "has been merged with" in str(description) 

485 

486 derived_from = list(prov_graph.objects(snapshot, PROV.wasDerivedFrom)) 

487 if i == 0: # First snapshot 

488 if derived_from: 

489 tqdm.write( 

490 f"Error in provenance file {prov_file_path}: First snapshot {snapshot} should not have prov:wasDerivedFrom relation" 

491 ) 

492 elif is_merge_snapshot: 

493 if len(derived_from) < 2: 

494 tqdm.write( 

495 f"Error in provenance file {prov_file_path}: Merge snapshot {snapshot} should be derived from at least two snapshots" 

496 ) 

497 else: # Regular modification snapshot 

498 if len(derived_from) != 1: 

499 tqdm.write( 

500 f"Error in provenance file {prov_file_path}: Regular modification snapshot {snapshot} should have exactly one prov:wasDerivedFrom relation" 

501 ) 

502 else: 

503 previous_snapshot = snapshots[i - 1] 

504 if derived_from[0] != previous_snapshot: 

505 tqdm.write( 

506 f"Error in provenance file {prov_file_path}: Snapshot {snapshot} is not derived from the previous snapshot" 

507 ) 

508 

509 if not is_surviving: 

510 # Check if the last snapshot is invalidated for merged entities 

511 last_snapshot = snapshots[-1] 

512 invalidation = list(prov_graph.objects(last_snapshot, PROV.invalidatedAtTime)) 

513 if not invalidation: 

514 tqdm.write( 

515 f"Error in provenance file {prov_file_path}: Last snapshot {last_snapshot} of merged entity {entity_uri} is not invalidated" 

516 ) 

517 

518 

519def main(): 

520 parser = argparse.ArgumentParser( 

521 description="Check merge process success on files and SPARQL endpoint for bibliographic resources", 

522 formatter_class=RichHelpFormatter, 

523 ) 

524 parser.add_argument( 

525 "csv_folder", type=str, help="Path to the folder containing CSV files" 

526 ) 

527 parser.add_argument("rdf_dir", type=str, help="Path to the RDF directory") 

528 parser.add_argument( 

529 "--meta_config", type=str, required=True, help="Path to meta configuration file" 

530 ) 

531 parser.add_argument( 

532 "--query_output", 

533 type=str, 

534 required=True, 

535 help="Path to the folder where SPARQL queries will be saved", 

536 ) 

537 args = parser.parse_args() 

538 

539 with open(args.meta_config, "r") as config_file: 

540 config = yaml.safe_load(config_file) 

541 

542 sparql_endpoint = config["triplestore_url"] 

543 

544 os.makedirs(args.query_output, exist_ok=True) 

545 

546 csv_files = [f for f in os.listdir(args.csv_folder) if f.endswith(".csv")] 

547 

548 # Use forkserver to avoid deadlocks when forking in a multi-threaded environment 

549 ctx = multiprocessing.get_context("forkserver") 

550 

551 # Process CSV files to gather tasks 

552 with ctx.Pool(processes=multiprocessing.cpu_count()) as pool: 

553 process_csv_partial = partial( 

554 process_csv, 

555 ( 

556 args.csv_folder, 

557 args.rdf_dir, 

558 args.meta_config, 

559 sparql_endpoint, 

560 args.query_output, 

561 ), 

562 ) 

563 all_tasks_list = list( 

564 tqdm( 

565 pool.imap(process_csv_partial, csv_files), 

566 total=len(csv_files), 

567 desc="Processing CSV files", 

568 ) 

569 ) 

570 

571 # Flatten the list of lists into a single list 

572 all_tasks = [task for sublist in all_tasks_list for task in sublist] 

573 

574 # Now we have a list of tasks (entity, is_surviving, file_path, rdf_dir, meta_config_path, sparql_endpoint, query_output_dir, surviving_entity) 

575 # We want to group by file_path to open each file only once 

576 tasks_by_file = {} 

577 for ( 

578 entity, 

579 is_surviving, 

580 file_path, 

581 rdf_dir, 

582 meta_config_path, 

583 sparql_endpoint, 

584 query_output_dir, 

585 surviving_entity, 

586 ) in all_tasks: 

587 if file_path not in tasks_by_file: 

588 tasks_by_file[file_path] = [] 

589 tasks_by_file[file_path].append((entity, is_surviving, surviving_entity)) 

590 

591 # Convert to a list of arguments for parallel processing 

592 file_groups = [ 

593 (file_path, tasks, sparql_endpoint, args.query_output) 

594 for file_path, tasks in tasks_by_file.items() 

595 ] 

596 

597 # Process each file group in parallel 

598 with ctx.Pool(processes=multiprocessing.cpu_count()) as pool: 

599 list( 

600 tqdm( 

601 pool.imap(process_file_group, file_groups), 

602 total=len(file_groups), 

603 desc="Processing files", 

604 ) 

605 ) 

606 

607 

608if __name__ == "__main__": 

609 main()