Coverage for ramose/operation.py: 96%

1110 statements  

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

1# SPDX-FileCopyrightText: 2018-2021 Silvio Peroni <silvio.peroni@unibo.it> 

2# SPDX-FileCopyrightText: 2020-2021 Marilena Daquino <marilena.daquino2@unibo.it> 

3# SPDX-FileCopyrightText: 2022 Davide Brembilla 

4# SPDX-FileCopyrightText: 2024 Ivan Heibi <ivan.heibi2@unibo.it> 

5# SPDX-FileCopyrightText: 2025 Sergei Slinkin 

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

7# 

8# SPDX-License-Identifier: ISC 

9 

10from __future__ import annotations 

11 

12import time 

13from concurrent.futures import ThreadPoolExecutor 

14from csv import DictReader, reader, writer 

15from dataclasses import dataclass 

16from dataclasses import field as dataclass_field 

17from http import HTTPStatus 

18from io import StringIO 

19from itertools import product 

20from json import dumps 

21from math import ceil 

22from operator import eq, gt, itemgetter, lt 

23from re import error as regex_error 

24from re import findall, fullmatch, match, search, sub 

25from typing import TYPE_CHECKING, NoReturn, TypedDict, cast 

26from urllib.parse import parse_qs, quote, urlsplit 

27 

28from requests.exceptions import RequestException 

29from requests.exceptions import Timeout as RequestsTimeout 

30 

31try: 

32 from pysparql_anything import SparqlAnything # pyright: ignore[reportMissingImports, reportAttributeAccessIssue] 

33except ImportError: 

34 SparqlAnything = None 

35 

36from ramose._constants import ( 

37 DEFAULT_HTTP_TIMEOUT, 

38 FIELD_TYPE_RE, 

39 _http_session, 

40 backend_auth, 

41 media_type_for_format, 

42) 

43from ramose.datatype import DataType 

44from ramose.escaping import InvalidParameterValueError, escape_iri, escape_literal 

45from ramose.filters import apply_filters 

46from ramose.paging import PaginationInfo, build_link_header, build_pagination_info 

47 

48if TYPE_CHECKING: 

49 import types 

50 from collections.abc import Callable, Mapping 

51 from typing import Protocol 

52 

53 from requests import Response 

54 

55 from ramose.cache import ResultCache 

56 from ramose.filters import FiltersConfig 

57 

58 class SparqlAnythingEngine(Protocol): 

59 def select(self, output_type: type[object], **kwargs: object) -> object: ... 

60 

61 

62_WRITE_METHODS = frozenset({"post", "put", "delete"}) 

63_RETRYABLE_STATUS_CODES = frozenset( 

64 { 

65 HTTPStatus.REQUEST_TIMEOUT, 

66 HTTPStatus.TOO_MANY_REQUESTS, 

67 HTTPStatus.INTERNAL_SERVER_ERROR, 

68 HTTPStatus.BAD_GATEWAY, 

69 HTTPStatus.SERVICE_UNAVAILABLE, 

70 HTTPStatus.GATEWAY_TIMEOUT, 

71 } 

72) 

73_SPARQL_ANYTHING_HTTP_STATUS_RE = r"\bHTTP/\d(?:\.\d)?\s+(\d{3})\b" 

74_SPARQL_ANYTHING_TIMEOUT_MARKERS = frozenset( 

75 { 

76 "sockettimeoutexception", 

77 "timed out", 

78 } 

79) 

80_SPARQL_ANYTHING_NETWORK_MARKERS = frozenset( 

81 { 

82 "httphostconnectexception", 

83 "connectexception", 

84 "noroutetohostexception", 

85 "unknownhostexception", 

86 "connection refused", 

87 } 

88) 

89_UNPROCESSABLE_CONTENT = 422 

90_JSON_TRANSFORM_RE = r'^(?P<op_type>array|dict)\((?P<separator>"[^"]+"),(?P<entries>[^)]+)\)$' 

91_DICT_TRANSFORM_MIN_FIELD_COUNT = 2 

92ResultRow = list[str] 

93ResultTable = list[ResultRow] 

94 

95 

96@dataclass(frozen=True) 

97class ConversionResult: 

98 status_code: int 

99 body: str 

100 

101 

102@dataclass(frozen=True) 

103class OperationResponse: 

104 status_code: int 

105 body: str 

106 content_type: str 

107 headers: dict[str, str] 

108 is_error_message: bool = False 

109 

110 @classmethod 

111 def error(cls, status_code: int, body: str) -> OperationResponse: 

112 return cls(status_code, body, "text/plain", {}, is_error_message=True) 

113 

114 

115class CachedPagination(TypedDict): 

116 page: int 

117 page_size: int 

118 total_items: int 

119 

120 

121class CachedResult(TypedDict): 

122 rows: ResultTable 

123 pagination: CachedPagination | None 

124 

125 

126class HttpError(Exception): 

127 def __init__(self, status_code: int, message: str) -> None: 

128 super().__init__(message) 

129 self.status_code = status_code 

130 

131 

132@dataclass 

133class OperationConfig: 

134 sparql_endpoint: str = "" 

135 update_endpoint: str = "" 

136 sparql_http_method: str = "get" 

137 addon: types.ModuleType | None = None 

138 format_map: dict = dataclass_field(default_factory=dict) 

139 format_media_types: dict = dataclass_field(default_factory=dict) 

140 sources_map: dict = dataclass_field(default_factory=dict) 

141 custom_params: dict = dataclass_field(default_factory=dict) 

142 disabled_params: set = dataclass_field(default_factory=set) 

143 requires_auth: bool = False 

144 cache: ResultCache | None = None 

145 default_cache_ttl: int = 86400 

146 custom_param_configs: dict[str, FiltersConfig] = dataclass_field(default_factory=dict) 

147 public_base_url: str = "" 

148 retry_attempts: int = 3 

149 retry_wait: float = 0.5 

150 retry_backoff: float = 2.0 

151 sparql_timeout: float = DEFAULT_HTTP_TIMEOUT 

152 

153 def __post_init__(self) -> None: 

154 if self.retry_attempts < 1: 

155 msg = "retry_attempts must be >= 1" 

156 raise ValueError(msg) 

157 if self.sparql_timeout <= 0: 

158 msg = "sparql_timeout must be > 0" 

159 raise ValueError(msg) 

160 if self.retry_wait < 0: 

161 msg = "retry_wait must be >= 0" 

162 raise ValueError(msg) 

163 if self.retry_backoff < 1: 

164 msg = "retry_backoff must be >= 1" 

165 raise ValueError(msg) 

166 

167 

168class Operation: 

169 def __init__( 

170 self, 

171 op_complete_url: str, 

172 op_key: str, 

173 op_item: dict[str, str], 

174 config: OperationConfig | None = None, 

175 ) -> None: 

176 if config is None: 

177 config = OperationConfig() 

178 self.url_parsed = urlsplit(op_complete_url) 

179 self.op_url = self.url_parsed.path 

180 self.op = op_key 

181 self.i = op_item 

182 self.tp = config.sparql_endpoint 

183 self.update_endpoint = config.update_endpoint 

184 self.sparql_http_method = config.sparql_http_method 

185 self.addon = config.addon 

186 self.format = config.format_map 

187 self.format_media_types = config.format_media_types 

188 self.sources_map = config.sources_map 

189 self.custom_params = config.custom_params 

190 self.disabled_params = config.disabled_params 

191 self.requires_auth = config.requires_auth 

192 self._sa_engine = None 

193 self._cache = config.cache 

194 self._default_cache_ttl = config.default_cache_ttl 

195 self.custom_param_configs = config.custom_param_configs 

196 self.public_base_url = config.public_base_url 

197 self.retry_attempts = config.retry_attempts 

198 self.retry_wait = config.retry_wait 

199 self.retry_backoff = config.retry_backoff 

200 self.sparql_timeout = config.sparql_timeout 

201 self.pagination_info: PaginationInfo | None = None 

202 

203 self.operation = {"=": eq, "<": lt, ">": gt} 

204 

205 self.dt = DataType() 

206 

207 def _public_request_url(self, request_url: str) -> str: 

208 parsed = urlsplit(request_url) 

209 if parsed.scheme and parsed.netloc: 

210 return request_url 

211 return f"{self.public_base_url.rstrip('/')}/{request_url.lstrip('/')}" 

212 

213 def _converter_request_url(self) -> str: 

214 if self.pagination_info is not None: 

215 return self._public_request_url(self.pagination_info.self_url) 

216 if self.url_parsed.query: 

217 return self._public_request_url(f"{self.op_url}?{self.url_parsed.query}") 

218 return self._public_request_url(self.op_url) 

219 

220 def _is_builtin_param_active(self, name: str) -> bool: 

221 return name not in self.disabled_params and name not in self.custom_params 

222 

223 @staticmethod 

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

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

226 

227 @staticmethod 

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

229 raw_value = params[name][0] 

230 try: 

231 value = int(raw_value) 

232 except ValueError: 

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

234 if value < 1: 

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

236 return value 

237 

238 @staticmethod 

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

240 if total_items and page > total_pages: 

241 Operation._raise_unprocessable(f"page {page} exceeds total pages {total_pages}") 

242 

243 @staticmethod 

244 def get_content_type(ct: str) -> str: 

245 """It returns the mime type of a given textual representation of a format, being it either 

246 'csv' or 'json.""" 

247 content_type = ct 

248 

249 if ct == "csv": 

250 content_type = "text/csv" 

251 elif ct == "json": 

252 content_type = "application/json" 

253 

254 return content_type 

255 

256 def _convert(self, s: str, fmt: str) -> ConversionResult: 

257 converter_func = getattr(self.addon, self.format[fmt]) 

