rest.py 34.8 KB
Newer Older
1 2
# -*- coding: utf-8 -*-
# Copyright (C) Brian Moe, Branson Stephens (2015)
3
#
4
# This file is part of gracedb
5
#
6 7 8 9
# gracedb is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
10
#
11 12 13 14 15 16 17
# It is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with gracedb.  If not, see <http://www.gnu.org/licenses/>.
18

19
import httplib, socket, ssl
20 21
import mimetypes
import urllib
22
import os, sys
23
import json
24
from urlparse import urlparse
25 26
from base64 import b64encode
import netrc
27
from ecp_client import EcpRest
28

29
DEFAULT_SERVICE_URL = "https://gracedb.ligo.org/apiweb/"
30
DEFAULT_BASIC_SERVICE_URL = "https://gracedb.ligo.org/apibasic/"
31
DEFAULT_SP_SESSION_ENDPOINT = "https://gracedb.ligo.org/Shibboleth.sso/Session"
32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170
KNOWN_TEST_HOSTS = ['moe.phys.uwm.edu', 'embb-dev.ligo.caltech.edu', 'simdb.phys.uwm.edu',]

#---------------------------------------------------------------------
# This monkey patch forces TLSv1 if the python version is 2.6.6.
# It was introduced because clients connection from CIT *occasionally*
# try to use SSLv3.  See:
# http://stackoverflow.com/questions/18669457/python-httplib-ssl23-get-server-hellounknown-protocol
#---------------------------------------------------------------------
if sys.hexversion < 0x20709f0:
    wrap_socket_orig = ssl.wrap_socket
    def wrap_socket_patched(sock, keyfile=None, certfile=None,
                            server_side=False, cert_reqs=ssl.CERT_NONE,
                            ssl_version=ssl.PROTOCOL_TLSv1, ca_certs=None,
                            do_handshake_on_connect=True,
                            suppress_ragged_eofs=True):
        return wrap_socket_orig(sock, keyfile, certfile, server_side,
                                cert_reqs, ssl_version, ca_certs,
                                do_handshake_on_connect,
                                suppress_ragged_eofs)
    ssl.wrap_socket = wrap_socket_patched

#-----------------------------------------------------------------
# Utilities 

# XXX It would be nice to get rid of this if we can.
# It seems that you can pass python lists via the requests package.
# That would make putting our lists into comma-separated strings 
# unnecessary.
def cleanListInput(list_arg):
    stringified_list = list_arg
    if isinstance(list_arg, float) or isinstance(list_arg, int):
        stringified_value = str(list_arg)
        return stringified_value
    if not isinstance(list_arg, basestring):
        stringified_list = ','.join(map(str,list_arg))            
    return stringified_list

# The following are used to check whether a user has tried to use
# an expired certificate.

# A utility for writing out an error message to the user and then stopping
# execution. This seems to behave sensibly in both the interpreter and in
# a script.
def output_and_die(msg):
    sys.stderr.write(msg)
    sys.exit(1)

# Given an HTTPResponse object, try to read it's content and interpret as
# JSON--or die trying.
def load_json_or_die(response):

    # First check that the response object actually exists.
    if not response:
        msg = "ERROR: no response object. \n\n"
        output_and_die(msg)

    # Next, try to read the content of the response.
    try:
        response_content = response.read()
    except Exception, e:
        msg = "ERROR: problem reading response. \n\n"
        output_and_die(msg)

    # Finally, try to create a dict by decoding the response as JSON.
    rdict = None
    try:
        rdict = json.loads(response_content)
    except Exception, e:
        msg = "ERROR: got unexpected content from the server:\n\n"
        msg += response_content + "\n\n"
        output_and_die(msg)
        
    return rdict          

#-----------------------------------------------------------------
# HTTP/S Proxy classes
# Taken from: http://code.activestate.com/recipes/456195/

