Coverage for ramose/skg_if/_base.py: 86%

732 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-24 21:59 +0000

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

2# 

3# SPDX-License-Identifier: ISC 

4 

5import csv 

6import json 

7from collections.abc import Callable 

8from dataclasses import dataclass 

9from dataclasses import field as dataclass_field 

10from io import StringIO 

11from math import ceil 

12from re import sub 

13from typing import NoReturn 

14from urllib.parse import parse_qs, urlencode, urlsplit 

15 

16from ramose import ConversionResult, HttpError 

17 

18_YEAR_MONTH_PART_COUNT = 2 

19_UNPROCESSABLE_CONTENT = 422 

20 

21_PRODUCT_COLUMNS: dict[str, str] = dict.fromkeys( 

22 ( 

23 "local_identifier", 

24 "identifier_scheme", 

25 "identifier_value", 

26 "title", 

27 "title_lang", 

28 "abstract", 

29 "abstract_lang", 

30 "product_type", 

31 "affiliation_name", 

32 "affiliation_short_name", 

33 "affiliation_country", 

34 "affiliation_local_identifier", 

35 "affiliation_identifier_scheme", 

36 "affiliation_identifier_value", 

37 "affiliation_type", 

38 "affiliation_website", 

39 "affiliation_other_name", 

40 "affiliation_role", 

41 "affiliation_period_start", 

42 "affiliation_period_end", 

43 "_affiliation_key", 

44 "topic_term", 

45 "topic_identifier_scheme", 

46 "topic_identifier_value", 

47 "topic_label", 

48 "topic_label_lang", 

49 "topic_provenance_associated_with", 

50 "topic_provenance_trust", 

51 "contribution_by_family_name", 

52 "contribution_by_given_name", 

53 "contribution_by_name", 

54 "contribution_by_identifier_scheme", 

55 "contribution_by_identifier_value", 

56 "contribution_by_local_identifier", 

57 "contribution_role", 

58 "contribution_type", 

59 "_contribution_key", 

60 "_contribution_next_key", 

61 "contribution_declared_affiliation_name", 

62 "contribution_declared_affiliation_short_name", 

63 "contribution_declared_affiliation_country", 

64 "contribution_declared_affiliation_local_identifier", 

65 "contribution_declared_affiliation_identifier_scheme", 

66 "contribution_declared_affiliation_identifier_value", 

67 "contribution_declared_affiliation_type", 

68 "contribution_declared_affiliation_website", 

69 "contribution_declared_affiliation_other_name", 

70 "manifestation_type_class", 

71 "manifestation_type_label", 

72 "manifestation_type_label_lang", 

73 "manifestation_type_defined_in", 

74 "_manifestation_key", 

75 "manifestation_identifier_scheme", 

76 "manifestation_identifier_value", 

77 "manifestation_dates_type", 

78 "manifestation_dates_value", 

79 "manifestation_peer_review_status", 

80 "manifestation_peer_review_description", 

81 "manifestation_access_rights_status", 

82 "manifestation_access_rights_description", 

83 "manifestation_licence", 

84 "manifestation_version", 

85 "manifestation_biblio_volume", 

86 "manifestation_biblio_issue", 

87 "manifestation_biblio_edition", 

88 "manifestation_biblio_number", 

89 "manifestation_biblio_pages_first", 

90 "manifestation_biblio_pages_last", 

91 "manifestation_biblio_in_name", 

92 "manifestation_biblio_in_local_identifier", 

93 "manifestation_biblio_in_identifier_scheme", 

94 "manifestation_biblio_in_identifier_value", 

95 "manifestation_biblio_in_acronym", 

96 "manifestation_biblio_hosting_data_source_local_identifier", 

97 "manifestation_biblio_hosting_data_source_name", 

98 "manifestation_biblio_hosting_data_source_identifier_scheme", 

99 "manifestation_biblio_hosting_data_source_identifier_value", 

100 "related_products_cites", 

101 "related_products_is_supplemented_by", 

102 "related_products_is_documented_by", 

103 "related_products_is_new_version_of", 

104 "related_products_is_part_of", 

105 "funding_local_identifier", 

106 "funding_grant_number", 

107 "funding_title", 

108 "funding_title_lang", 

109 "funding_abstract", 

110 "funding_abstract_lang", 

111 "funding_acronym", 

112 "funding_identifier_scheme", 

113 "funding_identifier_value", 

114 "funding_stream", 

115 "funding_agency_name", 

116 "funding_agency_short_name", 

117 "funding_agency_country", 

118 "funding_agency_local_identifier", 

119 "funding_agency_identifier_scheme", 

120 "funding_agency_identifier_value", 

121 "funding_agency_type", 

122 "funding_agency_website", 

123 "beneficiaries_name", 

124 "beneficiaries_short_name", 

125 "beneficiaries_country", 

126 "beneficiaries_local_identifier", 

127 "beneficiaries_identifier_scheme", 

128 "beneficiaries_identifier_value", 

129 "beneficiaries_type", 

130 "beneficiaries_website", 

131 "relevant_organisation_name", 

132 "relevant_organisation_short_name", 

133 "relevant_organisation_country", 

134 "relevant_organisation_local_identifier", 

135 "relevant_organisation_identifier_scheme", 

136 "relevant_organisation_identifier_value", 

137 "relevant_organisation_type", 

138 "relevant_organisation_website", 

139 "relevant_organisation_other_name", 

140 "research_product_types", 

141 "persistent_identity_system_for", 

142 "persistent_identity_system_pid_scheme", 

143 "_persistent_identity_system_key", 

144 "audience_type", 

145 "disciplines", 

146 "policy_about", 

147 "policy_target", 

148 "policy_documented_at", 

149 "policy_description", 

150 "_policy_key", 

151 ), 

152 "", 

153) 

154 

155SKGIF_CONTEXT_URLS = [ 

156 "https://w3id.org/skg-if/context/1.1.0/skg-if.json", 

157 "https://w3id.org/skg-if/context/1.0.0/skg-if-api.json", 

158] 

159 

160 

161def _build_context(base_url: str) -> list: 

162 return [*SKGIF_CONTEXT_URLS, {"@base": f"{base_url.rstrip('/')}/"}] 

163 

164 

