Coverage for src/time_agnostic_library/ocdm_converter.py: 100%

249 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-03 21:17 +0000

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

2# 

3# SPDX-License-Identifier: ISC 

4 

5import gzip 

6import re 

7from collections import defaultdict 

8from collections.abc import Callable, Generator 

9from concurrent.futures import ThreadPoolExecutor 

10from contextlib import contextmanager 

11from datetime import datetime 

12from pathlib import Path 

13 

14PROV_NS = "http://www.w3.org/ns/prov#" 

15OCO_NS = "https://w3id.org/oc/ontology/" 

16DCTERMS_NS = "http://purl.org/dc/terms/" 

17XSD_NS = "http://www.w3.org/2001/XMLSchema#" 

18 

19_TRIPLE_LEN = 3 

20 

21# Regex to parse an N-Triples line into (subject, predicate, object) in a single 

22# C-level pass. Falls back to the character-by-character parser on mismatch. 

23# 

24# N-Triples line format: <subject> <predicate> <object> . 

25# 

26# Group 1 - subject: URI <http://...> or blank node _:id 

27# Group 2 - predicate: always a URI <http://...> 

28# Group 3 - object, one of: 

29# - URI: <http://...> 

30# - literal: "text" optionally followed by @lang or ^^<datatype> 

31# - blank node: _:id 

32_NT_RE = re.compile( 

33 r"(<[^>]+>|_:\S+)\s+" # group 1: subject (URI or blank node) 

34 r"(<[^>]+>)\s+" # group 2: predicate (URI) 

35 r"(<[^>]+>" # group 3 option a: URI object 

36 r'|"(?:[^"\\]|\\.)*"' # group 3 option b: quoted literal (handles escapes) 

37 r"(?:@[a-zA-Z-]+|\^\^<[^>]+>)?" # optional language tag or datatype 

38 r"|_:\S+)" # group 3 option c: blank node object 

39 r"\s*\.\s*$" # trailing dot and whitespace 

40) 

41 

42 

43def parse_ntriples_line( 

44 line: str, 

45 term_normalizer: Callable[[str], str] | None = None, 

46) -> tuple[str, str, str] | None: 

47 line = line.strip() 

48 if not line or line.startswith("#"): 

49 return None 

50 m = _NT_RE.match(line) 

51 if m: 

52 subject, obj = m.group(1), m.group(3) 

53 if term_normalizer: 

54 subject = term_normalizer(subject) 

55 obj = term_normalizer(obj) 

56 return (subject, m.group(2), obj) 

57 if line.endswith(" ."): 

58 line = line[:-2] 

59 elif line.endswith("."): 

60 line = line[:-1] 

61 line = line.strip() 

62 parts = [] 

63 i = 0 

64 while i < len(line) and len(parts) < _TRIPLE_LEN: 

65 if line[i] == "<": 

66 end = line.index(">", i) 

67 parts.append(line[i : end + 1]) 

68 i = end + 1 

69 elif line[i] == '"': 

70 j = i + 1 

71 while j < len(line): 

72 if line[j] == "\\" and j + 1 < len(line): 

73 j += 2 

74 continue 

75 if line[j] == '"': 

76 break 

77 j += 1 

78 end_quote = j 

79 rest_start = end_quote + 1 

80 if rest_start < len(line) and line[rest_start : rest_start + 2] == "^^": 

81 dt_start = rest_start + 2 

82 if dt_start < len(line) and line[dt_start] == "<": 

83 dt_end = line.index(">", dt_start) 

84 parts.append(line[i : dt_end + 1]) 

85 i = dt_end + 1 

86 else: 

87 space = line.find(" ", dt_start) 

88 if space == -1: 

89 parts.append(line[i:]) 

90 i = len(line) 

91 else: 

92 parts.append(line[i:space]) 

93 i = space 

94 elif rest_start < len(line) and line[rest_start] == "@": 

95 space = line.find(" ", rest_start) 

96 if space == -1: 

97 parts.append(line[i:]) 

98 i = len(line) 

99 else: 

100 parts.append(line[i:space]) 

101 i = space 

102 else: 

103 parts.append(line[i : end_quote + 1]) 

104 i = end_quote + 1 

105 elif line[i] == "_": 

106 space = line.find(" ", i) 

107 if space == -1: 

108 parts.append(line[i:]) 

109 i = len(line) 

110 else: 

111 parts.append(line[i:space]) 

112 i = space 

113 elif line[i] == " " or line[i] == "\t": 

114 i += 1 

115 else: 

116 space = line.find(" ", i) 

117 if space == -1: 

118 parts.append(line[i:]) 