class ProxyHTTPConnection(httplib.HTTPConnection):

    _ports = {'http' : 80, 'https' : 443}

    def request(self, method, url, body=None, headers={}):
        #request is called before connect, so can interpret url and get
        #real host/port to be used to make CONNECT request to proxy
        o = urlparse(url)
        proto = o.scheme
        port = o.port
        host = o.hostname
        if proto is None:
            raise ValueError, "unknown URL type: %s" % url
        if port is None:
            try:
                port = self._ports[proto]
            except KeyError:
                raise ValueError, "unknown protocol for: %s" % url
        self._real_host = host
        self._real_port = port
        httplib.HTTPConnection.request(self, method, url, body, headers)


    def connect(self):
        httplib.HTTPConnection.connect(self)
        #send proxy CONNECT request
        self.send("CONNECT %s:%d HTTP/1.0\r\n\r\n" % (self._real_host, self._real_port))
        #expect a HTTP/1.0 200 Connection established
        response = self.response_class(self.sock, strict=self.strict, method=self._method)
        (version, code, message) = response._read_status()
        #probably here we can handle auth requests...
        if code != 200:
            #proxy returned and error, abort connection, and raise exception
            self.close()
            raise socket.error, "Proxy connection failed: %d %s" % (code, message.strip())
        #eat up header block from proxy....
        while True:
            #should not use directly fp probably
            line = response.fp.readline()
            if line == '\r\n': break

class ProxyHTTPSConnection(ProxyHTTPConnection):

    default_port = 443

    def __init__(self, host, port = None, key_file = None, cert_file = None,
        strict = None, context = None):
        ProxyHTTPConnection.__init__(self, host, port)
        self.key_file = key_file
        self.cert_file = cert_file
        self.context = context

    def connect(self):
        ProxyHTTPConnection.connect(self)
        #make the sock ssl-aware
        if sys.hexversion < 0x20709f0:
            ssl = socket.ssl(self.sock, self.key_file, self.cert_file)
            self.sock = httplib.FakeSocket(self.sock, ssl)
        else:
            self.sock = self.context.wrap_socket(self.sock)

171 172 173 174 175 176

#------------------------------------------------------------------
# GraceDB
#
# Example Gracedb REST client.

177
class GraceDb(EcpRest):
178 179 180 181 182 183 184 185 186 187 188 189
    """Example GraceDb REST client
    
    The GraceDB service URL may be passed to the constructor
    if an alternate GraceDb instance is desired:

        >>> g = GraceDb("https://alternate.gracedb.edu/api/")
        >>> r = g.ping()

    The proxy_host and proxy_port may also be passed in if accessing
    GraceDB behind a proxy. For other kwargs accepted by the constructor,
    consult the source code.
    """
190
    def __init__(self, service_url=DEFAULT_SERVICE_URL, 
191 192 193 194 195 196 197 198 199 200 201 202
            sp_session_endpoint=DEFAULT_SP_SESSION_ENDPOINT,
            *args, **kwargs):

        # Decide whether to verify the server's cert.
        o = urlparse(service_url)
        host = o.hostname
        #verify_server_cert = host not in KNOWN_TEST_HOSTS
        verify_server_cert = False

        # The ECP client doesn't work behind a proxy at the moment.
        EcpRest.__init__(self, service_url, sp_session_endpoint,
            verify_server_cert)
203 204 205 206 207 208 209

        self.service_url = service_url
        self._service_info = None

    @property
    def service_info(self):
        if not self._service_info:
210 211
            r = self.request("GET", self.service_url)
            self._service_info = r.json()
212 213 214 215 216 217 218 219 220 221 222 223 224
        return self._service_info

    @property
    def links(self):
        return self.service_info.get('links')

    @property
    def templates(self):
        return self.service_info.get('templates')

    @property
    def groups(self):
        return self.service_info.get('groups')
225 226 227 228 229 230 231 232
    
    @property
    def pipelines(self):
        return self.service_info.get('pipelines')

    @property
    def searches(self):
        return self.service_info.get('searches')
233

234
    @property
235 236
    def em_groups(self):
        return self.service_info.get('em-groups')
237 238 239 240 241 242 243 244 245 246 247 248 249

    @property
    def wavebands(self):
        return self.service_info.get('wavebands')

    @property
    def eel_statuses(self):
        return self.service_info.get('eel-statuses')

    @property
    def obs_statuses(self):
        return self.service_info.get('obs-statuses')

250 251 252 253
    @property
    def voevent_types(self):
        return self.service_info.get('voevent-types')

