sparqlconnector.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. from __future__ import annotations
  2. import base64
  3. import copy
  4. import logging
  5. from io import BytesIO
  6. from typing import TYPE_CHECKING, Optional, Tuple
  7. from urllib.error import HTTPError
  8. from urllib.parse import urlencode
  9. from urllib.request import Request, urlopen
  10. from rdflib.plugin import plugins
  11. from rdflib.query import Result, ResultParser
  12. from rdflib.term import BNode
  13. from rdflib.util import FORMAT_MIMETYPE_MAP, RESPONSE_TABLE_FORMAT_MIMETYPE_MAP
  14. log = logging.getLogger(__name__)
  15. if TYPE_CHECKING:
  16. import typing_extensions as te
  17. class SPARQLConnectorException(Exception): # noqa: N818
  18. pass
  19. class SPARQLConnector:
  20. """
  21. this class deals with nitty gritty details of talking to a SPARQL server
  22. """
  23. def __init__(
  24. self,
  25. query_endpoint: Optional[str] = None,
  26. update_endpoint: Optional[str] = None,
  27. returnFormat: Optional[str] = "xml", # noqa: N803
  28. method: te.Literal["GET", "POST", "POST_FORM"] = "GET",
  29. auth: Optional[Tuple[str, str]] = None,
  30. **kwargs,
  31. ):
  32. """
  33. auth, if present, must be a tuple of (username, password) used for Basic Authentication
  34. Any additional keyword arguments will be passed to to the request, and can be used to setup timeouts etc.
  35. """
  36. self._method: str
  37. self.returnFormat = returnFormat
  38. self.query_endpoint = query_endpoint
  39. self.update_endpoint = update_endpoint
  40. self.kwargs = kwargs
  41. self.method = method
  42. if auth is not None:
  43. if type(auth) is not tuple:
  44. raise SPARQLConnectorException("auth must be a tuple")
  45. if len(auth) != 2:
  46. raise SPARQLConnectorException("auth must be a tuple (user, password)")
  47. base64string = base64.b64encode(bytes("%s:%s" % auth, "ascii"))
  48. self.kwargs.setdefault("headers", {})
  49. self.kwargs["headers"].update(
  50. {"Authorization": "Basic %s" % base64string.decode("utf-8")}
  51. )
  52. @property
  53. def method(self) -> str:
  54. return self._method
  55. @method.setter
  56. def method(self, method: str) -> None:
  57. if method not in ("GET", "POST", "POST_FORM"):
  58. raise SPARQLConnectorException(
  59. 'Method must be "GET", "POST", or "POST_FORM"'
  60. )
  61. self._method = method
  62. def query(
  63. self,
  64. query: str,
  65. default_graph: Optional[str] = None,
  66. named_graph: Optional[str] = None,
  67. ) -> Result:
  68. if not self.query_endpoint:
  69. raise SPARQLConnectorException("Query endpoint not set!")
  70. params = {}
  71. # this test ensures we don't have a useless (BNode) default graph URI, which calls to Graph().query() will add
  72. if default_graph is not None and type(default_graph) is not BNode:
  73. params["default-graph-uri"] = default_graph
  74. headers = {"Accept": self.response_mime_types()}
  75. args = copy.deepcopy(self.kwargs)
  76. # merge params/headers dicts
  77. args.setdefault("params", {})
  78. args.setdefault("headers", {})
  79. args["headers"].update(headers)
  80. if self.method == "GET":
  81. params["query"] = query
  82. args["params"].update(params)
  83. qsa = "?" + urlencode(args["params"])
  84. try:
  85. res = urlopen(
  86. Request(self.query_endpoint + qsa, headers=args["headers"])
  87. )
  88. except Exception as e: # noqa: F841
  89. raise ValueError(
  90. "You did something wrong formulating either the URI or your SPARQL query"
  91. )
  92. elif self.method == "POST":
  93. args["headers"].update({"Content-Type": "application/sparql-query"})
  94. args["params"].update(params)
  95. qsa = "?" + urlencode(args["params"])
  96. try:
  97. res = urlopen(
  98. Request(
  99. self.query_endpoint + qsa,
  100. data=query.encode(),
  101. headers=args["headers"],
  102. )
  103. )
  104. except HTTPError as e:
  105. # type error: Incompatible return value type (got "Tuple[int, str, None]", expected "Result")
  106. return e.code, str(e), None # type: ignore[return-value]
  107. elif self.method == "POST_FORM":
  108. params["query"] = query
  109. args["params"].update(params)
  110. try:
  111. res = urlopen(
  112. Request(
  113. self.query_endpoint,
  114. data=urlencode(args["params"]).encode(),
  115. headers=args["headers"],
  116. )
  117. )
  118. except HTTPError as e:
  119. # type error: Incompatible return value type (got "Tuple[int, str, None]", expected "Result")
  120. return e.code, str(e), None # type: ignore[return-value]
  121. else:
  122. raise SPARQLConnectorException("Unknown method %s" % self.method)
  123. return Result.parse(
  124. BytesIO(res.read()), content_type=res.headers["Content-Type"].split(";")[0]
  125. )
  126. def update(
  127. self,
  128. query: str,
  129. default_graph: Optional[str] = None,
  130. named_graph: Optional[str] = None,
  131. ) -> None:
  132. if not self.update_endpoint:
  133. raise SPARQLConnectorException("Query endpoint not set!")
  134. params = {}
  135. if default_graph is not None:
  136. params["using-graph-uri"] = default_graph
  137. if named_graph is not None:
  138. params["using-named-graph-uri"] = named_graph
  139. headers = {
  140. "Accept": self.response_mime_types(),
  141. "Content-Type": "application/sparql-update; charset=UTF-8",
  142. }
  143. args = copy.deepcopy(self.kwargs) # other QSAs
  144. args.setdefault("params", {})
  145. args["params"].update(params)
  146. args.setdefault("headers", {})
  147. args["headers"].update(headers)
  148. qsa = "?" + urlencode(args["params"])
  149. res = urlopen( # noqa: F841
  150. Request(
  151. self.update_endpoint + qsa, data=query.encode(), headers=args["headers"]
  152. )
  153. )
  154. def response_mime_types(self) -> str:
  155. """Construct a HTTP-Header Accept field to reflect the supported mime types.
  156. If the return_format parameter is set, the mime types are restricted to these accordingly.
  157. """
  158. sparql_format_mimetype_map = {
  159. k: FORMAT_MIMETYPE_MAP.get(k, [])
  160. + RESPONSE_TABLE_FORMAT_MIMETYPE_MAP.get(k, [])
  161. for k in list(FORMAT_MIMETYPE_MAP.keys())
  162. + list(RESPONSE_TABLE_FORMAT_MIMETYPE_MAP.keys())
  163. }
  164. supported_formats = set()
  165. for plugin in plugins(name=self.returnFormat, kind=ResultParser):
  166. if "/" not in plugin.name:
  167. supported_formats.update(
  168. sparql_format_mimetype_map.get(plugin.name, [])
  169. )
  170. else:
  171. supported_formats.add(plugin.name)
  172. return ", ".join(supported_formats)
  173. __all__ = ["SPARQLConnector", "SPARQLConnectorException"]