Coverage for oc_ds_converter / datasource / orcid_index.py: 81%
151 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-09-09 15:50 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-09-09 15:50 +0000
1# SPDX-FileCopyrightText: 2026 Arcangelo Massari <arcangelo.massari@unibo.it>
2#
3# SPDX-License-Identifier: ISC
5from __future__ import annotations
7import json
8import os
9from collections import defaultdict
10from concurrent.futures import ProcessPoolExecutor, as_completed
11from csv import DictReader
12from multiprocessing import get_context
13from os import cpu_count, sep, walk
14from os.path import exists
15from typing import Protocol, cast
17import fakeredis
19from oc_ds_converter.datasource.redis import RedisDataSource
20from oc_ds_converter.lib.console import create_progress
21from oc_ds_converter.oc_idmanager import DOIManager
24class OrcidIndexInterface(Protocol):
25 def get_value(self, id_string: str, /) -> set[str] | None: ...
26 def get_values_batch(self, ids: list[str], /) -> dict[str, set[str]]: ...
29class OrcidIndexRedis:
30 def __init__(self, testing: bool = False) -> None:
31 if testing:
32 self._r = fakeredis.FakeStrictRedis(decode_responses=True)
33 else:
34 self._redis = RedisDataSource("DOI-ORCID-INDEX")
35 self._r = self._redis._r
37 def get_value(self, doi: str) -> set[str] | None:
38 result = cast(set[str], self._r.smembers(doi))
39 if result:
40 return result
41 return None
43 def get_values_batch(self, dois: list[str]) -> dict[str, set[str]]:
44 if not dois:
45 return {}
46 pipe = self._r.pipeline()
47 for doi in dois:
48 pipe.smembers(doi)
49 results = pipe.execute()
50 return {
51 doi: cast(set[str], members)
52 for doi, members in zip(dois, results)
53 if members
54 }
56 def add_values_batch(self, data: dict[str, set[str]]) -> None:
57 pipe = self._r.pipeline()
58 for doi, values in data.items():
59 if values:
60 pipe.sadd(doi, *values)
61 pipe.execute()
63 def has_data(self) -> bool:
64 return cast(int, self._r.dbsize()) > 0
66 def clear(self) -> None:
67 self._r.flushdb()
70def _process_csv_file(csv_path: str) -> dict[str, set[str]]:
71 """Process a single CSV file and return DOI -> ORCID mappings.
73 This function runs in a separate process for parallelization.
74 """
75 doi_manager = DOIManager()
76 result: dict[str, set[str]] = defaultdict(set)
78 with open(csv_path, 'r', encoding='utf-8') as f:
79 reader = DictReader(f)
80 for row in reader:
81 raw_doi = row['id']
82 doi = doi_manager.normalise(raw_doi, include_prefix=True)
83 if doi:
84 result[doi].add(row['value'])
86 return dict(result)
89def load_orcid_index_to_redis(
90 orcid_index_dir: str,
91 orcid_index_redis: OrcidIndexRedis,
92 batch_size: int = 1000000,
93 max_workers: int | None = None,
94) -> None:
95 if not exists(orcid_index_dir):
96 return
98 files_to_process: list[str] = []
99 for cur_dir, _, cur_files in walk(orcid_index_dir):
100 for cur_file in cur_files:
101 if cur_file.endswith('.csv'):
102 files_to_process.append(cur_dir + sep + cur_file)
104 if not files_to_process:
105 return
107 if max_workers is None:
108 max_workers = min(cpu_count() or 4, len(files_to_process))
110 batch: dict[str, set[str]] = {}
111 count = 0
113 with create_progress() as progress:
114 task = progress.add_task(
115 "[green]Loading DOI-ORCID index files", total=len(files_to_process)
116 )
118 mp_method = "spawn" if os.name == "nt" else "forkserver"
120 with ProcessPoolExecutor(
121 max_workers=max_workers, mp_context=get_context(mp_method)
122 ) as executor:
123 futures = {
124 executor.submit(_process_csv_file, path): path
125 for path in files_to_process
126 }
128 for future in as_completed(futures):
129 file_data = future.result()
131 # Merge results into batch
132 for doi, values in file_data.items():
133 if doi in batch:
134 batch[doi].update(values)
135 else:
136 batch[doi] = values
137 count += len(values)
139 # Flush to Redis when batch is large enough
140 if count >= batch_size:
141 orcid_index_redis.add_values_batch(batch)
142 batch = {}
143 count = 0
145 progress.update(task, advance=1)
147 if batch:
148 orcid_index_redis.add_values_batch(batch)
151class PublishersRedis:
152 MEMBER_PREFIX = "member:"
153 DOI_PREFIX_KEY = "prefix:"
155 def __init__(self, testing: bool = False) -> None:
156 if testing:
157 self._r = fakeredis.FakeStrictRedis(decode_responses=True)
158 else:
159 self._redis = RedisDataSource("PUBLISHERS-INDEX")
160 self._r = self._redis._r
162 def get_by_member(self, member_id: str) -> dict[str, str | set[str]] | None:
163 key = f"{self.MEMBER_PREFIX}{member_id}"
164 data = self._r.get(key)
165 if data:
166 result = json.loads(str(data))
167 result["prefixes"] = set(result["prefixes"])
168 return result
169 return None
171 def get_by_prefix(self, prefix: str) -> dict[str, str | set[str]] | None:
172 key = f"{self.DOI_PREFIX_KEY}{prefix}"
173 member_id = self._r.get(key)
174 if member_id:
175 return self.get_by_member(str(member_id))
176 return None
178 def set_publisher(self, member_id: str, name: str, prefixes: set[str]) -> None:
179 member_key = f"{self.MEMBER_PREFIX}{member_id}"
180 data = {"name": name, "prefixes": list(prefixes)}
181 self._r.set(member_key, json.dumps(data))
182 for prefix in prefixes:
183 prefix_key = f"{self.DOI_PREFIX_KEY}{prefix}"
184 self._r.set(prefix_key, member_id)
186 def set_publishers_batch(self, publishers: dict[str, dict[str, str | set[str]]]) -> None:
187 pipe = self._r.pipeline()
188 for member_id, data in publishers.items():
189 member_key = f"{self.MEMBER_PREFIX}{member_id}"
190 prefixes_list = list(data["prefixes"])
191 pipe.set(member_key, json.dumps({"name": data["name"], "prefixes": prefixes_list}))
192 for prefix in prefixes_list:
193 prefix_key = f"{self.DOI_PREFIX_KEY}{prefix}"
194 pipe.set(prefix_key, member_id)
195 pipe.execute()
197 def has_data(self) -> bool:
198 return cast(int, self._r.dbsize()) > 0
200 def clear(self) -> None:
201 self._r.flushdb()
204def load_publishers_to_redis(
205 publishers_filepath: str,
206 publishers_redis: PublishersRedis,
207 batch_size: int = 5000,
208) -> None:
209 if not exists(publishers_filepath):
210 return
212 batch: dict[str, dict[str, str | set[str]]] = {}
213 count = 0
215 with open(publishers_filepath, 'r', encoding='utf-8') as f:
216 reader = DictReader(f)
217 for row in reader:
218 pub_id = row['id']
219 if pub_id not in batch:
220 batch[pub_id] = {'name': row['name'], 'prefixes': set()}
221 prefixes = batch[pub_id]['prefixes']
222 prefixes.add(row['prefix']) # type: ignore[union-attr]
223 count += 1
225 if count >= batch_size:
226 publishers_redis.set_publishers_batch(batch)
227 batch = {}
228 count = 0
230 if batch:
231 publishers_redis.set_publishers_batch(batch)