254 255 256
    def request(self, method, *args, **kwargs):
        if method.lower() in ['post', 'put']:
            kwargs['priming_url'] = self.service_url
257
        return EcpRest.request(self, method, *args, **kwargs)
258

259 260 261 262 263 264 265 266 267 268 269 270 271 272
    def _getCode(self, input_value, code_dict):
        """Check if input is valid.
           Return coded version if it is"""
        #  code_dict is dict of { code : descriptive_name }
        if input_value in code_dict.keys():
            # Already coded
            return input_value
        if not input_value in code_dict.values():
            # Not valid
            return None
        return [code
                for code, name in code_dict.items()
                if name == input_value][0]

273
    # Search and filecontents are optional when creating an event.
274
    def createEvent(self, group, pipeline, filename, search=None, filecontents=None, **kwargs):
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292
        """Create a new GraceDB event

        Required args: group, pipeline, filename

        Optional args: search, filecontents

        The filename is the path to a file containing information about the event.
        The values for 'group', 'pipeline', and 'search' are restricted to those
        stored in the database.

        Example:

            >>> g = GraceDb()
            >>> r = g.createEvent('CBC', 'gstlal', '/path/to/something.xml', 'LowMass')
            >>> r.status
            201

        """
293 294 295
        errors = []
        if group not in self.groups:
            errors += ["bad group"]
296 297 298 299
        if pipeline not in self.pipelines:
            errors += ["bad pipeline"]
        if search and search not in self.searches:
            errors += ["bad search"]
300 301 302 303
        if errors:
            # XXX Terrible error messages / weak exception type
            raise Exception(str(errors))
        if filecontents is None:
304 305 306 307 308
            if filename == '-':
                filename = 'initial.data'
                filecontents = sys.stdin.read()
            else:
                filecontents = open(filename, 'rb').read() 
309 310
        fields = [
                  ('group', group),
311
                  ('pipeline', pipeline),
312
                 ]
313 314
        if search:
            fields.append(('search', search))
315 316 317 318 319

        # Update fields with additional keyword arguments
        for key, value in kwargs.iteritems():
            fields.append((key, value))

320 321 322 323 324 325
        files = [('eventFile', filename, filecontents)]
        # Python httplib bug?  unicode link
        uri = str(self.links['events'])
        return self.post(uri, fields, files=files)

    def replaceEvent(self, graceid, filename, filecontents=None):
326 327 328 329 330 331 332 333 334 335 336 337 338
        """Replace an existing GraceDB event

        Required args: graceid, filename

        This function uploads a new event file, hence changing the basic details
        of an existing event. 
        
        Example:

            >>> g = GraceDb()
            >>> r = g.replaceEvent('T101383', '/path/to/new/something.xml') 

        """
339
        if filecontents is None:
340 341
            # Note: not allowing filename '-' here.  We want the event datafile
            # to be versioned.
342 343 344 345 346 347
            filecontents = open(filename, 'rb').read()
        return self.put(
                self.templates['event-detail-template'].format(graceid=graceid),
                files=[('eventFile', filename, filecontents)])

    def event(self, graceid):
348 349 350 351 352 353 354 355 356 357
        """Get information about a specific event

        Args: graceid 

        Example:

            >>> g = GraceDb()
            >>> event_dict = g.event('T101383').json()

        """
358 359 360
        return self.get(
                self.templates['event-detail-template'].format(graceid=graceid))

361
    def events(self, query=None, orderby=None, count=None, columns=None):
362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377
        """Get a iterator of events in response to a query

        This function returns an iterator which yields event dictionaries.
        Optional arguments are query, orderby, count, and columns. The 
        columns argument is a comma separated list of attributes that the 
        user would like in each event dictionary. If columns are not specified,
        all attributes of the events are returned.

        Example:

            >>> g = GraceDb()
            >>> for event in g.events('ER5 submitter: "gstlalcbc"', columns='graceid,far,gpstime'):
            ...     print "%s %e %d" % (event['graceid'], event['far'], event['gpstime'])


        """
378 379 380 381 382
        uri = self.links['events']
        qdict = {}
        if query:   qdict['query'] = query
        if count:   qdict['count'] = count
        if orderby: qdict['orderby'] = orderby
