Coverage for oc_meta / lib / rdf_patch.py: 90%
170 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-07-25 10:39 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-07-25 10:39 +0000
1# SPDX-FileCopyrightText: 2026 Arcangelo Massari <arcangelo.massari@unibo.it>
2#
3# SPDX-License-Identifier: ISC
5from __future__ import annotations
7import hashlib
8import multiprocessing
9import os
10from collections import defaultdict
11from concurrent.futures import ProcessPoolExecutor
12from dataclasses import dataclass
13from typing import TypeVar, cast
15import orjson
16import yaml
17from oc_ocdm.graph import GraphSet
18from oc_ocdm.graph.entities.bibliographic.agent_role import AgentRole
19from oc_ocdm.graph.entities.bibliographic.responsible_agent import ResponsibleAgent
20from oc_ocdm.graph.entities.identifier import Identifier
22from oc_meta.lib.file_manager import find_rdf_file
23from oc_meta.run.meta.generate_csv import load_json_from_file
25_forkserver_context = multiprocessing.get_context("forkserver")
27HAS_IDENTIFIER = "http://purl.org/spar/datacite/hasIdentifier"
28USES_IDENTIFIER_SCHEME = "http://purl.org/spar/datacite/usesIdentifierScheme"
29HAS_LITERAL_VALUE = (
30 "http://www.essepuntato.it/2010/06/literalreification/hasLiteralValue"
31)
32IS_DOCUMENT_CONTEXT_FOR = "http://purl.org/spar/pro/isDocumentContextFor"
33IS_HELD_BY = "http://purl.org/spar/pro/isHeldBy"
34WITH_ROLE = "http://purl.org/spar/pro/withRole"
35HAS_NEXT = "https://w3id.org/oc/ontology/hasNext"
36FOAF_NAME = "http://xmlns.com/foaf/0.1/name"
37GIVEN_NAME = "http://xmlns.com/foaf/0.1/givenName"
38FAMILY_NAME = "http://xmlns.com/foaf/0.1/familyName"
39PROV_SPECIALIZATION_OF = "http://www.w3.org/ns/prov#specializationOf"
40DATACITE_PREFIX = "http://purl.org/spar/datacite/"
41ROLE_MAP = {
42 "http://purl.org/spar/pro/author": "author",
43 "http://purl.org/spar/pro/editor": "editor",
44 "http://purl.org/spar/pro/publisher": "publisher",
45}
47Item = TypeVar("Item")
50@dataclass(frozen=True, slots=True)
51class AuditConfig:
52 rdf_dir: str
53 dir_split: int
54 items_per_file: int
55 zip_output: bool
58@dataclass(frozen=True, slots=True)
59class EntityFileLocator:
60 rdf_dir: str
61 dir_split: int
62 items_per_file: int
63 zip_output: bool
65 def path(self, uri: str) -> str:
66 return find_rdf_file(
67 uri,
68 self.rdf_dir,
69 self.dir_split,
70 self.items_per_file,
71 self.zip_output,
72 )
75def values(entity: dict[str, object], predicate: str, key: str) -> list[str]:
76 raw = entity[predicate] if predicate in entity else []
77 if isinstance(raw, dict):
78 raw = [raw]
79 if not isinstance(raw, list):
80 return []
81 return [
82 value[key]
83 for value in raw
84 if isinstance(value, dict) and isinstance(value.get(key), str)
85 ]
88def ids(entity: dict[str, object], predicate: str) -> list[str]:
89 return values(entity, predicate, "@id")
92def literals(entity: dict[str, object], predicate: str) -> list[str]:
93 return values(entity, predicate, "@value")
96def first(values: list[str]) -> str:
97 return values[0] if values else ""
100def batches(items: list[Item], size: int) -> list[list[Item]]:
101 return [items[start : start + size] for start in range(0, len(items), size)]
104def sha256(path: str) -> str:
105 digest = hashlib.sha256()
106 with open(path, "rb") as stream:
107 while chunk := stream.read(1024 * 1024):
108 digest.update(chunk)
109 return digest.hexdigest()
112def ensure_parent(path: str) -> None:
113 os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
116def write_json(path: str, value: object) -> None:
117 ensure_parent(path)
118 temporary_path = f"{path}.tmp"
119 with open(temporary_path, "wb") as stream:
120 stream.write(
121 orjson.dumps(value, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS)
122 )
123 stream.write(b"\n")
124 os.replace(temporary_path, path)
127def read_json_object(path: str) -> dict[str, object]:
128 with open(path, "rb") as stream:
129 value = orjson.loads(stream.read())
130 if not isinstance(value, dict):
131 raise ValueError(f"Expected a JSON object in {path}")
132 return cast(dict[str, object], value)
135def load_entities(path: str) -> dict[str, dict[str, object]]:
136 if path.endswith(".zip"):
137 graphs = load_json_from_file(path)
138 else:
139 with open(path, "rb") as stream:
140 graphs = orjson.loads(stream.read())
141 entities = {}
142 for graph in graphs:
143 for raw_entity in graph["@graph"]:
144 entity = cast(dict[str, object], raw_entity)
145 uri = entity["@id"]
146 if isinstance(uri, str):
147 entities[uri] = entity
148 return entities
151def data_files(directory: str, zip_output: bool) -> list[str]:
152 extension = ".zip" if zip_output else ".json"
153 paths = []
154 for root, _, filenames in os.walk(directory):
155 relative_parts = os.path.relpath(root, directory).split(os.sep)
156 if "prov" in relative_parts:
157 continue
158 for filename in filenames:
159 if not filename.endswith(extension):
160 continue
161 paths.append(os.path.join(root, filename))
162 return sorted(paths)
165def load_audit_config(path: str) -> AuditConfig:
166 with open(path, encoding="utf-8") as stream:
167 loaded = yaml.safe_load(stream)
168 if not isinstance(loaded, dict):
169 raise ValueError("Meta configuration must be a YAML mapping")
170 config = cast(dict[str, object], loaded)
171 output_key = "output_rdf_dir" if "output_rdf_dir" in config else "base_output_dir"
172 output_dir = os.path.abspath(cast(str, config[output_key]))
173 return AuditConfig(
174 rdf_dir=os.path.join(output_dir, "rdf"),
175 dir_split=cast(int, config["dir_split_number"]),
176 items_per_file=cast(int, config["items_per_file"]),
177 zip_output=cast(bool, config["zip_output_rdf"]),
178 )
181def _load_target_batch(
182 tasks: list[tuple[str, frozenset[str]]],
183) -> dict[str, dict[str, object]]:
184 result = {}
185 for path, targets in tasks:
186 if not os.path.exists(path):
187 continue
188 for uri, entity in load_entities(path).items():
189 if uri in targets:
190 result[uri] = entity
191 return result
194def load_available_entities(
195 uris: set[str], locator: EntityFileLocator, workers: int
196) -> dict[str, dict[str, object]]:
197 targets_by_path: dict[str, set[str]] = defaultdict(set)
198 for uri in uris:
199 targets_by_path[locator.path(uri)].add(uri)
200 tasks = [(path, frozenset(targets)) for path, targets in targets_by_path.items()]
201 result = {}
202 with ProcessPoolExecutor(
203 max_workers=workers,
204 mp_context=_forkserver_context,
205 ) as executor:
206 for partial in executor.map(_load_target_batch, batches(tasks, 24)):
207 result.update(partial)
208 return result
211def provenance_path(data_path: str, zip_output: bool) -> str:
212 stem = os.path.splitext(data_path)[0]
213 extension = "zip" if zip_output else "json"
214 return os.path.join(stem, "prov", f"se.{extension}")
217def snapshot_number(uri: str) -> int:
218 value = uri.rsplit("/", 1)[-1]
219 return int(value) if value.isdigit() else 0
222def load_progress(path: str, plan_sha256: str, review_sha256: str) -> set[str]:
223 if not os.path.exists(path):
224 return set()
225 progress = read_json_object(path)
226 if progress["plan_sha256"] != plan_sha256:
227 raise ValueError("Progress file belongs to a different correction plan")
228 if progress["review_sha256"] != review_sha256:
229 raise ValueError("Review decisions changed after execution started")
230 completed = progress["completed_groups"]
231 if not isinstance(completed, list) or not all(
232 isinstance(group_id, str) for group_id in completed
233 ):
234 raise ValueError("Invalid completed_groups in progress file")
235 return set(cast(list[str], completed))
238def save_progress(
239 path: str, plan_sha256: str, review_sha256: str, completed: set[str]
240) -> None:
241 write_json(
242 path,
243 {
244 "plan_sha256": plan_sha256,
245 "review_sha256": review_sha256,
246 "completed_groups": sorted(completed),
247 },
248 )
251def responsible_agent(g_set: GraphSet, uri: str) -> ResponsibleAgent:
252 entity = g_set.get_entity(uri)
253 if not isinstance(entity, ResponsibleAgent):
254 raise ValueError(f"Responsible agent not imported: {uri}")
255 return entity
258def identifier(g_set: GraphSet, uri: str) -> Identifier:
259 entity = g_set.get_entity(uri)
260 if not isinstance(entity, Identifier):
261 raise ValueError(f"Identifier not imported: {uri}")
262 return entity
265def agent_role(g_set: GraphSet, uri: str) -> AgentRole:
266 entity = g_set.get_entity(uri)
267 if not isinstance(entity, AgentRole):
268 raise ValueError(f"Agent role not imported: {uri}")
269 return entity