258 converted = converter_func(s, request_url=self._converter_request_url(), base_url=self.public_base_url) 

259 if isinstance(converted, ConversionResult): 

260 return converted 

261 return ConversionResult(status_code=HTTPStatus.OK, body=converted) 

262 

263 def _resolve_format(self, s: str, query_string: dict[str, list[str]]) -> tuple[ConversionResult, str] | None: 

264 if "format" in query_string and self._is_builtin_param_active("format"): 

265 for req_format in query_string["format"]: 

266 if req_format in self.format: 

267 return self._convert(s, req_format), self._media_type_for_format(req_format) 

268 elif "default_format" in self.i: 

269 default_fmt = self.i["default_format"].strip() 

270 if default_fmt in self.format: 

271 return self._convert(s, default_fmt), self._media_type_for_format(default_fmt) 

272 return None 

273 

274 def _media_type_for_format(self, fmt: str) -> str: 

275 if fmt in self.format_media_types: 

276 return self.format_media_types[fmt] 

277 return Operation.get_content_type(fmt) 

278 

279 def _validate_format_values(self, formats: list[str]) -> None: 

280 supported_formats = {"csv", "json", *self.format} 

281 for req_format in formats: 

282 if req_format not in supported_formats: 

283 Operation._raise_unprocessable(f"unsupported format {req_format!r}") 

284 

285 def media_type_to_format(self) -> dict[str, str]: 

286 if not self._is_builtin_param_active("format"): 

287 return {} 

288 default_token = self.i["default_format"].strip() if "default_format" in self.i else "json" 

289 media_type_to_token: dict[str, str] = {} 

290 for token in [default_token, "json", "csv", *self.format]: 

291 if token in self.format_media_types: 

292 media_type = self.format_media_types[token] 

293 else: 

294 media_type = media_type_for_format(token) 

295 if media_type is not None: 

296 media_type_to_token.setdefault(media_type, token) 

297 return media_type_to_token 

298 

299 def _format_response( 

300 self, s: str, query_string: dict[str, list[str]], c_type: str = "text/csv" 

301 ) -> tuple[ConversionResult, str]: 

302 content_type = Operation.get_content_type(c_type) 

303 

304 if "format" in query_string and self._is_builtin_param_active("format"): 

305 self._validate_format_values(query_string["format"]) 

306 

307 resolved = self._resolve_format(s, query_string) 

308 if resolved is not None: 

309 return resolved 

310 

311 if "format" in query_string and self._is_builtin_param_active("format"): 

312 for req_format in query_string["format"]: 

313 content_type = Operation.get_content_type(req_format) 

314 elif "default_format" in self.i: 

315 content_type = Operation.get_content_type(self.i["default_format"].strip()) 

316 

317 if content_type not in ("text/csv", "application/json"): 

318 content_type = "text/csv" 

319 

320 if "application/json" in content_type: 

321 with StringIO(s) as f: 

322 r = [dict(i) for i in DictReader(f)] 

323 

324 if self._is_builtin_param_active("json"): 

325 r = Operation.structured(query_string, r) # type: ignore[arg-type] 

326 

327 return ConversionResult(HTTPStatus.OK, dumps(r, ensure_ascii=False, indent=4)), content_type 

328 else: 

329 return ConversionResult(HTTPStatus.OK, s), content_type 

330 

331 def conv(self, s: str, query_string: dict[str, list[str]], c_type: str = "text/csv") -> OperationResponse: 

332 """This method takes a string representing a CSV document and converts it in the requested format according 

333 to what content type is specified as input.""" 

334 formatted, content_type = self._format_response(s, query_string, c_type) 

335 return OperationResponse(formatted.status_code, formatted.body, content_type, {}) 

336 

337 @staticmethod 

338 def pv(i: int | tuple[object, str], r: list[tuple[object, str]] | None = None) -> str: 

339 """This method returns the plain value of a particular item 'i' of the result returned by the SPARQL query. 

340 

341 In case 'r' is specified (i.e. a row containing a set of results), then 'i' must be the index of the item 

342 within that row.""" 

343 if r is None: 

344 return i[1] # type: ignore[index] 

345 return Operation.pv(r[i]) # type: ignore[index] 

346 

347 @staticmethod 

348 def tv(i: int | tuple[object, str], r: list[tuple[object, str]] | None = None) -> object: 

349 """This method returns the typed value of a particular item 'i' of the result returned by the SPARQL query. 

350 The type associated to that value is actually specified by means of the particular configuration provided 

351 in the specification file of the API - field 'field_type'. 

352 

353 In case 'r' is specified (i.e. a row containing a set of results), then 'i' must be the index of the item 

354 within that row.""" 

355 if r is None: 

356 return i[0] # type: ignore[index] 

357 return Operation.tv(r[i]) # type: ignore[index] 

358 

359 @staticmethod 

360 def do_overlap(r1: tuple[int, int], r2: tuple[int, int]) -> bool: 

361 """This method returns a boolean that says if the two ranges (i.e. two pairs of integers) passed as inputs 

362 actually overlap one with the other.""" 

363 r1_s, r1_e = r1 

364 r2_s, r2_e = r2 

365 

366 return r1_s <= r2_s <= r1_e or r2_s <= r1_s <= r2_e 

367 

368 @staticmethod 

369 def get_item_in_dict( 

370 d_or_l: dict[str, object] | list[dict[str, object]], key_list: list[str], prev: list[object] | None = None 

371 ) -> list[object]: 

372 """This method takes as input a dictionary or a list of dictionaries and browses it until the value 

373 specified following the chain indicated in 'key_list' is not found. It returns a list of all the 

374 values that matched with such search.""" 

375 res = [] if prev is None else prev.copy() 

376 

377 d_list = [d_or_l] if isinstance(d_or_l, dict) else d_or_l 

378 

379 for d in d_list: 

380 key_list_len = len(key_list) 

381 

382 if key_list_len >= 1: 

383 key = key_list[0] 

384 if key in d: 

385 if key_list_len == 1: 

386 res.append(d[key]) 

387 else: 

388 res = Operation.get_item_in_dict(d[key], key_list[1:], res) # type: ignore[arg-type] 

389 

390 return res 

391 

392 @staticmethod 

393 def add_item_in_dict( 

394 d_or_l: dict[str, object] | list[dict[str, object]], key_list: list[str], item: object, idx: int 

395 ) -> None: 

396 """This method takes as input a dictionary or a list of dictionaries, browses it until the value 

397 specified following the chain indicated in 'key_list' is not found, and then substitutes it with 'item'. 

398 In case the final object retrieved is a list, it selects the object in position 'idx' before the 

399 substitution.""" 

400 key_list_len = len(key_list) 

401 

402 if key_list_len >= 1: 

403 key = key_list[0] 

404 

405 if isinstance(d_or_l, list): 

406 if key_list_len == 1: 

407 d_or_l[idx][key] = item 

408 else: 

409 for i in d_or_l: 

410 Operation.add_item_in_dict(i, key_list, item, idx) 

411 elif key in d_or_l: 

412 if key_list_len == 1: 

413 d_or_l[key] = item 

414 else: 

415 Operation.add_item_in_dict(d_or_l[key], key_list[1:], item, idx) # type: ignore[arg-type] 

416 

417 @staticmethod 

418 def _apply_array_transform(row: dict[str, object], keys: list[str], separator: str, v_list: list[object]) -> None: 

419 for idx, v in enumerate(v_list): 

420 if isinstance(v, str): 

421 Operation.add_item_in_dict(row, keys, v.split(separator) if v != "" else [], idx) 

422 

423 @staticmethod 

424 def _apply_dict_transform( 

425 row: dict[str, object], keys: list[str], separator: str, entries: list[str], v_list: list[object] 

426 ) -> None: 

427 new_fields = entries[1:] 

428 new_fields_max_split = len(new_fields) - 1 

429 for idx, v in enumerate(v_list): 

430 if isinstance(v, str): 

431 new_values = v.split(separator, new_fields_max_split) 

432 Operation.add_item_in_dict( 

433 row, 

434 keys, 

435 dict(zip(new_fields, new_values, strict=False)) if v != "" else {}, 

436 idx, 

437 ) 

438 elif isinstance(v, list): 

439 new_list = [dict(zip(new_fields, i.split(separator, new_fields_max_split), strict=False)) for i in v] 

440 Operation.add_item_in_dict(row, keys, new_list, idx) 

441 

442 @staticmethod 

443 def structured(params: dict[str, list[str]], json_table: list[dict[str, object]]) -> list[dict[str, object]]: 

444 """This method checks if there are particular transformation rules specified in 'params' for a JSON output, 

445 and convert each row of the input table ('json_table') according to these rules. 

446 There are two specific rules that can be applied: 

447 

448 1. array("<separator>",<field>): it converts the string value associated to the field name '<field>' into 

449 an array by splitting the various textual parts by means of '<separator>'. For instance, consider the 

450 following JSON structure: 

451 

452 [ 

453 { "names": "Doe, John; Doe, Jane" }, 

454 { "names": "Doe, John; Smith, John" } 

455 ] 

456 

457 Executing the rule 'array("; ",names)' returns the following new JSON structure: 

458 

459 [ 

460 { "names": [ "Doe, John", "Doe, Jane" ], 

461 { "names": [ "Doe, John", "Smith, John" ] 

462 ] 

463 

464 2. dict("separator",<field>,<new_field_1>,<new_field_2>,...): it converts the string value associated to 

465 the field name '<field>' into an dictionary by splitting the various textual parts by means of 

466 '<separator>' and by associating the new fields '<new_field_1>', '<new_field_2>', etc., to these new 

467 parts. For instance, consider the following JSON structure: 

468 

469 [ 

470 { "name": "Doe, John" }, 

471 { "name": "Smith, John" } 

472 ] 

473 

474 Executing the rule 'array(", ",name,family_name,given_name)' returns the following new JSON structure: 

475 

476 [ 

477 { "name": { "family_name": "Doe", "given_name: "John" } }, 

478 { "name": { "family_name": "Smith", "given_name: "John" } } 

479 ] 

480 

481 Each of the specified rules is applied in order, and it works on the JSON structure returned after 

482 the execution of the previous rule.""" 