383
        if columns: qdict['columns'] = columns
384 385 386 387
        if qdict:
            uri += "?" + urllib.urlencode(qdict)
        while uri:
            response = self.get(uri).json()
388 389
            events = response.get('events',[])
            uri = response.get('links',{}).get('next')
390 391 392 393
            for event in events:
                 yield event

    def numEvents(self, query=None):
394 395 396 397 398 399 400 401 402
        """Get the number of events satisfying a query 

        Example:

            >>> g = GraceDb()
            >>> g.numEvents('ER5 submitter: "gstlalcbc"')
            213

        """
403 404 405 406 407 408
        uri = self.links['events']
        if query:
            uri += "?" + urllib.urlencode({'query': query})
        return self.get(uri).json()['numRows']

    def files(self, graceid, filename=None, raw=False):
409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431
        """Files for a particular event

        Required args: graceid

        Given a graceid, this function fetches a dictionary of the form
        { 'filename': 'file url' }

        Example:

            >>> g = GraceDb()
            >>> event_files = g.files('T101383').json()
            >>> for filename in event_files.keys():
            ...     # do something
            ...     pass

        This function can also be used to download a particular file:

            >>> outfile = open('my_skymap.png', 'w')
            >>> r = g.files('T101383', 'skymap.png')
            >>> outfile.write(r.read())
            >>> outfile.close()

        """
432 433 434 435 436
        template = self.templates['files-template']
        uri = template.format(graceid=graceid, filename=filename or "")
        return self.get(uri)

    def writeFile(self, graceid, filename, filecontents=None):
437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452
        """Upload a file

        Required args: graceid, filename

        This method creates a new log message with your file attached. It is 
        strongly preferred to use writeLog() instead of writeFile() so that you
        can add a more suitable comment. That will make it easier for other 
        users to know what your file contains.

        Example:

            >>> g = GraceDb()
            >>> r = g.writeFile('T101383', '/path/to/my_interesting_plot.png')
            >>> print r.status

        """
453 454 455
        template = self.templates['files-template']
        uri = template.format(graceid=graceid, filename=os.path.basename(filename))
        if filecontents is None:
456 457 458 459 460
            if filename == '-':
                filename = 'stdin'
                filecontents = sys.stdin.read()
            else:
                filecontents = open(filename, "rb").read()
461 462 463 464 465 466 467
        elif isinstance(filecontents, file):
            # XXX Does not scale well.
            filecontents = filecontents.read()
        files = [('upload', os.path.basename(filename), filecontents)]
        return self.put(uri, files=files)

    def logs(self, graceid):
468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484
        """Get all log messages associated with an event

        Required args: graceid

        This function returns a JSON representation of all of an event's
        log messages. 

        Example:

            >>> g = GraceDb()
            >>> response_dict = g.logs('T101383').json()
            >>> print "Num logs = %d" % response_dict['numRows']
            >>> log_list = response_dict['log']
            >>> for log in log_list:
            ...     print log['comment']

        """
485 486 487 488
        template = self.templates['event-log-template']
        uri = template.format(graceid=graceid)
        return self.get(uri)

489 490
    def writeLog(self, graceid, message, filename=None, filecontents=None, 
            tagname=None, displayName=None):
491 492 493 494 495
        """Create a new log message

        Required args: graceid, message
        Optional: filename, tagname

496 497 498 499 500 501
        Note: The tagname argument accepts either a single string or a python
        list of string.  For example, both of these are acceptable:

        tagname = "sky_loc"
        tagname = ["sky_loc", "my_lovely_skymap"]

502 503 504 505 506 507 508 509 510 511 512 513
        If only graceid and message are provided, a text comment will be created
        in the event log. If a filename is also specified, the file will be attached 
        to the log message and displayed along side the message text. If a tagname 
        is provided, the message will be tagged.

        Example:

            >>> g = GraceDb()
            >>> r = g.writeLog('T101383', 'Good stuff.', '/path/to/my_interesting_plot.png', tagname='analyst_comments')
            >>> print r.status

        """
514 515
        template = self.templates['event-log-template']
        uri = template.format(graceid=graceid)