165def _collect_identifiers( 

166 rows: list[dict], 

167 scheme_col: str = "identifier_scheme", 

168 value_col: str = "identifier_value", 

169) -> list[dict]: 

170 seen = set() 

171 identifiers = [] 

172 for row in rows: 

173 scheme = row[scheme_col] 

174 value = row[value_col] 

175 if scheme and value and (scheme, value) not in seen: 

176 seen.add((scheme, value)) 

177 identifiers.append({"value": value, "scheme": scheme}) 

178 return identifiers 

179 

180 

181def _order_linked_list(items: dict[str, dict], next_map: dict[str, str | None]) -> list[dict]: 

182 if not items: 

183 return [] 

184 

185 next_values = set(next_map.values()) - {None} 

186 start_candidates = [key for key in items if key not in next_values] 

187 if not start_candidates: 

188 return list(items.values()) 

189 

190 ordered = [] 

191 current = start_candidates[0] 

192 visited = set() 

193 while current and current in items and current not in visited: 

194 visited.add(current) 

195 ordered.append(items[current]) 

196 current = next_map.get(current) 

197 

198 for key, contributor in items.items(): 

199 if key not in visited: 

200 ordered.append(contributor) 

201 

202 return ordered 

203 

204 

205def _merge_agent_identifier(agent: dict, row: dict) -> None: 

206 scheme = row["contribution_by_identifier_scheme"] 

207 value = row["contribution_by_identifier_value"] 

208 if not scheme or not value: 

209 return 

210 identifier = {"value": value, "scheme": scheme} 

211 identifiers = agent.setdefault("identifiers", []) 

212 if identifier not in identifiers: 

213 identifiers.append(identifier) 

214 

215 

216def _build_agent(row: dict, non_person_entity_type: str) -> dict | None: 

217 family_name = row["contribution_by_family_name"] 

218 given_name = row["contribution_by_given_name"] 

219 full_name = row["contribution_by_name"] 

220 agent_local_id = row["contribution_by_local_identifier"] 

221 

222 is_person = bool(family_name or given_name) 

223 

224 if is_person: 

225 display_name = f"{family_name}, {given_name}" if family_name and given_name else (family_name or given_name) 

226 entity_type = "person" 

227 elif full_name: 

228 display_name = full_name 

229 entity_type = non_person_entity_type 

230 else: 

231 return None 

232 

233 agent: dict = {"name": display_name, "entity_type": entity_type} 

234 if is_person: 

235 if family_name: 

236 agent["family_name"] = family_name 

237 if given_name: 

238 agent["given_name"] = given_name 

239 _merge_agent_identifier(agent, row) 

240 if not agent_local_id: 

241 msg = f"Missing required local_identifier for {entity_type} '{display_name}'" 

242 raise ValueError(msg) 

243 agent["local_identifier"] = agent_local_id 

244 return agent 

245 

246 

247def _build_org(row: dict, prefix: str) -> dict: 

248 org: dict = {"entity_type": "organisation"} 

249 name = row[f"{prefix}_name"] 

250 if name: 

251 org["name"] = name 

252 for field in ("short_name", "country", "website"): 

253 val = row[f"{prefix}_{field}"] 

254 if val: 

255 org[field] = val 

256 local_id = row[f"{prefix}_local_identifier"] 

257 if not local_id: 

258 msg = f"Missing required local_identifier for organisation '{row[f'{prefix}_name']}'" 

259 raise ValueError(msg) 

260 org["local_identifier"] = local_id 

261 return org 

262 

263 

264def _org_entry(row: dict, prefix: str) -> dict: 

265 return {"obj": _build_org(row, prefix), "seen_ids": set(), "seen_types": set(), "seen_other_names": set()} 

266 

267 

268def _merge_org_multivalued(entry: dict, row: dict, prefix: str) -> None: 

269 id_scheme = row[f"{prefix}_identifier_scheme"] 

270 id_value = row[f"{prefix}_identifier_value"] 

271 if id_scheme and id_value and (id_scheme, id_value) not in entry["seen_ids"]: 

272 entry["seen_ids"].add((id_scheme, id_value)) 

273 entry["obj"].setdefault("identifiers", []).append({"value": id_value, "scheme": id_scheme}) 

274 org_type = row[f"{prefix}_type"] 

275 if org_type and org_type not in entry["seen_types"]: 

276 entry["seen_types"].add(org_type) 

277 entry["obj"].setdefault("types", []).append(org_type) 

278 if f"{prefix}_other_name" in row: 

279 other_name = row[f"{prefix}_other_name"] 

280 if other_name and other_name not in entry["seen_other_names"]: 

281 entry["seen_other_names"].add(other_name) 

282 entry["obj"].setdefault("other_names", []).append(other_name) 

283 

284 

285_DECLARED_AFFILIATION_PREFIX = "contribution_declared_affiliation" 

286 

287 

288def _merge_declared_affiliation(store: dict, row: dict) -> None: 

289 prefix = _DECLARED_AFFILIATION_PREFIX 

290 aff_name = row[f"{prefix}_name"] 

291 aff_local_id = row[f"{prefix}_local_identifier"] 

292 if not aff_name and not aff_local_id: 

293 return 

294 if aff_local_id not in store: 

295 store[aff_local_id] = _org_entry(row, prefix) 

296 _merge_org_multivalued(store[aff_local_id], row, prefix) 

297 

298 

299def _collect_declared_affiliations(rows: list[dict], role: str, key: str, store: dict) -> None: 

300 role_store = store.setdefault(role, {}).setdefault(key, {}) 

301 for row in rows: 

302 if row["contribution_role"] == role and row["_contribution_key"] == key: 

303 _merge_declared_affiliation(role_store, row) 

304 

305 

306def _enrich_contributor( 

307 contributor: dict, 

308 key: str, 

309 role_type: str, 

310 contribution_types: dict, 

311 affiliations: dict, 

312) -> None: 

313 types = contribution_types.get(role_type, {}).get(key) 

314 if types: 

315 contributor["contribution_types"] = types 

316 affs = affiliations.get(role_type, {}).get(key) 

317 if affs: 

318 contributor["declared_affiliations"] = [entry["obj"] for entry in affs.values()] 

319 

320 

321@dataclass 

322class _ContributorAccumulator: 