483 if "json" in params: 

484 fields = params["json"] 

485 for field in fields: 

486 op_match = fullmatch(_JSON_TRANSFORM_RE, field) 

487 if op_match is None: 

488 Operation._raise_unprocessable(f"invalid json transform {field!r}") 

489 op_type = op_match.group("op_type") 

490 quoted_separator = op_match.group("separator") 

491 entries = [i.strip() for i in op_match.group("entries").split(",")] 

492 separator = quoted_separator[1:-1] 

493 if op_type == "array" and len(entries) != 1: 

494 Operation._raise_unprocessable(f"json array transform expects one field, got {field!r}") 

495 if op_type == "dict" and len(entries) < _DICT_TRANSFORM_MIN_FIELD_COUNT: 

496 Operation._raise_unprocessable(f"json dict transform expects output fields, got {field!r}") 

497 keys = entries[0].split(".") 

498 

499 for row in json_table: 

500 v_list = Operation.get_item_in_dict(row, keys) 

501 if op_type == "array": 

502 Operation._apply_array_transform(row, keys, separator, v_list) 

503 elif op_type == "dict": 

504 Operation._apply_dict_transform(row, keys, separator, entries, v_list) 

505 

506 return json_table 

507 

508 def preprocess( 

509 self, par_dict: dict[str, object], op_item: dict[str, str], addon: types.ModuleType 

510 ) -> dict[str, object]: 

511 """This method takes the a dictionary of parameters with the current typed values associated to them and 

512 the item of the API specification defining the behaviour of that operation, and preprocesses the parameters 

513 according to the functions specified in the '#preprocess' field (e.g. "#preprocess lower(doi)"), which is 

514 applied to the specified parameters as input of the function in consideration (e.g. 

515 "/api/v1/citations/10.1108/jd-12-2013-0166", converting the DOI in lowercase). 

516 

517 It is possible to run multiple functions sequentially by concatenating them with "-->" in the API 

518 specification document. In this case the output of the function f_i will becomes the input operation URL 

519 of the function f_i+1. 

520 

521 Finally, it is worth mentioning that all the functions specified in the "#preprocess" field must return 

522 a tuple of values defining how the particular value passed in the dictionary must be changed.""" 

523 result = par_dict 

524 

525 if "preprocess" in op_item: 

526 for pre in [sub(r"\s+", "", i) for i in op_item["preprocess"].split(" --> ")]: 

527 func_name = sub(r"^([^\(\)]+)\(.+$", r"\1", pre).strip() 

528 params_name = sub(r"^.+\(([^\(\)]+)\).*", r"\1", pre).split(",") 

529 

530 param_list = tuple(result[param_name] for param_name in params_name) 

531 

532 # run function 

533 func = getattr(addon, func_name) 

534 res = func(*param_list) 

535 

536 # substitute res to the current parameter in result 

537 for idx, val in enumerate(res): 

538 result[params_name[idx]] = val 

539 

540 return result 

541 

542 def postprocess( 

543 self, res: list[list[str] | list[tuple[object, str]]], op_item: dict[str, str], addon: types.ModuleType 

544 ) -> list[list[str] | list[tuple[object, str]]]: 

545 """This method takes the result table returned by running the SPARQL query in an API operation (specified 

546 as input) and change some of such results according to the functions specified in the '#postprocess' 

547 field (e.g. "#postprocess remove_date("2018")"). These functions can take parameters as input, while the first 

548 unspecified parameters will be always the result table. It is worth mentioning that this result table (i.e. 

549 a list of tuples) actually contains, in each cell, a tuple defining the plain value as well as the typed 

550 value for enabling better comparisons and operations if needed. An example of this table of result is shown as 

551 follows: 

552 

553 [ 

554 ("id", "date"), 

555 ("my_id_1", "my_id_1"), (datetime(2018, 3, 2), "2018-03-02"), 

556 ... 

557 ] 

558 

559 Note that the typed value and the plain value of each cell can be selected by using the methods "tv" and "pv" 

560 respectively. In addition, it is possible to run multiple functions sequentially by concatenating them 

561 with "-->" in the API specification document. In this case the output of the function f_i will becomes 

562 the input result table of the function f_i+1.""" 

563 result = res 

564 

565 if "postprocess" in op_item: 

566 for post in [i.strip() for i in op_item["postprocess"].split(" --> ")]: 

567 func_name = sub(r"^([^\(\)]+)\(.+$", r"\1", post).strip() 

568 param_str = sub(r"^.+\(([^\(\)]*)\).*", r"\1", post) 

569 params_values = () if param_str == "" else next(reader(param_str.splitlines(), skipinitialspace=True)) 

570 

571 func = getattr(addon, func_name) 

572 func_params = (result, *tuple(params_values)) 

573 result, do_type_fields = func(*func_params) 

574 if do_type_fields: 

575 result = self.type_fields(result, op_item) 

576 

577 return result 

578 

579 @staticmethod 

580 def _apply_require( 

581 header: list[str], result: list[list[tuple[object, str]]], fields: list[str] 

582 ) -> list[list[tuple[object, str]]]: 

583 """Exclude rows with empty values in the specified fields.""" 

584 for field in fields: 

585 if field not in header: 

586 Operation._raise_unprocessable(f"require field {field!r} is not in the result header") 

587 field_idx = header.index(field) 

588 result = [row for row in result if Operation.pv(field_idx, row) not in (None, "")] 

589 return result 

590 

591 def _apply_filter( 

592 self, header: list[str], result: list[list[tuple[object, str]]], fields: list[str] 

593 ) -> list[list[tuple[object, str]]]: 

594 """Filter rows by comparison operators or regex patterns.""" 

595 for field in fields: 

596 if ":" not in field: 

597 Operation._raise_unprocessable(f"filter must use field:value syntax, got {field!r}") 

598 field_name, field_value = field.split(":", 1) 

599 if field_name not in header: 

600 Operation._raise_unprocessable(f"filter field {field_name!r} is not in the result header") 

601 if field_value == "": 

602 Operation._raise_unprocessable(f"filter value for field {field_name!r} must not be empty") 

603 field_idx = header.index(field_name) 

604 if not result: 

605 continue 

606 flag = field_value[0] 

607 if flag in ("<", ">", "="): 

608 value = field_value[1:].lower() 

609 if value == "": 

610 Operation._raise_unprocessable( 

611 f"filter comparison value for field {field_name!r} must not be empty" 

612 ) 

613 try: 

614 typed_value = self.dt.get_func(type(Operation.tv(field_idx, result[0])).__name__)(value) 

615 except ValueError: 

616 Operation._raise_unprocessable(f"filter value {value!r} is invalid for field {field_name!r}") 

617 result = [ 

618 row 

619 for row in result 

620 if self.operation[flag]( 

621 Operation.tv(field_idx, row), 

622 typed_value, 

623 ) 

624 ] 

625 else: 

626 pattern = field_value.lower() 

627 try: 

628 result = [row for row in result if search(pattern, Operation.pv(field_idx, row).lower())] 

629 except regex_error as exc: 

630 Operation._raise_unprocessable(f"filter regex for field {field_name!r} is invalid: {exc}") 

631 return result 

632 

633 @staticmethod 

634 def _apply_sort( 

635 header: list[str], result: list[list[tuple[object, str]]], fields: list[str] 

636 ) -> list[list[tuple[object, str]]]: 

637 """Sort rows by the specified fields and directions.""" 

638 for field in sorted(fields, reverse=True): 

639 order_match = fullmatch(r"(?P<direction>desc|asc)\((?P<field_name>[^()]+)\)", field) 

640 if order_match: 

641 direction = order_match.group("direction") 

642 field_name = order_match.group("field_name") 

643 else: 

644 if "(" in field or ")" in field: 

645 Operation._raise_unprocessable(f"invalid sort expression {field!r}") 

646 direction, field_name = "asc", field 

647 if field_name not in header: 

648 Operation._raise_unprocessable(f"sort field {field_name!r} is not in the result header") 

649 field_idx = header.index(field_name) 

650 result = sorted(result, key=itemgetter(field_idx), reverse=(direction == "desc")) 

651 return result 

652 

653 def handling_params( 

654 self, params: dict[str, list[str]], table: list[list[str] | list[tuple[object, str]]] 

655 ) -> list[list[str] | list[tuple[object, str]]]: 

656 """This method is used for filtering the results that are returned after the post-processing 

657 phase. In particular, it is possible to: 

658 

659 1. [require=<field_name>] exclude all the rows that have an empty value in the field specified - e.g. the 

660 "require=doi" remove all the rows that do not have any string specified in the "doi" field; 

661 

662 2. [filter=<field_name>:<operator><value>] consider only the rows where the string in the input field 

663 is compliant with the value specified. If no operation is specified, the value is interpreted as a 

664 regular expression, otherwise it is compared according to the particular type associated to that field. 

665 Possible operators are "=", "<", and ">" - e.g. "filter=title:semantics?" returns all the rows that contain 

666 the string "semantic" or "semantics" in the field title, while "filter=date:>2016-05" returns all the rows 

667 that have a date greater than May 2016; 

668 

669 3. [sort=<order>(<field_name>)] sort all the results according to the value and type of the particular 

670 field specified in input. It is possible to sort the rows either in ascending ("asc") or descending 

671 ("desc") order - e.g. "sort=desc(date)" sort all the rows according to the value specified in the 

672 field "date" in descending order. 

673 

674 Note that these filtering operations are applied in the order presented above - first the "require", then 

675 the "filter", and finally the "sort". It is possible to specify one or more filtering operation of the 

676 same kind (e.g. "require=doi&require=title"). 

677 """ 