516 517 518 519 520 521 522 523 524 525 526 527 528
        files = None
        if filename:
            if filecontents is None:
                if filename == '-':
                    filename = 'stdin'
                    filecontents = sys.stdin.read()
                else:
                    filecontents = open(filename, "rb").read()
            elif isinstance(filecontents, file):
                # XXX Does not scale well.
                filecontents = filecontents.read()
            files = [('upload', os.path.basename(filename), filecontents)]

529 530 531 532 533 534 535
        # Let's see if tagname is a string or a list 
        if tagname and not isinstance(tagname, basestring):
            tagnames = ','.join(tagname)
        else:
            tagnames = tagname if tagname else None

        return self.post(uri, body={'message' : message, 'tagname': tagnames, 
536
            'displayName': displayName}, files=files)
537

538
    def eels(self, graceid):
539 540 541 542 543 544 545 546 547
        """Given a GraceID, get a list of EMBB log entries 

        Example:
        
            >>> g = GraceDb()       
            >>> r = g.eels('T101383') 
            >>> full_dictionary = r.json()            # Convert the response to a dictionary
            >>> eel_list = full_dictionary['embblog'] # Pull out a list of EEL dicts

548 549 550 551 552 553
        """
    
        template = self.templates['embb-event-log-template']
        uri = template.format(graceid=graceid)
        return self.get(uri)

554
    def writeEel(self, graceid, group, waveband, eel_status, 
555
            obs_status, **kwargs):
556 557 558 559 560 561
        """Write an EMBB event log entry 
        
        Required args: graceid, group, waveband, eel_status, obs_status

        (Note that 'group' here is the name of the EM MOU group, not 
        the LVC data analysis group responsible for the original detection.)
562 563 564 565 566 567 568 569 570 571 572 573

        Additional keyword arguments may be passed in to be sent in the POST
        data. Only the following kwargs are recognized:
            ra
            dec
            raWidth
            decWidth
            gpstime
            duration
            comment
            extra_info_dict

574 575 576 577
        Most of these are self-explanatory, but the 'extra_info_dict' is meant
        to be a JSON string containing any additional information the user may
        wish to convey.

578 579 580
        Any other kwargs will be ignored.
        """ 
        # validate facility, waveband, eel_status, and obs_status
581 582
        if not group in self.em_groups:
            raise ValueError("group must be one of %s" % self.em_groups)
583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598
        
        if not waveband in self.wavebands.keys():
            raise ValueError("waveband must be one of %s" % self.wavebands.keys())

        eel_status = self._getCode(eel_status, self.eel_statuses)
        if not eel_status:
            raise ValueError("EEL status must be one of %s" % self.eel_statuses.values())

        obs_status = self._getCode(obs_status, self.obs_statuses)
        if not obs_status:
            raise ValueError("Observation status must be one of %s" % self.obs_statuses.values())

        template = self.templates['embb-event-log-template']
        uri = template.format(graceid=graceid)

        body = {
599
            'group' : group,
600 601 602 603 604 605 606
            'waveband' : waveband,
            'eel_status' : eel_status,
            'obs_status' : obs_status,
        }
        body.update(**kwargs)
        return self.post(uri, body=body)