323 by_role_type: dict[str, dict[str, dict]] = dataclass_field(default_factory=dict) 

324 next_map: dict[str, dict[str, str | None]] = dataclass_field(default_factory=dict) 

325 types: dict[str, dict[str, list[str]]] = dataclass_field(default_factory=dict) 

326 affiliations: dict[str, dict[str, dict]] = dataclass_field(default_factory=dict) 

327 

328 

329def _process_contributor_row( 

330 row: dict, 

331 rows: list[dict], 

332 acc: _ContributorAccumulator, 

333) -> None: 

334 role = row["contribution_role"] 

335 key = row["_contribution_key"] 

336 if not role or not key: 

337 return 

338 

339 if role not in acc.by_role_type: 

340 acc.by_role_type[role] = {} 

341 acc.next_map[role] = {} 

342 acc.types[role] = {} 

343 

344 contribution_type = row["contribution_type"] 

345 if contribution_type: 

346 type_list = acc.types[role].setdefault(key, []) 

347 if contribution_type not in type_list: 

348 type_list.append(contribution_type) 

349 

350 if key in acc.by_role_type[role]: 

351 existing = acc.by_role_type[role][key] 

352 _merge_agent_identifier(existing["by"], row) 

353 return 

354 

355 _collect_declared_affiliations(rows, role, key, acc.affiliations) 

356 agent = _build_agent(row, "organisation" if role == "publisher" else "agent") 

357 if not agent: 

358 return 

359 acc.by_role_type[role][key] = {"role": role, "by": agent} 

360 acc.next_map[role][key] = row["_contribution_next_key"] or None 

361 

362 

363def _collect_contributors(rows: list[dict]) -> list[dict]: 

364 acc = _ContributorAccumulator() 

365 

366 for row in rows: 

367 _process_contributor_row(row, rows, acc) 

368 

369 result = [] 

370 for role_type in ["author", "editor", "publisher"]: 

371 if role_type not in acc.by_role_type: 

372 continue 

373 ordered = _order_linked_list(acc.by_role_type[role_type], acc.next_map[role_type]) 

374 for rank, contributor in enumerate(ordered, start=1): 

375 contributor["rank"] = rank 

376 key = next(k for k, v in acc.by_role_type[role_type].items() if v is contributor) 

377 _enrich_contributor(contributor, key, role_type, acc.types, acc.affiliations) 

378 result.append(contributor) 

379 

380 return result 

381 

382 

383def _collect_grouped_contributors(rows: list[dict]) -> list[dict]: 

384 contributions: dict[str, dict] = {} 

385 affiliations: dict[str, dict] = {} 

386 

387 for row in rows: 

388 key = row["_contribution_key"] 

389 if not key: 

390 continue 

391 if key not in contributions: 

392 agent = _build_agent(row, "organisation") 

393 if not agent: 

394 continue 

395 contributions[key] = {"by": agent, "roles": []} 

396 affiliations[key] = {} 

397 role = row["contribution_role"] 

398 if role and role not in contributions[key]["roles"]: 

399 contributions[key]["roles"].append(role) 

400 _merge_agent_identifier(contributions[key]["by"], row) 

401 _merge_declared_affiliation(affiliations[key], row) 

402 

403 for key, contribution in contributions.items(): 

404 if not contribution["roles"]: 

405 del contribution["roles"] 

406 if affiliations[key]: 

407 contribution["declared_affiliations"] = [entry["obj"] for entry in affiliations[key].values()] 

408 return list(contributions.values()) 

409 

410 

411def _build_venue(rows: list[dict], venue_name: str, venue_local_id: str) -> dict: 

412 venue: dict = {"name": venue_name, "entity_type": "venue"} 

413 if not venue_local_id: 

414 msg = f"Missing required local_identifier for venue '{venue_name}'" 

415 raise ValueError(msg) 

416 venue["local_identifier"] = venue_local_id 

417 acronym = rows[0]["manifestation_biblio_in_acronym"] 

418 if acronym: 

419 venue["acronym"] = acronym 

420 

421 venue_ids_seen = set() 

422 venue_identifiers = [] 

423 for row in rows: 

424 venue_scheme = row["manifestation_biblio_in_identifier_scheme"] 

425 venue_value = row["manifestation_biblio_in_identifier_value"] 

426 if venue_scheme and venue_value and (venue_scheme, venue_value) not in venue_ids_seen: 

427 venue_ids_seen.add((venue_scheme, venue_value)) 

428 venue_identifiers.append({"value": venue_value, "scheme": venue_scheme}) 

429 if venue_identifiers: 

430 venue["identifiers"] = venue_identifiers 

431 return venue 

432 

433 

434def _normalize_datetime(date_str: str) -> str: 

435 parts = date_str.split("-") 

436 if len(parts) == 1: 

437 return f"{parts[0]}-01-01T00:00:00" 

438 if len(parts) == _YEAR_MONTH_PART_COUNT: 

439 return f"{parts[0]}-{parts[1]}-01T00:00:00" 

440 if "T" not in date_str: 

441 return f"{date_str}T00:00:00" 

442 return date_str 

443 

444 

445def _collect_manifestation_dates(rows: list[dict]) -> dict[str, list[str]]: 

446 dates: dict[str, list[str]] = {} 

447 seen: set[tuple[str, str]] = set() 

448 for row in rows: 

449 date_type = row["manifestation_dates_type"] 

450 date_value = row["manifestation_dates_value"] 

451 if date_type and date_value and (date_type, date_value) not in seen: 

452 seen.add((date_type, date_value)) 

453 dates.setdefault(date_type, []).append(_normalize_datetime(date_value)) 

454 return dates 

455 

456 

457_BIBLIO_SIMPLE_FIELDS: tuple[tuple[str, str], ...] = ( 

458 ("manifestation_biblio_volume", "volume"), 

459 ("manifestation_biblio_issue", "issue"), 

460 ("manifestation_biblio_edition", "edition"), 

461 ("manifestation_biblio_number", "number"), 

462) 

463 

464 

465def _build_biblio_venue(rows: list[dict], first_row: dict) -> dict | None: 

466 venue_name = first_row["manifestation_biblio_in_name"] 

467 if not venue_name: 

468 return None 