678 header = table[0] 

679 result = table[1:] 

680 

681 overridden = set(self.custom_params) | self.disabled_params 

682 

683 if ("exclude" in params or "require" in params) and "require" not in overridden and "exclude" not in overridden: 

684 fields = params["exclude"] if "exclude" in params else params["require"] 

685 result = self._apply_require(header, result, fields) # type: ignore[arg-type] 

686 

687 if "filter" in params and "filter" not in overridden: 

688 result = self._apply_filter(header, result, params["filter"]) # type: ignore[arg-type] 

689 

690 if "sort" in params and "sort" not in overridden: 

691 result = self._apply_sort(header, result, params["sort"]) # type: ignore[arg-type] 

692 

693 return [header, *result] 

694 

695 def type_fields( 

696 self, res: list[list[str] | list[tuple[object, str]] | list[str | object]], op_item: dict[str, str] 

697 ) -> list[list[str] | list[tuple[object, str]]]: 

698 """It creates a version of the results 'res' that adds, to each value of the fields, the same value interpreted 

699 with the type specified in the specification file (field 'field_type'). Note that 'str' is used as default in 

700 case no further specifications are provided.""" 

701 result = [] 

702 cast_func = {} 

703 header = res[0] 

704 for heading in header: 

705 cast_func[heading] = DataType.str 

706 

707 if "field_type" in op_item: 

708 for f, p in findall(FIELD_TYPE_RE, op_item["field_type"]): 

709 cast_func[p] = self.dt.get_func(f) 

710 

711 for row in res[1:]: 

712 new_row = [] 

713 for idx, heading in enumerate(header): 

714 cur_value = row[idx] 

715 if isinstance(cur_value, tuple): 

716 cur_value = cur_value[1] 

717 new_row.append((cast_func[heading](cur_value), cur_value)) 

718 result.append(new_row) 

719 

720 return [header, *result] # type: ignore[return-value] 

721 

722 def remove_types(self, res: list[list[str] | list[tuple[object, str]]]) -> ResultTable: 

723 """This method takes the results 'res' that include also the typed value and returns a version of such 

724 results without the types that is ready to be stored on the file system.""" 

725 result: ResultTable = [cast("list[str]", res[0])] 

726 result.extend([Operation.pv(idx, row) for idx in range(len(row))] for row in res[1:]) # type: ignore[arg-type] 

727 return result 

728 

729 @staticmethod 

730 def _is_directive(line: str) -> bool: 

731 return line.strip().startswith("@@") 

732 

733 @staticmethod 

734 def _parse_directive_args( 

735 tokens: list[str], param_names: list[str], defaults: dict[str, str] | None = None 

736 ) -> dict[str, str]: 

737 defaults = defaults or {} 

738 all_names = set(param_names) | set(defaults) 

739 result = {} 

740 positional_index = 0 

741 seen_keyword = False 

742 

743 for token in tokens: 

744 if "=" in token: 

745 key, value = token.split("=", 1) 

746 if key in all_names: 

747 if key in result: 

748 msg = f"Duplicate parameter {key!r}" 

749 raise ValueError(msg) 

750 seen_keyword = True 

751 result[key] = value 

752 continue 

753 if seen_keyword: 

754 msg = f"Positional argument {token!r} cannot follow keyword argument" 

755 raise ValueError(msg) 

756 if positional_index >= len(param_names): 

757 msg = f"Unexpected argument {token!r}" 

758 raise ValueError(msg) 

759 result[param_names[positional_index]] = token 

760 positional_index += 1 

761 

762 for name, default in defaults.items(): 

763 if name not in result: 

764 result[name] = default 

765 

766 missing = [name for name in param_names if name not in result] 

767 if missing: 

768 msg = f"Missing required parameter(s): {', '.join(missing)}" 

769 raise ValueError(msg) 

770 

771 return result 

772 

773 def _handle_directive_with(self, parts: list[str]) -> tuple[str | None, str, None]: 

774 args = Operation._parse_directive_args( 

775 parts[1:], 

776 ["source"], 

777 defaults={"source": "", "endpoint": "", "engine": "sparql"}, 

778 ) 

779 name = args["source"].strip() 

780 endpoint = args["endpoint"].strip() 

781 engine = args["engine"].strip().lower() 

782 if engine not in {"sparql", "sparql-anything"}: 

783 msg = f"Unknown engine '{args['engine']}' in @@with" 

784 raise ValueError(msg) 

785 if name and endpoint: 

786 msg = "@@with cannot combine source and endpoint" 

787 raise ValueError(msg) 

788 endpoint_was_given = any(token.startswith("endpoint=") for token in parts[1:]) 

789 if endpoint_was_given and not endpoint: 

790 msg = "@@with endpoint cannot be empty" 

791 raise ValueError(msg) 

792 if engine == "sparql" and not name and not endpoint: 

793 msg = "@@with source or endpoint is required when engine=sparql" 

794 raise ValueError(msg) 

795 if name and name not in self.sources_map: 

796 msg = f"Unknown source '{name}' in @@with; declare it in #sources." 

797 raise ValueError(msg) 

798 if endpoint: 

799 return endpoint, engine, None 

800 if name: 

801 return self.sources_map[name], engine, None 

802 return None, engine, None 

803 

804 @staticmethod 

805 def _handle_directive_join(parts: list[str]) -> tuple[None, None, tuple[str, str, str, str]]: 

806 args = Operation._parse_directive_args(parts[1:], ["left_var", "right_var"], defaults={"type": "inner"}) 

807 return None, None, ("JOIN", args["left_var"], args["right_var"], args["type"].lower()) 

808 

809 @staticmethod 

810 def _handle_directive_values(parts: list[str]) -> tuple[None, None, tuple[str, list[str], int | None, int]]: 

811 variables = [token for token in parts[1:] if "=" not in token] 

812 if not variables: 

813 msg = "@@values needs at least one variable" 

814 raise ValueError(msg) 

815 batch_size: int | None = None 

816 workers = 1 

817 seen_keys: set[str] = set() 

818 for token in parts[1:]: 

819 if "=" not in token: 

820 continue 

821 key, raw_value = token.split("=", 1) 

822 if key not in ("batch_size", "workers") or key in seen_keys: 

823 msg = f"Unexpected argument {token!r}" 

824 raise ValueError(msg) 

825 seen_keys.add(key) 

826 if not raw_value: 

827 continue 

828 value = int(raw_value) 

829 if value < 1: 

830 msg = f"{key} must be >= 1, got {value}" 

831 raise ValueError(msg) 

832 if key == "batch_size": 

833 batch_size = value 

834 else: 

835 workers = value 

836 return None, None, ("VALUES_INJECT", variables, batch_size, workers) 

837 

838 @staticmethod 

839 def _handle_directive_page(parts: list[str]) -> tuple[None, None, tuple[str, str, str, str]]: 

840 args = Operation._parse_directive_args(parts[1:], ["variable"], defaults={"default_size": "", "max_size": ""}) 

841 var_name = args["variable"] 

842 if not var_name.startswith("?"): 

843 msg = f"@@page variable must start with '?', got {var_name!r}" 

844 raise ValueError(msg) 

845 return None, None, ("PAGE", var_name, args["default_size"], args["max_size"]) 

846 

847 def _process_directive( 

848 self, line: str, directive_handlers: dict[str, Callable[..., object]] 

849 ) -> tuple[str | None, str | None, tuple[str, ...] | None]: 

850 body = line.strip()[2:].strip() 

851 parts = body.split() 

852 cmd = parts[0].lower() 

853 

854 handler = directive_handlers.get(cmd) 

855 if handler is None: 

856 msg = f"Unknown directive @@{cmd}" 

857 raise ValueError(msg) 

858 

859 return handler(parts) # type: ignore[return-value] 

860 

861 @staticmethod 

862 def _update_directive_state( 

863 current_endpoint: str, 

864 current_engine: str, 

865 new_endpoint: str | None, 

866 new_engine: str | None, 

867 ) -> tuple[str, str]: 

868 if new_endpoint is not None: 

869 current_endpoint = new_endpoint 

870 if new_engine is not None: 

871 current_engine = new_engine 

872 return current_endpoint, current_engine 

873 

874 def _parse_steps(self, text: str, default_endpoint: str, params: dict[str, object]) -> list[tuple[str, ...]]: 

875 """ 

876 Returns a list of steps: 

877 - ("QUERY", endpoint_url, engine, query_text) 

878 - ("JOIN", left_var, right_var, how) # how in {"inner","left"} 

879 - ("REMOVE", [vars]) 

880 - ("VALUES_INJECT", [vars], batch_size, workers) # @@values ?var1 ... [batch_size=N] [workers=N] 

881 - ("PAGE", var_name, default_size, max_size) # @@page ?var [default_size=N] [max_size=M] 

882 """ 

883 for p, v in params.items(): 

884 text = text.replace(f"[[{p}]]", str(v)) 

885 steps: list[tuple[str, ...]] = [] 

886 cur_query: list[str] = [] 

887 current_endpoint = default_endpoint 

888 current_engine = "sparql" 

889 

890 directive_handlers = { 

891 "with": self._handle_directive_with, 

892 "join": self._handle_directive_join, 

893 "remove": lambda parts: (None, None, ("REMOVE", parts[1:])), 

894 "values": self._handle_directive_values, 

895 "page": self._handle_directive_page, 

896 } 

897 

898 def flush_query() -> None: 