607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664
    def emobservations(self, graceid):
        """Given a GraceID, get a list of EM observation entries 

        Example:
        
            >>> g = GraceDb()       
            >>> r = g.emobserations('T101383') 
            >>> full_dictionary = r.json()            # Convert the response to a dictionary
            >>> emo_list = full_dictionary['observations'] # Pull out a list of EMO dicts

        """

        template = self.templates['emobservation-list-template']
        uri = template.format(graceid=graceid)
        return self.get(uri)

    def writeEMObservation(self, graceid, group, raList, raWidthList,
        decList, decWidthList, startTimeList, durationList, comment=None):
        """Write an EM observation entry 
        
        Required args: graceid, group, raList, decList, raWidthList,
            decWidthList, startTimeList, durationList

        The various lists arguments should contain Python lists or 
        comma-separated values (or a single value). Start times are 
        in ISO 8601 UTC. Durations are in seconds.

        (Note that 'group' here is the name of the EM MOU group, not 
        the LVC data analysis group responsible for the original detection.)
        """
        # validate facility, waveband, eel_status, and obs_status
        if not group in self.em_groups:
            raise ValueError("group must be one of %s" % self.em_groups)

        # NOTE: One could do validation of the various list inputs here
        # rather than relying on the server.
        # These arguments can consist of a single element, a python
        # list, or a string.  Transform the list args to csv (unless they 
        # already are)
        raList, raWidthList, decList, decWidthList, startTimeList, durationList = map(
            cleanListInput,
            [raList, raWidthList, decList, decWidthList, startTimeList, durationList])

        template = self.templates['emobservation-list-template']
        uri = template.format(graceid=graceid)

        body = {
            'group' : group,
            'raList' : raList,
            'raWidthList': raWidthList,
            'decList' : decList,
            'decWidthList': decWidthList,
            'startTimeList': startTimeList,
            'durationList': durationList,
            'comment': comment,
        }
        return self.post(uri, body=body)

665
    def labels(self, graceid, label=""):
666 667 668 669 670 671 672 673 674 675 676
        """Get a list of labels for an event

        Example:

            >>> g = GraceDb()
            >>> label_list = g.labels('T101383').json()['labels']
            >>> for label in label_list:
            ...     print label['name']

        """

677 678 679 680
        template = self.templates['event-label-template']
        uri = template.format(graceid=graceid, label=label)
        return self.get(uri)

681
    def writeLabel(self, graceid, label):
682 683 684 685 686 687 688 689 690 691 692 693 694 695 696
        """Add a new label to an event

        Required args:  graceid, label name

        The label name must correspond to one of the existing 
        GraceDB labels, else an error will result.

        Example:

            >>> g = GraceDb()
            >>> r = g.writeLabel('T101383', 'DQV')
            >>> r.status
            201

        """
697 698
        template = self.templates['event-label-template']
        uri = template.format(graceid=graceid, label=label)
699
        return self.put(uri)
700 701

    def removeLabel(self, graceid, label):
702 703 704 705 706 707 708 709 710 711 712 713 714 715 716
        """Remove a label from an event

        Required args: graceid, label name

        Warning: This was not implemented on the server side as of October, 2014.
        It is unlikely to be implemented unless people ask for it.

        Example:

            >>> g = GraceDb()
            >>> r = g.removeLabel('T101383', 'DQV')
            >>> r.status
            501

        """
717 718 719 720
        template = self.templates['event-label-template']
        uri = template.format(graceid=graceid, label=label)
        return self.delete(uri)

721
    def tags(self, graceid, n):
722 723 724 725 726 727 728 729 730 731 732 733 734
        """Get a list of tags associated with a particular log message

        Required arguments: graceid, n (the number of the log message)

        The dictionary returned contains a list of URLs for each tag.

        Example:

            >>> g = GraceDb()
            >>> tag_list = g.tags('T101383', 56).json()['tags']
            >>> print "Number of tags for message 56: %d" % len(tag_list)

        """
735 736 737 738 739
        template = self.templates['taglist-template']
        uri = template.format(graceid=graceid, n=n)
        return self.get(uri)

    def createTag(self, graceid, n, tagname, displayName=None):
740 741 742 743 744 745 746 747 748 749 750 751 752 753 754
        """Add a new tag to a log message

        Required arguments: graceid, n (the number of the log message)
        and the tagname. If a displayName is provided (and if the tag
        doesn't already exist), a new tag will be created with the 
        provided display name.

        Example:

            >>> g = GraceDb()
            >>> r = g.createTag('T101383', 56, 'sky_loc')
            >>> r.status
            201

        """
755 756 757 758 759
        template = self.templates['tag-template']
        uri = template.format(graceid=graceid, n=n, tagname=tagname)
        return self.put(uri, body={'displayName': displayName})

    def deleteTag(self, graceid, n, tagname):
760 761 762 763 764 765 766 767 768 769 770 771 772
        """Remove a tag from a given log message

        Required arguments: graceid, n (the number of the log message)
        and the tagname.        

        Example:
            
            >>> g = GraceDb()
            >>> r = g.deleteTag('T101383', 56, 'sky_loc')
            >>> r.status
            200

        """