469 venue_local_id = first_row["manifestation_biblio_in_local_identifier"] 

470 return _build_venue(rows, venue_name, venue_local_id) 

471 

472 

473def _build_biblio_hosting(rows: list[dict], first_row: dict) -> dict | None: 

474 hosting_local_id = first_row["manifestation_biblio_hosting_data_source_local_identifier"] 

475 if not hosting_local_id: 

476 return None 

477 hosting: dict = {"local_identifier": hosting_local_id, "entity_type": "datasource"} 

478 hosting_name = first_row["manifestation_biblio_hosting_data_source_name"] 

479 if hosting_name: 

480 hosting["name"] = hosting_name 

481 hosting_identifiers = _collect_identifiers( 

482 rows, 

483 "manifestation_biblio_hosting_data_source_identifier_scheme", 

484 "manifestation_biblio_hosting_data_source_identifier_value", 

485 ) 

486 if hosting_identifiers: 

487 hosting["identifiers"] = hosting_identifiers 

488 return hosting 

489 

490 

491def _build_biblio(rows: list[dict]) -> dict: 

492 first_row = rows[0] 

493 biblio: dict = {} 

494 for sparql_var, json_key in _BIBLIO_SIMPLE_FIELDS: 

495 value = first_row[sparql_var] 

496 if value: 

497 biblio[json_key] = value 

498 first_page = first_row["manifestation_biblio_pages_first"] 

499 last_page = first_row["manifestation_biblio_pages_last"] 

500 if first_page and last_page: 

501 biblio["pages"] = {"first": first_page, "last": last_page} 

502 venue = _build_biblio_venue(rows, first_row) 

503 if venue: 

504 biblio["in"] = venue 

505 hosting = _build_biblio_hosting(rows, first_row) 

506 if hosting: 

507 biblio["hosting_data_source"] = hosting 

508 return biblio 

509 

510 

511def _build_manifestation_type(first_row: dict) -> dict | None: 

512 type_class = first_row["manifestation_type_class"] 

513 type_label = first_row["manifestation_type_label"] 

514 defined_in = first_row["manifestation_type_defined_in"] 

515 if not type_class and not type_label and not defined_in: 

516 return None 

517 manifestation_type: dict = {} 

518 if type_class: 

519 manifestation_type["class"] = type_class 

520 if type_label: 

521 label_lang = first_row["manifestation_type_label_lang"] or "none" 

522 manifestation_type["labels"] = {label_lang: type_label} 

523 if defined_in: 

524 manifestation_type["defined_in"] = defined_in 

525 return manifestation_type 

526 

527 

528def _build_status_with_description(first_row: dict, status_field: str, desc_field: str) -> dict | None: 

529 status = first_row[status_field] 

530 if not status: 

531 return None 

532 result: dict = {"status": status} 

533 desc = first_row[desc_field] 

534 if desc: 

535 result["description"] = desc 

536 return result 

537 

538 

539def _build_manifestation(rows: list[dict]) -> dict | None: 

540 first_row = rows[0] 

541 manifestation: dict = {} 

542 

543 manifestation_type = _build_manifestation_type(first_row) 

544 if manifestation_type: 

545 manifestation["type"] = manifestation_type 

546 

547 dates = _collect_manifestation_dates(rows) 

548 if dates: 

549 manifestation["dates"] = dates 

550 

551 identifiers = _collect_identifiers(rows, "manifestation_identifier_scheme", "manifestation_identifier_value") 

552 if identifiers: 

553 manifestation["identifiers"] = identifiers 

554 

555 peer_review = _build_status_with_description( 

556 first_row, "manifestation_peer_review_status", "manifestation_peer_review_description" 

557 ) 

558 if peer_review: 

559 manifestation["peer_review"] = peer_review 

560 

561 access_rights = _build_status_with_description( 

562 first_row, "manifestation_access_rights_status", "manifestation_access_rights_description" 

563 ) 

564 if access_rights: 

565 manifestation["access_rights"] = access_rights 

566 

567 licence = first_row["manifestation_licence"] 

568 if licence: 

569 manifestation["licence"] = licence 

570 

571 version = first_row["manifestation_version"] 

572 if version: 

573 manifestation["version"] = version 

574 

575 biblio = _build_biblio(rows) 

576 if biblio: 

577 manifestation["biblio"] = biblio 

578 

579 return manifestation or None 

580 

581 

582def _collect_manifestations(rows: list[dict]) -> list[dict]: 

583 groups: dict[str, list[dict]] = {} 

584 for row in rows: 

585 if not any( 

586 value 

587 for column, value in row.items() 

588 if column.startswith("manifestation_") and column != "_manifestation_key" 

589 ): 

590 continue 

591 groups.setdefault(row["_manifestation_key"], []).append(row) 

592 return [manifestation for group in groups.values() if (manifestation := _build_manifestation(group))] 

593 

594 

595_RELATED_PRODUCT_COLUMNS = [ 

596 "related_products_cites", 

597 "related_products_is_supplemented_by", 

598 "related_products_is_documented_by", 

599 "related_products_is_new_version_of", 

600 "related_products_is_part_of", 

601] 

602 

603 

604def _collect_related_products(rows: list[dict]) -> dict: 

605 result: dict[str, list[str]] = {} 

606 for column in _RELATED_PRODUCT_COLUMNS: 

607 key = column.replace("related_products_", "") 

608 seen: set[str] = set() 

609 values: list[str] = [] 

610 for row in rows: 

611 val = row[column] 

612 if val and val not in seen: 

613 seen.add(val) 

614 values.append(val) 

615 if values: 

616 result[key] = values 

617 return result 

618 

619 

620def _collect_topics(rows: list[dict]) -> list[dict]: 

621 topics_by_uri: dict[str, dict] = {} 

622 seen_identifiers: dict[str, set] = {} 

623 seen_provenance: dict[str, set] = {} 

624 

625 for row in rows: 

626 uri = row["topic_term"] 

627 if not uri: 

628 continue 

629 

630 if uri not in topics_by_uri: 

631 topics_by_uri[uri] = {"term": {"local_identifier": uri, "entity_type": "topic"}} 

632 seen_identifiers[uri] = set() 

633 seen_provenance[uri] = set() 

634 

635 topic = topics_by_uri[uri] 

636 term = topic["term"] 