899 if cur_query: 

900 q = "\n".join(cur_query).strip() 

901 if not q: 

902 cur_query.clear() 

903 return 

904 for p, v in params.items(): 

905 q = q.replace(f"[[{p}]]", str(v)) 

906 steps.append(("QUERY", current_endpoint, current_engine, q)) 

907 cur_query.clear() 

908 

909 for raw in text.splitlines(): 

910 line = raw.rstrip("\n") 

911 if not self._is_directive(line): 

912 cur_query.append(line) 

913 continue 

914 

915 flush_query() 

916 

917 new_endpoint, new_engine, step = self._process_directive(line, directive_handlers) 

918 current_endpoint, current_engine = Operation._update_directive_state( 

919 current_endpoint, 

920 current_engine, 

921 new_endpoint, 

922 new_engine, 

923 ) 

924 if step is not None: 

925 steps.append(step) 

926 

927 flush_query() 

928 return steps 

929 

930 def _send_sparql_csv_request(self, endpoint_url: str, query_text: str) -> Response: 

931 headers = { 

932 "Accept": "text/csv", 

933 "User-Agent": "RAMOSE/2.0.0", 

934 } 

935 auth = backend_auth(endpoint_url) 

936 if self.sparql_http_method == "get": 

937 return _http_session.get( 

938 endpoint_url + "?query=" + quote(query_text), 

939 headers=headers, 

940 auth=auth, 

941 timeout=self.sparql_timeout, 

942 ) 

943 return _http_session.post( 

944 endpoint_url, 

945 data=query_text, 

946 headers={ 

947 **headers, 

948 "Content-Type": "application/sparql-query", 

949 }, 

950 auth=auth, 

951 timeout=self.sparql_timeout, 

952 ) 

953 

954 def _request_sparql_csv(self, endpoint_url: str, query_text: str) -> Response: 

955 retry_wait = self.retry_wait 

956 for attempt in range(self.retry_attempts): 

957 try: 

958 response = self._send_sparql_csv_request(endpoint_url, query_text) 

959 except (RequestsTimeout, TimeoutError) as exc: 

960 if attempt + 1 == self.retry_attempts: 

961 msg = f"HTTP status code 408: SPARQL request timeout: {exc}" 

962 raise HttpError(HTTPStatus.REQUEST_TIMEOUT, msg) from exc 

963 self._sleep_before_retry(retry_wait) 

964 retry_wait *= self.retry_backoff 

965 continue 

966 except RequestException as exc: 

967 if attempt + 1 == self.retry_attempts: 

968 msg = f"HTTP status code 502: SPARQL request failed: {exc}" 

969 raise HttpError(HTTPStatus.BAD_GATEWAY, msg) from exc 

970 self._sleep_before_retry(retry_wait) 

971 retry_wait *= self.retry_backoff 

972 continue 

973 

974 response.encoding = "utf-8" 

975 if response.status_code not in _RETRYABLE_STATUS_CODES or attempt + 1 == self.retry_attempts: 

976 return response 

977 self._sleep_before_retry(retry_wait) 

978 retry_wait *= self.retry_backoff 

979 

980 msg = "SPARQL request did not run" 

981 raise RuntimeError(msg) 

982 

983 @staticmethod 

984 def _sleep_before_retry(retry_wait: float) -> None: 

985 if retry_wait: 

986 time.sleep(retry_wait) 

987 

988 def _run_sparql_dicts(self, endpoint_url: str, query_text: str) -> list[dict[str, object]]: 

989 r = self._request_sparql_csv(endpoint_url, query_text) 

990 r.encoding = "utf-8" 

991 if r.status_code != HTTPStatus.OK: 

992 msg = f"SPARQL {r.status_code}: {r.reason}" 

993 raise RuntimeError(msg) 

994 text = r.content.decode("utf-8-sig", errors="replace") 

995 list_of_lines = text.splitlines() 

996 return list(DictReader(list_of_lines)) # type: ignore[return-value] 

997 

998 @staticmethod 

999 def _normalize_sparql_json_resultset(result: dict[str, object]) -> list[dict[str, object]]: 

1000 """Convert a SPARQL JSON ResultSet dict to a list of flat dicts.""" 

1001 vars_ = result["head"].get("vars") or [] # type: ignore[union-attr] 

1002 return [ 

1003 {v: (b[v].get("value") if isinstance(b.get(v), dict) else b.get(v)) for v in vars_} 

1004 for b in result["results"].get("bindings", []) # type: ignore[union-attr] 

1005 ] 

1006 

1007 @staticmethod 

1008 def _normalize_columnar_dict(result: dict[str, object]) -> list[dict[str, object]]: 

1009 """Convert a column-oriented dict {col: [values]} to a list of row dicts.""" 

1010 cols = list(result.keys()) 

1011 max_len = max((len(v) for v in result.values() if isinstance(v, (list, tuple))), default=0) 

1012 

1013 if not max_len: 

1014 return [result] 

1015 

1016 rows = [] 

1017 for i in range(max_len): 

1018 row = {} 

1019 for c in cols: 

1020 v = result[c] 

1021 row[c] = ( 

1022 v[i] 

1023 if isinstance(v, (list, tuple)) and i < len(v) 

1024 else (v if not isinstance(v, (list, tuple)) else None) 

1025 ) 

1026 rows.append(row) 

1027 return rows 

1028 

1029 @staticmethod 

1030 def _sparql_anything_error_status(exc: Exception) -> int | None: 

1031 message = str(exc) 

1032 status_match = search(_SPARQL_ANYTHING_HTTP_STATUS_RE, message) 

1033 if status_match is not None: 

1034 return int(status_match.group(1)) 

1035 

1036 message = message.lower() 

1037 if any(marker in message for marker in _SPARQL_ANYTHING_TIMEOUT_MARKERS): 

1038 return int(HTTPStatus.REQUEST_TIMEOUT) 

1039 if any(marker in message for marker in _SPARQL_ANYTHING_NETWORK_MARKERS): 

1040 return int(HTTPStatus.BAD_GATEWAY) 

1041 return None 

1042 

1043 @staticmethod 

1044 def _raise_sparql_anything_error(status_code: int, exc: Exception) -> NoReturn: 

1045 msg = f"HTTP status code {status_code}: SPARQL Anything request failed: {exc}" 

1046 raise HttpError(status_code, msg) from exc 

1047 

1048 def _request_sparql_anything_select(self, kwargs: dict[str, object]) -> object: 

1049 if self._sa_engine is None: 

1050 msg = "SPARQL Anything engine not initialized" 

1051 raise RuntimeError(msg) 

1052 sa_engine = cast("SparqlAnythingEngine", self._sa_engine) 

1053 

1054 retry_wait = self.retry_wait 

1055 for attempt in range(self.retry_attempts): 

1056 try: 

1057 return sa_engine.select(output_type=dict, **kwargs) 

1058 except Exception as exc: # noqa: PERF203 

1059 status_code = self._sparql_anything_error_status(exc) 

1060 if status_code is None: 

1061 raise 

1062 if status_code not in _RETRYABLE_STATUS_CODES or attempt + 1 == self.retry_attempts: 

1063 self._raise_sparql_anything_error(status_code, exc) 

1064 self._sleep_before_retry(retry_wait) 

1065 retry_wait *= self.retry_backoff 

1066 

1067 msg = "SPARQL Anything request did not run" 

1068 raise RuntimeError(msg) 

1069 

1070 def _run_sparql_anything_dicts( 

1071 self, query_text: str, values: dict[str, str] | None = None 

1072 ) -> list[dict[str, object]]: 

1073 """ 

1074 Execute a SPARQL Anything SELECT query via PySPARQL-Anything and return 

1075 a list of dicts (one per row), in the same shape as _run_sparql_dicts. 

1076 

1077 query_text: full SPARQL (Anything) query string 

1078 (typically containing SERVICE <x-sparql-anything:...>). 

1079 values: optional dict of template parameters for the query 

1080 (name -> value), passed to SPARQL Anything's `values=`. 

1081 """ 

1082 if self._sa_engine is None: 

1083 if SparqlAnything is None: 

1084 msg = "pysparql_anything not installed. Install with: pip install ramose[sparql-anything]" 

1085 raise ImportError(msg) 

1086 self._sa_engine = SparqlAnything() 

1087 

1088 kwargs: dict[str, object] = {"query": query_text} 

1089 if values: 

1090 kwargs["values"] = {str(k): str(v) for k, v in values.items()} # type: ignore[assignment] 

1091 

1092 result = self._request_sparql_anything_select(kwargs) 

1093 

1094 # Normalize to list[dict] 

1095 if isinstance(result, list): 

1096 if result and isinstance(result[0], dict): 

1097 return result 

1098 return [dict(row) for row in result] 

1099 

1100 if not isinstance(result, dict): 

1101 return [{"result": result}] 

1102 

1103 # Standard SPARQL JSON ResultSet shape 

1104 head = result.get("head") 

1105 results_obj = result.get("results") 

1106 if isinstance(head, dict) and isinstance(results_obj, dict) and "bindings" in results_obj: 

1107 return self._normalize_sparql_json_resultset(result) 

1108 

1109 # Column-oriented dict or single-row fallback 

1110 return self._normalize_columnar_dict(result) 

1111 

1112 def _run_query_dicts(self, endpoint_url: str, engine: str, query_text: str) -> list[dict[str, object]]: 

1113 if engine == "sparql-anything": 

1114 return self._run_sparql_anything_dicts(query_text) 

1115 if engine != "sparql": 

1116 msg = f"Unknown query engine {engine!r}" 

1117 raise ValueError(msg) 

1118 return self._run_sparql_dicts(endpoint_url, query_text) 

1119 