773 774 775 776
        template = self.templates['tag-template']
        uri = template.format(graceid=graceid, n=n, tagname=tagname)
        return self.delete(uri)

Branson Stephens's avatar
Branson Stephens committed
777
    def ping(self):
778 779 780 781 782 783 784 785 786 787 788 789 790
        """Ping the server

        If you get back an HTTPResponse object, it's alive.
        The JSON content is the service info, which is normally not of
        interest. But you can use it to get the known Groups, Searches,
        Pipelines, etc.

        Example:

            >>> g = GraceDb()
            >>> groups = g.ping().json()['groups']

        """
Branson Stephens's avatar
Branson Stephens committed
791
        return self.get(self.links['self'])
792

793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833
    def voevents(self, graceid):
        """Given a GraceID, get a list of VOEvents

        Example:
        
            >>> g = GraceDb()       
            >>> r = g.voevents('T101383') 
            >>> voevent_list = r.json()['voevents'] 

        """
    
        template = self.templates['voevent-list-template']
        uri = template.format(graceid=graceid)
        return self.get(uri)

    def createVOEvent(self, graceid, voevent_type, **kwargs):
        """Create a new VOEvent
        
        Required args: graceid, voevent_type

        Additional keyword arguments may be passed in to be sent in the POST
        data. Only the following kwargs are recognized:
            skymap_filename
            skyamp_type

        Any other kwargs will be ignored.
        """ 
        # validate facility, waveband, eel_status, and obs_status
        voevent_type = self._getCode(voevent_type.lower(), self.voevent_types)
        if not voevent_type:
            raise ValueError("voevent_type must be one of: %s" % self.voevent_types.values())
        template = self.templates['voevent-list-template']
        uri = template.format(graceid=graceid)

        body = {
            'voevent_type' : voevent_type,
        }
        body.update(**kwargs)
        return self.post(uri, body=body)


834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849
#-----------------------------------------------------------------
# TBD
# Media Types

# Root

# Collection

# Event

# Log

# File

# Label

850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865
#-----------------------------------------------------------------
# Basic auth for the LV-EM users

class GraceDbBasic(GraceDb):
    """Example GraceDb REST client with basic auth
    
    The GraceDB service URL may be passed to the constructor
    if an alternate GraceDb instance is desired:

        >>> g = GraceDb("https://alternate.gracedb.edu/api/")
        >>> r = g.ping()

    The proxy_host and proxy_port may also be passed in if accessing
    GraceDB behind a proxy. For other kwargs accepted by the constructor,
    consult the source code.
    """
866
    def __init__(self, service_url=DEFAULT_SERVICE_URL,
867 868 869 870 871 872 873 874 875 876 877 878 879 880 881
            proxy_host=None, proxy_port=3128, username=None, password=None, 
            *args, **kwargs):

        o = urlparse(service_url)
        port = o.port
        host = o.hostname
        port = port or 443

        if not username or not password:
            try:
                username, account, password = netrc.netrc().authenticators(host)
            except:
                pass 

        if not username or not password:
882 883 884
            msg = "Could not find user credentials. " 
            msg +="Please use a .netrc file or provide username and password." 
            raise ValueError(msg)
885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920

        # Construct authorization header
        userAndPass = b64encode(b"%s:%s" % (username, password)).decode("ascii")
        self.authn_header = { 'Authorization' : 'Basic %s' %  userAndPass }

        # Versions of Python earlier than 2.7.9 don't use SSL Context
        # objects for this purpose, and do not do any server cert verification.
        ssl_context = None
        if sys.hexversion >= 0x20709f0:
            # Use the new method with SSL Context
            # Prepare SSL context
            ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
            # Generally speaking, test boxes use cheap/free certs from the LIGO CA.
            # These cannot be verified by the client.
            if host in KNOWN_TEST_HOSTS:
                ssl_context.verify_mode = ssl.CERT_NONE
            else:
                ssl_context.verify_mode = ssl.CERT_REQUIRED
                ssl_context.check_hostname = True
                # Find the various CA cert bundles stored on the system
                ssl_context.load_default_certs()        

            if proxy_host:
                self.connector = lambda: ProxyHTTPSConnection(proxy_host, proxy_port, context=ssl_context)
            else:
                self.connector = lambda: httplib.HTTPSConnection(host, port, context=ssl_context)            
        else:
            # Using and older version of python. We'll pass in the cert and key files.
            if proxy_host:
                self.connector = lambda: ProxyHTTPSConnection(proxy_host, proxy_port)
            else:
                self.connector = lambda: httplib.HTTPSConnection(host, port)

        self.service_url = service_url
        self._service_info = None