637 

638 label = row["topic_label"] 

639 if label: 

640 lang = row["topic_label_lang"] or "none" 

641 term.setdefault("labels", {})[lang] = label 

642 

643 id_scheme = row["topic_identifier_scheme"] 

644 id_value = row["topic_identifier_value"] 

645 if id_scheme and id_value and (id_scheme, id_value) not in seen_identifiers[uri]: 

646 seen_identifiers[uri].add((id_scheme, id_value)) 

647 term.setdefault("identifiers", []).append({"scheme": id_scheme, "value": id_value}) 

648 

649 prov_agent = row["topic_provenance_associated_with"] 

650 prov_trust = row["topic_provenance_trust"] 

651 if prov_agent and prov_trust and prov_agent not in seen_provenance[uri]: 

652 seen_provenance[uri].add(prov_agent) 

653 topic.setdefault("provenance", []).append({"associated_with": prov_agent, "trust": float(prov_trust)}) 

654 

655 return list(topics_by_uri.values()) 

656 

657 

658def _collect_organisation(rows: list[dict], prefix: str) -> list[dict]: 

659 entries: dict[str, dict] = {} 

660 for row in rows: 

661 name = row[f"{prefix}_name"] 

662 local_id = row[f"{prefix}_local_identifier"] 

663 if not name and not local_id: 

664 continue 

665 if local_id not in entries: 

666 entries[local_id] = _org_entry(row, prefix) 

667 _merge_org_multivalued(entries[local_id], row, prefix) 

668 return [entry["obj"] for entry in entries.values()] 

669 

670 

671def _collect_single_organisation(rows: list[dict], prefix: str) -> dict | None: 

672 base_row = next((row for row in rows if row[f"{prefix}_name"]), None) 

673 if base_row is None: 

674 return None 

675 entry = _org_entry(base_row, prefix) 

676 for row in rows: 

677 _merge_org_multivalued(entry, row, prefix) 

678 return entry["obj"] 

679 

680 

681def _build_person_affiliation(rows: list[dict]) -> dict: 

682 affiliation = {"affiliation": _collect_organisation(rows, "affiliation")[0]} 

683 first_row = rows[0] 

684 if role := first_row["affiliation_role"]: 

685 affiliation["role"] = role 

686 period = { 

687 key: first_row[column] 

688 for key, column in (("start", "affiliation_period_start"), ("end", "affiliation_period_end")) 

689 if first_row[column] 

690 } 

691 if period: 

692 affiliation["period"] = period 

693 return affiliation 

694 

695 

696def _collect_person_affiliations(rows: list[dict]) -> list[dict]: 

697 groups: dict[str, list[dict]] = {} 

698 for row in rows: 

699 local_identifier = row["affiliation_local_identifier"] 

700 if not local_identifier: 

701 continue 

702 key = row["_affiliation_key"] or local_identifier 

703 groups.setdefault(key, []).append(row) 

704 return [_build_person_affiliation(group) for group in groups.values()] 

705 

706 

707def _build_grant(row: dict) -> dict: 

708 funding_local_id = row["funding_local_identifier"] 

709 if not funding_local_id: 

710 msg = "Missing required local_identifier for grant" 

711 raise ValueError(msg) 

712 grant: dict = {"local_identifier": funding_local_id, "entity_type": "grant"} 

713 for field, csv_col in ( 

714 ("grant_number", "funding_grant_number"), 

715 ("acronym", "funding_acronym"), 

716 ("funding_stream", "funding_stream"), 

717 ): 

718 val = row[csv_col] 

719 if val: 

720 grant[field] = val 

721 title = row["funding_title"] 

722 if title: 

723 grant["titles"] = {row["funding_title_lang"] or "none": title} 

724 abstract = row["funding_abstract"] 

725 if abstract: 

726 grant["abstracts"] = {row["funding_abstract_lang"] or "none": abstract} 

727 agency_name = row["funding_agency_name"] 

728 if agency_name: 

729 grant["funding_agency"] = _build_org(row, "funding_agency") 

730 return grant 

731 

732 

733def _collect_funding(rows: list[dict]) -> list[dict]: 

734 funding_by_key: dict[str, dict] = {} 

735 seen_ids: dict[str, set[tuple[str, str]]] = {} 

736 agency_trackers: dict[str, dict] = {} 

737 

738 for row in rows: 

739 local_id = row["funding_local_identifier"] 

740 if not local_id: 

741 continue 

742 if local_id not in funding_by_key: 

743 funding_by_key[local_id] = _build_grant(row) 

744 seen_ids[local_id] = set() 

745 agency_trackers[local_id] = {"seen_ids": set(), "seen_types": set()} 

746 

747 id_scheme = row["funding_identifier_scheme"] 

748 id_value = row["funding_identifier_value"] 

749 if id_scheme and id_value and (id_scheme, id_value) not in seen_ids[local_id]: 

750 seen_ids[local_id].add((id_scheme, id_value)) 

751 funding_by_key[local_id].setdefault("identifiers", []).append({"value": id_value, "scheme": id_scheme}) 

752 

753 if "funding_agency" not in funding_by_key[local_id]: 

754 continue 

755 tracker = agency_trackers[local_id] 

756 agency = funding_by_key[local_id]["funding_agency"] 

757 a_scheme = row["funding_agency_identifier_scheme"] 

758 a_value = row["funding_agency_identifier_value"] 

759 if a_scheme and a_value and (a_scheme, a_value) not in tracker["seen_ids"]: 

760 tracker["seen_ids"].add((a_scheme, a_value)) 

761 agency.setdefault("identifiers", []).append({"value": a_value, "scheme": a_scheme}) 

762 a_type = row["funding_agency_type"] 

763 if a_type and a_type not in tracker["seen_types"]: 

764 tracker["seen_types"].add(a_type) 

765 agency.setdefault("types", []).append(a_type) 

766 

767 return list(funding_by_key.values()) 

768 

769 

770def normalize_local_identifier_url(local_identifier: str) -> tuple[str]: 

771 # Reverse proxies (e.g. Traefik) merge duplicate slashes in request paths, 

772 # turning "https://example.org/..." into "https:/example.org/...": restore the scheme separator. 

773 return (sub(r"^(https?):/+", r"\1://", local_identifier),) 

