use six.moves to work around urllib / urllib2 / urlparse to urllib 2to3 cleanup
[htsworkflow.git] / htsworkflow / submission / encoded.py
1 """Interface with encoded software for ENCODE3 data submission & warehouse
2
3 This allows retrieving blocks
4 """
5 from __future__ import print_function
6 import base64
7 import collections
8 import hashlib
9 import logging
10 import json
11 import jsonschema
12 import os
13 import requests
14 import types
15 from six.moves.urllib.parse import urljoin, urlparse, urlunparse
16
17 LOGGER = logging.getLogger(__name__)
18
19 ENCODED_CONTEXT = {
20     # The None context will get added to the root of the tree and will
21     # provide common defaults.
22     None: {
23         # terms in multiple encoded objects
24         'award': {'@type': '@id'},
25         'dataset': {'@type': '@id'},
26         'description': 'rdf:description',
27         'documents': {'@type': '@id'},
28         'experiment': {'@type': '@id'},
29         'href': {'@type': '@id'},
30         'lab': {'@type': '@id'},
31         'library': {'@type': '@id'},
32         'pi': {'@type': '@id'},
33         'platform': {'@type': '@id'},
34         'replicates': {'@type': '@id'},
35         'submitted_by': {'@type': '@id'},
36         'url': {'@type': '@id'},
37     },
38     # Identify and markup contained classes.
39     # e.g. in the tree there was a sub-dictionary named 'biosample'
40     # That dictionary had a term 'biosample_term_id, which is the
41     # term that should be used as the @id.
42     'biosample': {
43         'biosample_term_id': {'@type': '@id'},
44     },
45     'experiment': {
46         "assay_term_id": {"@type": "@id"},
47         "files": {"@type": "@id"},
48         "original_files": {"@type": "@id"},
49     },
50     # I tried to use the JSON-LD mapping capabilities to convert the lab
51     # contact information into a vcard record, but the encoded model
52     # didn't lend itself well to the vcard schema
53     #'lab': {
54     #    "address1": "vcard:street-address",
55     #    "address2": "vcard:street-address",
56     #    "city": "vcard:locality",
57     #    "state": "vcard:region",
58     #    "country": "vcard:country"
59     #},
60     'library': {
61         'nucleic_acid_term_id': {'@type': '@id'}
62     }
63 }
64
65 #FIXME: this needs to be initialized from rdfns
66 ENCODED_NAMESPACES = {
67     # JSON-LD lets you define namespaces so you can used the shorted url syntax.
68     # (instead of http://www.w3.org/2000/01/rdf-schema#label you can do
69     # rdfs:label)
70     "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
71     "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
72     "owl": "http://www.w3.org/2002/07/owl#",
73     "dc": "htp://purl.org/dc/elements/1.1/",
74     "xsd": "http://www.w3.org/2001/XMLSchema#",
75     "vcard": "http://www.w3.org/2006/vcard/ns#",
76
77     # for some namespaces I made a best guess for the ontology root.
78     "EFO": "http://www.ebi.ac.uk/efo/",  # EFO ontology
79     "OBO": "http://purl.obolibrary.org/obo/",  # OBO ontology
80     "OBI": "http://purl.obolibrary.org/obo/OBI_",  # Ontology for Biomedical Investigations
81     # OBI: available from http://svn.code.sf.net/p/obi/code/releases/2012-07-01/merged/merged-obi-comments.owl
82     "SO": "http://purl.obolibrary.org/obo/SO_",  # Sequence ontology
83     # SO: available from http://www.berkeleybop.org/ontologies/so.owl
84     # NTR: New Term Request space for DCC to implement new ontology terms
85
86 }
87
88 ENCODED_SCHEMA_ROOT = '/profiles/'
89
90
91 class ENCODED:
92     '''Programatic access encoded, the software powering ENCODE3's submit site.
93     '''
94     def __init__(self, server, contexts=None, namespaces=None):
95         self.server = server
96         self.scheme = 'https'
97         self.username = None
98         self.password = None
99         self.contexts = contexts if contexts else ENCODED_CONTEXT
100         self.namespaces = namespaces if namespaces else ENCODED_NAMESPACES
101         self.json_headers = {'content-type': 'application/json', 'accept': 'application/json'}
102         self.schemas = {}
103
104     def get_auth(self):
105         return (self.username, self.password)
106     auth = property(get_auth)
107
108     def load_netrc(self):
109         import netrc
110         session = netrc.netrc()
111         authenticators = session.authenticators(self.server)
112         if authenticators:
113             self.username = authenticators[0]
114             self.password = authenticators[2]
115
116     def add_jsonld_context(self, tree, default_base):
117         """Add contexts to various objects in the tree.
118
119         tree is a json tree returned from the DCC's encoded database.
120         contexts is a dictionary of dictionaries containing contexts
121                 for the various  possible encoded classes.
122         base, if supplied allows setting the base url that relative
123             urls will be resolved against.
124         """
125         self.add_jsonld_child_context(tree, default_base)
126         self.add_jsonld_namespaces(tree['@context'])
127
128     def add_jsonld_child_context(self, obj, default_base):
129         '''Add JSON-LD context to the encoded JSON.
130
131         This is recursive because some of the IDs were relative URLs
132         and I needed a way to properly compute a the correct base URL.
133         '''
134         # pretend strings aren't iterable
135         if type(obj) in types.StringTypes:
136             return
137
138         # recurse on container types
139         if isinstance(obj, collections.Sequence):
140             # how should I update lists?
141             for v in obj:
142                 self.add_jsonld_child_context(v, default_base)
143             return
144
145         if isinstance(obj, collections.Mapping):
146             for v in obj.values():
147                 self.add_jsonld_child_context(v, default_base)
148
149         # we have an object. attach a context to it.
150         if self._is_encoded_object(obj):
151             context = self.create_jsonld_context(obj, default_base)
152             if len(context) > 0:
153                 obj.setdefault('@context', {}).update(context)
154
155     def add_jsonld_namespaces(self, context):
156         '''Add shortcut namespaces to a context
157
158         Only needs to be run on the top-most context
159         '''
160         context.update(self.namespaces)
161
162     def create_jsonld_context(self, obj, default_base):
163         '''Synthesize the context for a encoded type
164
165         self.contexts[None] = default context attributes added to any type
166         self.contexts[type] = context attributes for this type.
167         '''
168         obj_type = self.get_object_type(obj)
169         context = {'@base': urljoin(default_base, obj['@id']),
170                    '@vocab': self.get_schema_url(obj_type)}
171         # add in defaults
172         context.update(self.contexts[None])
173         for t in obj['@type']:
174             if t in self.contexts:
175                 context.update(self.contexts[t])
176         return context
177
178     def get_json(self, obj_id, **kwargs):
179         '''GET an ENCODE object as JSON and return as dict
180
181         Uses prepare_url to allow url short-cuts
182         if no keyword arguments are specified it will default to adding limit=all
183         Alternative keyword arguments can be passed in and will be sent to the host.
184
185         Known keywords are:
186           limit - (integer or 'all') how many records to return, all for all of them
187           embed - (bool) if true expands linking ids into their associated object.
188           format - text/html or application/json
189         '''
190         if len(kwargs) == 0:
191             kwargs['limit'] = 'all'
192
193         url = self.prepare_url(obj_id)
194         LOGGER.info('requesting url: {}'.format(url))
195
196         # do the request
197
198         LOGGER.debug('username: %s, password: %s', self.username, self.password)
199         arguments = {}
200         if self.username and self.password:
201             arguments['auth'] = self.auth
202         response = requests.get(url, headers=self.json_headers,
203                                 params=kwargs,
204                                 **arguments)
205         if not response.status_code == requests.codes.ok:
206             LOGGER.error("Error http status: {}".format(response.status_code))
207             response.raise_for_status()
208         return response.json()
209
210     def get_jsonld(self, obj_id, **kwargs):
211         '''Get ENCODE object as JSONLD annotated with classses contexts
212
213         see get_json for documentation about what keywords can be passed.
214         '''
215         url = self.prepare_url(obj_id)
216         json = self.get_json(obj_id, **kwargs)
217         self.add_jsonld_context(json, url)
218         return json
219
220     def get_object_type(self, obj):
221         """Return type for a encoded object
222         """
223         obj_type = obj.get('@type')
224         if not obj_type:
225             raise ValueError('None type')
226         if type(obj_type) in types.StringTypes:
227             raise ValueError('@type should be a list, not a string')
228         if not isinstance(obj_type, collections.Sequence):
229             raise ValueError('@type is not a sequence')
230         return obj_type[0]
231
232     def get_schema_url(self, object_type):
233         return self.prepare_url(ENCODED_SCHEMA_ROOT + object_type + '.json') + '#'
234
235     def _is_encoded_object(self, obj):
236         '''Test to see if an object is a JSON-LD object
237
238         Some of the nested dictionaries lack the @id or @type
239         information necessary to convert them.
240         '''
241         if not isinstance(obj, collections.Iterable):
242             return False
243
244         if '@id' in obj and '@type' in obj:
245             return True
246         return False
247
248     def patch_json(self, obj_id, changes):
249         """Given a dictionary of changes push them as a HTTP patch request
250         """
251         url = self.prepare_url(obj_id)
252         LOGGER.info('PATCHing to %s', url)
253         payload = json.dumps(changes)
254         response = requests.patch(url, auth=self.auth, headers=self.json_headers, data=payload)
255         if response.status_code != requests.codes.ok:
256             LOGGER.error("Error http status: {}".format(response.status_code))
257             LOGGER.error("Response: %s", response.text)
258             response.raise_for_status()
259         return response.json()
260
261     def put_json(self, obj_id, new_object):
262         url = self.prepare_url(obj_id)
263         LOGGER.info('PUTing to %s', url)
264         payload = json.dumps(new_object)
265         response = requests.put(url, auth=self.auth, headers=self.json_headers, data=payload)
266         if response.status_code != requests.codes.created:
267             LOGGER.error("Error http status: {}".format(response.status_code))
268             response.raise_for_status()
269         return response.json()
270
271     def post_json(self, collection_id, new_object):
272         url = self.prepare_url(collection_id)
273         LOGGER.info('POSTing to %s', url)
274         payload = json.dumps(new_object)
275
276         response = requests.post(url, auth=self.auth, headers=self.json_headers, data=payload)
277         if response.status_code != requests.codes.created:
278             LOGGER.error("Error http status: {}".format(response.status_code))
279             response.raise_for_status()
280         return response.json()
281
282     def prepare_url(self, request_url):
283         '''This attempts to provide some convienence for accessing a URL
284
285         Given a url fragment it will default to :
286         * requests over http
287         * requests to self.server
288
289         This allows fairly flexible urls. e.g.
290
291         prepare_url('/experiments/ENCSR000AEG')
292         prepare_url('submit.encodedcc.org/experiments/ENCSR000AEG')
293         prepare_url('http://submit.encodedcc.org/experiments/ENCSR000AEG?limit=all')
294
295         should all return the same url
296         '''
297         # clean up potentially messy urls
298         url = urlparse(request_url)._asdict()
299         if not url['scheme']:
300             url['scheme'] = self.scheme
301         if not url['netloc']:
302             url['netloc'] = self.server
303         url = urlunparse(url.values())
304         return url
305
306     def search_jsonld(self, **kwargs):
307         '''Send search request to ENCODED
308
309         to do a general search do
310             searchTerm=term
311         '''
312         url = self.prepare_url('/search/')
313         result = self.get_json(url, **kwargs)
314         self.convert_search_to_jsonld(result)
315         return result
316
317     def convert_search_to_jsonld(self, result):
318         '''Add the context to search result
319
320         Also remove hard to handle nested attributes
321           e.g. remove object.term when we have no id
322         '''
323         graph = result['@graph']
324         for i, obj in enumerate(graph):
325             # suppress nested attributes
326             graph[i] = {k: v for k, v in obj.items() if '.' not in k}
327
328         self.add_jsonld_context(result, self.prepare_url(result['@id']))
329         return result
330
331     def validate(self, obj, object_type=None):
332         object_type = object_type if object_type else self.get_object_type(obj)
333         schema_url = self.get_schema_url(object_type)
334         if not schema_url:
335             raise ValueError("Unable to construct schema url")
336
337         schema = self.schemas.setdefault(object_type, self.get_json(schema_url))
338         hidden = obj.copy()
339         if '@id' in hidden:
340             del hidden['@id']
341         if '@type' in hidden:
342             del hidden['@type']
343         jsonschema.validate(hidden, schema)
344
345 class TypedColumnParser(object):
346     @staticmethod
347     def parse_sheet_array_type(value):
348         """Helper function to parse :array columns in sheet
349         """
350         return value.split(', ')
351
352     @staticmethod
353     def parse_sheet_integer_type(value):
354         """Helper function to parse :integer columns in sheet
355         """
356         return int(value)
357
358     @staticmethod
359     def parse_sheet_boolean_type(value):
360         """Helper function to parse :boolean columns in sheet
361         """
362         return bool(value)
363
364     @staticmethod
365     def parse_sheet_timestamp_type(value):
366         """Helper function to parse :date columns in sheet
367         """
368         return value.strftime('%Y-%m-%d')
369
370     @staticmethod
371     def parse_sheet_string_type(value):
372         """Helper function to parse :string columns in sheet (the default)
373         """
374         return unicode(value)
375
376     def __getitem__(self, name):
377         parser = {
378             'array': self.parse_sheet_array_type,
379             'boolean': self.parse_sheet_boolean_type,
380             'integer': self.parse_sheet_integer_type,
381             'date': self.parse_sheet_timestamp_type,
382             'string': self.parse_sheet_string_type
383         }.get(name)
384         if parser:
385             return parser
386         else:
387             raise RuntimeError("unrecognized column type")
388
389     def __call__(self, header, value):
390         header = header.split(':')
391         column_type = 'string'
392         if len(header) > 1:
393             if header[1] == 'skip':
394                 return None, None
395             else:
396                 column_type = header[1]
397         return header[0], self[column_type](value)
398
399 typed_column_parser = TypedColumnParser()
400
401 class Document(object):
402     """Helper class for registering documents
403
404     Usage:
405     lysis_uuid = 'f0cc5a7f-96a5-4970-9f46-317cc8e2d6a4'
406     lysis = Document(url_to_pdf, 'extraction protocol', 'Lysis Protocol')
407     lysis.create_if_needed(server, lysis_uuid)
408     """
409     award = 'U54HG006998'
410     lab = '/labs/barbara-wold'
411
412     def __init__(self, url, document_type, description, aliases=None):
413         self.url = url
414         self.filename = os.path.basename(url)
415         self.document_type = document_type
416         self.description = description
417
418         self.references = []
419         self.aliases = aliases if aliases is not None else []
420         self.content_type = None
421         self.document = None
422         self.md5sum = None
423         self.urls = None
424         self.uuid = None
425
426         self.get_document()
427
428     def get_document(self):
429         if os.path.exists(self.url):
430             with open(self.url, 'r') as instream:
431                 assert self.url.endswith('pdf')
432                 self.content_type = 'application/pdf'
433                 self.document = instream.read()
434                 self.md5sum = hashlib.md5(self.document)
435         else:
436             req = requests.get(self.url)
437             if req.status_code == 200:
438                 self.content_type = req.headers['content-type']
439                 self.document = req.content
440                 self.md5sum = hashlib.md5(self.document)
441                 self.urls = [self.url]
442
443     def create_payload(self):
444         document_payload = {
445             'attachment': {
446               'download': self.filename,
447               'type': self.content_type,
448               'href': 'data:'+self.content_type+';base64,' + base64.b64encode(self.document),
449               'md5sum': self.md5sum.hexdigest()
450             },
451             'document_type': self.document_type,
452             'description': self.description,
453             'award': self.award,
454             'lab': self.lab,
455         }
456         if self.aliases:
457             document_payload['aliases'] = self.aliases
458         if self.references:
459             document_payload['references'] = self.references
460         if self.urls:
461             document_payload['urls'] = self.urls
462
463         return document_payload
464
465     def post(self, server):
466         document_payload = self.create_payload()
467         return server.post_json('/documents/', document_payload)
468
469     def save(self, filename):
470         payload = self.create_payload()
471         with open(filename, 'w') as outstream:
472             outstream.write(pformat(payload))
473
474     def create_if_needed(self, server, uuid):
475         self.uuid = uuid
476         if uuid is None:
477             return self.post(server)
478         else:
479             return server.get_json(uuid, embed=False)
480
481 if __name__ == '__main__':
482     # try it
483     from htsworkflow.util.rdfhelp import get_model, dump_model
484     from htsworkflow.util.rdfjsonld import load_into_model
485     from pprint import pprint
486     model = get_model()
487     logging.basicConfig(level=logging.DEBUG)
488     encoded = ENCODED('test.encodedcc.org')
489     encoded.load_netrc()
490     body = encoded.get_jsonld('/experiments/ENCSR000AEC/')
491     pprint(body)
492     load_into_model(model, body)
493     #dump_model(model)