Coverage for oc_meta / run / meta / preprocess_input.py: 99%

163 statements  

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

1#!/usr/bin/python 

2 

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

4# 

5# SPDX-License-Identifier: ISC 

6 

7from __future__ import annotations 

8 

9import argparse 

10import multiprocessing 

11import os 

12from concurrent.futures import ProcessPoolExecutor, as_completed 

13from dataclasses import dataclass 

14from typing import Callable, List 

15 

16import redis 

17import yaml 

18from rich.table import Table 

19from rich_argparse import RichHelpFormatter 

20 

21from oc_meta.constants import QLEVER_BATCH_SIZE, QLEVER_MAX_WORKERS 

22from oc_meta.lib.console import console, create_progress 

23from oc_meta.lib.file_manager import get_csv_data, normalize_path, write_csv 

24from oc_meta.lib.sparql import run_queries_parallel 

25from oc_meta.run.meta.merge_csv import resolve_output_path 

26 

27DATACITE_PREFIX = "http://purl.org/spar/datacite/" 

28 

29 

30@dataclass 

31class ProcessingStats: 

32 total_rows: int = 0 

33 duplicate_rows: int = 0 

34 existing_ids_rows: int = 0 

35 processed_rows: int = 0 

36 

37 

38@dataclass 

39class FileResult: 

40 file_path: str 

41 rows: list[tuple[tuple[tuple[str, str], ...], dict[str, str]]] 

42 stats: ProcessingStats 

43 

44 

45def create_redis_connection(host: str, port: int, db: int = 10) -> redis.Redis: 

46 return redis.Redis(host=host, port=port, db=db, decode_responses=True) 

47 

48 

49def check_ids_existence_batch( 

50 rows: list[dict[str, str]], redis_client: redis.Redis 

51) -> list[bool]: 

52 row_id_lists: list[list[str]] = [] 

53 for row in rows: 

54 ids_str = row["id"] 

55 row_id_lists.append(ids_str.split() if ids_str else []) 

56 

57 pipe = redis_client.pipeline() 

58 for id_list in row_id_lists: 

59 for id_str in id_list: 

60 pipe.exists(id_str) 

61 

62 results = pipe.execute() 

63 

64 row_results: list[bool] = [] 

65 idx = 0 

66 for id_list in row_id_lists: 

67 if not id_list: 

68 row_results.append(False) 

69 else: 

70 all_exist = True 

71 for _ in id_list: 

72 if not results[idx]: 

73 all_exist = False 

74 idx += 1 

75 row_results.append(all_exist) 

76 

77 return row_results 

78 

79 

80def check_ids_sparql( 

81 identifiers: set[str], 

82 endpoint_url: str, 

83 workers: int = QLEVER_MAX_WORKERS, 

84 progress_callback: Callable[[int], None] | None = None, 

85) -> set[str]: 

86 if not identifiers: 

87 return set() 

88 

89 id_list = sorted(identifiers) 

90 batch_queries: list[str] = [] 

91 batch_sizes: list[int] = [] 

92 

93 for i in range(0, len(id_list), QLEVER_BATCH_SIZE): 

94 batch = id_list[i : i + QLEVER_BATCH_SIZE] 

95 values_entries = [] 

96 for id_str in batch: 

97 schema, value = id_str.split(":", 1) 

98 escaped_value = value.replace("\\", "\\\\").replace('"', '\\"') 

99 values_entries.append( 

100 '("{}"^^xsd:string datacite:{})'.format(escaped_value, schema) 

101 ) 

102 

103 query = ( 

104 "PREFIX datacite: <http://purl.org/spar/datacite/>\n" 

105 "PREFIX literal: <http://www.essepuntato.it/2010/06/literalreification/>\n" 

106 "PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>\n" 

107 "SELECT ?val ?scheme WHERE {{\n" 

108 " VALUES (?val ?scheme) {{ {} }}\n" 

109 " ?id literal:hasLiteralValue ?val ;\n" 

110 " datacite:usesIdentifierScheme ?scheme .\n" 

111 "}}" 

112 ).format(" ".join(values_entries)) 

113 batch_queries.append(query) 