774 

775 

776def _canonical_path(path: str) -> str: 

777 segments = [segment for segment in path.split("/") if segment] 

778 for index, segment in enumerate(segments): 

779 if segment in ENTITY_TYPES and index + 1 < len(segments): 

780 identifier = normalize_local_identifier_url("/".join(segments[index + 1 :]))[0] 

781 return "/" + "/".join(segments[: index + 1]) + "/" + identifier 

782 return path 

783 

784 

785def _build_search_result_page(url: str) -> dict: 

786 return {"local_identifier": url, "entity_type": "search_result_page"} 

787 

788 

789def _meta_base_url(request_url: str) -> str: 

790 parsed = urlsplit(request_url) 

791 path = _canonical_path(parsed.path) 

792 if parsed.scheme and parsed.netloc: 

793 return f"{parsed.scheme}://{parsed.netloc}{path}" 

794 return path 

795 

796 

797def _page_url(base_path: str, params: dict[str, list[str]], page: int) -> str: 

798 page_params = {**params, "page": [str(page)]} 

799 return f"{base_path}?{urlencode(page_params, doseq=True, safe=':,')}" 

800 

801 

802def _raise_unprocessable(message: str) -> NoReturn: 

803 raise HttpError(_UNPROCESSABLE_CONTENT, f"HTTP status code {_UNPROCESSABLE_CONTENT}: {message}") 

804 

805 

806def _parse_positive_int_param(params: dict[str, list[str]], name: str) -> int: 

807 raw_value = params[name][0] 

808 try: 

809 value = int(raw_value) 

810 except ValueError: 

811 _raise_unprocessable(f"{name} must be an integer, got {raw_value!r}") 

812 if value < 1: 

813 _raise_unprocessable(f"{name} must be >= 1, got {value}") 

814 return value 

815 

816 

817def _validate_page_range(page: int, total_items: int, total_pages: int) -> None: 

818 if total_items and page > total_pages: 

819 _raise_unprocessable(f"page {page} exceeds total pages {total_pages}") 

820 

821 

822def _build_meta(request_url: str, graph_size: int) -> dict: 

823 parsed = urlsplit(request_url) 

824 base_url = _meta_base_url(request_url) 

825 if _is_single_entity_request(request_url): 

826 return {"local_identifier": base_url, "entity_type": "single_entity"} 

827 params = parse_qs(parsed.query) 

828 if "total_items" in params: 

829 total_items = int(params["total_items"][0]) 

830 page = _parse_positive_int_param(params, "page") 

831 page_size = _parse_positive_int_param(params, "page_size") 

832 elif "page_size" in params: 

833 total_items = graph_size 

834 page = _parse_positive_int_param(params, "page") if "page" in params else 1 

835 page_size = _parse_positive_int_param(params, "page_size") 

836 elif "page" in params: 

837 _raise_unprocessable("page requires page_size") 

838 else: 

839 total_items = graph_size 

840 page = 1 

841 page_size = max(graph_size, 1) 

842 total_pages = ceil(total_items / page_size) if page_size > 0 else 0 

843 non_pagination_params = {k: v for k, v in params.items() if k not in ("page", "page_size", "total_items")} 

844 clean_params = {**non_pagination_params, "page": [str(page)], "page_size": [str(page_size)]} 

845 self_url = f"{base_url}?{urlencode(clean_params, doseq=True, safe=':,')}" 

846 meta = _build_search_result_page(self_url) 

847 if page < total_pages: 

848 meta["next_page"] = _build_search_result_page(_page_url(base_url, clean_params, page + 1)) 

849 if page > 1: 

850 meta["prev_page"] = _build_search_result_page(_page_url(base_url, clean_params, page - 1)) 

851 base_params = {k: v for k, v in clean_params.items() if k not in ("page", "page_size")} 

852 search_result_url = f"{base_url}?{urlencode(base_params, doseq=True, safe=':,')}" if base_params else base_url 

853 meta["part_of"] = { 

854 "local_identifier": search_result_url, 

855 "entity_type": "search_result", 

856 "total_items": total_items, 

857 "first_page": _build_search_result_page(_page_url(base_url, clean_params, 1)), 

858 "last_page": _build_search_result_page(_page_url(base_url, clean_params, max(total_pages, 1))), 

859 } 

860 return meta 

861 

862 

863_BUILDER_COLUMN_PREFIXES = ( 

864 "identifier_", 

865 "affiliation_", 

866 "contribution_", 

867 "manifestation_", 

868 "related_products_", 

869 "topic_", 

870 "funding_", 

871 "relevant_organisation_", 

872 "beneficiaries_", 

873 "access_rights_", 

874 "duration_", 

875 "persistent_identity_system_", 

876 "audience_", 

877 "policy_", 

878) 

879 

880 

881_MULTI_VALUED_COLUMNS: tuple[str, ...] = ( 

882 "types", 

883 "other_names", 

884 "keywords", 

885 "research_product_types", 

886 "disciplines", 

887) 

888 

889 

890def _collect_passthrough_fields(first_row: dict, excluded: set[str]) -> dict: 

891 entity: dict = {} 

892 for col, val in first_row.items(): 

893 if col.startswith("_") or col in excluded: 

894 continue 

895 if any(col.startswith(prefix) for prefix in _BUILDER_COLUMN_PREFIXES): 

896 continue 

897 if val: 

898 entity[col] = val 

899 return entity 

900 

901 

902def _add_formatted_text(entity: dict, rows: list[dict], text_field: tuple[str, str, str], *, as_list: bool) -> None: 

903 field, lang_field, output_key = text_field 

904 if as_list: 

905 values: dict[str, list[str]] = {} 

906 for row in rows: 

907 value = row.get(field) 

908 if value: 

909 language_values = values.setdefault(row.get(lang_field) or "none", []) 

910 if value not in language_values: 

911 language_values.append(value) 

912 if values: 

913 entity[output_key] = values 

914 return 

915 first_row = rows[0] 

916 if first_row.get(field): 

917 entity[output_key] = {first_row.get(lang_field) or "none": first_row[field]} 

918 

919 

