util.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. """
  2. Some utility functions.
  3. Miscellaneous utilities
  4. * list2set
  5. * first
  6. * uniq
  7. * more_than
  8. Term characterisation and generation
  9. * to_term
  10. * from_n3
  11. Date/time utilities
  12. * date_time
  13. * parse_date_time
  14. """
  15. from __future__ import annotations
  16. from calendar import timegm
  17. from os.path import splitext
  18. # from time import daylight
  19. from time import altzone, gmtime, localtime, time, timezone
  20. from typing import (
  21. TYPE_CHECKING,
  22. Any,
  23. Callable,
  24. Dict,
  25. Hashable,
  26. Iterable,
  27. Iterator,
  28. List,
  29. Optional,
  30. Set,
  31. Tuple,
  32. TypeVar,
  33. Union,
  34. overload,
  35. )
  36. from urllib.parse import quote, urlsplit, urlunsplit
  37. import rdflib.graph # avoid circular dependency
  38. import rdflib.namespace
  39. import rdflib.term
  40. from rdflib.compat import sign
  41. if TYPE_CHECKING:
  42. from rdflib.graph import Graph
  43. __all__ = [
  44. "list2set",
  45. "first",
  46. "uniq",
  47. "more_than",
  48. "to_term",
  49. "from_n3",
  50. "date_time",
  51. "parse_date_time",
  52. "guess_format",
  53. "find_roots",
  54. "get_tree",
  55. "_coalesce",
  56. "_iri2uri",
  57. ]
  58. _HashableT = TypeVar("_HashableT", bound=Hashable)
  59. _AnyT = TypeVar("_AnyT")
  60. SUFFIX_FORMAT_MAP = {
  61. "xml": "xml",
  62. "rdf": "xml",
  63. "owl": "xml",
  64. "n3": "n3",
  65. "ttl": "turtle",
  66. "nt": "nt",
  67. "trix": "trix",
  68. "xhtml": "rdfa",
  69. "html": "rdfa",
  70. "svg": "rdfa",
  71. "nq": "nquads",
  72. "nquads": "nquads",
  73. "trig": "trig",
  74. "json": "json-ld",
  75. "jsonld": "json-ld",
  76. "json-ld": "json-ld",
  77. }
  78. FORMAT_MIMETYPE_MAP = {
  79. "xml": ["application/rdf+xml"],
  80. "n3": ["text/n3"],
  81. "turtle": ["text/turtle"],
  82. "nt": ["application/n-triples"],
  83. "trix": ["application/trix"],
  84. "rdfa": ["text/html", "application/xhtml+xml"],
  85. "nquads": ["application/n-quads"],
  86. "trig": ["application/trig"],
  87. "json-ld": ["application/ld+json"],
  88. }
  89. RESPONSE_TABLE_FORMAT_MIMETYPE_MAP = {
  90. "xml": ["application/sparql-results+xml"],
  91. "json": ["application/sparql-results+json"],
  92. "csv": ["text/csv"],
  93. "tsv": ["text/tab-separated-values"],
  94. }
  95. def list2set(seq: Iterable[_HashableT]) -> List[_HashableT]:
  96. """
  97. Return a new list without duplicates.
  98. Preserves the order, unlike set(seq)
  99. """
  100. seen = set()
  101. # type error: "add" of "set" does not return a value
  102. return [x for x in seq if x not in seen and not seen.add(x)] # type: ignore[func-returns-value]
  103. def first(seq: Iterable[_AnyT]) -> Optional[_AnyT]:
  104. """
  105. return the first element in a python sequence
  106. for graphs, use graph.value instead
  107. """
  108. for result in seq:
  109. return result
  110. return None
  111. def uniq(sequence: Iterable[str], strip: int = 0) -> Set[str]:
  112. """removes duplicate strings from the sequence."""
  113. if strip:
  114. return set(s.strip() for s in sequence)
  115. else:
  116. return set(sequence)
  117. def more_than(sequence: Iterable[Any], number: int) -> int:
  118. "Returns 1 if sequence has more items than number and 0 if not."
  119. i = 0
  120. for item in sequence:
  121. i += 1
  122. if i > number:
  123. return 1
  124. return 0
  125. def to_term(
  126. s: Optional[str], default: Optional[rdflib.term.Identifier] = None
  127. ) -> Optional[rdflib.term.Identifier]:
  128. """
  129. Creates and returns an Identifier of type corresponding
  130. to the pattern of the given positional argument string `s`:
  131. '' returns the `default` keyword argument value or `None`
  132. '<s>' returns `URIRef(s)` (i.e. without angle brackets)
  133. '"s"' returns `Literal(s)` (i.e. without doublequotes)
  134. '_s' returns `BNode(s)` (i.e. without leading underscore)
  135. """
  136. if not s:
  137. return default
  138. elif s.startswith("<") and s.endswith(">"):
  139. return rdflib.term.URIRef(s[1:-1])
  140. elif s.startswith('"') and s.endswith('"'):
  141. return rdflib.term.Literal(s[1:-1])
  142. elif s.startswith("_"):
  143. return rdflib.term.BNode(s)
  144. else:
  145. msg = "Unrecognised term syntax: '%s'" % s
  146. raise Exception(msg)
  147. def from_n3(
  148. s: str,
  149. default: Optional[str] = None,
  150. backend: Optional[str] = None,
  151. nsm: Optional[rdflib.namespace.NamespaceManager] = None,
  152. ) -> Optional[Union[rdflib.term.Node, str]]:
  153. r'''Creates the Identifier corresponding to the given n3 string.
  154. ```python
  155. >>> from rdflib.term import URIRef, Literal
  156. >>> from rdflib.namespace import NamespaceManager
  157. >>> from_n3('<http://ex.com/foo>') == URIRef('http://ex.com/foo')
  158. True
  159. >>> from_n3('"foo"@de') == Literal('foo', lang='de')
  160. True
  161. >>> from_n3('"""multi\nline\nstring"""@en') == Literal(
  162. ... 'multi\nline\nstring', lang='en')
  163. True
  164. >>> from_n3('42') == Literal(42)
  165. True
  166. >>> from_n3(Literal(42).n3()) == Literal(42)
  167. True
  168. >>> from_n3('"42"^^xsd:integer') == Literal(42)
  169. True
  170. >>> from rdflib import RDFS
  171. >>> from_n3('rdfs:label') == RDFS['label']
  172. True
  173. >>> nsm = NamespaceManager(rdflib.graph.Graph())
  174. >>> nsm.bind('dbpedia', 'http://dbpedia.org/resource/')
  175. >>> berlin = URIRef('http://dbpedia.org/resource/Berlin')
  176. >>> from_n3('dbpedia:Berlin', nsm=nsm) == berlin
  177. True
  178. ```
  179. '''
  180. if not s:
  181. return default
  182. if s.startswith("<"):
  183. # Hack: this should correctly handle strings with either native unicode
  184. # characters, or \u1234 unicode escapes.
  185. return rdflib.term.URIRef(
  186. s[1:-1].encode("raw-unicode-escape").decode("unicode-escape")
  187. )
  188. elif s.startswith('"'):
  189. if s.startswith('"""'):
  190. quotes = '"""'
  191. else:
  192. quotes = '"'
  193. value, rest = s.rsplit(quotes, 1)
  194. value = value[len(quotes) :] # strip leading quotes
  195. datatype = None
  196. language = None
  197. # as a given datatype overrules lang-tag check for it first
  198. dtoffset = rest.rfind("^^")
  199. if dtoffset >= 0:
  200. # found a datatype
  201. # datatype has to come after lang-tag so ignore everything before
  202. # see: http://www.w3.org/TR/2011/WD-turtle-20110809/
  203. # #prod-turtle2-RDFLiteral
  204. datatype = from_n3(rest[dtoffset + 2 :], default, backend, nsm)
  205. else:
  206. if rest.startswith("@"):
  207. language = rest[1:] # strip leading at sign
  208. value = value.replace(r"\"", '"')
  209. # unicode-escape interprets \xhh as an escape sequence,
  210. # but n3 does not define it as such.
  211. value = value.replace(r"\x", r"\\x")
  212. # Hack: this should correctly handle strings with either native unicode
  213. # characters, or \u1234 unicode escapes.
  214. value = value.encode("raw-unicode-escape").decode("unicode-escape")
  215. # type error: Argument 3 to "Literal" has incompatible type "Union[Node, str, None]"; expected "Optional[str]"
  216. return rdflib.term.Literal(value, language, datatype) # type: ignore[arg-type]
  217. elif s == "true" or s == "false":
  218. return rdflib.term.Literal(s == "true")
  219. elif (
  220. s.lower()
  221. .replace(".", "", 1)
  222. .replace("-", "", 1)
  223. .replace("e", "", 1)
  224. .isnumeric()
  225. ):
  226. if "e" in s.lower():
  227. return rdflib.term.Literal(s, datatype=rdflib.namespace.XSD.double)
  228. if "." in s:
  229. return rdflib.term.Literal(float(s), datatype=rdflib.namespace.XSD.decimal)
  230. return rdflib.term.Literal(int(s), datatype=rdflib.namespace.XSD.integer)
  231. elif s.startswith("{"):
  232. identifier = from_n3(s[1:-1])
  233. # type error: Argument 1 to "QuotedGraph" has incompatible type "Optional[str]"; expected "Union[Store, str]"
  234. # type error: Argument 2 to "QuotedGraph" has incompatible type "Union[Node, str, None]"; expected "Union[IdentifiedNode, str, None]"
  235. return rdflib.graph.QuotedGraph(backend, identifier) # type: ignore[arg-type]
  236. elif s.startswith("["):
  237. identifier = from_n3(s[1:-1])
  238. # type error: Argument 1 to "Graph" has incompatible type "Optional[str]"; expected "Union[Store, str]"
  239. # type error: Argument 2 to "Graph" has incompatible type "Union[Node, str, None]"; expected "Union[IdentifiedNode, str, None]"
  240. return rdflib.graph.Graph(backend, identifier) # type: ignore[arg-type]
  241. elif s.startswith("_:"):
  242. return rdflib.term.BNode(s[2:])
  243. elif ":" in s:
  244. if nsm is None:
  245. # instantiate default NamespaceManager and rely on its defaults
  246. nsm = rdflib.namespace.NamespaceManager(rdflib.graph.Graph())
  247. prefix, last_part = s.split(":", 1)
  248. ns = dict(nsm.namespaces())[prefix]
  249. return rdflib.namespace.Namespace(ns)[last_part]
  250. else:
  251. return rdflib.term.BNode(s)
  252. def date_time(t=None, local_time_zone=False):
  253. """http://www.w3.org/TR/NOTE-datetime ex: 1997-07-16T19:20:30Z
  254. ```python
  255. >>> date_time(1126482850)
  256. '2005-09-11T23:54:10Z'
  257. @@ this will change depending on where it is run
  258. #>>> date_time(1126482850, local_time_zone=True)
  259. #'2005-09-11T19:54:10-04:00'
  260. >>> date_time(1)
  261. '1970-01-01T00:00:01Z'
  262. >>> date_time(0)
  263. '1970-01-01T00:00:00Z'
  264. ```
  265. """
  266. if t is None:
  267. t = time()
  268. if local_time_zone:
  269. time_tuple = localtime(t)
  270. if time_tuple[8]:
  271. tz_mins = altzone // 60
  272. else:
  273. tz_mins = timezone // 60
  274. tzd = "-%02d:%02d" % (tz_mins // 60, tz_mins % 60)
  275. else:
  276. time_tuple = gmtime(t)
  277. tzd = "Z"
  278. year, month, day, hh, mm, ss, wd, y, z = time_tuple
  279. s = "%0004d-%02d-%02dT%02d:%02d:%02d%s" % (year, month, day, hh, mm, ss, tzd)
  280. return s
  281. def parse_date_time(val: str) -> int:
  282. """always returns seconds in UTC
  283. ```python
  284. # tests are written like this to make any errors easier to understand
  285. >>> parse_date_time('2005-09-11T23:54:10Z') - 1126482850.0
  286. 0.0
  287. >>> parse_date_time('2005-09-11T16:54:10-07:00') - 1126482850.0
  288. 0.0
  289. >>> parse_date_time('1970-01-01T00:00:01Z') - 1.0
  290. 0.0
  291. >>> parse_date_time('1970-01-01T00:00:00Z') - 0.0
  292. 0.0
  293. >>> parse_date_time("2005-09-05T10:42:00") - 1125916920.0
  294. 0.0
  295. ```
  296. """
  297. if "T" not in val:
  298. val += "T00:00:00Z"
  299. ymd, time = val.split("T")
  300. hms, tz_str = time[0:8], time[8:]
  301. if not tz_str or tz_str == "Z":
  302. time = time[:-1]
  303. tz_offset = 0
  304. else:
  305. signed_hrs = int(tz_str[:3])
  306. mins = int(tz_str[4:6])
  307. secs = (sign(signed_hrs) * mins + signed_hrs * 60) * 60
  308. tz_offset = -secs
  309. year, month, day = ymd.split("-")
  310. hour, minute, second = hms.split(":")
  311. t = timegm(
  312. (int(year), int(month), int(day), int(hour), int(minute), int(second), 0, 0, 0)
  313. )
  314. t = t + tz_offset
  315. return t
  316. def guess_format(fpath: str, fmap: Optional[Dict[str, str]] = None) -> Optional[str]:
  317. """
  318. Guess RDF serialization based on file suffix. Uses
  319. `SUFFIX_FORMAT_MAP` unless `fmap` is provided.
  320. Example:
  321. ```python
  322. >>> guess_format('path/to/file.rdf')
  323. 'xml'
  324. >>> guess_format('path/to/file.owl')
  325. 'xml'
  326. >>> guess_format('path/to/file.ttl')
  327. 'turtle'
  328. >>> guess_format('path/to/file.json')
  329. 'json-ld'
  330. >>> guess_format('path/to/file.xhtml')
  331. 'rdfa'
  332. >>> guess_format('path/to/file.svg')
  333. 'rdfa'
  334. >>> guess_format('path/to/file.xhtml', {'xhtml': 'grddl'})
  335. 'grddl'
  336. ```
  337. This also works with just the suffixes, with or without leading dot, and
  338. regardless of letter case:
  339. ```python
  340. >>> guess_format('.rdf')
  341. 'xml'
  342. >>> guess_format('rdf')
  343. 'xml'
  344. >>> guess_format('RDF')
  345. 'xml'
  346. ```
  347. """
  348. fmap = fmap or SUFFIX_FORMAT_MAP
  349. return fmap.get(_get_ext(fpath)) or fmap.get(fpath.lower())
  350. def _get_ext(fpath: str, lower: bool = True) -> str:
  351. """
  352. Gets the file extension from a file(path); stripped of leading '.' and in
  353. lower case.
  354. Example:
  355. ```python
  356. >>> _get_ext("path/to/file.txt")
  357. 'txt'
  358. >>> _get_ext("OTHER.PDF")
  359. 'pdf'
  360. >>> _get_ext("noext")
  361. ''
  362. >>> _get_ext(".rdf")
  363. 'rdf'
  364. ```
  365. """
  366. ext = splitext(fpath)[-1]
  367. if ext == "" and fpath.startswith("."):
  368. ext = fpath
  369. if lower:
  370. ext = ext.lower()
  371. if ext.startswith("."):
  372. ext = ext[1:]
  373. return ext
  374. def find_roots(
  375. graph: Graph,
  376. prop: rdflib.term.URIRef,
  377. roots: Optional[Set[rdflib.term.Node]] = None,
  378. ) -> Set[rdflib.term.Node]:
  379. """Find the roots in some sort of transitive hierarchy.
  380. find_roots(graph, rdflib.RDFS.subClassOf)
  381. will return a set of all roots of the sub-class hierarchy
  382. Assumes triple of the form (child, prop, parent), i.e. the direction of
  383. `RDFS.subClassOf` or `SKOS.broader`
  384. """
  385. non_roots: Set[rdflib.term.Node] = set()
  386. if roots is None:
  387. roots = set()
  388. for x, y in graph.subject_objects(prop):
  389. non_roots.add(x)
  390. if x in roots:
  391. roots.remove(x)
  392. if y not in non_roots:
  393. roots.add(y)
  394. return roots
  395. def get_tree(
  396. graph: Graph,
  397. root: rdflib.term.Node,
  398. prop: rdflib.term.URIRef,
  399. mapper: Callable[[rdflib.term.Node], rdflib.term.Node] = lambda x: x,
  400. sortkey: Optional[Callable[[Any], Any]] = None,
  401. done: Optional[Set[rdflib.term.Node]] = None,
  402. dir: str = "down",
  403. ) -> Optional[Tuple[rdflib.term.Node, List[Any]]]:
  404. """
  405. Return a nested list/tuple structure representing the tree
  406. built by the transitive property given, starting from the root given
  407. i.e.
  408. ```python
  409. get_tree(
  410. graph,
  411. rdflib.URIRef("http://xmlns.com/foaf/0.1/Person"),
  412. rdflib.RDFS.subClassOf,
  413. )
  414. ```
  415. will return the structure for the subClassTree below person.
  416. dir='down' assumes triple of the form (child, prop, parent),
  417. i.e. the direction of RDFS.subClassOf or SKOS.broader
  418. Any other dir traverses in the other direction
  419. """
  420. if done is None:
  421. done = set()
  422. if root in done:
  423. # type error: Return value expected
  424. return # type: ignore[return-value]
  425. done.add(root)
  426. tree = []
  427. branches: Iterator[rdflib.term.Node]
  428. if dir == "down":
  429. branches = graph.subjects(prop, root)
  430. else:
  431. branches = graph.objects(root, prop)
  432. for branch in branches:
  433. t = get_tree(graph, branch, prop, mapper, sortkey, done, dir)
  434. if t:
  435. tree.append(t)
  436. return (mapper(root), sorted(tree, key=sortkey))
  437. @overload
  438. def _coalesce(*args: Optional[_AnyT], default: _AnyT) -> _AnyT: ...
  439. @overload
  440. def _coalesce(
  441. *args: Optional[_AnyT], default: Optional[_AnyT] = ...
  442. ) -> Optional[_AnyT]: ...
  443. def _coalesce(
  444. *args: Optional[_AnyT], default: Optional[_AnyT] = None
  445. ) -> Optional[_AnyT]:
  446. """
  447. This is a null coalescing function, it will return the first non-`None`
  448. argument passed to it, otherwise it will return `default` which is `None`
  449. by default.
  450. For more info regarding the rationale of this function see deferred
  451. [PEP 505](https://peps.python.org/pep-0505/).
  452. Args:
  453. *args: Values to consider as candidates to return, the first arg that
  454. is not `None` will be returned. If no argument is passed this function
  455. will return None.
  456. default: The default value to return if none of the args are not `None`.
  457. Returns:
  458. The first `args` that is not `None`, otherwise the value of
  459. `default` if there are no `args` or if all `args` are `None`.
  460. """
  461. for arg in args:
  462. if arg is not None:
  463. return arg
  464. return default
  465. _RFC3986_SUBDELIMS = "!$&'()*+,;="
  466. """
  467. `sub-delims` production from
  468. [RFC 3986, section 2.2](https://www.rfc-editor.org/rfc/rfc3986.html#section-2.2).
  469. """
  470. _RFC3986_PCHAR_NU = "%" + _RFC3986_SUBDELIMS + ":@"
  471. """
  472. The non-unreserved characters in the `pchar` production from RFC 3986.
  473. """
  474. _QUERY_SAFE_CHARS = _RFC3986_PCHAR_NU + "/?"
  475. """
  476. The non-unreserved characters that are safe to use in in the query and fragment
  477. components.
  478. ```
  479. pchar = unreserved / pct-encoded / sub-delims / ":" / "@" query
  480. = *( pchar / "/" / "?" ) fragment = *( pchar / "/" / "?" )
  481. ```
  482. """
  483. _USERNAME_SAFE_CHARS = _RFC3986_SUBDELIMS + "%"
  484. """
  485. The non-unreserved characters that are safe to use in the username and password
  486. components.
  487. ```
  488. userinfo = *( unreserved / pct-encoded / sub-delims / ":" )
  489. ```
  490. ":" is excluded as this is only used for the username and password components,
  491. and they are treated separately.
  492. """
  493. _PATH_SAFE_CHARS = _RFC3986_PCHAR_NU + "/"
  494. """
  495. The non-unreserved characters that are safe to use in the path component.
  496. This is based on various path-related productions from RFC 3986.
  497. """
  498. def _iri2uri(iri: str) -> str:
  499. """
  500. Prior art:
  501. - [iri_to_uri from Werkzeug](https://github.com/pallets/werkzeug/blob/92c6380248c7272ee668e1f8bbd80447027ccce2/src/werkzeug/urls.py#L926-L931)
  502. ```python
  503. >>> _iri2uri("https://dbpedia.org/resource/Almería")
  504. 'https://dbpedia.org/resource/Almer%C3%ADa'
  505. ```
  506. """
  507. # https://datatracker.ietf.org/doc/html/rfc3986
  508. # https://datatracker.ietf.org/doc/html/rfc3305
  509. parts = urlsplit(iri)
  510. (scheme, netloc, path, query, fragment) = parts
  511. # Just support http/https, otherwise return the iri unaltered
  512. if scheme not in ["http", "https"]:
  513. return iri
  514. path = quote(path, safe=_PATH_SAFE_CHARS)
  515. query = quote(query, safe=_QUERY_SAFE_CHARS)
  516. fragment = quote(fragment, safe=_QUERY_SAFE_CHARS)
  517. if parts.hostname:
  518. netloc = parts.hostname.encode("idna").decode("ascii")
  519. else:
  520. netloc = ""
  521. if ":" in netloc:
  522. # Quote IPv6 addresses
  523. netloc = f"[{netloc}]"
  524. if parts.port:
  525. netloc = f"{netloc}:{parts.port}"
  526. if parts.username:
  527. auth = quote(parts.username, safe=_USERNAME_SAFE_CHARS)
  528. if parts.password:
  529. pass_quoted = quote(parts.password, safe=_USERNAME_SAFE_CHARS)
  530. auth = f"{auth}:{pass_quoted}"
  531. netloc = f"{auth}@{netloc}"
  532. uri = urlunsplit((scheme, netloc, path, query, fragment))
  533. if iri.endswith("#") and not uri.endswith("#"):
  534. uri += "#"
  535. return uri