114 batch_sizes.append(len(batch)) 

115 

116 all_bindings = run_queries_parallel( 

117 endpoint_url, batch_queries, batch_sizes, workers, progress_callback 

118 ) 

119 

120 found: set[str] = set() 

121 for bindings in all_bindings: 

122 for result in bindings: 

123 val = result["val"]["value"] 

124 scheme_uri = result["scheme"]["value"] 

125 scheme = ( 

126 scheme_uri[len(DATACITE_PREFIX) :] 

127 if scheme_uri.startswith(DATACITE_PREFIX) 

128 else scheme_uri 

129 ) 

130 found.add("{}:{}".format(scheme, val)) 

131 

132 return found 

133 

134 

135def get_csv_files(directory: str) -> List[str]: 

136 if not os.path.isdir(directory): 

137 raise ValueError("The specified path '{}' is not a directory".format(directory)) 

138 

139 return [ 

140 os.path.join(directory, f) 

141 for f in os.listdir(directory) 

142 if f.endswith(".csv") and os.path.isfile(os.path.join(directory, f)) 

143 ] 

144 

145 

146def collect_rows_from_file(file_path: str) -> FileResult: 

147 data = get_csv_data(file_path, clean_data=False) 

148 stats = ProcessingStats() 

149 stats.total_rows = len(data) 

150 valid_rows: list[tuple[tuple[tuple[str, str], ...], dict[str, str]]] = [] 

151 for row in data: 

152 row_hash = tuple(sorted(row.items())) 

153 valid_rows.append((row_hash, row)) 

154 return FileResult(file_path=file_path, rows=valid_rows, stats=stats) 

155 

156 

157def filter_existing_ids_from_file( 

158 file_path: str, redis_host: str, redis_port: int, redis_db: int 

159) -> FileResult: 

160 redis_client = create_redis_connection(redis_host, redis_port, redis_db) 

161 data = get_csv_data(file_path, clean_data=False) 

162 

163 stats = ProcessingStats() 

164 stats.total_rows = len(data) 

165 

166 existence_results = check_ids_existence_batch(data, redis_client) 

167 

168 valid_rows: list[tuple[tuple[tuple[str, str], ...], dict[str, str]]] = [] 

169 for row, exists in zip(data, existence_results): 

170 if exists: 

171 stats.existing_ids_rows += 1 

172 else: 

173 row_hash = tuple(sorted(row.items())) 

174 valid_rows.append((row_hash, row)) 

175 

176 return FileResult(file_path=file_path, rows=valid_rows, stats=stats) 

177 

178 

179def filter_sparql_results( 

180 results: list[FileResult], 

181 found_ids: set[str], 

182) -> None: 

183 for result in results: 

184 filtered: list[tuple[tuple[tuple[str, str], ...], dict[str, str]]] = [] 

185 for row_hash, row in result.rows: 

186 ids_str = row["id"] 

187 if ids_str: 

188 row_ids = ids_str.split() 

189 if row_ids and all(id_str in found_ids for id_str in row_ids): 

190 result.stats.existing_ids_rows += 1 

191 continue 

192 filtered.append((row_hash, row)) 

193 result.rows = filtered 

194 

195 

196def deduplicate_and_write( 

197 results: list[FileResult], 

198 output_path: str, 

199 rows_per_file: int | None = None, 

200 subfolders: bool = False, 

201) -> ProcessingStats: 

202 def chunk_file(num: int) -> str: 

203 if subfolders: 

204 return os.path.join(output_path, str(num), "{}.csv".format(num)) 

205 return os.path.join(output_path, "{}.csv".format(num)) 

206 

207 seen_rows: set[tuple[tuple[str, str], ...]] = set() 

208 rows_to_write: list[dict[str, str]] = [] 

209 file_num = 0 

210 

211 total_stats = ProcessingStats() 

212 

213 with create_progress() as progress: 

214 task = progress.add_task("Deduplicating and writing", total=len(results)) 

215 

216 for result in results: 

217 total_stats.total_rows += result.stats.total_rows 

218 total_stats.existing_ids_rows += result.stats.existing_ids_rows 

219 