920_SECTION_BUILDERS: tuple[tuple[str, str, Callable], ...] = ( 

921 ("identifier_scheme", "identifiers", _collect_identifiers), 

922 ("topic_term", "topics", _collect_topics), 

923 ("funding_local_identifier", "funding", _collect_funding), 

924) 

925 

926 

927def _collect_contributions(rows: list[dict], entity_type: str | None) -> list[dict]: 

928 if entity_type in {"grant", "venue"}: 

929 return _collect_grouped_contributors(rows) 

930 return _collect_contributors(rows) 

931 

932 

933def _parse_number(value: str) -> int | float: 

934 number = float(value) 

935 return int(number) if number.is_integer() else number 

936 

937 

938def _apply_grant_fields(entity: dict, rows: list[dict], first_row: dict, columns: set[str]) -> None: 

939 if "funded_amount" in entity: 

940 if "currency" not in entity: 

941 msg = "Missing required currency for funded_amount" 

942 raise ValueError(msg) 

943 entity["funded_amount"] = _parse_number(entity["funded_amount"]) 

944 if "duration_start" in columns and (start := first_row["duration_start"]): 

945 duration = {"start": start} 

946 if "duration_end" in columns and (end := first_row["duration_end"]): 

947 duration["end"] = end 

948 entity["duration"] = duration 

949 if "funding_stream" in columns and (funding_stream := first_row["funding_stream"]): 

950 entity["funding_stream"] = funding_stream 

951 if "funding_agency_name" in columns and (agency := _collect_single_organisation(rows, "funding_agency")): 

952 entity["funding_agency"] = agency 

953 

954 

955_FORMATTED_TEXT_FIELDS: tuple[tuple[str, str, str], ...] = ( 

956 ("title", "title_lang", "titles"), 

957 ("abstract", "abstract_lang", "abstracts"), 

958 ("label", "label_lang", "labels"), 

959) 

960 

961# Research products hold a list of values per language; every other entity holds a single string. 

962_MULTI_VALUED_TEXT_ENTITY_TYPE = "product" 

963 

964 

965def _active_formatted_columns(columns: set[str]) -> set[str]: 

966 active: set[str] = set() 

967 for field, lang_field, _ in _FORMATTED_TEXT_FIELDS: 

968 if field in columns: 

969 active.update((field, lang_field)) 

970 return active 

971 

972 

973def _attach_multi_valued_columns(entity: dict, rows: list[dict], columns: set[str]) -> None: 

974 for column in _MULTI_VALUED_COLUMNS: 

975 if column not in columns: 

976 continue 

977 values = list(dict.fromkeys(row[column] for row in rows if row[column])) 

978 if values: 

979 entity[column] = values 

980 

981 

982def _attach_organisation_lists(entity: dict, rows: list[dict], columns: set[str]) -> None: 

983 if "relevant_organisation_name" in columns and ( 

984 organisations := _collect_organisation(rows, "relevant_organisation") 

985 ): 

986 entity["relevant_organisations"] = organisations 

987 if "beneficiaries_name" in columns and (beneficiaries := _collect_organisation(rows, "beneficiaries")): 

988 entity["beneficiaries"] = beneficiaries 

989 

990 

991def _collect_persistent_identity_systems(rows: list[dict]) -> list[dict]: 

992 systems: dict[str, dict] = {} 

993 for row in rows: 

994 product_type = row["persistent_identity_system_for"] 

995 pid_scheme = row["persistent_identity_system_pid_scheme"] 

996 if not product_type or not pid_scheme: 

997 continue 

998 key = row["_persistent_identity_system_key"] or product_type 

999 system = systems.setdefault(key, {"for": product_type, "pid_schemes": []}) 

1000 if pid_scheme not in system["pid_schemes"]: 

1001 system["pid_schemes"].append(pid_scheme) 

1002 return list(systems.values()) 

1003 

1004 

1005def _collect_audience(rows: list[dict]) -> list[dict]: 

1006 audience_types = list(dict.fromkeys(row["audience_type"] for row in rows if row["audience_type"])) 

1007 return [{"audience_type": audience_type} for audience_type in audience_types] 

1008 

1009 

1010def _collect_policies(rows: list[dict]) -> list[dict]: 

1011 policies: dict[str, dict] = {} 

1012 for row in rows: 

1013 about = row["policy_about"] 

1014 if not about: 

1015 continue 

1016 key = row["_policy_key"] or about 

1017 policy = policies.setdefault(key, {"about": about}) 

1018 target = row["policy_target"] 

1019 if target: 

1020 targets = policy.setdefault("targets", []) 

1021 if target not in targets: 

1022 targets.append(target) 

1023 if row["policy_documented_at"]: 

1024 policy["documented_at"] = row["policy_documented_at"] 

1025 if row["policy_description"]: 

1026 policy["description"] = row["policy_description"] 

1027 return list(policies.values()) 

1028 

1029 

1030def _apply_data_source_fields(entity: dict, rows: list[dict], columns: set[str]) -> None: 

1031 if "persistent_identity_system_for" in columns and (systems := _collect_persistent_identity_systems(rows)): 

1032 entity["persistent_identity_systems"] = systems 

1033 if "audience_type" in columns and (audience := _collect_audience(rows)): 

1034 entity["audience"] = audience 

1035 if "policy_about" in columns and (policies := _collect_policies(rows)): 

1036 entity["policies"] = policies 

1037 

1038 

1039def _apply_entity_type( 

1040 entity: dict, rows: list[dict], first_row: dict, columns: set[str], entity_type: str | None 

1041) -> None: 

1042 if entity_type == "grant": 

1043 _apply_grant_fields(entity, rows, first_row, columns) 

1044 if entity_type == "venue" and "access_rights_status" in columns: 

1045 access_rights = _build_status_with_description(first_row, "access_rights_status", "access_rights_description") 

1046 if access_rights: 

1047 entity["access_rights"] = access_rights 

1048 if ( 

1049 entity_type == "person" 

1050 and "affiliation_local_identifier" in columns 

1051 and (affiliations := _collect_person_affiliations(rows)) 

1052 ): 

1053 entity["affiliations"] = affiliations 

1054 if entity_type == "datasource": 

1055 _apply_data_source_fields(entity, rows, columns) 

1056 if entity_type is not None: 

1057 entity["entity_type"] = entity_type 

1058 

1059 