1120 def _inject_values_clause(self, query_text: str, vars_: list[str], acc_rows: list[dict[str, object]] | None) -> str: 

1121 # None means no prior step ran: leave the query unrestricted. 

1122 # An empty list means a prior step matched nothing: keep going so the empty 

1123 # accumulator injects an empty VALUES block, which correctly yields zero solutions. 

1124 if acc_rows is None: 

1125 return query_text 

1126 

1127 # build distinct tuples for requested vars from the accumulator 

1128 cols = [v.lstrip("?") for v in vars_] 

1129 tuples, seen = [], set() 

1130 for row in acc_rows: 

1131 tup = tuple(row.get(c, "") for c in cols) 

1132 if all(tup) and tup not in seen: 

1133 seen.add(tup) 

1134 tuples.append(tup) 

1135 

1136 # format literals vs IRIs 

1137 def fmt(x: object) -> str: 

1138 s = str(x) 

1139 if s.startswith(("http://", "https://")): 

1140 return f"<{s}>" 

1141 return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' 

1142 

1143 head = "VALUES (" + " ".join(vars_) + ") {\n" 

1144 body = "\n".join(" (" + " ".join(fmt(v) for v in tup) + ")" for tup in tuples) 

1145 tail = "\n}\n" 

1146 

1147 i = query_text.find("{") 

1148 if i == -1: 

1149 # no WHERE brace: put VALUES at top (legal SPARQL) 

1150 return head + body + tail + query_text 

1151 j = i + 1 

1152 return query_text[:j] + "\n" + head + body + tail + query_text[j:] 

1153 

1154 @staticmethod 

1155 def _drop_columns(rows: list[dict[str, object]], vars_: list[str]) -> list[dict[str, object]]: 

1156 if not rows: 

1157 return rows 

1158 vars_set = {v.lstrip("?") for v in vars_} 

1159 return [{k: v for k, v in r.items() if k not in vars_set and ("?" + k) not in vars_set} for r in rows] 

1160 

1161 def _norm_join_key(self, v: object) -> str | None: 

1162 if v is None: 

1163 return None 

1164 s = str(v).strip() 

1165 if not s: 

1166 return None 

1167 # unify scheme for w3id IRIs (and similar) 

1168 if s.startswith("http://"): 

1169 s = "https://" + s[len("http://") :] 

1170 # drop a single trailing slash for stability 

1171 return s.removesuffix("/") 

1172 

1173 @staticmethod 

1174 def _merge_row( 

1175 left_row: dict[str, object], right_row: dict[str, object], right_cols: list[str] 

1176 ) -> dict[str, object]: 

1177 merged = dict(left_row) 

1178 for col in right_cols: 

1179 right_val = right_row.get(col) 

1180 if right_val is None: 

1181 continue 

1182 if col not in merged or merged[col] in ("", None): 

1183 merged[col] = right_val 

1184 else: 

1185 alt = f"{col}_r" 

1186 if alt not in merged or merged[alt] in ("", None): 

1187 merged[alt] = right_val 

1188 return merged 

1189 

1190 def _join( 

1191 self, 

1192 left_rows: list[dict[str, object]] | None, 

1193 right_rows: list[dict[str, object]] | None, 

1194 lkey: str, 

1195 rkey: str, 

1196 how: str = "inner", 

1197 ) -> list[dict[str, object]]: 

1198 """ 

1199 Merge two row sets on lkey (from left_rows) and rkey (from right_rows). 

1200 - lkey/rkey may be passed as '?var' or 'var' -> we normalize to bare names. 

1201 - Keys are normalized with _norm_join_key (e.g., http -> https, trim slash). 

1202 - When 'left', all left rows are preserved even if no match on the right. 

1203 - Right-hand columns are copied into the merged row; collisions are avoided. 

1204 """ 

1205 lcol = lkey.lstrip("?") 

1206 rcol = rkey.lstrip("?") 

1207 

1208 left_rows = left_rows or [] 

1209 right_rows = right_rows or [] 

1210 

1211 rindex: dict[str, list[dict[str, object]]] = {} 

1212 for r in right_rows: 

1213 rk = self._norm_join_key(r.get(rcol)) 

1214 if rk is None: 

1215 continue 

1216 rindex.setdefault(rk, []).append(r) 

1217 

1218 right_cols = [c for c in (right_rows[0].keys() if right_rows else []) if c != rcol] 

1219 

1220 out: list[dict[str, object]] = [] 

1221 for left_row in left_rows: 

1222 lk = self._norm_join_key(left_row.get(lcol)) 

1223 matches = rindex.get(lk, []) # type: ignore[arg-type] 

1224 if matches: 

1225 out.extend(self._merge_row(left_row, r, right_cols) for r in matches) 

1226 elif how == "left": 

1227 out.append(dict(left_row)) 

1228 return out 

1229 

1230 def _apply_custom_postprocess_params(self, table: ResultTable, q_string: dict[str, list[str]]) -> ResultTable: 

1231 for param_name, param_conf in self.custom_params.items(): 

1232 if param_conf["phase"] != "postprocess": 

1233 continue 

1234 if param_name in q_string: 

1235 handler = getattr(self.addon, param_conf["handler"]) 

1236 table = handler(table, q_string[param_name]) 

1237 return table 

1238 

1239 @property 

1240 def _cache_ttl(self) -> int: 

1241 if "cache_duration" in self.i: 

1242 return int(self.i["cache_duration"]) 

1243 return self._default_cache_ttl 

1244 

1245 def _build_cache_key(self, q_string: dict[str, list[str]]) -> str: 

1246 presentation_params = {"format", "json"} 

1247 if "@@page" not in self.i["sparql"]: 

1248 presentation_params |= {"page", "page_size"} 

1249 data_params = sorted((name, values) for name, values in q_string.items() if name not in presentation_params) 

1250 if data_params: 

1251 query_string = "&".join(f"{name}={value}" for name, values in data_params for value in values) 

1252 return f"{self.tp}:{self.op_url}?{query_string}" 

1253 return f"{self.tp}:{self.op_url}" 

1254 

1255 def _extract_pagination_params(self, q_string: dict[str, list[str]]) -> tuple[int, int] | None: 

1256 page_size_active = self._is_builtin_param_active("page_size") 

1257 page_active = self._is_builtin_param_active("page") 

1258 if "page_size" not in q_string or not page_size_active: 

1259 if page_size_active and page_active and "page" in q_string: 

1260 Operation._raise_unprocessable("page requires page_size") 

1261 return None 

1262 page_size = Operation._parse_positive_int_param(q_string, "page_size") 

1263 page = 1 

1264 if "page" in q_string and page_active: 

1265 page = Operation._parse_positive_int_param(q_string, "page") 

1266 return page, page_size 

1267 

1268 def _has_custom_converter(self, q_string: dict[str, list[str]]) -> bool: 

1269 if "format" in q_string and self._is_builtin_param_active("format"): 

1270 for req_format in q_string["format"]: 

1271 if req_format in self.format: 

1272 return True 

1273 elif "default_format" in self.i and self.i["default_format"].strip() in self.format: 

1274 return True 

1275 return False 

1276 

1277 def _paginate_and_format( 

1278 self, 

1279 table: ResultTable, 

1280 q_string: dict[str, list[str]], 

1281 content_type: str, 

1282 ) -> OperationResponse: 

1283 has_page_directive = "@@page" in self.i["sparql"] 

1284 if self._has_custom_converter(q_string) and not has_page_directive: 

1285 self.pagination_info = None 

1286 # A @@page step already paginated upstream; do not paginate again here. 

1287 elif not has_page_directive: 

1288 page_params = self._extract_pagination_params(q_string) 

1289 if page_params is not None: 

1290 page, page_size = page_params 

1291 total_items = len(table) - 1 

1292 total_pages = ceil(total_items / page_size) 

1293 Operation._validate_page_range(page, total_items, total_pages) 

1294 start = (page - 1) * page_size 

1295 end = start + page_size 

1296 table = [table[0], *table[1 + start : 1 + end]] 

1297 self.pagination_info = build_pagination_info(self.op_url, q_string, page, page_size, total_items) 

1298 else: 

1299 self.pagination_info = None 

1300 

1301 s_res = StringIO() 

1302 writer(s_res).writerows(table) 

1303 formatted, ctype = self._format_response(s_res.getvalue(), q_string, content_type) 

1304 return OperationResponse(formatted.status_code, formatted.body, ctype, {}) 

1305 

1306 def _cache_value(self, rows: ResultTable) -> CachedResult: 

1307 pagination: CachedPagination | None = None 

1308 if "@@page" in self.i["sparql"] and self.pagination_info is not None: 

1309 pagination = { 

1310 "page": self.pagination_info.page, 

1311 "page_size": self.pagination_info.page_size, 

1312 "total_items": self.pagination_info.total_items, 

1313 } 

1314 return {"rows": rows, "pagination": pagination} 

1315 

1316 def _format_cached_result( 

1317 self, 

1318 cached_value: object, 

1319 q_string: dict[str, list[str]], 

1320 content_type: str, 

1321 ) -> OperationResponse: 

1322 entry = cast("CachedResult", cached_value) 

1323 if entry["pagination"] is not None: 

1324 pagination = entry["pagination"] 

1325 self.pagination_info = build_pagination_info( 

1326 self.op_url, 

1327 q_string, 

1328 pagination["page"], 

1329 pagination["page_size"], 

1330 pagination["total_items"], 

1331 ) 

1332 return self._paginate_and_format(entry["rows"], q_string, content_type) 

1333 

1334 def _finalize_result( 

1335 self, 

1336 csv_rows: list[list[str]] | list[list[str | object]], 

1337 content_type: str, 

1338 output_fields: list[str] | None = None, 

1339 ) -> OperationResponse: 

