ebd2d2695781a5166d2955aefdc2be9375a5af9d
[htsworkflow.git] / htsworkflow / util / rdfhelp.py
1 """Helper features for working with librdf
2 """
3 from __future__ import print_function
4
5 import collections
6 from datetime import datetime
7 from glob import glob
8 import six
9 from six.moves import urllib
10 import logging
11 import os
12 import sys
13 import types
14 from pkg_resources import resource_listdir, resource_string
15
16 import lxml.html
17 import lxml.html.clean
18 import RDF
19
20 logger = logging.getLogger(__name__)
21
22 from htsworkflow.util.rdfns import *
23
24 SCHEMAS_URL='http://jumpgate.caltech.edu/phony/schemas'
25 INFERENCE_URL='http://jumpgate.caltech.edu/phony/inference'
26
27 ISOFORMAT_MS = "%Y-%m-%dT%H:%M:%S.%f"
28 ISOFORMAT_SHORT = "%Y-%m-%dT%H:%M:%S"
29
30 def sparql_query(model, query_filename, output_format='text'):
31     """Execute sparql query from file
32     """
33     logger.info("Opening: %s" % (query_filename,))
34     query_body = open(query_filename, 'r').read()
35     query = RDF.SPARQLQuery(query_body)
36     results = query.execute(model)
37     if output_format == 'html':
38         html_query_results(results)
39     else:
40         display_query_results(results)
41
42
43 def display_query_results(results):
44     """A very simple display of sparql query results showing name value pairs
45     """
46     for row in results:
47         for k, v in row.items()[::-1]:
48             print("{0}: {1}".format(k, v))
49         print()
50
51 def html_query_results(result_stream):
52     from django.conf import settings
53     from django.template import Context, loader
54
55     # I did this because I couldn't figure out how to
56     # get simplify_rdf into the django template as a filter
57     class Simplified(object):
58         def __init__(self, value):
59             self.simple = simplify_rdf(value)
60             if value.is_resource():
61                 self.url = value
62             else:
63                 self.url = None
64
65     template = loader.get_template('rdf_report.html')
66     results = []
67     for row in result_stream:
68         new_row = collections.OrderedDict()
69         row_urls = []
70         for k,v in row.items():
71             new_row[k] = Simplified(v)
72         results.append(new_row)
73     context = Context({'results': results,})
74     print(template.render(context))
75
76 def blankOrUri(value=None):
77     """Return a blank node for None or a resource node for strings.
78     """
79     node = None
80     if value is None:
81         node = RDF.Node()
82     elif isinstance(value, six.string_types):
83         node = RDF.Node(uri_string=value)
84     elif isinstance(value, RDF.Node):
85         node = value
86
87     return node
88
89
90 def toTypedNode(value, language="en"):
91     """Convert a python variable to a RDF Node with its closest xsd type
92     """
93     if isinstance(value, bool):
94         value_type = xsdNS['boolean'].uri
95         if value:
96             value = u'1'
97         else:
98             value = u'0'
99     elif isinstance(value, int):
100         value_type = xsdNS['decimal'].uri
101         value = unicode(value)
102     elif isinstance(value, float):
103         value_type = xsdNS['float'].uri
104         value = unicode(value)
105     elif isinstance(value, datetime):
106         value_type = xsdNS['dateTime'].uri
107         if value.microsecond == 0:
108             value = value.strftime(ISOFORMAT_SHORT)
109         else:
110             value = value.strftime(ISOFORMAT_MS)
111     else:
112         value_type = None
113         value = unicode(value)
114
115     if value_type is not None:
116         node = RDF.Node(literal=value, datatype=value_type)
117     else:
118         node = RDF.Node(literal=unicode(value).encode('utf-8'), language=language)
119     return node
120
121
122 def fromTypedNode(node):
123     """Convert a typed RDF Node to its closest python equivalent
124     """
125     if not isinstance(node, RDF.Node):
126         return node
127     if node.is_resource():
128         return node
129
130     value_type = get_node_type(node)
131     literal = node.literal_value['string']
132     literal_lower = literal.lower()
133
134     if value_type == 'boolean':
135         if literal_lower in ('1', 'yes', 'true'):
136             return True
137         elif literal_lower in ('0', 'no', 'false'):
138             return False
139         else:
140             raise ValueError("Unrecognized boolean %s" % (literal,))
141     elif value_type == 'integer':
142         return int(literal)
143     elif value_type == 'decimal' and literal.find('.') == -1:
144         return int(literal)
145     elif value_type in ('decimal', 'float', 'double'):
146         return float(literal)
147     elif value_type in ('string'):
148         return literal
149     elif value_type in ('dateTime'):
150         try:
151             return datetime.strptime(literal, ISOFORMAT_MS)
152         except ValueError:
153             return datetime.strptime(literal, ISOFORMAT_SHORT)
154     return literal
155
156
157 def get_node_type(node):
158     """Return just the base name of a XSD datatype:
159     e.g. http://www.w3.org/2001/XMLSchema#integer -> integer
160     """
161     # chop off xml schema declaration
162     value_type = node.literal_value['datatype']
163     if value_type is None:
164         return "string"
165     else:
166         value_type = str(value_type)
167         return value_type.replace(str(xsdNS[''].uri), '')
168
169
170 def simplify_rdf(value):
171     """Return a short name for a RDF object
172     e.g. The last part of a URI or an untyped string.
173     """
174     if isinstance(value, RDF.Node):
175         if value.is_resource():
176             name = simplify_uri(str(value.uri))
177         elif value.is_blank():
178             name = '<BLANK>'
179         else:
180             name = value.literal_value['string']
181     elif isinstance(value, RDF.Uri):
182         name = split_uri(str(value))
183     else:
184         name = value
185     return str(name)
186
187
188 def simplify_uri(uri):
189     """Split off the end of a uri
190
191     >>> simplify_uri('http://asdf.org/foo/bar')
192     'bar'
193     >>> simplify_uri('http://asdf.org/foo/bar#bleem')
194     'bleem'
195     >>> simplify_uri('http://asdf.org/foo/bar/')
196     'bar'
197     >>> simplify_uri('http://asdf.org/foo/bar?was=foo')
198     'was=foo'
199     """
200     if isinstance(uri, RDF.Node):
201         if uri.is_resource():
202             uri = uri.uri
203         else:
204             raise ValueError("Can't simplify an RDF literal")
205     if isinstance(uri, RDF.Uri):
206         uri = str(uri)
207
208     parsed = urllib.parse.urlparse(uri)
209     if len(parsed.query) > 0:
210         return parsed.query
211     elif len(parsed.fragment) > 0:
212         return parsed.fragment
213     elif len(parsed.path) > 0:
214         for element in reversed(parsed.path.split('/')):
215             if len(element) > 0:
216                 return element
217     raise ValueError("Unable to simplify %s" % (uri,))
218
219 def strip_namespace(namespace, term):
220     """Remove the namespace portion of a term
221
222     returns None if they aren't in common
223     """
224     if isinstance(term, RDF.Node):
225         if  term.is_resource():
226             term = term.uri
227         else:
228             raise ValueError("This works on resources")
229     elif not isinstance(term, RDF.Uri):
230         raise ValueError("This works on resources")
231     term_s = str(term)
232     if not term_s.startswith(namespace._prefix):
233         return None
234     return term_s.replace(namespace._prefix, "")
235
236
237 def get_model(model_name=None, directory=None, use_contexts=True):
238     if directory is None:
239         directory = os.getcwd()
240
241     contexts = 'yes' if use_contexts else 'no'
242
243     if model_name is None:
244         storage = RDF.MemoryStorage(options_string="contexts='{}'".format(contexts))
245         logger.info("Using RDF Memory model")
246     else:
247         options = "contexts='{0}',hash-type='bdb',dir='{1}'".format(contexts, directory)
248         storage = RDF.HashStorage(model_name,
249                       options=options)
250         logger.info("Using {0} with options {1}".format(model_name, options))
251     model = RDF.Model(storage)
252     return model
253
254
255 def load_into_model(model, parser_name, path, ns=None):
256     if isinstance(ns, six.string_types):
257         ns = RDF.Uri(ns)
258
259     if isinstance(path, RDF.Node):
260         if path.is_resource():
261             path = str(path.uri)
262         else:
263             raise ValueError("url to load can't be a RDF literal")
264
265     url_parts = list(urllib.parse.urlparse(path))
266     if len(url_parts[0]) == 0 or url_parts[0] == 'file':
267         url_parts[0] = 'file'
268         url_parts[2] = os.path.abspath(url_parts[2])
269     if parser_name is None or parser_name == 'guess':
270         parser_name = guess_parser_by_extension(path)
271     url = urllib.parse.urlunparse(url_parts)
272     logger.info("Opening {0} with parser {1}".format(url, parser_name))
273
274     rdf_parser = RDF.Parser(name=parser_name)
275
276     statements = []
277     retries = 3
278     succeeded = False
279     while retries > 0:
280         try:
281             retries -= 1
282             statements = rdf_parser.parse_as_stream(url, ns)
283             retries = 0
284             succeeded = True
285         except RDF.RedlandError as e:
286             errmsg = "RDF.RedlandError: {0} {1} tries remaining"
287             logger.error(errmsg.format(str(e), retries))
288
289     if not succeeded:
290         logger.warn("Unable to download %s", url)
291
292     for s in statements:
293         conditionally_add_statement(model, s, ns)
294
295 def load_string_into_model(model, parser_name, data, ns=None):
296     ns = fixup_namespace(ns)
297     logger.debug("load_string_into_model parser={0}, len={1}".format(
298         parser_name, len(data)))
299     rdf_parser = RDF.Parser(name=str(parser_name))
300
301     for s in rdf_parser.parse_string_as_stream(data, ns):
302         conditionally_add_statement(model, s, ns)
303
304
305 def fixup_namespace(ns):
306     if ns is None:
307         ns = RDF.Uri("http://localhost/")
308     elif isinstance(ns, six.string_types):
309         ns = RDF.Uri(ns)
310     elif not(isinstance(ns, RDF.Uri)):
311         errmsg = "Namespace should be string or uri not {0}"
312         raise ValueError(errmsg.format(str(type(ns))))
313     return ns
314
315
316 def conditionally_add_statement(model, s, ns):
317     imports = owlNS['imports']
318     if s.predicate == imports:
319         obj = str(s.object)
320         logger.info("Importing %s" % (obj,))
321         load_into_model(model, None, obj, ns)
322     if s.object.is_literal():
323         value_type = get_node_type(s.object)
324         if value_type == 'string':
325             s.object = sanitize_literal(s.object)
326     model.add_statement(s)
327
328
329 def add_default_schemas(model, schema_path=None):
330     """Add default schemas to a model
331     Looks for turtle files in either htsworkflow/util/schemas
332     or in the list of directories provided in schema_path
333     """
334
335     schemas = resource_listdir(__name__, 'schemas')
336     for s in schemas:
337         schema = resource_string(__name__,  'schemas/' + s)
338         namespace = 'file://localhost/htsworkflow/schemas/'+s
339         add_schema(model, schema, namespace)
340
341     if schema_path:
342         if type(schema_path) in types.StringTypes:
343             schema_path = [schema_path]
344
345         for path in schema_path:
346             for pathname in glob(os.path.join(path, '*.turtle')):
347                 url = 'file://' + os.path.splitext(pathname)[0]
348                 stream = open(pathname, 'rt')
349                 add_schema(model, stream, url)
350                 stream.close()
351
352 def add_schema(model, schema, url):
353     """Add a schema to a model.
354
355     Main difference from 'load_into_model' is it tags it with
356     a RDFlib context so I can remove them later.
357     """
358     parser = RDF.Parser(name='turtle')
359     context = RDF.Node(RDF.Uri(SCHEMAS_URL))
360     for s in parser.parse_string_as_stream(schema, url):
361         try:
362             model.append(s, context)
363         except RDF.RedlandError as e:
364             logger.error("%s with %s", str(e), str(s))
365
366
367 def remove_schemas(model):
368     """Remove statements labeled with our schema context"""
369     context = RDF.Node(RDF.Uri(SCHEMAS_URL))
370     model.context_remove_statements(context)
371
372
373 def sanitize_literal(node):
374     """Clean up a literal string
375     """
376     if not isinstance(node, RDF.Node):
377         raise ValueError("sanitize_literal only works on RDF.Nodes")
378
379     s = node.literal_value['string']
380     if len(s) > 0:
381         element = lxml.html.fromstring(s)
382         cleaner = lxml.html.clean.Cleaner(page_structure=False)
383         element = cleaner.clean_html(element)
384         text = lxml.html.tostring(element)
385         p_len = 3
386         slash_p_len = 4
387
388         args = {'literal': text[p_len:-slash_p_len]}
389     else:
390         args = {'literal': ''}
391     datatype = node.literal_value['datatype']
392     if datatype is not None:
393         args['datatype'] = datatype
394     language = node.literal_value['language']
395     if language is not None:
396         args['language'] = language
397     return RDF.Node(**args)
398
399
400 def guess_parser(content_type, pathname):
401     if content_type in ('application/rdf+xml',):
402         return 'rdfxml'
403     elif content_type in ('application/x-turtle',):
404         return 'turtle'
405     elif content_type in ('text/html',):
406         return 'rdfa'
407     elif content_type is None or content_type in ('text/plain',):
408         return guess_parser_by_extension(pathname)
409
410 def guess_parser_by_extension(pathname):
411     _, ext = os.path.splitext(pathname)
412     if ext in ('.xml', '.rdf'):
413         return 'rdfxml'
414     elif ext in ('.html',):
415         return 'rdfa'
416     elif ext in ('.turtle',):
417         return 'turtle'
418     return 'guess'
419
420 def get_serializer(name='turtle'):
421     """Return a serializer with our standard prefixes loaded
422     """
423     writer = RDF.Serializer(name=name)
424     # really standard stuff
425     writer.set_namespace('rdf', rdfNS._prefix)
426     writer.set_namespace('rdfs', rdfsNS._prefix)
427     writer.set_namespace('owl', owlNS._prefix)
428     writer.set_namespace('dc', dcNS._prefix)
429     writer.set_namespace('xml', xmlNS._prefix)
430     writer.set_namespace('xsd', xsdNS._prefix)
431     writer.set_namespace('vs', vsNS._prefix)
432     writer.set_namespace('wot', wotNS._prefix)
433
434     # should these be here, kind of specific to an application
435     writer.set_namespace('htswlib', libraryOntology._prefix)
436     writer.set_namespace('ucscSubmission', submissionOntology._prefix)
437     writer.set_namespace('ucscDaf', dafTermOntology._prefix)
438     writer.set_namespace('geoSoft', geoSoftNS._prefix)
439     writer.set_namespace('encode3', encode3NS._prefix)
440     return writer
441
442 def get_turtle_header():
443     """Return a turtle header with our typical namespaces
444     """
445     serializer = get_serializer()
446     empty = get_model()
447     return serializer.serialize_model_to_string(empty)
448
449 def dump_model(model, destination=None):
450     if destination is None:
451         destination = sys.stdout
452     serializer = get_serializer()
453     destination.write(serializer.serialize_model_to_string(model))
454     destination.write(os.linesep)