csv2rdf.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. """
  2. A commandline tool for semi-automatically converting CSV to RDF.
  3. See also https://github.com/RDFLib/pyTARQL in the RDFlib family of tools
  4. try: `csv2rdf --help`
  5. """
  6. from __future__ import annotations
  7. import codecs
  8. import configparser
  9. import csv
  10. import datetime
  11. import fileinput
  12. import getopt
  13. import re
  14. import sys
  15. import time
  16. import warnings
  17. from typing import Any, Dict, List, Optional, Tuple, Union
  18. from urllib.parse import quote
  19. import rdflib
  20. from rdflib.namespace import RDF, RDFS, split_uri
  21. from rdflib.term import URIRef
  22. __all__ = ["CSV2RDF"]
  23. HELP = """
  24. csv2rdf.py \
  25. -b <instance-base> \
  26. -p <property-base> \
  27. [-D <default>] \
  28. [-c <classname>] \
  29. [-i <identity column(s)>] \
  30. [-l <label columns>] \
  31. [-s <N>] [-o <output>] \
  32. [-f configfile] \
  33. [--col<N> <colspec>] \
  34. [--prop<N> <property>] \
  35. <[-d <delim>] \
  36. [-C] [files...]"
  37. Reads csv files from stdin or given files
  38. if -d is given, use this delimiter
  39. if -s is given, skips N lines at the start
  40. Creates a URI from the columns given to -i, or automatically by numbering if
  41. none is given
  42. Outputs RDFS labels from the columns given to -l
  43. if -c is given adds a type triple with the given classname
  44. if -C is given, the class is defined as rdfs:Class
  45. Outputs one RDF triple per column in each row.
  46. Output is in n3 format.
  47. Output is stdout, unless -o is specified
  48. Long options also supported: \
  49. --base, \
  50. --propbase, \
  51. --ident, \
  52. --class, \
  53. --label, \
  54. --out, \
  55. --defineclass
  56. Long options --col0, --col1, ...
  57. can be used to specify conversion for columns.
  58. Conversions can be:
  59. ignore, float(), int(), split(sep, [more]), uri(base, [class]), date(format)
  60. Long options --prop0, --prop1, ...
  61. can be used to use specific properties, rather than ones auto-generated
  62. from the headers
  63. -D sets the default conversion for columns not listed
  64. -f says to read config from a .ini/config file - the file must contain one
  65. section called csv2rdf, with keys like the long options, i.e.:
  66. [csv2rdf]
  67. out=output.n3
  68. base=http://example.org/
  69. col0=split(";")
  70. col1=split(";", uri("http://example.org/things/",
  71. "http://xmlns.com/foaf/0.1/Person"))
  72. col2=float()
  73. col3=int()
  74. col4=date("%Y-%b-%d %H:%M:%S")
  75. """
  76. # bah - ugly global
  77. uris: Dict[Any, Tuple[URIRef, Optional[URIRef]]] = {}
  78. def toProperty(label: str): # noqa: N802
  79. """
  80. CamelCase + lowercase initial a string
  81. FIRST_NM => firstNm
  82. firstNm => firstNm
  83. """
  84. label = re.sub(r"[^\w]", " ", label)
  85. label = re.sub("([a-z])([A-Z])", "\\1 \\2", label)
  86. # type error: Incompatible types in assignment (expression has type "None", variable has type "BinaryIO")
  87. label = label.split(" ") # type: ignore[assignment]
  88. return "".join([label[0].lower()] + [x.capitalize() for x in label[1:]])
  89. def toPropertyLabel(label): # noqa: N802
  90. if not label[1:2].isupper():
  91. return label[0:1].lower() + label[1:]
  92. return label
  93. def index(l_: List[int], i: Tuple[int, ...]) -> Tuple[int, ...]:
  94. """return a set of indexes from a list
  95. >>> index([1,2,3],(0,2))
  96. (1, 3)
  97. """
  98. return tuple([l_[x] for x in i])
  99. def csv_reader(csv_data, dialect=csv.excel, **kwargs):
  100. csv_reader = csv.reader(csv_data, dialect=dialect, **kwargs)
  101. for row in csv_reader:
  102. yield row
  103. def prefixuri(x, prefix, class_: Optional[URIRef] = None):
  104. if prefix:
  105. r = rdflib.URIRef(prefix + quote(x.encode("utf8").replace(" ", "_"), safe=""))
  106. else:
  107. r = rdflib.URIRef(x)
  108. uris[x] = (r, class_)
  109. return r
  110. # meta-language for config
  111. class NodeMaker:
  112. def range(self):
  113. return rdflib.RDFS.Literal
  114. def __call__(self, x: Any):
  115. return rdflib.Literal(x)
  116. class NodeUri(NodeMaker):
  117. def __init__(self, prefix, class_):
  118. self.class_: Optional[URIRef] = None
  119. self.prefix = prefix
  120. if class_:
  121. self.class_ = rdflib.URIRef(class_)
  122. else:
  123. self.class_ = None
  124. def __call__(self, x):
  125. return prefixuri(x, self.prefix, self.class_)
  126. def range(self):
  127. return self.class_ or rdflib.RDF.Resource
  128. class NodeLiteral(NodeMaker):
  129. def __init__(self, f=None):
  130. self.f = f
  131. class NodeFloat(NodeLiteral):
  132. def __call__(self, x):
  133. if not self.f:
  134. return rdflib.Literal(float(x))
  135. if callable(self.f):
  136. return rdflib.Literal(float(self.f(x)))
  137. raise Exception("Function passed to float is not callable")
  138. def range(self):
  139. return rdflib.XSD.double
  140. class NodeInt(NodeLiteral):
  141. def __call__(self, x):
  142. if not self.f:
  143. return rdflib.Literal(int(x))
  144. if callable(self.f):
  145. return rdflib.Literal(int(self.f(x)))
  146. raise Exception("Function passed to int is not callable")
  147. def range(self):
  148. return rdflib.XSD.int
  149. class NodeBool(NodeLiteral):
  150. def __call__(self, x):
  151. if not self.f:
  152. return rdflib.Literal(bool(x))
  153. if callable(self.f):
  154. return rdflib.Literal(bool(self.f(x)))
  155. raise Exception("Function passed to bool is not callable")
  156. def range(self):
  157. return rdflib.XSD.bool
  158. class NodeReplace(NodeMaker):
  159. def __init__(self, a, b):
  160. self.a = a
  161. self.b = b
  162. def __call__(self, x):
  163. return x.replace(self.a, self.b)
  164. class NodeDate(NodeLiteral):
  165. def __call__(self, x):
  166. return rdflib.Literal(datetime.datetime.strptime(x, self.f))
  167. def range(self):
  168. return rdflib.XSD.dateTime
  169. class NodeSplit(NodeMaker):
  170. def __init__(self, sep, f):
  171. self.sep = sep
  172. self.f = f
  173. def __call__(self, x):
  174. if not self.f:
  175. self.f = rdflib.Literal
  176. if not callable(self.f):
  177. raise Exception("Function passed to split is not callable!")
  178. return [self.f(y.strip()) for y in x.split(self.sep) if y.strip() != ""]
  179. def range(self):
  180. if self.f and isinstance(self.f, NodeMaker):
  181. return self.f.range()
  182. return NodeMaker.range(self)
  183. default_node_make = NodeMaker()
  184. def _config_ignore(*args, **kwargs):
  185. return "ignore"
  186. def _config_uri(prefix=None, class_=None):
  187. return NodeUri(prefix, class_)
  188. def _config_literal():
  189. return NodeLiteral()
  190. def _config_float(f=None):
  191. return NodeFloat(f)
  192. def _config_replace(a, b):
  193. return NodeReplace(a, b)
  194. def _config_int(f=None):
  195. return NodeInt(f)
  196. def _config_bool(f=None):
  197. return NodeBool(f)
  198. def _config_date(format_):
  199. return NodeDate(format_)
  200. def _config_split(sep=None, f=None):
  201. return NodeSplit(sep, f)
  202. config_functions = {
  203. "ignore": _config_ignore,
  204. "uri": _config_uri,
  205. "literal": _config_literal,
  206. "float": _config_float,
  207. "int": _config_int,
  208. "date": _config_date,
  209. "split": _config_split,
  210. "replace": _config_replace,
  211. "bool": _config_bool,
  212. }
  213. def column(v):
  214. """Return a function for column mapping"""
  215. return eval(v, config_functions)
  216. class CSV2RDF:
  217. def __init__(self):
  218. self.CLASS = None
  219. self.BASE = None
  220. self.PROPBASE = None
  221. self.IDENT: Union[Tuple[str, ...], str] = "auto"
  222. self.LABEL = None
  223. self.DEFINECLASS = False
  224. self.SKIP = 0
  225. self.DELIM = ","
  226. self.DEFAULT = None
  227. self.COLUMNS = {}
  228. self.PROPS = {}
  229. self.OUT = sys.stdout
  230. self.triples = 0
  231. def triple(self, s, p, o):
  232. self.OUT.write("%s %s %s .\n" % (s.n3(), p.n3(), o.n3()))
  233. self.triples += 1
  234. def convert(self, csvreader):
  235. start = time.time()
  236. if self.OUT:
  237. sys.stderr.write("Output to %s\n" % self.OUT.name)
  238. if self.IDENT != "auto" and not isinstance(self.IDENT, tuple):
  239. self.IDENT = (self.IDENT,)
  240. if not self.BASE:
  241. warnings.warn("No base given, using http://example.org/instances/")
  242. self.BASE = rdflib.Namespace("http://example.org/instances/")
  243. if not self.PROPBASE:
  244. warnings.warn("No property base given, using http://example.org/property/")
  245. self.PROPBASE = rdflib.Namespace("http://example.org/props/")
  246. # skip lines at the start
  247. for x in range(self.SKIP):
  248. next(csvreader)
  249. # read header line
  250. header_labels = list(next(csvreader))
  251. headers = dict(enumerate([self.PROPBASE[toProperty(x)] for x in header_labels]))
  252. # override header properties if some are given
  253. for k, v in self.PROPS.items():
  254. headers[k] = v
  255. header_labels[k] = split_uri(v)[1]
  256. if self.DEFINECLASS:
  257. # output class/property definitions
  258. self.triple(self.CLASS, RDF.type, RDFS.Class)
  259. for i in range(len(headers)):
  260. h, l_ = headers[i], header_labels[i]
  261. if h == "" or l_ == "":
  262. continue
  263. if self.COLUMNS.get(i, self.DEFAULT) == "ignore":
  264. continue
  265. self.triple(h, RDF.type, RDF.Property)
  266. self.triple(h, RDFS.label, rdflib.Literal(toPropertyLabel(l_)))
  267. self.triple(h, RDFS.domain, self.CLASS)
  268. self.triple(
  269. h, RDFS.range, self.COLUMNS.get(i, default_node_make).range()
  270. )
  271. rows = 0
  272. for l_ in csvreader:
  273. try:
  274. if self.IDENT == "auto":
  275. uri = self.BASE["%d" % rows]
  276. else:
  277. uri = self.BASE[
  278. "_".join(
  279. [
  280. # type error: "int" has no attribute "encode"
  281. quote(x.encode("utf8").replace(" ", "_"), safe="") # type: ignore[attr-defined]
  282. # type error: Argument 2 to "index" has incompatible type "Union[Tuple[str, ...], str]"; expected "Tuple[int, ...]"
  283. for x in index(l_, self.IDENT) # type: ignore[arg-type]
  284. ]
  285. )
  286. ]
  287. if self.LABEL:
  288. self.triple(
  289. # type error: Argument 1 to "join" of "str" has incompatible type "Tuple[int, ...]"; expected "Iterable[str]"
  290. uri,
  291. RDFS.label,
  292. rdflib.Literal(" ".join(index(l_, self.LABEL))), # type: ignore[arg-type]
  293. )
  294. if self.CLASS:
  295. # type triple
  296. self.triple(uri, RDF.type, self.CLASS)
  297. for i, x in enumerate(l_):
  298. # type error: "int" has no attribute "strip"
  299. x = x.strip() # type: ignore[attr-defined]
  300. if x != "":
  301. if self.COLUMNS.get(i, self.DEFAULT) == "ignore":
  302. continue
  303. try:
  304. o = self.COLUMNS.get(i, rdflib.Literal)(x)
  305. if isinstance(o, list):
  306. for _o in o:
  307. self.triple(uri, headers[i], _o)
  308. else:
  309. self.triple(uri, headers[i], o)
  310. except Exception as e:
  311. warnings.warn(
  312. "Could not process value for column "
  313. + "%d:%s in row %d, ignoring: %s "
  314. # type error: "Exception" has no attribute "message"
  315. % (i, headers[i], rows, e.message) # type: ignore[attr-defined]
  316. )
  317. rows += 1
  318. if rows % 100000 == 0:
  319. sys.stderr.write(
  320. "%d rows, %d triples, elapsed %.2fs.\n"
  321. % (rows, self.triples, time.time() - start)
  322. )
  323. except Exception:
  324. sys.stderr.write("Error processing line: %d\n" % rows)
  325. raise
  326. # output types/labels for generated URIs
  327. classes = set()
  328. # type error: Incompatible types in assignment (expression has type "Tuple[URIRef, Optional[URIRef]]", variable has type "int")
  329. for l_, x in uris.items(): # type: ignore[assignment]
  330. # type error: "int" object is not iterable
  331. u, c = x # type: ignore[misc]
  332. # type error: Cannot determine type of "u"
  333. self.triple(u, RDFS.label, rdflib.Literal(l_)) # type: ignore[has-type]
  334. # type error: Cannot determine type of "c"
  335. if c: # type: ignore[has-type]
  336. # type error: Cannot determine type of "c"
  337. c = rdflib.URIRef(c) # type: ignore[has-type]
  338. classes.add(c)
  339. # type error: Cannot determine type of "u"
  340. self.triple(u, RDF.type, c) # type: ignore[has-type]
  341. for c in classes:
  342. self.triple(c, RDF.type, RDFS.Class)
  343. self.OUT.close()
  344. sys.stderr.write("Converted %d rows into %d triples.\n" % (rows, self.triples))
  345. sys.stderr.write("Took %.2f seconds.\n" % (time.time() - start))
  346. def main():
  347. csv2rdf = CSV2RDF()
  348. opts: Union[Dict[str, str], List[Tuple[str, str]]]
  349. opts, files = getopt.getopt(
  350. sys.argv[1:],
  351. "hc:b:p:i:o:Cf:l:s:d:D:",
  352. [
  353. "out=",
  354. "base=",
  355. "delim=",
  356. "propbase=",
  357. "class=",
  358. "default=" "ident=",
  359. "label=",
  360. "skip=",
  361. "defineclass",
  362. "help",
  363. ],
  364. )
  365. opts = dict(opts)
  366. if "-h" in opts or "--help" in opts:
  367. print(HELP)
  368. sys.exit(-1)
  369. if "-f" in opts:
  370. config = configparser.ConfigParser()
  371. config.read_file(open(opts["-f"]))
  372. for k, v in config.items("csv2rdf"):
  373. if k == "out":
  374. csv2rdf.OUT = codecs.open(v, "w", "utf-8")
  375. elif k == "base":
  376. csv2rdf.BASE = rdflib.Namespace(v)
  377. elif k == "propbase":
  378. csv2rdf.PROPBASE = rdflib.Namespace(v)
  379. elif k == "class":
  380. csv2rdf.CLASS = rdflib.URIRef(v)
  381. elif k == "defineclass":
  382. csv2rdf.DEFINECLASS = bool(v)
  383. elif k == "ident":
  384. csv2rdf.IDENT = eval(v)
  385. elif k == "label":
  386. csv2rdf.LABEL = eval(v)
  387. elif k == "delim":
  388. csv2rdf.DELIM = v
  389. elif k == "skip":
  390. csv2rdf.SKIP = int(v)
  391. elif k == "default":
  392. csv2rdf.DEFAULT = column(v)
  393. elif k.startswith("col"):
  394. csv2rdf.COLUMNS[int(k[3:])] = column(v)
  395. elif k.startswith("prop"):
  396. csv2rdf.PROPS[int(k[4:])] = rdflib.URIRef(v)
  397. if "-o" in opts:
  398. csv2rdf.OUT = codecs.open(opts["-o"], "w", "utf-8")
  399. if "--out" in opts:
  400. csv2rdf.OUT = codecs.open(opts["--out"], "w", "utf-8")
  401. if "-b" in opts:
  402. csv2rdf.BASE = rdflib.Namespace(opts["-b"])
  403. if "--base" in opts:
  404. csv2rdf.BASE = rdflib.Namespace(opts["--base"])
  405. if "-d" in opts:
  406. csv2rdf.DELIM = opts["-d"]
  407. if "--delim" in opts:
  408. csv2rdf.DELIM = opts["--delim"]
  409. if "-D" in opts:
  410. csv2rdf.DEFAULT = column(opts["-D"])
  411. if "--default" in opts:
  412. csv2rdf.DEFAULT = column(opts["--default"])
  413. if "-p" in opts:
  414. csv2rdf.PROPBASE = rdflib.Namespace(opts["-p"])
  415. if "--propbase" in opts:
  416. csv2rdf.PROPBASE = rdflib.Namespace(opts["--propbase"])
  417. if "-l" in opts:
  418. csv2rdf.LABEL = eval(opts["-l"])
  419. if "--label" in opts:
  420. csv2rdf.LABEL = eval(opts["--label"])
  421. if "-i" in opts:
  422. csv2rdf.IDENT = eval(opts["-i"])
  423. if "--ident" in opts:
  424. csv2rdf.IDENT = eval(opts["--ident"])
  425. if "-s" in opts:
  426. csv2rdf.SKIP = int(opts["-s"])
  427. if "--skip" in opts:
  428. csv2rdf.SKIP = int(opts["--skip"])
  429. if "-c" in opts:
  430. csv2rdf.CLASS = rdflib.URIRef(opts["-c"])
  431. if "--class" in opts:
  432. csv2rdf.CLASS = rdflib.URIRef(opts["--class"])
  433. for k, v in opts.items():
  434. if k.startswith("--col"):
  435. csv2rdf.COLUMNS[int(k[5:])] = column(v)
  436. elif k.startswith("--prop"):
  437. csv2rdf.PROPS[int(k[6:])] = rdflib.URIRef(v)
  438. if csv2rdf.CLASS and ("-C" in opts or "--defineclass" in opts):
  439. csv2rdf.DEFINECLASS = True
  440. csv2rdf.convert(csv_reader(fileinput.input(files), delimiter=csv2rdf.DELIM))
  441. if __name__ == "__main__":
  442. main()