util.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. # https://github.com/RDFLib/rdflib-jsonld/blob/feature/json-ld-1.1/rdflib_jsonld/util.py
  2. from __future__ import annotations
  3. import json
  4. import pathlib
  5. from html.parser import HTMLParser
  6. from io import StringIO, TextIOBase, TextIOWrapper
  7. from typing import IO, TYPE_CHECKING, Any, Dict, List, Optional, TextIO, Tuple, Union
  8. if TYPE_CHECKING:
  9. import json
  10. else:
  11. try:
  12. import json
  13. assert json # workaround for pyflakes issue #13
  14. except ImportError:
  15. import simplejson as json
  16. from posixpath import normpath, sep
  17. from typing import TYPE_CHECKING, cast
  18. from urllib.parse import urljoin, urlsplit, urlunsplit
  19. try:
  20. import orjson
  21. _HAS_ORJSON = True
  22. except ImportError:
  23. orjson = None # type: ignore[assignment, unused-ignore]
  24. _HAS_ORJSON = False
  25. from rdflib.parser import (
  26. BytesIOWrapper,
  27. InputSource,
  28. PythonInputSource,
  29. StringInputSource,
  30. URLInputSource,
  31. create_input_source,
  32. )
  33. def source_to_json(
  34. source: Optional[
  35. Union[IO[bytes], TextIO, InputSource, str, bytes, pathlib.PurePath]
  36. ],
  37. fragment_id: Optional[str] = None,
  38. extract_all_scripts: Optional[bool] = False,
  39. ) -> Tuple[Union[Dict, List[Dict]], Any]:
  40. """Extract JSON from a source document.
  41. The source document can be JSON or HTML with embedded JSON script elements (type attribute = "application/ld+json").
  42. To process as HTML `source.content_type` must be set to "text/html" or "application/xhtml+xml".
  43. Args:
  44. source: the input source document (JSON or HTML)
  45. fragment_id: if source is an HTML document then extract only the script element with matching id attribute, defaults to None
  46. extract_all_scripts: if source is an HTML document then extract all script elements (unless fragment_id is provided), defaults to False (extract only the first script element)
  47. Returns:
  48. Tuple with the extracted JSON document and value of the HTML base element
  49. """
  50. if isinstance(source, PythonInputSource):
  51. return source.data, None
  52. if isinstance(source, StringInputSource):
  53. # A StringInputSource is assumed to be never a HTMLJSON doc
  54. html_base: Any = None
  55. # We can get the original string from the StringInputSource
  56. # It's hidden in the BytesIOWrapper 'wrapped' attribute
  57. b_stream = source.getByteStream()
  58. original_string: Optional[str] = None
  59. json_dict: Union[Dict, List[Dict]]
  60. if isinstance(b_stream, BytesIOWrapper):
  61. wrapped_inner = cast(Union[str, StringIO, TextIOBase], b_stream.wrapped)
  62. if isinstance(wrapped_inner, str):
  63. original_string = wrapped_inner
  64. elif isinstance(wrapped_inner, StringIO):
  65. original_string = wrapped_inner.getvalue()
  66. if _HAS_ORJSON:
  67. if original_string is not None:
  68. json_dict = orjson.loads(original_string)
  69. elif isinstance(b_stream, BytesIOWrapper):
  70. # use the CharacterStream instead
  71. c_stream = source.getCharacterStream()
  72. json_dict = orjson.loads(c_stream.read())
  73. else:
  74. # orjson assumes its in utf-8 encoding so
  75. # don't bother to check the source.getEncoding()
  76. json_dict = orjson.loads(b_stream.read())
  77. else:
  78. if original_string is not None:
  79. json_dict = json.loads(original_string)
  80. else:
  81. json_dict = json.load(source.getCharacterStream())
  82. return json_dict, html_base
  83. # TODO: conneg for JSON (fix support in rdflib's URLInputSource!)
  84. source = create_input_source(source, format="json-ld")
  85. try:
  86. content_type = source.content_type
  87. except (AttributeError, LookupError):
  88. content_type = None
  89. is_html = content_type is not None and content_type.lower() in (
  90. "text/html",
  91. "application/xhtml+xml",
  92. )
  93. if is_html:
  94. html_docparser: Optional[HTMLJSONParser] = HTMLJSONParser(
  95. fragment_id=fragment_id, extract_all_scripts=extract_all_scripts
  96. )
  97. else:
  98. html_docparser = None
  99. try:
  100. b_stream = source.getByteStream()
  101. except (AttributeError, LookupError):
  102. b_stream = None
  103. try:
  104. c_stream = source.getCharacterStream()
  105. except (AttributeError, LookupError):
  106. c_stream = None
  107. if b_stream is None and c_stream is None:
  108. raise ValueError(
  109. f"Source does not have a character stream or a byte stream and cannot be used {type(source)}"
  110. )
  111. try:
  112. b_encoding: Optional[str] = None if b_stream is None else source.getEncoding()
  113. except (AttributeError, LookupError):
  114. b_encoding = None
  115. underlying_string: Optional[str] = None
  116. if b_stream is not None and isinstance(b_stream, BytesIOWrapper):
  117. # Try to find an underlying wrapped Unicode string to use?
  118. wrapped_inner = b_stream.wrapped
  119. if isinstance(wrapped_inner, str):
  120. underlying_string = wrapped_inner
  121. elif isinstance(wrapped_inner, StringIO):
  122. underlying_string = wrapped_inner.getvalue()
  123. try:
  124. if is_html and html_docparser is not None:
  125. # Offload parsing to the HTMLJSONParser
  126. if underlying_string is not None:
  127. html_string: str = underlying_string
  128. elif c_stream is not None:
  129. html_string = c_stream.read()
  130. else:
  131. if TYPE_CHECKING:
  132. assert b_stream is not None
  133. if b_encoding is None:
  134. b_encoding = "utf-8"
  135. html_string = TextIOWrapper(b_stream, encoding=b_encoding).read()
  136. html_docparser.feed(html_string)
  137. json_dict, html_base = html_docparser.get_json(), html_docparser.get_base()
  138. elif _HAS_ORJSON:
  139. html_base = None
  140. if underlying_string is not None:
  141. json_dict = orjson.loads(underlying_string)
  142. elif (
  143. (b_stream is not None and isinstance(b_stream, BytesIOWrapper))
  144. or b_stream is None
  145. ) and c_stream is not None:
  146. # use the CharacterStream instead
  147. json_dict = orjson.loads(c_stream.read())
  148. else:
  149. if TYPE_CHECKING:
  150. assert b_stream is not None
  151. # b_stream is not None
  152. json_dict = orjson.loads(b_stream.read())
  153. else:
  154. html_base = None
  155. if underlying_string is not None:
  156. return json.loads(underlying_string)
  157. if c_stream is not None:
  158. use_stream = c_stream
  159. else:
  160. if TYPE_CHECKING:
  161. assert b_stream is not None
  162. # b_stream is not None
  163. if b_encoding is None:
  164. b_encoding = "utf-8"
  165. use_stream = TextIOWrapper(b_stream, encoding=b_encoding)
  166. json_dict = json.load(use_stream)
  167. return json_dict, html_base
  168. finally:
  169. if b_stream is not None:
  170. try:
  171. b_stream.close()
  172. except AttributeError:
  173. pass
  174. if c_stream is not None:
  175. try:
  176. c_stream.close()
  177. except AttributeError:
  178. pass
  179. VOCAB_DELIMS = ("#", "/", ":")
  180. def split_iri(iri: str) -> Tuple[str, Optional[str]]:
  181. for delim in VOCAB_DELIMS:
  182. at = iri.rfind(delim)
  183. if at > -1:
  184. return iri[: at + 1], iri[at + 1 :]
  185. return iri, None
  186. def norm_url(base: str, url: str) -> str:
  187. """
  188. ```python
  189. >>> norm_url('http://example.org/', '/one')
  190. 'http://example.org/one'
  191. >>> norm_url('http://example.org/', '/one#')
  192. 'http://example.org/one#'
  193. >>> norm_url('http://example.org/one', 'two')
  194. 'http://example.org/two'
  195. >>> norm_url('http://example.org/one/', 'two')
  196. 'http://example.org/one/two'
  197. >>> norm_url('http://example.org/', 'http://example.net/one')
  198. 'http://example.net/one'
  199. >>> norm_url('http://example.org/', 'http://example.org//one')
  200. 'http://example.org//one'
  201. ```
  202. """
  203. if "://" in url:
  204. return url
  205. # Fix for URNs
  206. parsed_base = urlsplit(base)
  207. parsed_url = urlsplit(url)
  208. if parsed_url.scheme:
  209. # Assume full URL
  210. return url
  211. if parsed_base.scheme in ("urn", "urn-x"):
  212. # No scheme -> assume relative and join paths
  213. base_path_parts = parsed_base.path.split("/", 1)
  214. base_path = "/" + (base_path_parts[1] if len(base_path_parts) > 1 else "")
  215. joined_path = urljoin(base_path, parsed_url.path)
  216. fragment = f"#{parsed_url.fragment}" if parsed_url.fragment else ""
  217. result = f"{parsed_base.scheme}:{base_path_parts[0]}{joined_path}{fragment}"
  218. else:
  219. parts = urlsplit(urljoin(base, url))
  220. path = normpath(parts[2])
  221. if sep != "/":
  222. path = "/".join(path.split(sep))
  223. if parts[2].endswith("/") and not path.endswith("/"):
  224. path += "/"
  225. result = urlunsplit(parts[0:2] + (path,) + parts[3:])
  226. if url.endswith("#") and not result.endswith("#"):
  227. result += "#"
  228. return result
  229. # type error: Missing return statement
  230. def context_from_urlinputsource(source: URLInputSource) -> Optional[str]: # type: ignore[return]
  231. """
  232. Please note that JSON-LD documents served with the `application/ld+json` media type
  233. MUST have all context information, including references to external contexts,
  234. within the body of the document. Contexts linked via a
  235. http://www.w3.org/ns/json-ld#context HTTP Link Header MUST be
  236. ignored for such documents.
  237. """
  238. if source.content_type != "application/ld+json":
  239. try:
  240. # source.links is the new way of getting Link headers from URLInputSource
  241. links = source.links
  242. except AttributeError:
  243. # type error: Return value expected
  244. return # type: ignore[return-value]
  245. for link in links:
  246. if ' rel="http://www.w3.org/ns/json-ld#context"' in link:
  247. i, j = link.index("<"), link.index(">")
  248. if i > -1 and j > -1:
  249. # type error: Value of type variable "AnyStr" of "urljoin" cannot be "Optional[str]"
  250. return urljoin(source.url, link[i + 1 : j]) # type: ignore[type-var]
  251. __all__ = [
  252. "json",
  253. "source_to_json",
  254. "split_iri",
  255. "norm_url",
  256. "context_from_urlinputsource",
  257. "orjson",
  258. "_HAS_ORJSON",
  259. ]
  260. class HTMLJSONParser(HTMLParser):
  261. def __init__(
  262. self,
  263. fragment_id: Optional[str] = None,
  264. extract_all_scripts: Optional[bool] = False,
  265. ):
  266. super().__init__()
  267. self.fragment_id = fragment_id
  268. self.json: List[Dict] = []
  269. self.contains_json = False
  270. self.fragment_id_does_not_match = False
  271. self.base = None
  272. self.extract_all_scripts = extract_all_scripts
  273. self.script_count = 0
  274. def handle_starttag(self, tag, attrs):
  275. self.contains_json = False
  276. self.fragment_id_does_not_match = False
  277. # Only set self. contains_json to True if the
  278. # type is 'application/ld+json'
  279. if tag == "script":
  280. for attr, value in attrs:
  281. if attr == "type" and value == "application/ld+json":
  282. self.contains_json = True
  283. elif attr == "id" and self.fragment_id and value != self.fragment_id:
  284. self.fragment_id_does_not_match = True
  285. elif tag == "base":
  286. for attr, value in attrs:
  287. if attr == "href":
  288. self.base = value
  289. def handle_data(self, data):
  290. # Only do something when we know the context is a
  291. # script element containing application/ld+json
  292. if self.contains_json is True and self.fragment_id_does_not_match is False:
  293. if not self.extract_all_scripts and self.script_count > 0:
  294. return
  295. if data.strip() == "":
  296. # skip empty data elements
  297. return
  298. # Try to parse the json
  299. if _HAS_ORJSON:
  300. # orjson can load a unicode string
  301. # if that's the only thing we have,
  302. # its not worth encoding it to bytes
  303. parsed = orjson.loads(data)
  304. else:
  305. parsed = json.loads(data)
  306. # Add to the result document
  307. if isinstance(parsed, list):
  308. self.json.extend(parsed)
  309. else:
  310. self.json.append(parsed)
  311. self.script_count += 1
  312. def get_json(self) -> List[Dict]:
  313. return self.json
  314. def get_base(self):
  315. return self.base