119 i = len(line) 

120 else: 

121 parts.append(line[i:space]) 

122 i = space 

123 if len(parts) == _TRIPLE_LEN: 

124 subject, obj = parts[0], parts[2] 

125 if term_normalizer: 

126 subject = term_normalizer(subject) 

127 obj = term_normalizer(obj) 

128 return (subject, parts[1], obj) 

129 return None 

130 

131 

132def extract_subject_uri(s_term: str) -> str: 

133 if s_term.startswith("<") and s_term.endswith(">"): 

134 return s_term[1:-1] 

135 return s_term 

136 

137 

138def _open_ntriples(filepath: Path): 

139 if filepath.suffix == ".gz": 

140 return gzip.open(filepath, "rt", encoding="utf-8", errors="replace") 

141 return filepath.open(encoding="utf-8", errors="replace") 

142 

143 

144def _open_for_writing(filepath: Path): 

145 if filepath.suffix == ".gz": 

146 return gzip.open(filepath, "wt", encoding="utf-8", compresslevel=6) 

147 return filepath.open("w", encoding="utf-8") 

148 

149 

150def read_ntriples_file( 

151 filepath: Path, 

152 term_normalizer: Callable[[str], str] | None = None, 

153) -> list[tuple[str, str, str]]: 

154 triples = [] 

155 with _open_ntriples(filepath) as f: 

156 for line in f: 

157 parsed = parse_ntriples_line(line, term_normalizer) 

158 if parsed: 

159 triples.append(parsed) 

160 return triples 

161 

162 

163def group_triples_by_subject( 

164 triples: list[tuple[str, str, str]], 

165) -> dict[str, set[tuple[str, str]]]: 

166 by_subject: dict[str, set[tuple[str, str]]] = defaultdict(set) 

167 for s, p, o in triples: 

168 uri = extract_subject_uri(s) 

169 by_subject[uri].add((p, o)) 

170 return by_subject 

171 

172 

173def _read_and_group( 

174 filepath: Path, 

175 term_normalizer: Callable[[str], str] | None = None, 

176) -> dict[str, set[tuple[str, str]]]: 

177 by_subject: dict[str, set[tuple[str, str]]] = defaultdict(set) 

178 match = _NT_RE.match 

179 with _open_ntriples(filepath) as f: 

180 for line in f: 

181 m = match(line) 

182 if m: 

183 s, p, obj = m.groups() 

184 if term_normalizer: 

185 s = term_normalizer(s) 

186 obj = term_normalizer(obj) 

187 uri = s[1:-1] if s[0] == "<" else s 

188 else: 

189 parsed = parse_ntriples_line(line, term_normalizer) 

190 if not parsed: 

191 continue 

192 s, p, obj = parsed 

193 uri = s[1:-1] if s[0] == "<" and s[-1] == ">" else s 

194 by_subject[uri].add((p, obj)) 

195 return by_subject 

196 

197 

198def _format_timestamp(dt: datetime) -> str: 

199 return dt.strftime("%Y-%m-%dT%H:%M:%S+00:00") 

200 

201 

202def _build_update_query( 

203 entity_uri: str, 

204 data_graph_uri: str, 

205 deleted_po: set[tuple[str, str]], 

206 added_po: set[tuple[str, str]], 

207) -> str: 

208 parts = [] 

209 if deleted_po: 

210 triples = " ".join(f"<{entity_uri}> {p} {o} ." for p, o in deleted_po) 

211 parts.append(f"DELETE DATA {{ GRAPH <{data_graph_uri}> {{ {triples} }} }}") 

212 if added_po: 

213 triples = " ".join(f"<{entity_uri}> {p} {o} ." for p, o in added_po) 

214 parts.append(f"INSERT DATA {{ GRAPH <{data_graph_uri}> {{ {triples} }} }}") 

215 return "; ".join(parts) 

216 

217 

218def _escape_sparql_for_nquads(query: str) -> str: 

219 escaped = query.replace("\\", "\\\\") 

220 escaped = escaped.replace('"', '\\"') 

221 escaped = escaped.replace("\n", "\\n") 

222 escaped = escaped.replace("\r", "\\r") 

223 return escaped.replace("\t", "\\t") 

224 

225 

226class _ProvenanceWriter: 

227 """Serializes the provenance of every entity while the diffs are computed.""" 

228 

229 def __init__( 

230 self, 

231 out, 

232 data_graph_uri: str, 

233 agent_uri: str, 

234 timestamps: list[datetime], 

235 ) -> None: 

236 self._out = out 

237 self._data_graph_uri = data_graph_uri 

238 self._agent_uri = agent_uri 