220 for row_hash, row in result.rows: 

221 if row_hash in seen_rows: 

222 total_stats.duplicate_rows += 1 

223 continue 

224 

225 seen_rows.add(row_hash) 

226 total_stats.processed_rows += 1 

227 rows_to_write.append(row) 

228 

229 if rows_per_file and len(rows_to_write) >= rows_per_file: 

230 write_csv(chunk_file(file_num), rows_to_write) 

231 file_num += 1 

232 rows_to_write = [] 

233 

234 progress.advance(task) 

235 

236 if rows_to_write: 

237 output_file = ( 

238 chunk_file(file_num) if rows_per_file else resolve_output_path(output_path) 

239 ) 

240 write_csv(output_file, rows_to_write) 

241 

242 return total_stats 

243 

244 

245def print_processing_report(stats: ProcessingStats, num_files: int) -> None: 

246 table = Table(title="Processing Report") 

247 table.add_column("Metric", style="cyan") 

248 table.add_column("Value", style="green") 

249 

250 table.add_row("Total input files processed", str(num_files)) 

251 table.add_row("Total input rows", str(stats.total_rows)) 

252 table.add_row("Rows discarded (duplicates)", str(stats.duplicate_rows)) 

253 table.add_row("Rows discarded (existing IDs)", str(stats.existing_ids_rows)) 

254 table.add_row("Rows written to output", str(stats.processed_rows)) 

255 

256 if stats.total_rows > 0: 

257 duplicate_percent = (stats.duplicate_rows / stats.total_rows) * 100 

258 existing_percent = (stats.existing_ids_rows / stats.total_rows) * 100 

259 processed_percent = (stats.processed_rows / stats.total_rows) * 100 

260 

261 table.add_row("", "") 

262 table.add_row("Duplicate rows %", "{:.1f}%".format(duplicate_percent)) 

263 table.add_row("Existing IDs %", "{:.1f}%".format(existing_percent)) 

264 table.add_row("Processed rows %", "{:.1f}%".format(processed_percent)) 

265 

266 console.print(table) 

267 

268 

269def main(): # pragma: no cover 

270 parser = argparse.ArgumentParser( 

271 description=( 

272 "Split the input CSVs declared in a meta_config.yaml into chunks. " 

273 "Input directory and SPARQL endpoint are read from the config; the worker " 

274 "count is set with --workers. " 

275 "When rdf_files_only is True each chunk is written to its own subfolder " 

276 "(<output>/<n>/<n>.csv), so it can be processed and re-indexed one at a time " 

277 "(meta_process does not upload, so cross-chunk de-duplication needs the " 

278 "triplestore refreshed between chunks). When rdf_files_only is False all chunks " 

279 "share one folder, since meta_process uploads inline and resolves cross-chunk " 

280 "duplicates within a single run." 

281 ), 

282 formatter_class=RichHelpFormatter, 

283 ) 

284 parser.add_argument("config", help="Path to meta_config.yaml") 

285 parser.add_argument("output", help="Directory where chunk files are written") 

286 

287 output_group = parser.add_mutually_exclusive_group() 

288 output_group.add_argument( 

289 "--rows-per-file", 

290 type=int, 

291 default=None, 

292 help="Split output into files of N rows each (default: 3000)", 

293 ) 

294 output_group.add_argument( 

295 "--single-file", 

296 action="store_true", 

297 help="Write all output rows to a single CSV file", 

298 ) 

299 

300 filter_group = parser.add_mutually_exclusive_group() 

301 filter_group.add_argument( 

302 "--sparql", 

303 action="store_true", 

304 help="Drop rows whose IDs all already exist on Meta, queried via the config triplestore_url", 

305 ) 

306 filter_group.add_argument( 

307 "--redis-port", 

308 type=int, 

309 help="Drop rows whose IDs all already exist, checked against Redis on this port", 

310 ) 

311 parser.add_argument( 

312 "--redis-host", default="localhost", help="Redis host (default: localhost)" 

313 ) 

314 parser.add_argument( 

315 "--redis-db", type=int, default=10, help="Redis database number (default: 10)" 

316 ) 