1340 """Run the shared pipeline: type fields, postprocess, select the output fields, filter, remove types, 

1341 cache, paginate, format.""" 

1342 q_string = parse_qs(quote(self.url_parsed.query, safe="&=")) 

1343 res = self.type_fields(csv_rows, self.i) # type: ignore[arg-type] 

1344 if self.addon is not None: 

1345 res = self.postprocess(res, self.i, self.addon) 

1346 if output_fields is not None: 

1347 res = Operation._select_columns(res, output_fields) 

1348 res = self.handling_params(q_string, res) 

1349 res = self.remove_types(res) 

1350 if self.custom_params: 

1351 res = self._apply_custom_postprocess_params(res, q_string) 

1352 if self._cache is not None and "cache_disable" not in self.i: 

1353 self._cache.set(self._build_cache_key(q_string), self._cache_value(res), expire=self._cache_ttl) 

1354 return self._paginate_and_format(res, q_string, content_type) 

1355 

1356 @staticmethod 

1357 def _header_from_field_type(op_item: dict[str, str], acc: list[dict[str, object]]) -> list[str]: 

1358 # Respect #field_type order if provided, else derive from data 

1359 if "field_type" in op_item: 

1360 # FIELD_TYPE_RE is global in this file 

1361 return [f for (_, f) in findall(FIELD_TYPE_RE, op_item["field_type"])] 

1362 # fallback to keys of first row 

1363 return list(acc[0].keys()) if acc else [] 

1364 

1365 @staticmethod 

1366 def _select_columns( 

1367 res: list[list[str] | list[tuple[object, str]]], fields: list[str] 

1368 ) -> list[list[str] | list[tuple[object, str]]]: 

1369 header = cast("list[str]", res[0]) 

1370 indexes = [header.index(field) for field in fields] 

1371 return [fields, *([row[idx] for idx in indexes] for row in res[1:])] # type: ignore[list-item] 

1372 

1373 @staticmethod 

1374 def _to_csv_rows(header: list[str], acc: list[dict[str, object]]) -> list[list[object]]: 

1375 rows: list[list[object]] = [header] # type: ignore[list-item] 

1376 rows.extend([d.get(h, "") for h in header] for d in acc) 

1377 return rows 

1378 

1379 def _extract_params(self, body_params: Mapping[str, object] | None = None) -> dict[str, object]: 

1380 """Extract URL parameters (and request body parameters, for write operations) and apply type 

1381 conversions based on the operation spec.""" 

1382 par_dict: dict[str, object] = {} 

1383 url_match = match(self.op, self.op_url) 

1384 if url_match is None: 

1385 msg = f"URL {self.op_url} does not match pattern {self.op}" 

1386 raise ValueError(msg) 

1387 par_man = url_match.groups() 

1388 for idx, par in enumerate(findall("{([^{}]+)}", self.i["url"])): 

1389 par_dict[par] = self._bind_value(par, par_man[idx]) 

1390 for par, val in (body_params or {}).items(): 

1391 par_dict[par] = self._bind_value(par, val) 

1392 return par_dict 

1393 

1394 def _bind_value(self, param: str, value: object) -> object: 

1395 kind = self.i[param].split("(")[0] if param in self.i else "str" 

1396 text = "" if value is None else str(value) 

1397 if kind == "iri": 

1398 return escape_iri(text) 

1399 if kind in ("str", "literal"): 

1400 return escape_literal(text) 

1401 return self.dt.get_func(kind)(text) 

1402 

1403 @staticmethod 

1404 def _apply_config_filters(config: FiltersConfig, values: list[str]) -> dict[str, str]: 

1405 try: 

1406 return apply_filters(config, values) 

1407 except InvalidParameterValueError as exc: 

1408 raise HttpError(HTTPStatus.BAD_REQUEST, f"HTTP status code 400: {exc}") from exc 

1409 except ValueError as exc: 

1410 Operation._raise_unprocessable(str(exc)) 

1411 

1412 def _resolve_preprocess_handler(self, param_name: str, handler: str) -> Callable[[list[str]], dict[str, str]]: 

1413 if param_name in self.custom_param_configs: 

1414 config = self.custom_param_configs[param_name] 

1415 return lambda values: Operation._apply_config_filters(config, values) 

1416 return getattr(self.addon, handler) 

1417 

1418 def _apply_custom_preprocess_params(self, par_dict: dict[str, object]) -> None: 

1419 q_string = parse_qs(quote(self.url_parsed.query, safe="&=")) 

1420 for param_name, param_conf in self.custom_params.items(): 

1421 if param_conf["phase"] != "preprocess": 

1422 continue 

1423 if param_name in q_string: 

1424 handler = self._resolve_preprocess_handler(param_name, param_conf["handler"]) 

1425 par_dict.update(handler(q_string[param_name])) 

1426 elif param_name not in par_dict: 

1427 par_dict[param_name] = "" 

1428 for placeholder in findall(r"\[\[(\w+)\]\]", self.i["sparql"]): 

1429 if placeholder not in par_dict: 

1430 par_dict[placeholder] = "" 

1431 

1432 def _exec_standard_sparql(self, par_dict: dict[str, object], content_type: str) -> OperationResponse: 

1433 """Execute standard SPARQL queries, handling parameter combinations via cartesian product.""" 

1434 # Wrap scalar values in lists for cartesian product 

1435 par_dict = {k: v if isinstance(v, list) else [v] for k, v in par_dict.items()} 

1436 

1437 parameters_comb = [ 

1438 dict(zip(par_dict.keys(), combination, strict=False)) 

1439 for combination in product(*par_dict.values()) # type: ignore[arg-type] 

1440 ] 

1441 

1442 # Example: {"id":"5","area":["A1","A2"]} -> [{"id":"5","area":"A1"}, {"id":"5","area":"A2"}] 

1443 

1444 list_of_res = [] 

1445 include_header_line = True 

1446 for comb in parameters_comb: 

1447 query = self.i["sparql"] 

1448 for param, val in comb.items(): 

1449 query = query.replace(f"[[{param}]]", str(val)) 

1450 

1451 r = self._request_sparql_csv(self.tp, query) 

1452 

1453 if r.status_code != HTTPStatus.OK: 

1454 return OperationResponse.error(r.status_code, f"HTTP status code {r.status_code}: {r.reason}") 

1455 

1456 # Re-encode to handle non-UTF8 characters in splitlines 

1457 list_of_lines = [line.decode("utf-8") for line in r.text.encode("utf-8").splitlines()] 

1458 

1459 # Include the CSV header only from the first response 

1460 if not include_header_line: 

1461 list_of_lines = list_of_lines[1:] 

1462 include_header_line = False 

1463 

1464 list_of_res += list_of_lines 

1465 

1466 return self._finalize_result(list(reader(list_of_res)), content_type) 

1467 

1468 def _exec_multi_source_query_step( 

1469 self, endpoint_url: str, engine: str, qtxt: str, state: dict[str, object] 

1470 ) -> None: 

1471 """Handle a QUERY step in the multi-source pipeline.""" 

1472 pending_values = state["pending_values"] 

1473 if pending_values: 

1474 variables, batch_size, workers = pending_values # type: ignore[misc] 

1475 value_batches = self._values_batches(variables, state["acc"], batch_size) # type: ignore[arg-type] 

1476 batch_queries = [self._inject_values_clause(qtxt, variables, value_batch) for value_batch in value_batches] 

1477 rows = [] 

1478 with ThreadPoolExecutor(max_workers=workers) as executor: 

1479 for batch_rows in executor.map( 

1480 lambda batch_query: self._run_query_dicts(endpoint_url, engine, batch_query), batch_queries 

1481 ): 

1482 rows.extend(batch_rows) 

1483 state["pending_values"] = None 

1484 else: 

1485 rows = self._run_query_dicts(endpoint_url, engine, qtxt) 

1486 

1487 if state["acc"] is None: 

1488 state["acc"] = rows 

1489 elif state["pending_join"]: 

1490 lvar, rvar, how = state["pending_join"] # type: ignore[misc] 

1491 state["acc"] = self._join(state["acc"], rows, lvar, rvar, how) # type: ignore[arg-type] 

1492 state["pending_join"] = None 

1493 else: 

1494 msg = "Multiple QUERY steps without an explicit @@join directive" 

1495 raise ValueError(msg) 

1496 

1497 @staticmethod 

1498 def _values_batches( 

1499 variables: list[str], acc: list[dict[str, object]] | None, batch_size: int | None 

1500 ) -> list[list[dict[str, object]] | None]: 

1501 if acc is None: 

1502 return [None] 

1503 columns = [variable.lstrip("?") for variable in variables] 

1504 distinct_rows: list[dict[str, object]] = [] 

1505 seen: set[tuple[object, ...]] = set() 

1506 for row in acc: 

1507 if not all(column in row for column in columns): 

1508 continue 

1509 values = tuple(row[column] for column in columns) 

1510 if all(values) and values not in seen: 

1511 seen.add(values) 

1512 distinct_rows.append(row) 

1513 if batch_size is None: 

1514 return [distinct_rows] 

1515 return [distinct_rows[start : start + batch_size] for start in range(0, len(distinct_rows), batch_size)] or [[]] 

1516 

1517 def _exec_page_step(self, var: str, default_size: str, max_size: str, state: dict[str, object]) -> None: 

1518 q_string = parse_qs(quote(self.url_parsed.query, safe="&=")) 

1519 

1520 page_size_active = self._is_builtin_param_active("page_size") 

1521 page_active = self._is_builtin_param_active("page") 

1522 explicit_page_size = page_size_active and "page_size" in q_string 

1523 

1524 if explicit_page_size: 

1525 page_size = Operation._parse_positive_int_param(q_string, "page_size") 

