client.py 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265
  1. """RDF4J client module."""
  2. from __future__ import annotations
  3. import contextlib
  4. import io
  5. import typing as t
  6. from dataclasses import dataclass
  7. from typing import Any, BinaryIO, Iterable
  8. import httpx
  9. from rdflib import BNode
  10. from rdflib.contrib.rdf4j.exceptions import (
  11. RDF4JUnsupportedProtocolError,
  12. RDFLibParserError,
  13. RepositoryAlreadyExistsError,
  14. RepositoryError,
  15. RepositoryNotFoundError,
  16. RepositoryNotHealthyError,
  17. RepositoryResponseFormatError,
  18. TransactionClosedError,
  19. TransactionCommitError,
  20. TransactionPingError,
  21. TransactionRollbackError,
  22. )
  23. from rdflib.contrib.rdf4j.util import (
  24. build_context_param,
  25. build_infer_param,
  26. build_sparql_query_accept_header,
  27. build_spo_param,
  28. rdf_payload_to_stream,
  29. validate_graph_name,
  30. validate_no_bnodes,
  31. )
  32. from rdflib.graph import DATASET_DEFAULT_GRAPH_ID, Dataset, Graph
  33. from rdflib.query import Result
  34. from rdflib.term import IdentifiedNode, Literal, URIRef
  35. SubjectType = t.Union[URIRef, None]
  36. PredicateType = t.Union[URIRef, None]
  37. ObjectType = t.Union[URIRef, Literal, None]
  38. @dataclass(frozen=True)
  39. class NamespaceListingResult:
  40. """RDF4J namespace and prefix name result."""
  41. prefix: str
  42. namespace: str
  43. class RDF4JNamespaceManager:
  44. """A namespace manager for RDF4J repositories.
  45. Parameters:
  46. identifier: The identifier of the repository.
  47. http_client: The httpx.Client instance.
  48. """
  49. def __init__(self, identifier: str, http_client: httpx.Client):
  50. self._identifier = identifier
  51. self._http_client = http_client
  52. @property
  53. def http_client(self):
  54. return self._http_client
  55. @property
  56. def identifier(self):
  57. """Repository identifier."""
  58. return self._identifier
  59. def list(self) -> list[NamespaceListingResult]:
  60. """List all namespace declarations in the repository.
  61. Returns:
  62. list[NamespaceListingResult]: List of namespace and prefix name results.
  63. Raises:
  64. RepositoryResponseFormatError: If the response format is unrecognized.
  65. """
  66. headers = {
  67. "Accept": "application/sparql-results+json",
  68. }
  69. response = self.http_client.get(
  70. f"/repositories/{self.identifier}/namespaces", headers=headers
  71. )
  72. response.raise_for_status()
  73. try:
  74. data = response.json()
  75. results = data["results"]["bindings"]
  76. return [
  77. NamespaceListingResult(
  78. prefix=row["prefix"]["value"],
  79. namespace=row["namespace"]["value"],
  80. )
  81. for row in results
  82. ]
  83. except (KeyError, ValueError) as err:
  84. raise RepositoryResponseFormatError(f"Unrecognised response format: {err}")
  85. def clear(self):
  86. """Clear all namespace declarations in the repository."""
  87. headers = {
  88. "Accept": "application/sparql-results+json",
  89. }
  90. response = self.http_client.delete(
  91. f"/repositories/{self.identifier}/namespaces", headers=headers
  92. )
  93. response.raise_for_status()
  94. def get(self, prefix: str) -> str | None:
  95. """Get the namespace URI for a given prefix.
  96. Parameters:
  97. prefix: The prefix to lookup.
  98. Returns:
  99. The namespace URI or `None` if not found.
  100. """
  101. if not prefix:
  102. raise ValueError("Prefix cannot be empty.")
  103. headers = {
  104. "Accept": "text/plain",
  105. }
  106. try:
  107. response = self.http_client.get(
  108. f"/repositories/{self.identifier}/namespaces/{prefix}", headers=headers
  109. )
  110. response.raise_for_status()
  111. return response.text
  112. except httpx.HTTPStatusError as err:
  113. if err.response.status_code == 404:
  114. return None
  115. raise
  116. def set(self, prefix: str, namespace: str):
  117. """Set the namespace URI for a given prefix.
  118. !!! note
  119. If the prefix was previously mapped to a different namespace, this will be
  120. overwritten.
  121. Parameters:
  122. prefix: The prefix to set.
  123. namespace: The namespace URI to set.
  124. """
  125. if not prefix:
  126. raise ValueError("Prefix cannot be empty.")
  127. if not namespace:
  128. raise ValueError("Namespace cannot be empty.")
  129. headers = {
  130. "Content-Type": "text/plain",
  131. }
  132. response = self.http_client.put(
  133. f"/repositories/{self.identifier}/namespaces/{prefix}",
  134. headers=headers,
  135. content=namespace,
  136. )
  137. response.raise_for_status()
  138. def remove(self, prefix: str):
  139. """Remove the namespace declaration for a given prefix.
  140. Parameters:
  141. prefix: The prefix to remove.
  142. """
  143. if not prefix:
  144. raise ValueError("Prefix cannot be empty.")
  145. response = self.http_client.delete(
  146. f"/repositories/{self.identifier}/namespaces/{prefix}"
  147. )
  148. response.raise_for_status()
  149. class GraphStoreManager:
  150. """An RDF4J Graph Store Protocol Client.
  151. Parameters:
  152. identifier: The identifier of the repository.
  153. http_client: The httpx.Client instance.
  154. """
  155. def __init__(self, identifier: str, http_client: httpx.Client):
  156. self._identifier = identifier
  157. self._http_client = http_client
  158. self._content_type = "application/n-triples"
  159. @property
  160. def http_client(self):
  161. return self._http_client
  162. @property
  163. def identifier(self):
  164. """Repository identifier."""
  165. return self._identifier
  166. @staticmethod
  167. def _build_graph_name_params(graph_name: URIRef | str):
  168. params = {}
  169. if (
  170. isinstance(graph_name, URIRef)
  171. and graph_name == DATASET_DEFAULT_GRAPH_ID
  172. or isinstance(graph_name, str)
  173. and graph_name == str(DATASET_DEFAULT_GRAPH_ID)
  174. ):
  175. # Do nothing; GraphDB does not work with `?default=`
  176. # (note the trailing equal character), which is the default
  177. # behavior of httpx when setting the param value to an empty string.
  178. # httpx completely omits query parameters whose values are `None`, so that's
  179. # not an option either.
  180. # The workaround is to construct our own query parameter URL when we target
  181. # the default graph.
  182. pass
  183. else:
  184. params["graph"] = str(graph_name)
  185. return params
  186. def _build_url(self, graph_name: URIRef | str):
  187. url = f"/repositories/{self.identifier}/rdf-graphs/service"
  188. if isinstance(graph_name, URIRef) and graph_name == DATASET_DEFAULT_GRAPH_ID:
  189. url += "?default"
  190. return url
  191. def get(self, graph_name: URIRef | str) -> Graph:
  192. """Fetch all statements in the specified graph.
  193. Parameters:
  194. graph_name: The graph name of the graph.
  195. For the default graph, use
  196. [`DATASET_DEFAULT_GRAPH_ID`][rdflib.graph.DATASET_DEFAULT_GRAPH_ID].
  197. Returns:
  198. A [`Graph`][rdflib.graph.Graph] object containing all statements in the
  199. graph.
  200. """
  201. if not graph_name:
  202. raise ValueError("Graph name must be provided.")
  203. validate_graph_name(graph_name)
  204. headers = {
  205. "Accept": self._content_type,
  206. }
  207. params = self._build_graph_name_params(graph_name) or None
  208. response = self.http_client.get(
  209. self._build_url(graph_name),
  210. headers=headers,
  211. params=params,
  212. )
  213. response.raise_for_status()
  214. return Graph(identifier=graph_name).parse(
  215. data=response.text, format=self._content_type
  216. )
  217. def add(self, graph_name: URIRef | str, data: str | bytes | BinaryIO | Graph):
  218. """Add statements to the specified graph.
  219. Parameters:
  220. graph_name: The graph name of the graph.
  221. For the default graph, use
  222. [`DATASET_DEFAULT_GRAPH_ID`][rdflib.graph.DATASET_DEFAULT_GRAPH_ID].
  223. data: The RDF data to add.
  224. """
  225. if not graph_name:
  226. raise ValueError("Graph name must be provided.")
  227. validate_graph_name(graph_name)
  228. stream, should_close = rdf_payload_to_stream(data)
  229. headers = {
  230. "Content-Type": self._content_type,
  231. }
  232. params = self._build_graph_name_params(graph_name) or None
  233. try:
  234. response = self.http_client.post(
  235. self._build_url(graph_name),
  236. headers=headers,
  237. params=params,
  238. content=stream,
  239. )
  240. response.raise_for_status()
  241. finally:
  242. if should_close:
  243. stream.close()
  244. def overwrite(self, graph_name: URIRef | str, data: str | bytes | BinaryIO | Graph):
  245. """Overwrite statements in the specified graph.
  246. Parameters:
  247. graph_name: The graph name of the graph.
  248. For the default graph, use
  249. [`DATASET_DEFAULT_GRAPH_ID`][rdflib.graph.DATASET_DEFAULT_GRAPH_ID].
  250. data: The RDF data to overwrite with.
  251. """
  252. if not graph_name:
  253. raise ValueError("Graph name must be provided.")
  254. validate_graph_name(graph_name)
  255. stream, should_close = rdf_payload_to_stream(data)
  256. headers = {
  257. "Content-Type": self._content_type,
  258. }
  259. params = self._build_graph_name_params(graph_name) or None
  260. try:
  261. response = self.http_client.put(
  262. self._build_url(graph_name),
  263. headers=headers,
  264. params=params,
  265. content=stream,
  266. )
  267. response.raise_for_status()
  268. finally:
  269. if should_close:
  270. stream.close()
  271. def clear(self, graph_name: URIRef | str):
  272. """Clear all statements in the specified graph.
  273. Parameters:
  274. graph_name: The graph name of the graph.
  275. For the default graph, use
  276. [`DATASET_DEFAULT_GRAPH_ID`][rdflib.graph.DATASET_DEFAULT_GRAPH_ID].
  277. """
  278. if not graph_name:
  279. raise ValueError("Graph name must be provided.")
  280. validate_graph_name(graph_name)
  281. params = self._build_graph_name_params(graph_name) or None
  282. response = self.http_client.delete(self._build_url(graph_name), params=params)
  283. response.raise_for_status()
  284. @dataclass(frozen=True)
  285. class RepositoryListingResult:
  286. """RDF4J repository listing result.
  287. Parameters:
  288. identifier: Repository identifier.
  289. uri: Repository URI.
  290. readable: Whether the repository is readable by the client.
  291. writable: Whether the repository is writable by the client.
  292. title: Repository title.
  293. """
  294. identifier: str
  295. uri: str
  296. readable: bool
  297. writable: bool
  298. title: str | None = None
  299. class Repository:
  300. """RDF4J repository client.
  301. Parameters:
  302. identifier: The identifier of the repository.
  303. http_client: The httpx.Client instance.
  304. """
  305. def __init__(self, identifier: str, http_client: httpx.Client):
  306. self._identifier = identifier
  307. self._http_client = http_client
  308. self._namespace_manager: RDF4JNamespaceManager | None = None
  309. self._graph_store_manager: GraphStoreManager | None = None
  310. @property
  311. def http_client(self):
  312. return self._http_client
  313. @property
  314. def identifier(self):
  315. """Repository identifier."""
  316. return self._identifier
  317. @property
  318. def namespaces(self) -> RDF4JNamespaceManager:
  319. """Namespace manager for the repository."""
  320. if self._namespace_manager is None:
  321. self._namespace_manager = RDF4JNamespaceManager(
  322. self.identifier, self.http_client
  323. )
  324. return self._namespace_manager
  325. @property
  326. def graphs(self) -> GraphStoreManager:
  327. """Graph store manager for the repository."""
  328. if self._graph_store_manager is None:
  329. self._graph_store_manager = GraphStoreManager(
  330. self.identifier, self.http_client
  331. )
  332. return self._graph_store_manager
  333. def health(self) -> bool:
  334. """Repository health check.
  335. Returns:
  336. bool: True if the repository is healthy, otherwise an error is raised.
  337. Raises:
  338. RepositoryNotFoundError: If the repository is not found.
  339. RepositoryNotHealthyError: If the repository is not healthy.
  340. """
  341. headers = {
  342. "Content-Type": "application/sparql-query",
  343. "Accept": "application/sparql-results+json",
  344. }
  345. try:
  346. response = self.http_client.post(
  347. f"/repositories/{self._identifier}", headers=headers, content="ASK {}"
  348. )
  349. response.raise_for_status()
  350. return True
  351. except httpx.HTTPStatusError as err:
  352. if err.response.status_code == 404:
  353. raise RepositoryNotFoundError(
  354. f"Repository {self._identifier} not found."
  355. )
  356. raise RepositoryNotHealthyError(
  357. f"Repository {self._identifier} is not healthy. {err.response.status_code} - {err.response.text}"
  358. )
  359. def size(self, graph_name: URIRef | Iterable[URIRef] | str | None = None) -> int:
  360. """The number of statements in the repository or in the specified graph name.
  361. Parameters:
  362. graph_name: Graph name(s) to restrict to.
  363. The default value `None` queries all graphs.
  364. To query just the default graph, use
  365. [`DATASET_DEFAULT_GRAPH_ID`][rdflib.graph.DATASET_DEFAULT_GRAPH_ID].
  366. Returns:
  367. The number of statements.
  368. Raises:
  369. RepositoryResponseFormatError: Fails to parse the repository size.
  370. """
  371. validate_graph_name(graph_name)
  372. params: dict[str, str] = {}
  373. build_context_param(params, graph_name)
  374. response = self.http_client.get(
  375. f"/repositories/{self.identifier}/size", params=params
  376. )
  377. response.raise_for_status()
  378. return self._to_size(response.text)
  379. @staticmethod
  380. def _to_size(size: str):
  381. try:
  382. value = int(size)
  383. if value >= 0:
  384. return value
  385. raise ValueError(f"Invalid repository size: {value}")
  386. except ValueError as err:
  387. raise RepositoryResponseFormatError(
  388. f"Failed to parse repository size: {err}"
  389. ) from err
  390. def query(self, query: str, **kwargs):
  391. """Execute a SPARQL query against the repository.
  392. !!! note
  393. A POST request is used by default. If any keyword arguments are provided,
  394. a GET request is used instead, and the arguments are passed as query parameters.
  395. Parameters:
  396. query: The SPARQL query to execute.
  397. **kwargs: Additional keyword arguments to include as query parameters
  398. in the request. See
  399. [RDF4J REST API - Execute SPARQL query](https://rdf4j.org/documentation/reference/rest-api/#tag/SPARQL/paths/~1repositories~1%7BrepositoryID%7D/get)
  400. for the list of supported query parameters.
  401. """
  402. headers = {"Content-Type": "application/sparql-query"}
  403. build_sparql_query_accept_header(query, headers)
  404. if not kwargs:
  405. response = self.http_client.post(
  406. f"/repositories/{self.identifier}", headers=headers, content=query
  407. )
  408. else:
  409. response = self.http_client.get(
  410. f"/repositories/{self.identifier}",
  411. headers=headers,
  412. params={"query": query, **kwargs},
  413. )
  414. response.raise_for_status()
  415. try:
  416. return Result.parse(
  417. io.BytesIO(response.content),
  418. content_type=response.headers["Content-Type"].split(";")[0],
  419. )
  420. except KeyError as err:
  421. raise RDFLibParserError(
  422. f"Failed to parse SPARQL query result {response.headers.get('Content-Type')}: {err}"
  423. ) from err
  424. def update(self, query: str):
  425. """Execute a SPARQL update operation on the repository.
  426. Parameters:
  427. query: The SPARQL update query to execute.
  428. """
  429. headers = {"Content-Type": "application/sparql-update"}
  430. response = self.http_client.post(
  431. f"/repositories/{self.identifier}/statements",
  432. headers=headers,
  433. content=query,
  434. )
  435. response.raise_for_status()
  436. def graph_names(self) -> list[IdentifiedNode]:
  437. """Get a list of all graph names in the repository.
  438. Returns:
  439. A list of graph names.
  440. Raises:
  441. RepositoryResponseFormatError: Fails to parse the repository graph names.
  442. """
  443. headers = {
  444. "Accept": "application/sparql-results+json",
  445. }
  446. response = self.http_client.get(
  447. f"/repositories/{self.identifier}/contexts", headers=headers
  448. )
  449. response.raise_for_status()
  450. try:
  451. values: list[IdentifiedNode] = []
  452. for row in response.json()["results"]["bindings"]:
  453. value = row["contextID"]["value"]
  454. value_type = row["contextID"]["type"]
  455. if value_type == "uri":
  456. values.append(URIRef(value))
  457. elif value_type == "bnode":
  458. values.append(BNode(value))
  459. else:
  460. raise ValueError(f"Invalid graph name type: {value_type}")
  461. return values
  462. except Exception as err:
  463. raise RepositoryResponseFormatError(
  464. f"Failed to parse repository graph names: {err}"
  465. ) from err
  466. def get(
  467. self,
  468. subj: SubjectType = None,
  469. pred: PredicateType = None,
  470. obj: ObjectType = None,
  471. graph_name: URIRef | Iterable[URIRef] | str | None = None,
  472. infer: bool = True,
  473. content_type: str | None = None,
  474. ) -> Graph | Dataset:
  475. """Get RDF statements from the repository matching the filtering parameters.
  476. !!! Note
  477. The terms for `subj`, `pred`, `obj` or `graph_name` cannot be
  478. [`BNodes`][rdflib.term.BNode].
  479. Parameters:
  480. subj: Subject of the statement to filter by, or `None` to match all.
  481. pred: Predicate of the statement to filter by, or `None` to match all.
  482. obj: Object of the statement to filter by, or `None` to match all.
  483. graph_name: Graph name(s) to restrict to.
  484. The default value `None` queries all graphs.
  485. To query just the default graph, use
  486. [`DATASET_DEFAULT_GRAPH_ID`][rdflib.graph.DATASET_DEFAULT_GRAPH_ID].
  487. infer: Specifies whether inferred statements should be included in the
  488. result.
  489. content_type: The content type of the response.
  490. A triple-based format returns a [Graph][rdflib.graph.Graph], while a
  491. quad-based format returns a [`Dataset`][rdflib.graph.Dataset].
  492. Returns:
  493. A [`Graph`][rdflib.graph.Graph] or [`Dataset`][rdflib.graph.Dataset] object
  494. with the repository namespace prefixes bound to it.
  495. """
  496. validate_no_bnodes(subj, pred, obj, graph_name)
  497. if content_type is None:
  498. content_type = "application/n-quads"
  499. headers = {"Accept": content_type}
  500. params: dict[str, str] = {}
  501. build_context_param(params, graph_name)
  502. build_spo_param(params, subj, pred, obj)
  503. build_infer_param(params, infer=infer)
  504. response = self.http_client.get(
  505. f"/repositories/{self.identifier}/statements",
  506. headers=headers,
  507. params=params,
  508. )
  509. response.raise_for_status()
  510. triple_formats = [
  511. "application/n-triples",
  512. "text/turtle",
  513. "application/rdf+xml",
  514. ]
  515. try:
  516. if content_type in triple_formats:
  517. retval = Graph().parse(data=response.text, format=content_type)
  518. else:
  519. retval = Dataset().parse(data=response.text, format=content_type)
  520. for result in self.namespaces.list():
  521. retval.bind(result.prefix, result.namespace, replace=True)
  522. return retval
  523. except Exception as err:
  524. raise RDFLibParserError(f"Error parsing RDF: {err}") from err
  525. def upload(
  526. self,
  527. data: str | bytes | BinaryIO | Graph | Dataset,
  528. base_uri: str | None = None,
  529. content_type: str | None = None,
  530. ):
  531. """Upload and append statements to the repository.
  532. Parameters:
  533. data: The RDF data to upload.
  534. base_uri: The base URI to resolve against for any relative URIs in the data.
  535. content_type: The content type of the data. Defaults to
  536. `application/n-quads` when the value is `None`.
  537. """
  538. stream, should_close = rdf_payload_to_stream(data)
  539. try:
  540. headers = {"Content-Type": content_type or "application/n-quads"}
  541. params = {}
  542. if base_uri is not None:
  543. params["baseURI"] = base_uri
  544. response = self.http_client.post(
  545. f"/repositories/{self.identifier}/statements",
  546. headers=headers,
  547. params=params,
  548. content=stream,
  549. )
  550. response.raise_for_status()
  551. finally:
  552. if should_close:
  553. stream.close()
  554. def overwrite(
  555. self,
  556. data: str | bytes | BinaryIO | Graph | Dataset,
  557. graph_name: URIRef | Iterable[URIRef] | str | None = None,
  558. base_uri: str | None = None,
  559. content_type: str | None = None,
  560. ):
  561. """Upload and overwrite statements in the repository.
  562. Parameters:
  563. data: The RDF data to upload.
  564. graph_name: Graph name(s) to restrict to.
  565. The default value `None` applies to all graphs.
  566. To apply to just the default graph, use
  567. [`DATASET_DEFAULT_GRAPH_ID`][rdflib.graph.DATASET_DEFAULT_GRAPH_ID].
  568. base_uri: The base URI to resolve against for any relative URIs in the data.
  569. content_type: The content type of the data. Defaults to
  570. `application/n-quads` when the value is `None`.
  571. """
  572. stream, should_close = rdf_payload_to_stream(data)
  573. validate_graph_name(graph_name)
  574. try:
  575. headers = {"Content-Type": content_type or "application/n-quads"}
  576. params: dict[str, str] = {}
  577. build_context_param(params, graph_name)
  578. if base_uri is not None:
  579. params["baseURI"] = base_uri
  580. response = self.http_client.put(
  581. f"/repositories/{self.identifier}/statements",
  582. headers=headers,
  583. params=params,
  584. content=stream,
  585. )
  586. response.raise_for_status()
  587. finally:
  588. if should_close:
  589. stream.close()
  590. def delete(
  591. self,
  592. subj: SubjectType = None,
  593. pred: PredicateType = None,
  594. obj: ObjectType = None,
  595. graph_name: URIRef | Iterable[URIRef] | str | None = None,
  596. ) -> None:
  597. """Deletes statements from the repository matching the filtering parameters.
  598. !!! Note
  599. The terms for `subj`, `pred`, `obj` or `graph_name` cannot be
  600. [`BNodes`][rdflib.term.BNode].
  601. Parameters:
  602. subj: Subject of the statement to filter by, or `None` to match all.
  603. pred: Predicate of the statement to filter by, or `None` to match all.
  604. obj: Object of the statement to filter by, or `None` to match all.
  605. graph_name: Graph name(s) to restrict to.
  606. The default value `None` queries all graphs.
  607. To query just the default graph, use
  608. [`DATASET_DEFAULT_GRAPH_ID`][rdflib.graph.DATASET_DEFAULT_GRAPH_ID].
  609. """
  610. validate_no_bnodes(subj, pred, obj, graph_name)
  611. params: dict[str, str] = {}
  612. build_context_param(params, graph_name)
  613. build_spo_param(params, subj, pred, obj)
  614. response = self.http_client.delete(
  615. f"/repositories/{self.identifier}/statements",
  616. params=params,
  617. )
  618. response.raise_for_status()
  619. @contextlib.contextmanager
  620. def transaction(self):
  621. """Create a new transaction for the repository.
  622. !!! warning
  623. Transaction instances are not thread-safe. Do not share a single
  624. Transaction instance across multiple threads. Each thread should create
  625. its own transaction, or use appropriate synchronization if sharing is
  626. required.
  627. """
  628. with Transaction.create(self) as txn:
  629. yield txn
  630. class Transaction:
  631. """An RDF4J transaction.
  632. !!! warning
  633. Transaction instances are not thread-safe. Do not share a single
  634. Transaction instance across multiple threads. Each thread should create
  635. its own transaction, or use appropriate synchronization if sharing is
  636. required.
  637. Parameters:
  638. repo: The repository instance.
  639. """
  640. def __init__(self, repo: Repository, url: str):
  641. self._repo = repo
  642. self._url: str = url
  643. self._closed: bool = False
  644. def __enter__(self):
  645. return self
  646. def __exit__(self, exc_type, exc_val, exc_tb):
  647. if not self.is_closed:
  648. if exc_type is None:
  649. self.commit()
  650. else:
  651. try:
  652. self.rollback()
  653. except Exception:
  654. pass
  655. # Propagate errors.
  656. return False
  657. @property
  658. def repo(self):
  659. """The repository instance."""
  660. return self._repo
  661. @property
  662. def url(self):
  663. """The transaction URL."""
  664. return self._url
  665. @property
  666. def is_closed(self) -> bool:
  667. """Whether the transaction is closed."""
  668. return self._closed
  669. def _raise_for_closed(self):
  670. if self.is_closed:
  671. raise TransactionClosedError("The transaction has been closed.")
  672. @classmethod
  673. def create(cls, repo: Repository) -> Transaction:
  674. """Create a new transaction for the repository.
  675. Parameters:
  676. repo: The repository instance.
  677. Returns:
  678. A new Transaction instance.
  679. """
  680. response = repo.http_client.post(
  681. f"/repositories/{repo.identifier}/transactions"
  682. )
  683. response.raise_for_status()
  684. url = response.headers["Location"]
  685. return cls(repo, url)
  686. def commit(self):
  687. """Commit the transaction.
  688. Raises:
  689. TransactionCommitError: If the transaction commit fails.
  690. TransactionClosedError: If the transaction is closed.
  691. """
  692. self._raise_for_closed()
  693. params = {"action": "COMMIT"}
  694. response = self.repo.http_client.put(self.url, params=params)
  695. if response.status_code != 200:
  696. raise TransactionCommitError(
  697. f"Transaction commit failed: {response.status_code} - {response.text}"
  698. )
  699. self._closed = True
  700. def rollback(self):
  701. """Roll back the transaction.
  702. Raises:
  703. TransactionRollbackError: If the transaction rollback fails.
  704. TransactionClosedError: If the transaction is closed.
  705. """
  706. self._raise_for_closed()
  707. response = self.repo.http_client.delete(self.url)
  708. if response.status_code != 204:
  709. raise TransactionRollbackError(
  710. f"Transaction rollback failed: {response.status_code} - {response.text}"
  711. )
  712. self._closed = True
  713. def ping(self):
  714. """Ping the transaction.
  715. Raises:
  716. RepositoryTransactionPingError: If the transaction ping fails.
  717. TransactionClosedError: If the transaction is closed.
  718. """
  719. self._raise_for_closed()
  720. params = {"action": "PING"}
  721. response = self.repo.http_client.put(self.url, params=params)
  722. if response.status_code != 200:
  723. raise TransactionPingError(
  724. f"Transaction ping failed: {response.status_code} - {response.text}"
  725. )
  726. def size(self, graph_name: URIRef | Iterable[URIRef] | str | None = None):
  727. """The number of statements in the repository or in the specified graph name.
  728. Parameters:
  729. graph_name: Graph name(s) to restrict to.
  730. The default value `None` queries all graphs.
  731. To query just the default graph, use
  732. [`DATASET_DEFAULT_GRAPH_ID`][rdflib.graph.DATASET_DEFAULT_GRAPH_ID].
  733. Returns:
  734. The number of statements.
  735. Raises:
  736. RepositoryResponseFormatError: Fails to parse the repository size.
  737. """
  738. self._raise_for_closed()
  739. validate_graph_name(graph_name)
  740. params = {"action": "SIZE"}
  741. build_context_param(params, graph_name)
  742. response = self.repo.http_client.put(self.url, params=params)
  743. response.raise_for_status()
  744. return self.repo._to_size(response.text)
  745. def query(self, query: str, **kwargs):
  746. """Execute a SPARQL query against the repository.
  747. Parameters:
  748. query: The SPARQL query to execute.
  749. **kwargs: Additional keyword arguments to include as query parameters
  750. in the request. See
  751. [RDF4J REST API - Execute SPARQL query](https://rdf4j.org/documentation/reference/rest-api/#tag/SPARQL/paths/~1repositories~1%7BrepositoryID%7D/get)
  752. for the list of supported query parameters.
  753. """
  754. self._raise_for_closed()
  755. headers: dict[str, str] = {}
  756. build_sparql_query_accept_header(query, headers)
  757. params = {"action": "QUERY", "query": query}
  758. response = self.repo.http_client.put(
  759. self.url, headers=headers, params={**params, **kwargs}
  760. )
  761. response.raise_for_status()
  762. try:
  763. return Result.parse(
  764. io.BytesIO(response.content),
  765. content_type=response.headers["Content-Type"].split(";")[0],
  766. )
  767. except KeyError as err:
  768. raise RDFLibParserError(
  769. f"Failed to parse SPARQL query result {response.headers.get('Content-Type')}: {err}"
  770. ) from err
  771. def update(self, query: str, **kwargs):
  772. """Execute a SPARQL update operation on the repository.
  773. Parameters:
  774. query: The SPARQL update query to execute.
  775. **kwargs: Additional keyword arguments to include as query parameters
  776. See [RDF4J REST API - Execute a transaction action](https://rdf4j.org/documentation/reference/rest-api/#tag/Transactions/paths/~1repositories~1%7BrepositoryID%7D~1transactions~1%7BtransactionID%7D/put)
  777. for the list of supported query parameters.
  778. """
  779. self._raise_for_closed()
  780. params = {"action": "UPDATE", "update": query}
  781. response = self.repo.http_client.put(
  782. self.url,
  783. params={**params, **kwargs},
  784. )
  785. response.raise_for_status()
  786. def upload(
  787. self,
  788. data: str | bytes | BinaryIO | Graph | Dataset,
  789. base_uri: str | None = None,
  790. content_type: str | None = None,
  791. ):
  792. """Upload and append statements to the repository.
  793. Parameters:
  794. data: The RDF data to upload.
  795. base_uri: The base URI to resolve against for any relative URIs in the data.
  796. content_type: The content type of the data. Defaults to
  797. `application/n-quads` when the value is `None`.
  798. """
  799. self._raise_for_closed()
  800. stream, should_close = rdf_payload_to_stream(data)
  801. headers = {"Content-Type": content_type or "application/n-quads"}
  802. params = {"action": "ADD"}
  803. if base_uri is not None:
  804. params["baseURI"] = base_uri
  805. try:
  806. response = self.repo.http_client.put(
  807. self.url,
  808. headers=headers,
  809. params=params,
  810. content=stream,
  811. )
  812. response.raise_for_status()
  813. finally:
  814. if should_close:
  815. stream.close()
  816. def get(
  817. self,
  818. subj: SubjectType = None,
  819. pred: PredicateType = None,
  820. obj: ObjectType = None,
  821. graph_name: URIRef | Iterable[URIRef] | str | None = None,
  822. infer: bool = True,
  823. content_type: str | None = None,
  824. ) -> Graph | Dataset:
  825. """Get RDF statements from the repository matching the filtering parameters.
  826. !!! Note
  827. The terms for `subj`, `pred`, `obj` or `graph_name` cannot be
  828. [`BNodes`][rdflib.term.BNode].
  829. Parameters:
  830. subj: Subject of the statement to filter by, or `None` to match all.
  831. pred: Predicate of the statement to filter by, or `None` to match all.
  832. obj: Object of the statement to filter by, or `None` to match all.
  833. graph_name: Graph name(s) to restrict to.
  834. The default value `None` queries all graphs.
  835. To query just the default graph, use
  836. [`DATASET_DEFAULT_GRAPH_ID`][rdflib.graph.DATASET_DEFAULT_GRAPH_ID].
  837. infer: Specifies whether inferred statements should be included in the
  838. result.
  839. content_type: The content type of the response.
  840. A triple-based format returns a [Graph][rdflib.graph.Graph], while a
  841. quad-based format returns a [`Dataset`][rdflib.graph.Dataset].
  842. Returns:
  843. A [`Graph`][rdflib.graph.Graph] or [`Dataset`][rdflib.graph.Dataset] object
  844. with the repository namespace prefixes bound to it.
  845. """
  846. self._raise_for_closed()
  847. validate_no_bnodes(subj, pred, obj, graph_name)
  848. if content_type is None:
  849. content_type = "application/n-quads"
  850. headers = {"Accept": content_type}
  851. params: dict[str, str] = {"action": "GET"}
  852. build_context_param(params, graph_name)
  853. build_spo_param(params, subj, pred, obj)
  854. build_infer_param(params, infer=infer)
  855. response = self.repo.http_client.put(
  856. self.url,
  857. headers=headers,
  858. params=params,
  859. )
  860. response.raise_for_status()
  861. triple_formats = [
  862. "application/n-triples",
  863. "text/turtle",
  864. "application/rdf+xml",
  865. ]
  866. try:
  867. if content_type in triple_formats:
  868. retval = Graph().parse(data=response.text, format=content_type)
  869. else:
  870. retval = Dataset().parse(data=response.text, format=content_type)
  871. for result in self.repo.namespaces.list():
  872. retval.bind(result.prefix, result.namespace, replace=True)
  873. return retval
  874. except Exception as err:
  875. raise RDFLibParserError(f"Error parsing RDF: {err}") from err
  876. def delete(
  877. self,
  878. data: str | bytes | BinaryIO | Graph | Dataset,
  879. base_uri: str | None = None,
  880. content_type: str | None = None,
  881. ) -> None:
  882. """Delete statements from the repository.
  883. !!! Note
  884. This function operates differently to
  885. [`Repository.delete`][rdflib.contrib.rdf4j.client.Repository.delete]
  886. as it does not use filter parameters. Instead, it expects a data payload.
  887. See the notes from
  888. [graphdb.js#Deleting](https://github.com/Ontotext-AD/graphdb.js?tab=readme-ov-file#deleting-1)
  889. for more information.
  890. Parameters:
  891. data: The RDF data to upload.
  892. base_uri: The base URI to resolve against for any relative URIs in the data.
  893. content_type: The content type of the data. Defaults to
  894. `application/n-quads` when the value is `None`.
  895. """
  896. self._raise_for_closed()
  897. params: dict[str, str] = {"action": "DELETE"}
  898. stream, should_close = rdf_payload_to_stream(data)
  899. headers = {"Content-Type": content_type or "application/n-quads"}
  900. if base_uri is not None:
  901. params["baseURI"] = base_uri
  902. try:
  903. response = self.repo.http_client.put(
  904. self.url,
  905. headers=headers,
  906. params=params,
  907. content=stream,
  908. )
  909. response.raise_for_status()
  910. finally:
  911. if should_close:
  912. stream.close()
  913. class RepositoryManager:
  914. """A client to manage server-level repository operations.
  915. Parameters:
  916. http_client: The httpx.Client instance.
  917. """
  918. def __init__(self, http_client: httpx.Client):
  919. self._http_client = http_client
  920. @property
  921. def http_client(self):
  922. return self._http_client
  923. def list(self) -> list[RepositoryListingResult]:
  924. """List all available repositories.
  925. Returns:
  926. list[RepositoryListingResult]: List of repository results.
  927. Raises:
  928. RepositoryResponseFormatError: If the response format is unrecognized.
  929. """
  930. headers = {
  931. "Accept": "application/sparql-results+json",
  932. }
  933. response = self.http_client.get("/repositories", headers=headers)
  934. response.raise_for_status()
  935. try:
  936. data = response.json()
  937. results = data["results"]["bindings"]
  938. return [
  939. RepositoryListingResult(
  940. identifier=repo["id"]["value"],
  941. uri=repo["uri"]["value"],
  942. readable=repo["readable"]["value"],
  943. writable=repo["writable"]["value"],
  944. title=repo.get("title", {}).get("value"),
  945. )
  946. for repo in results
  947. ]
  948. except (KeyError, ValueError) as err:
  949. raise RepositoryResponseFormatError(f"Unrecognised response format: {err}")
  950. def get(self, repository_id: str) -> Repository:
  951. """Get a repository by ID.
  952. !!! Note
  953. This performs a health check before returning the repository object.
  954. Parameters:
  955. repository_id: The identifier of the repository.
  956. Returns:
  957. Repository: The repository instance.
  958. Raises:
  959. RepositoryNotFoundError: If the repository is not found.
  960. RepositoryNotHealthyError: If the repository is not healthy.
  961. """
  962. repo = Repository(repository_id, self.http_client)
  963. repo.health()
  964. return repo
  965. def create(
  966. self, repository_id: str, data: str, content_type: str = "text/turtle"
  967. ) -> Repository:
  968. """Create a new repository.
  969. Parameters:
  970. repository_id: The identifier of the repository.
  971. data: The repository configuration in RDF.
  972. content_type: The repository configuration content type.
  973. Raises:
  974. RepositoryAlreadyExistsError: If the repository already exists.
  975. RepositoryNotHealthyError: If the repository is not healthy.
  976. """
  977. try:
  978. headers = {"Content-Type": content_type}
  979. response = self.http_client.put(
  980. f"/repositories/{repository_id}", headers=headers, content=data
  981. )
  982. response.raise_for_status()
  983. return self.get(repository_id)
  984. except httpx.HTTPStatusError as err:
  985. if err.response.status_code == 409:
  986. raise RepositoryAlreadyExistsError(
  987. f"Repository {repository_id} already exists."
  988. )
  989. raise
  990. def delete(self, repository_id: str) -> None:
  991. """Delete a repository.
  992. Parameters:
  993. repository_id: The identifier of the repository.
  994. Raises:
  995. RepositoryNotFoundError: If the repository is not found.
  996. RepositoryError: If the repository is not deleted successfully.
  997. """
  998. try:
  999. response = self.http_client.delete(f"/repositories/{repository_id}")
  1000. response.raise_for_status()
  1001. if response.status_code != 204:
  1002. raise RepositoryError(
  1003. f"Unexpected response status code when deleting repository {repository_id}: {response.status_code} - {response.text.strip()}"
  1004. )
  1005. except httpx.HTTPStatusError as err:
  1006. if err.response.status_code == 404:
  1007. raise RepositoryNotFoundError(f"Repository {repository_id} not found.")
  1008. raise
  1009. class RDF4JClient:
  1010. """RDF4J client.
  1011. This client and its inner management objects perform HTTP requests via
  1012. httpx and may raise httpx-specific exceptions. Errors documented by RDF4J
  1013. in its protocol specification are mapped to specific exceptions in this
  1014. library where applicable. Error mappings are documented on each management
  1015. method. The underlying httpx client is reused across requests, and
  1016. connection pooling is handled automatically by httpx.
  1017. Parameters:
  1018. base_url: The base URL of the RDF4J server.
  1019. auth: Authentication credentials. Can be a tuple (username, password) for
  1020. basic auth, or a string for token-based auth (e.g., "GDB <token>")
  1021. which is added as the Authorization header.
  1022. timeout: Request timeout in seconds or an httpx.Timeout for fine-grained control (default: 30.0).
  1023. kwargs: Additional keyword arguments to pass to the httpx.Client.
  1024. """
  1025. def __init__(
  1026. self,
  1027. base_url: str,
  1028. auth: tuple[str, str] | str | None = None,
  1029. timeout: float | httpx.Timeout = 30.0,
  1030. **kwargs: Any,
  1031. ):
  1032. if not base_url.endswith("/"):
  1033. base_url += "/"
  1034. httpx_auth: tuple[str, str] | None = None
  1035. if isinstance(auth, tuple):
  1036. httpx_auth = auth
  1037. elif isinstance(auth, str):
  1038. headers = kwargs.get("headers", {})
  1039. headers["Authorization"] = auth
  1040. kwargs["headers"] = headers
  1041. self._http_client = httpx.Client(
  1042. base_url=base_url, auth=httpx_auth, timeout=timeout, **kwargs
  1043. )
  1044. self._repository_manager = RepositoryManager(self.http_client)
  1045. try:
  1046. protocol_version = self.protocol
  1047. except httpx.RequestError as err:
  1048. self.close()
  1049. raise RDF4JUnsupportedProtocolError(
  1050. f"Failed to check protocol version: {err}"
  1051. ) from err
  1052. if protocol_version < 12:
  1053. self.close()
  1054. raise RDF4JUnsupportedProtocolError(
  1055. f"RDF4J server protocol version {protocol_version} is not supported. Minimum required version is 12."
  1056. )
  1057. def __enter__(self):
  1058. return self
  1059. def __exit__(self, exc_type, exc_val, exc_tb):
  1060. self.close()
  1061. @property
  1062. def http_client(self):
  1063. return self._http_client
  1064. @property
  1065. def repositories(self) -> RepositoryManager:
  1066. """Server-level repository management operations."""
  1067. return self._repository_manager
  1068. @property
  1069. def protocol(self) -> float:
  1070. """The RDF4J REST API protocol version.
  1071. Returns:
  1072. The protocol version number.
  1073. """
  1074. response = self.http_client.get("/protocol", headers={"Accept": "text/plain"})
  1075. response.raise_for_status()
  1076. return float(response.text.strip())
  1077. def close(self):
  1078. """Close the underlying httpx.Client."""
  1079. self.http_client.close()