317 parser.add_argument( 

318 "--workers", type=int, default=4, help="Number of parallel workers (default: 4)" 

319 ) 

320 args = parser.parse_args() 

321 

322 with open(args.config, encoding="utf-8") as f: 

323 settings = yaml.full_load(f) 

324 

325 input_dir = normalize_path(settings["input_csv_dir"]) 

326 sparql_endpoint = settings["triplestore_url"] 

327 rdf_files_only = settings["rdf_files_only"] 

328 

329 if args.single_file: 

330 rows_per_file = None 

331 elif args.rows_per_file is not None: 

332 rows_per_file = args.rows_per_file 

333 else: 

334 rows_per_file = 3000 

335 

336 if rows_per_file: 

337 os.makedirs(args.output, exist_ok=True) 

338 

339 csv_files = get_csv_files(input_dir) 

340 if not csv_files: 

341 console.print( 

342 "[red]No CSV files found in directory: {}[/red]".format(input_dir) 

343 ) 

344 return 1 

345 

346 subfolders = rows_per_file is not None and rdf_files_only 

347 layout = "one subfolder per chunk" if subfolders else "single folder" 

348 if args.redis_port is not None: 

349 mode = "redis" 

350 elif args.sparql: 

351 mode = "sparql" 

352 else: 

353 mode = "split-only" 

354 

355 console.print( 

356 "Found [green]{}[/green] CSV files; [green]{}[/green] workers; mode [green]{}[/green]; layout [green]{}[/green]".format( 

357 len(csv_files), args.workers, mode, layout 

358 ) 

359 ) 

360 

361 file_order = {f: i for i, f in enumerate(csv_files)} 

362 

363 if args.redis_port is not None: 

364 results: list[FileResult] = [] 

365 with create_progress() as progress: 

366 task = progress.add_task("Filtering existing IDs", total=len(csv_files)) 

367 with ProcessPoolExecutor( 

368 max_workers=args.workers, 

369 mp_context=multiprocessing.get_context("forkserver"), 

370 ) as executor: 

371 futures = { 

372 executor.submit( 

373 filter_existing_ids_from_file, 

374 csv_file, 

375 args.redis_host, 

376 args.redis_port, 

377 args.redis_db, 

378 ): csv_file 

379 for csv_file in csv_files 

380 } 

381 for future in as_completed(futures): 

382 results.append(future.result()) 

383 progress.advance(task) 

384 else: 

385 results = [] 

386 with create_progress() as progress: 

387 task = progress.add_task("Reading CSV files", total=len(csv_files)) 

388 with ProcessPoolExecutor( 

389 max_workers=args.workers, 

390 mp_context=multiprocessing.get_context("forkserver"), 

391 ) as executor: 

392 futures = { 

393 executor.submit(collect_rows_from_file, f): f for f in csv_files 

394 } 

395 for future in as_completed(futures): 

396 results.append(future.result()) 

397 progress.advance(task) 

398 

399 results.sort(key=lambda r: file_order[r.file_path]) 

400 

401 if args.sparql: 

402 all_ids: set[str] = set() 

403 for result in results: 

404 for _hash, row in result.rows: 

405 ids_str = row["id"] 

406 if ids_str: 

407 all_ids.update(ids_str.split()) 

408 

409 if all_ids: 

410 console.print( 

411 "Checking [green]{}[/green] unique identifiers against SPARQL endpoint".format( 

412 len(all_ids) 

413 ) 

414 ) 

415 with create_progress() as progress: 

416 task = progress.add_task("Querying SPARQL", total=len(all_ids)) 

417 

418 def on_batch(batch_size: int) -> None: 

419 progress.advance(task, batch_size) 

420 

421 found_ids = check_ids_sparql( 

422 all_ids, sparql_endpoint, args.workers, on_batch 

423 ) 

424 else: 

425 found_ids = set() 

426 

427 filter_sparql_results(results, found_ids) 

428 

429 total_stats = deduplicate_and_write( 

430 results, args.output, rows_per_file, subfolders=subfolders 

431 ) 

432 

433 print_processing_report(total_stats, len(csv_files)) 

434 

435 return 0 

436 

437 

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

439 main()