239 self._timestamps = timestamps 

240 self._changes: dict[str, int] = {} 

241 # Entities whose last change left them without triples, and the version 

242 # it happened at. A reappearance removes the entry, so what is left at 

243 # the end are the entities to mark as invalidated. 

244 self._emptied_at: dict[str, int] = {} 

245 

246 def created(self, entity_uri: str) -> None: 

247 """Record an entity the conversion has not seen before.""" 

248 if entity_uri in self._changes: 

249 return 

250 self._changes[entity_uri] = 0 

251 prov_graph = f"<{entity_uri}/prov/>" 

252 se1_uri = f"<{entity_uri}/prov/se/1>" 

253 t0 = _format_timestamp(self._timestamps[0]) 

254 self._out.write( 

255 f"{se1_uri} <{PROV_NS}specializationOf> <{entity_uri}> {prov_graph} .\n" 

256 f'{se1_uri} <{PROV_NS}generatedAtTime> "{t0}"^^<{XSD_NS}dateTime> ' 

257 f"{prov_graph} .\n" 

258 f"{se1_uri} <{PROV_NS}wasAttributedTo> <{self._agent_uri}> {prov_graph} .\n" 

259 f'{se1_uri} <{DCTERMS_NS}description> "The entity has been created." ' 

260 f"{prov_graph} .\n" 

261 ) 

262 

263 def changed( 

264 self, 

265 entity_uri: str, 

266 version_idx: int, 

267 deleted_po: set[tuple[str, str]], 

268 added_po: set[tuple[str, str]], 

269 *, 

270 emptied: bool, 

271 ) -> None: 

272 se_num = self._changes[entity_uri] + 2 

273 self._changes[entity_uri] += 1 

274 prov_graph = f"<{entity_uri}/prov/>" 

275 se_uri = f"<{entity_uri}/prov/se/{se_num}>" 

276 prev_se_uri = f"<{entity_uri}/prov/se/{se_num - 1}>" 

277 timestamp = _format_timestamp(self._timestamps[version_idx]) 

278 update_query = _build_update_query( 

279 entity_uri, self._data_graph_uri, deleted_po, added_po 

280 ) 

281 escaped_query = _escape_sparql_for_nquads(update_query) 

282 

283 self._out.write( 

284 f"{se_uri} <{PROV_NS}specializationOf> <{entity_uri}> {prov_graph} .\n" 

285 f'{se_uri} <{PROV_NS}generatedAtTime> "{timestamp}"' 

286 f"^^<{XSD_NS}dateTime> {prov_graph} .\n" 

287 f"{se_uri} <{PROV_NS}wasAttributedTo> <{self._agent_uri}> {prov_graph} .\n" 

288 f'{se_uri} <{OCO_NS}hasUpdateQuery> "{escaped_query}" {prov_graph} .\n' 

289 f"{se_uri} <{DCTERMS_NS}description> " 

290 f'"The entity has been modified." {prov_graph} .\n' 

291 f"{se_uri} <{PROV_NS}wasDerivedFrom> {prev_se_uri} {prov_graph} .\n" 

292 ) 

293 

294 if emptied: 

295 self._emptied_at[entity_uri] = version_idx 

296 else: 

297 self._emptied_at.pop(entity_uri, None) 

298 

299 def finish(self) -> None: 

300 """Mark as invalidated the entities that never came back.""" 

301 for entity_uri, version_idx in self._emptied_at.items(): 

302 se_num = self._changes[entity_uri] + 1 

303 self._out.write( 

304 f"<{entity_uri}/prov/se/{se_num}> <{PROV_NS}invalidatedAtTime> " 

305 f'"{_format_timestamp(self._timestamps[version_idx])}"' 

306 f"^^<{XSD_NS}dateTime> <{entity_uri}/prov/> .\n" 

307 ) 

308 

309 

310class OCDMConverter: 

311 def __init__( 

312 self, 

313 data_graph_uri: str, 

314 agent_uri: str, 

315 term_normalizer: Callable[[str], str] | None = None, 

316 ): 

317 self.data_graph_uri = data_graph_uri 

318 self.agent_uri = agent_uri 

319 self.term_normalizer = term_normalizer 

320 

321 def convert_from_ic( 

322 self, 

323 ic_files: list[Path], 

324 timestamps: list[datetime], 

325 dataset_output: Path, 

326 provenance_output: Path, 

327 ) -> None: 

328 prev_by_subject: dict[str, set[tuple[str, str]]] = {} 

329 latest_by_subject: dict[str, set[tuple[str, str]]] = {} 

330 

331 # Prefetch pipeline: a single background thread reads and parses the 