921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947
    # When there is a problem with the SSL connection or authentication,
    # either conn.request() or conn.getresponse() will throw an exception.
    def get_response(self, conn):
        try:
            return conn.getresponse()
        except ssl.SSLError, e:
            msg = "\nERROR \n\n"
            msg += "Problem establishing secure connection: %s \n\n" % str(e)
            output_and_die(msg)
        except Exception, e:
            msg = "\nERROR \n\n"
            msg += "%s \n\n" % str(e)
            output_and_die(msg)

    # A wrapper for making the request.
    def make_request(self, conn, *args, **kwargs):
        try:
            conn.request(*args, **kwargs)
        except ssl.SSLError, e:
            msg = "\nERROR \n\n"
            msg += "Problem establishing secure connection: %s \n\n" % str(e)
            output_and_die(msg)
        except Exception, e:
            msg = "\nERROR \n\n"
            msg += "%s \n\n" % str(e)
            output_and_die(msg)

948 949 950 951 952 953 954 955 956 957 958 959 960
    def request(self, method, url, body=None, headers=None):
        # Bug in Python (versions < 2.7.1 (?))
        # http://bugs.python.org/issue11898
        # if the URL is unicode and the body of a request is binary,
        # the POST/PUT action fails because it tries to concatenate
        # the two which fails due to encoding problems.
        # Workaround is to cast all URLs to str.
        # This is probably bad in general,
        # but for our purposes, today, this will do.
        url = url and str(url)
        conn = self.getConnection()
        headers = headers or {}
        headers.update(self.authn_header)
961 962 963 964 965 966 967 968 969 970 971 972 973
        self.make_request(conn, method, url, body, headers)
        response = self.get_response(conn)
        # Catch the 401 unauthorized response before sending to adjust
        # response. Effectively, the 401 response will have special status.
        if response.status == 401:
            try:
                msg = "\nERROR: %s \n\n" % json.loads(response.read())['error']
            except:
                msg = "\nERROR: \n\n"
            msg += "Please check the username/password in your .netrc file. \n"
            msg += "Note: If your password is more than a year old, you will \n"
            msg += "need to use the web interface to generate a new one. \n\n"
            output_and_die(msg)
974 975
        return self.adjustResponse(response)

976
#-----------------------------------------------------------------
977

978 979 980 981 982 983 984 985 986 987 988 989 990
# HTTP upload encoding
# Taken from http://code.activestate.com/recipes/146306/

def encode_multipart_formdata(fields, files):
    """
    fields is a sequence of (name, value) elements for regular form fields.
    files is a sequence of (name, filename, value) elements for data to be uploaded as files
    Return (content_type, body) ready for httplib.HTTP instance
    """
    BOUNDARY = '----------ThIs_Is_tHe_bouNdaRY_$'
    CRLF = '\r\n'
    L = []
    for (key, value) in fields:
991
        if value is None: continue
992 993 994
        L.append('--' + BOUNDARY)
        L.append('Content-Disposition: form-data; name="%s"' % key)
        L.append('')
995 996
        # str(value) in case it is unicode
        L.append(str(value))
997
    for (key, filename, value) in files:
998
        if value is None: continue
999
        L.append('--' + BOUNDARY)
1000 1001
        # str(filename) in case it is unicode
        L.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, str(filename)))
1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015
        L.append('Content-Type: %s' % get_content_type(filename))
        L.append('')
        L.append(value)
    L.append('--' + BOUNDARY + '--')
    L.append('')
    body = CRLF.join(L)
    content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
    return content_type, body

def get_content_type(filename):
    return mimetypes.guess_type(filename)[0] or 'application/octet-stream'