1526 elif default_size: 

1527 page_size = int(default_size) 

1528 else: 

1529 if page_size_active and page_active and "page" in q_string: 

1530 Operation._raise_unprocessable("page requires page_size") 

1531 return 

1532 if page_size < 1: 

1533 msg = f"page_size must be >= 1, got {page_size}" 

1534 raise ValueError(msg) 

1535 if max_size: 

1536 max_page_size = int(max_size) 

1537 if explicit_page_size and page_size > max_page_size: 

1538 Operation._raise_unprocessable(f"page_size must be <= {max_page_size}, got {page_size}") 

1539 page_size = min(page_size, max_page_size) 

1540 

1541 page = 1 

1542 if "page" in q_string and page_active: 

1543 page = Operation._parse_positive_int_param(q_string, "page") 

1544 

1545 column = var.lstrip("?") 

1546 acc = state["acc"] 

1547 rows = [] if acc is None else cast("list[dict[str, object]]", acc) 

1548 distinct = list(dict.fromkeys(row[column] for row in rows if row.get(column))) 

1549 total_items = len(distinct) 

1550 total_pages = ceil(total_items / page_size) 

1551 Operation._validate_page_range(page, total_items, total_pages) 

1552 

1553 start = (page - 1) * page_size 

1554 keep = set(distinct[start : start + page_size]) 

1555 state["acc"] = [row for row in rows if row.get(column) in keep] 

1556 self.pagination_info = build_pagination_info(self.op_url, q_string, page, page_size, total_items) 

1557 

1558 def _exec_multi_source(self, par_dict: dict[str, object], content_type: str) -> OperationResponse: 

1559 """Execute a multi-source query pipeline with @@ directives.""" 

1560 steps = self._parse_steps(self.i["sparql"], self.tp, par_dict) 

1561 

1562 state: dict[str, object] = { 

1563 "acc": None, 

1564 "pending_join": None, 

1565 "pending_values": None, 

1566 } 

1567 

1568 for st in steps: 

1569 tag = st[0] 

1570 

1571 if tag == "QUERY": 

1572 self._exec_multi_source_query_step(st[1], st[2], st[3], state) 

1573 elif tag == "JOIN": 

1574 state["pending_join"] = (st[1], st[2], st[3]) 

1575 elif tag == "REMOVE": 

1576 state["acc"] = self._drop_columns(state["acc"] or [], st[1]) # type: ignore[arg-type] 

1577 elif tag == "VALUES_INJECT": 

1578 state["pending_values"] = (st[1], st[2], st[3]) 

1579 elif tag == "PAGE": 

1580 self._exec_page_step(st[1], st[2], st[3], state) 

1581 else: 

1582 msg = f"Unknown step tag {tag}" 

1583 raise RuntimeError(msg) 

1584 

1585 acc = cast("list[dict[str, object]]", state["acc"] or []) 

1586 output_fields = self._header_from_field_type(self.i, acc) 

1587 # Postprocess functions also see the step columns missing from #field_type; 

1588 # the output keeps only the declared ones. 

1589 step_fields = [key for key in dict.fromkeys(key for row in acc for key in row) if key not in output_fields] 

1590 csv_rows = self._to_csv_rows(output_fields + step_fields, acc) 

1591 return self._finalize_result(csv_rows, content_type, output_fields) 

1592 

1593 @staticmethod 

1594 def _format_error(sc: int, e: Exception, prefix: str = "") -> OperationResponse: 

1595 """Format an error response with traceback line info.""" 

1596 tb = e.__traceback__ 

1597 line = tb.tb_lineno if tb else "?" 

1598 msg = f"HTTP status code {sc}: {prefix}{type(e).__name__}: {e} (line {line})" 

1599 return OperationResponse.error(sc, msg) 

1600 

1601 def exec( 

1602 self, 

1603 method: str = "get", 

1604 content_type: str = "application/json", 

1605 body_params: Mapping[str, object] | None = None, 

1606 ) -> OperationResponse: 

1607 """This method takes in input the HTTP method to use for the call 

1608 and the content type to return, and execute the operation as indicated 

1609 in the specification file, by running (in the following order): 

1610 

1611 1. the methods to preprocess the query; 

1612 2. the SPARQL query related to the operation called, by using the parameters indicated in the URL; 

1613 3. the specification of all the types of the various rows returned; 

1614 4. the methods to postprocess the result; 

1615 5. the application of the filter to remove, filter, sort the result; 

1616 6. the removal of the types added at the step 3, so as to have a data structure ready to be returned; 

1617 7. the conversion in the format requested by the user.""" 

1618 str_method = method.lower() 

1619 if str_method not in self.i["method"].split(): 

1620 return OperationResponse.error(405, f"HTTP status code 405: '{str_method}' method not allowed") 

1621 

1622 try: 

1623 if self._is_write(str_method): 

1624 response = self._exec_update(self._prepare_params(body_params), content_type) 

1625 else: 

1626 response = self._dispatch_exec(content_type, body_params) 

1627 except HttpError as err: 

1628 return OperationResponse.error(err.status_code, str(err)) 

1629 except TimeoutError as e: 

1630 return self._format_error(408, e, "request timeout - ") 

1631 except (TypeError, ValueError) as e: 

1632 return self._format_error(400, e, "parameter in the request not compliant with the type specified - ") 

1633 except Exception as e: # noqa: BLE001 

1634 return self._format_error(500, e, "something unexpected happened - ") 

1635 

1636 headers = dict(response.headers) 

1637 if self.pagination_info is not None: 

1638 link_header = build_link_header(self.pagination_info) 

1639 if link_header: 

1640 headers["Link"] = link_header 

1641 return OperationResponse( 

1642 response.status_code, 

1643 response.body, 

1644 response.content_type, 

1645 headers, 

1646 response.is_error_message, 

1647 ) 

1648 

1649 def _prepare_params(self, body_params: Mapping[str, object] | None = None) -> dict[str, object]: 

1650 par_dict = self._extract_params(body_params) 

1651 if self.addon is not None: 

1652 self.preprocess(par_dict, self.i, self.addon) 

1653 if self.custom_params: 

1654 self._apply_custom_preprocess_params(par_dict) 

1655 return par_dict 

1656 

1657 def _dispatch_exec( 

1658 self, 

1659 content_type: str, 

1660 body_params: Mapping[str, object] | None = None, 

1661 ) -> OperationResponse: 

1662 """Dispatch to the appropriate read execution path based on the SPARQL text content.""" 

1663 par_dict = self._prepare_params(body_params) 

1664 

1665 if self._cache is not None and "cache_disable" not in self.i: 

1666 q_string = parse_qs(quote(self.url_parsed.query, safe="&=")) 

1667 cached_table = self._cache.get(self._build_cache_key(q_string)) 

1668 if cached_table is not None: 

1669 return self._format_cached_result(cached_table, q_string, content_type) 

1670 

1671 sparql_text = self.i["sparql"] 

1672 resolved_text = sparql_text 

1673 for param, val in par_dict.items(): 

1674 resolved_text = resolved_text.replace(f"[[{param}]]", str(val)) 

1675 

1676 if "@@" not in resolved_text: 

1677 return self._exec_standard_sparql(par_dict, content_type) 

1678 

1679 try: 

1680 return self._exec_multi_source(par_dict, content_type) 

1681 except ValueError as ve: 

1682 return OperationResponse.error(400, f"HTTP status code 400: {ve}") 

1683 except RuntimeError as re_err: 

1684 return OperationResponse.error(502, f"HTTP status code 502: {re_err}") 

1685 

1686 @staticmethod 

1687 def _is_write(method: str) -> bool: 

1688 return method.lower() in _WRITE_METHODS 

1689 

1690 def _format_write_success(self, content_type: str) -> OperationResponse: 

1691 if content_type == "text/csv": 

1692 return OperationResponse(HTTPStatus.OK, "status,message\r\n200,operation completed\r\n", "text/csv", {}) 

1693 return OperationResponse( 

1694 HTTPStatus.OK, 

1695 dumps({"status": 200, "message": "operation completed"}), 

1696 "application/json", 

1697 {}, 

1698 ) 

1699 

1700 def _exec_update(self, par_dict: dict[str, object], content_type: str) -> OperationResponse: 

1701 """Send a SPARQL 1.1 Update to the update endpoint and return a confirmation with no result set.""" 

1702 update_text = self.i["sparql"] 

1703 for param, val in par_dict.items(): 

1704 update_text = update_text.replace(f"[[{param}]]", str(val)) 

1705 

1706 unresolved = findall(r"\[\[(\w+)\]\]", update_text) 

1707 if unresolved: 

1708 missing = ", ".join(dict.fromkeys(unresolved)) 

1709 message = f"HTTP status code 400: missing required parameter(s): {missing}" 

1710 return OperationResponse.error(HTTPStatus.BAD_REQUEST, message) 

1711 

1712 endpoint = self.update_endpoint or self.tp 

1713 try: 

1714 response = _http_session.post( 

1715 endpoint, 

1716 data={"update": update_text}, 

1717 headers={"Accept": "application/json"}, 

1718 auth=backend_auth(endpoint), 

1719 timeout=self.sparql_timeout, 

1720 ) 

1721 except RequestException as exc: 

1722 msg = f"SPARQL update request failed: {exc}" 

1723 raise RuntimeError(msg) from exc 

1724 

1725 if response.status_code not in (HTTPStatus.OK, HTTPStatus.CREATED, HTTPStatus.NO_CONTENT): 

1726 return OperationResponse.error( 

1727 response.status_code, f"HTTP status code {response.status_code}: {response.reason}" 

1728 ) 

1729 

1730 if self._cache is not None: 

1731 self._cache.clear() 

1732 return self._format_write_success(content_type)