332 # next IC file while the main thread diffs the current pair. Two 

333 # consecutive versions are all a conversion ever holds. 

334 with ( 

335 self._open_provenance(provenance_output, timestamps) as writer, 

336 ThreadPoolExecutor(max_workers=1) as executor, 

337 ): 

338 future = executor.submit(_read_and_group, ic_files[0], self.term_normalizer) 

339 

340 for version_idx in range(len(ic_files)): 

341 cur_by_subject = future.result() 

342 

343 if version_idx + 1 < len(ic_files): 

344 future = executor.submit( 

345 _read_and_group, 

346 ic_files[version_idx + 1], 

347 self.term_normalizer, 

348 ) 

349 

350 for entity_uri in cur_by_subject: 

351 writer.created(entity_uri) 

352 

353 if version_idx > 0: 

354 for entity_uri in prev_by_subject.keys() | cur_by_subject.keys(): 

355 prev_po = prev_by_subject.get(entity_uri, set()) 

356 cur_po = cur_by_subject.get(entity_uri, set()) 

357 deleted_po = prev_po - cur_po 

358 added_po = cur_po - prev_po 

359 if deleted_po or added_po: 

360 writer.changed( 

361 entity_uri, 

362 version_idx, 

363 deleted_po, 

364 added_po, 

365 emptied=not cur_po, 

366 ) 

367 

368 prev_by_subject = cur_by_subject 

369 if version_idx == len(ic_files) - 1: 

370 latest_by_subject = cur_by_subject 

371 

372 self._write_dataset(dataset_output, latest_by_subject) 

373 

374 def convert_from_cb( 

375 self, 

376 initial_snapshot: Path, 

377 changesets: list[tuple[Path, Path]], 

378 timestamps: list[datetime], 

379 dataset_output: Path, 

380 provenance_output: Path, 

381 ) -> None: 

382 current_state: dict[str, set[tuple[str, str]]] = defaultdict( 

383 set, _read_and_group(initial_snapshot, self.term_normalizer) 

384 ) 

385 

386 # Read the added and deleted files of each changeset in parallel. 

387 with ( 

388 self._open_provenance(provenance_output, timestamps) as writer, 

389 ThreadPoolExecutor(max_workers=2) as executor, 

390 ): 

391 for entity_uri in current_state: 

392 writer.created(entity_uri) 

393 

394 for changeset_idx, (added_file, deleted_file) in enumerate(changesets): 

395 version_idx = changeset_idx + 1 

396 

397 fut_del = executor.submit( 

398 _read_and_group, deleted_file, self.term_normalizer 

399 ) 

400 fut_add = executor.submit( 

401 _read_and_group, added_file, self.term_normalizer 

402 ) 

403 deleted_by_subject = fut_del.result() 

404 added_by_subject = fut_add.result() 

405 

406 for entity_uri in deleted_by_subject.keys() | added_by_subject.keys(): 

407 writer.created(entity_uri) 

408 deleted_po = deleted_by_subject.get(entity_uri, set()) 

409 added_po = added_by_subject.get(entity_uri, set()) 

410 

411 current_state[entity_uri] -= deleted_po 

412 current_state[entity_uri] |= added_po 

413 

414 emptied = not current_state[entity_uri] 

415 if emptied: 

416 del current_state[entity_uri] 

417 

418 if deleted_po or added_po: 

419 writer.changed( 

420 entity_uri, 

421 version_idx, 

422 deleted_po, 

423 added_po, 

424 emptied=emptied, 

425 ) 

426 

427 self._write_dataset(dataset_output, current_state) 

428 

429 @contextmanager 

430 def _open_provenance( 

431 self, provenance_output: Path, timestamps: list[datetime] 

432 ) -> Generator[_ProvenanceWriter]: 

433 provenance_output.parent.mkdir(parents=True, exist_ok=True) 

434 with _open_for_writing(Path(provenance_output)) as out: 

435 writer = _ProvenanceWriter( 

436 out, self.data_graph_uri, self.agent_uri, timestamps 

437 ) 

438 yield writer 

439 writer.finish() 

440 

441 def _write_dataset( 

442 self, dataset_output: Path, latest_by_subject: dict[str, set[tuple[str, str]]] 

443 ) -> None: 

444 dataset_output.parent.mkdir(parents=True, exist_ok=True) 

445 with _open_for_writing(Path(dataset_output)) as out: 

446 for entity_uri in sorted(latest_by_subject): 

447 out.writelines( 

448 f"<{entity_uri}> {p} {o} <{self.data_graph_uri}> .\n" 

449 for p, o in sorted(latest_by_subject[entity_uri]) 

450 )