1060def _build_entity(rows: list[dict], entity_type: str | None) -> dict: 

1061 first_row = rows[0] 

1062 columns = set(first_row) 

1063 

1064 entity = _collect_passthrough_fields(first_row, _active_formatted_columns(columns) | set(_MULTI_VALUED_COLUMNS)) 

1065 as_list = entity_type == _MULTI_VALUED_TEXT_ENTITY_TYPE 

1066 for text_field in _FORMATTED_TEXT_FIELDS: 

1067 _add_formatted_text(entity, rows, text_field, as_list=as_list) 

1068 

1069 _attach_multi_valued_columns(entity, rows, columns) 

1070 

1071 for anchor, key, builder in _SECTION_BUILDERS: 

1072 if anchor in columns and (section := builder(rows)): 

1073 entity[key] = section 

1074 if "contribution_role" in columns and (contributions := _collect_contributions(rows, entity_type)): 

1075 entity["contributions"] = contributions 

1076 if any(column.startswith("manifestation_") for column in columns) and ( 

1077 manifestations := _collect_manifestations(rows) 

1078 ): 

1079 entity["manifestations"] = manifestations 

1080 if columns & set(_RELATED_PRODUCT_COLUMNS) and (related := _collect_related_products(rows)): 

1081 entity["related_products"] = related 

1082 _attach_organisation_lists(entity, rows, columns) 

1083 _apply_entity_type(entity, rows, first_row, columns, entity_type) 

1084 

1085 return entity 

1086 

1087 

1088def _build_entities(rows: list[dict], entity_type: str | None) -> list[dict]: 

1089 if not rows: 

1090 return [] 

1091 groups: dict[str, list[dict]] = {} 

1092 for row in rows: 

1093 groups.setdefault(row["local_identifier"], []).append(row) 

1094 return [_build_entity(group, entity_type) for group in groups.values()] 

1095 

1096 

1097ENTITY_TYPES = frozenset({"products", "persons", "organisations", "grants", "venues", "topics", "datasources"}) 

1098 

1099_ENTITY_TYPE_MAP: dict[str, str] = { 

1100 "products": "product", 

1101 "persons": "person", 

1102 "organisations": "organisation", 

1103 "grants": "grant", 

1104 "venues": "venue", 

1105 "topics": "topic", 

1106 "datasources": "datasource", 

1107} 

1108 

1109 

1110def _extract_entity_type(request_url: str) -> str | None: 

1111 for segment in urlsplit(request_url).path.split("/"): 

1112 if segment in _ENTITY_TYPE_MAP: 

1113 return _ENTITY_TYPE_MAP[segment] 

1114 return None 

1115 

1116 

1117def _is_single_entity_request(request_url: str) -> bool: 

1118 segments = [s for s in urlsplit(request_url).path.split("/") if s] 

1119 for i, segment in enumerate(segments): 

1120 if segment in ENTITY_TYPES: 

1121 return i + 1 < len(segments) 

1122 return False 

1123 

1124 

1125_COLUMN_GROUP_PREFIXES = ( 

1126 "identifier_", 

1127 "affiliation_", 

1128 "_affiliation_", 

1129 "contribution_", 

1130 "_contribution_", 

1131 "manifestation_", 

1132 "_manifestation_", 

1133 "related_products_", 

1134 "topic_", 

1135 "funding_", 

1136 "relevant_organisation_", 

1137 "beneficiaries_", 

1138 "persistent_identity_system_", 

1139 "_persistent_identity_system_", 

1140 "audience_", 

1141 "policy_", 

1142 "_policy_", 

1143) 

1144 

1145_CORE_COLUMNS = frozenset(col for col in _PRODUCT_COLUMNS if not any(col.startswith(p) for p in _COLUMN_GROUP_PREFIXES)) 

1146 

1147 

1148def _fill_missing_columns(rows: list[dict[str, str]]) -> list[dict[str, str]]: 

1149 if not rows: 

1150 return rows 

1151 present = set(rows[0]) 

1152 active_prefixes = {prefix for prefix in _COLUMN_GROUP_PREFIXES if any(col.startswith(prefix) for col in present)} 

1153 missing = { 

1154 col: "" 

1155 for col in _PRODUCT_COLUMNS 

1156 if col not in present 

1157 and ( 

1158 (active_prefixes and col in _CORE_COLUMNS) 

1159 or any(col.startswith(p) for p in active_prefixes) 

1160 or (col == "_manifestation_key" and "manifestation_" in active_prefixes) 

1161 or (col == "_affiliation_key" and "affiliation_" in active_prefixes) 

1162 or (col == "_persistent_identity_system_key" and "persistent_identity_system_" in active_prefixes) 

1163 or (col == "_policy_key" and "policy_" in active_prefixes) 

1164 ) 

1165 } 

1166 if not missing: 

1167 return rows 

1168 return [missing | row for row in rows] 

1169 

1170 

1171def to_skg_if(csv_str: str, request_url: str = "", base_url: str = "") -> str | ConversionResult: 

1172 context = _build_context(base_url) 

1173 rows = _fill_missing_columns(list(csv.DictReader(StringIO(csv_str)))) 

1174 entity_type = _extract_entity_type(request_url) 

1175 graph = _build_entities(rows, entity_type) 

1176 

1177 total_entities = len(graph) 

1178 

1179 parsed = urlsplit(request_url) 

1180 params = parse_qs(parsed.query) 

1181 if "page_size" in params and "total_items" not in params: 

1182 page_size = _parse_positive_int_param(params, "page_size") 

1183 page = _parse_positive_int_param(params, "page") if "page" in params else 1 

1184 total_pages = ceil(total_entities / page_size) if page_size > 0 else 0 

1185 _validate_page_range(page, total_entities, total_pages) 

1186 start = (page - 1) * page_size 

1187 graph = graph[start : start + page_size] 

1188 elif "page" in params and "total_items" not in params: 

1189 _raise_unprocessable("page requires page_size") 

1190 

1191 result = { 

1192 "@context": context, 

1193 "meta": _build_meta(request_url, total_entities), 

1194 "@graph": graph, 

1195 } 

1196 body = json.dumps(result, ensure_ascii=False, indent=4) 

1197 if not graph: 

1198 return ConversionResult(status_code=404, body=body) 

1199 return body