hext.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. """
  2. HextuplesSerializer RDF graph serializer for RDFLib.
  3. See <https://github.com/ontola/hextuples> for details about the format.
  4. """
  5. from __future__ import annotations
  6. import json
  7. import warnings
  8. from typing import IO, Any, Callable, List, Optional, Type, Union, cast
  9. from rdflib.graph import DATASET_DEFAULT_GRAPH_ID, ConjunctiveGraph, Dataset, Graph
  10. from rdflib.namespace import RDF, XSD
  11. from rdflib.serializer import Serializer
  12. from rdflib.term import BNode, IdentifiedNode, Literal, URIRef
  13. try:
  14. import orjson
  15. _HAS_ORJSON = True
  16. except ImportError:
  17. orjson = None # type: ignore[assignment, unused-ignore]
  18. _HAS_ORJSON = False
  19. __all__ = ["HextuplesSerializer"]
  20. class HextuplesSerializer(Serializer):
  21. """
  22. Serializes RDF graphs to NTriples format.
  23. """
  24. contexts: List[Union[Graph, IdentifiedNode]]
  25. dumps: Callable
  26. def __new__(cls, store: Union[Graph, Dataset, ConjunctiveGraph]):
  27. if _HAS_ORJSON:
  28. cls.str_local_id: Union[str, Any] = orjson.Fragment(b'"localId"')
  29. cls.str_global_id: Union[str, Any] = orjson.Fragment(b'"globalId"')
  30. cls.empty: Union[str, Any] = orjson.Fragment(b'""')
  31. cls.lang_str: Union[str, Any] = orjson.Fragment(
  32. b'"' + RDF.langString.encode("utf-8") + b'"'
  33. )
  34. cls.xsd_string: Union[str, Any] = orjson.Fragment(
  35. b'"' + XSD.string.encode("utf-8") + b'"'
  36. )
  37. else:
  38. cls.str_local_id = "localId"
  39. cls.str_global_id = "globalId"
  40. cls.empty = ""
  41. cls.lang_str = f"{RDF.langString}"
  42. cls.xsd_string = f"{XSD.string}"
  43. return super(cls, cls).__new__(cls)
  44. def __init__(self, store: Union[Graph, Dataset, ConjunctiveGraph]):
  45. self.default_context: Optional[Union[Graph, IdentifiedNode]]
  46. self.graph_type: Union[Type[Graph], Type[Dataset], Type[ConjunctiveGraph]]
  47. if isinstance(store, (Dataset, ConjunctiveGraph)):
  48. self.graph_type = (
  49. Dataset if isinstance(store, Dataset) else ConjunctiveGraph
  50. )
  51. self.contexts = list(store.contexts())
  52. if store.default_context:
  53. self.default_context = store.default_context
  54. self.contexts.append(store.default_context)
  55. else:
  56. self.default_context = None
  57. else:
  58. self.graph_type = Graph
  59. self.contexts = [store]
  60. self.default_context = None
  61. Serializer.__init__(self, store)
  62. def serialize(
  63. self,
  64. stream: IO[bytes],
  65. base: Optional[str] = None,
  66. encoding: Optional[str] = "utf-8",
  67. **kwargs: Any,
  68. ) -> None:
  69. if base is not None:
  70. warnings.warn(
  71. "base has no meaning for Hextuples serialization. "
  72. "I will ignore this value"
  73. )
  74. if encoding not in [None, "utf-8"]:
  75. warnings.warn(
  76. f"Hextuples files are always utf-8 encoded. "
  77. f"I was passed: {encoding}, "
  78. "but I'm still going to use utf-8 anyway!"
  79. )
  80. if self.store.formula_aware is True:
  81. raise Exception(
  82. "Hextuple serialization can't (yet) handle formula-aware stores"
  83. )
  84. context: Union[Graph, IdentifiedNode]
  85. context_str: Union[bytes, str]
  86. for context in self.contexts:
  87. for triple in context:
  88. # Generate context string just once, because it doesn't change
  89. # for every triple in this context
  90. context_str = cast(
  91. Union[str, bytes],
  92. (
  93. self.empty
  94. if self.graph_type is Graph
  95. else (
  96. orjson.Fragment('"' + self._context_str(context) + '"')
  97. if _HAS_ORJSON
  98. else self._context_str(context)
  99. )
  100. ),
  101. )
  102. hl = self._hex_line(triple, context_str)
  103. if hl is not None:
  104. stream.write(hl if _HAS_ORJSON else hl.encode())
  105. def _hex_line(self, triple, context_str: Union[bytes, str]):
  106. if isinstance(
  107. triple[0], (URIRef, BNode)
  108. ): # exclude QuotedGraph and other objects
  109. # value
  110. value = (
  111. triple[2]
  112. if isinstance(triple[2], Literal)
  113. else self._iri_or_bn(triple[2])
  114. )
  115. # datatype
  116. if isinstance(triple[2], URIRef):
  117. # datatype = "http://www.w3.org/1999/02/22-rdf-syntax-ns#namedNode"
  118. datatype = self.str_global_id
  119. elif isinstance(triple[2], BNode):
  120. # datatype = "http://www.w3.org/1999/02/22-rdf-syntax-ns#blankNode"
  121. datatype = self.str_local_id
  122. elif isinstance(triple[2], Literal):
  123. if triple[2].datatype is not None:
  124. datatype = f"{triple[2].datatype}"
  125. else:
  126. if triple[2].language is not None: # language
  127. datatype = self.lang_str
  128. else:
  129. datatype = self.xsd_string
  130. else:
  131. return None # can't handle non URI, BN or Literal Object (QuotedGraph)
  132. # language
  133. if isinstance(triple[2], Literal):
  134. if triple[2].language is not None:
  135. language = f"{triple[2].language}"
  136. else:
  137. language = self.empty
  138. else:
  139. language = self.empty
  140. line_list = [
  141. self._iri_or_bn(triple[0]),
  142. triple[1],
  143. value,
  144. datatype,
  145. language,
  146. context_str,
  147. ]
  148. outline: Union[str, bytes]
  149. if _HAS_ORJSON:
  150. outline = orjson.dumps(line_list, option=orjson.OPT_APPEND_NEWLINE)
  151. else:
  152. outline = json.dumps(line_list) + "\n"
  153. return outline
  154. else: # do not return anything for non-IRIs or BNs, e.g. QuotedGraph, Subjects
  155. return None
  156. def _iri_or_bn(self, i_):
  157. if isinstance(i_, URIRef):
  158. return f"{i_}"
  159. elif isinstance(i_, BNode):
  160. return f"{i_.n3()}"
  161. else:
  162. return None
  163. def _context_str(self, context: Union[Graph, IdentifiedNode]) -> str:
  164. context_identifier: IdentifiedNode = (
  165. context.identifier if isinstance(context, Graph) else context
  166. )
  167. if context_identifier == DATASET_DEFAULT_GRAPH_ID:
  168. return ""
  169. if self.default_context is not None:
  170. if (
  171. isinstance(self.default_context, IdentifiedNode)
  172. and context_identifier == self.default_context
  173. ):
  174. return ""
  175. elif (
  176. isinstance(self.default_context, Graph)
  177. and context_identifier == self.default_context.identifier
  178. ):
  179. return ""
  180. if self.graph_type is Graph:
  181. # Only emit a context name when serializing a Dataset or ConjunctiveGraph
  182. return ""
  183. return (
  184. f"{context_identifier}"
  185. if isinstance(context_identifier, URIRef)
  186. else context_identifier.n3()
  187. )