# -*- coding: utf-8 -*-
from __future__ import absolute_import

import six
import json

from .client import client
from .utils.printing import pretty_int
from .utils.tabulate import tabulate
from .errors import SolveError

import copy
import logging
logger = logging.getLogger('solvebio')

class Filter(object):
    """
    Filter objects.

    Makes it easier to create filters cumulatively using ``&`` (and),
    ``|`` (or) and ``~`` (not) operations.

    For example::

        f = Filter()
        f &= Filter(price='Free')
        f |= Filter(style='Mexican')

    creates a filter "price = 'Free' or style = 'Mexican'".

    Each set of kwargs in a `Filter` are ANDed together:

      * `<field>='<value>'` matches if the field is that exact value
      * `<field>__in=[<item1>, ...]` matches any of the terms <item1> and so on
      * `<field>__range=[<start>, <end>]` matches anything from <start>
         to <end>
      * `<field>__between=[<start>, <end>]` matches anything between <start> to
         <end> not include either <start> or <end>

    String terms are not analyzed and are always assumed to be exact matches.

    Numeric columns can be selected by range using:

        * `<field>__gt`: greater than
        * `<field>__gte`: greater than or equal to
        * `<field>__lt`: less than
        * `<field>__lte`: less than or equal to

    Field action examples:

        dataset.query(gene__in=['BRCA', 'GATA3'],
                      chr='3',
                      start__gt=10000,
                      end__lte=20000)
    """
    def __init__(self, *raw_filters, **filters):
        """Creates a Filter"""
        # Set deepcopy to False for faster Filter building
        self.deepcopy = True

        filters = list(filters.items())
        for flt in raw_filters:
            try:
                flt = json.loads(flt)
                # If the result is a dict, wrap in a list.
                if isinstance(flt, dict):
                    flt = [flt]
            except:
                raise Exception(
                    'Invalid raw filter, must be a JSON string: {}'
                    .format(flt))

            filters += flt

        if len(filters) > 1:
            self.filters = [{'and': filters}]
        else:
            self.filters = filters

    def __repr__(self):
        return '<Filter {0}>'.format(self.filters)

    def _combine(self, other, conn='and'):
        """
        OR and AND will create a new Filter, with the filters from both Filter
        objects combined with the connector `conn`.
        """
        f = Filter()
        f.deepcopy = self.deepcopy and other.filters

        if f.deepcopy:
            self_filters = copy.deepcopy(self.filters)
            other_filters = copy.deepcopy(other.filters)
        else:
            self_filters = self.filters
            other_filters = other.filters

        if not self.filters:
            f.filters = other_filters
        elif not other.filters:
            f.filters = self_filters
        elif conn in self.filters[0]:
            f.filters = self_filters
            f.filters[0][conn].extend(other_filters)
        elif conn in other.filters[0]:
            f.filters = other_filters
            f.filters[0][conn].extend(self_filters)
        else:
            f.filters = [{conn: self_filters + other_filters}]

        return f

    def __or__(self, other):
        return self._combine(other, 'or')

    def __and__(self, other):
        return self._combine(other, 'and')

    def __invert__(self):
        f = Filter()
        f.deepcopy = self.deepcopy

        if f.deepcopy:
            self_filters = copy.deepcopy(self.filters)
        else:
            self_filters = self.filters

        if len(self.filters) == 0:
            # no change
            f.filters = []
        elif (len(self.filters) == 1 and
              isinstance(self.filters[0], dict) and
              self.filters[0].get('not', {})):
            # if the filters are already a single dictionary containing a 'not'
            # then swap out the 'not'
            f.filters = [self_filters[0]['not']]
        else:
            # length of self.filters should never be more than 1
            # 'not' blocks can contain only dicts or a single tuple filter
            # so we get the first element from the filter list
            f.filters = [{'not': self_filters[0]}]

        return f

class GenomicFilter(Filter):
    """
    Helper class that generates filters on genomic coordinates.

    Range filtering only works on "genomic" datasets
    (where dataset.is_genomic is True).
    """
    # Standardized fields for genomic coordinates in SolveBio
    FIELD_START = 'genomic_coordinates.start'
    FIELD_STOP = 'genomic_coordinates.stop'
    FIELD_CHR = 'genomic_coordinates.chromosome'

    @classmethod
    def from_string(cls, string, exact=False):
        """
        Handles UCSC-style range queries (chr1:100-200)
        """
        try:
            chromosome, pos = string.split(':')
        except ValueError:
            raise ValueError('Please use UCSC-style format: "chr2:1000-2000"')

        if '-' in pos:
            start, stop = pos.replace(',', '').split('-')
        else:
            start = stop = pos.replace(',', '')

        return cls(chromosome, start, stop, exact=exact)

    def __init__(self, chromosome, start, stop=None, exact=False):
        """
        This class supports single position and range filters.

        By default, the filter will match any record that overlaps with
        the position or range specified. Exact matches must be explicitly
        specified using the `exact` parameter.
        """
        try:
            # Allows start=None to filter items with no position
            if start is not None:
                start = int(start)

            if stop is None:
                stop = start
            else:
                stop = int(stop)
        except ValueError:
            raise ValueError(
                'Start and stop positions must be integers (or None)')

        if exact or start is None:
            # Handle the case where start is None because sometimes
            # a record will have only the chromosome set (no positions).
            f = Filter(**{self.FIELD_START: start, self.FIELD_STOP: stop})
        else:
            f = Filter(**{self.FIELD_START + '__lte': start,
                          self.FIELD_STOP + '__gte': stop})
            if start != stop:
                f = f | Filter(**{self.FIELD_START + '__range':
                                  [start, stop]})
                f = f | Filter(**{self.FIELD_STOP + '__range':
                                  [start, stop]})

        if chromosome is None:
            f = f & Filter(**{self.FIELD_CHR: None})
        else:
            f = f & \
                Filter(**{self.FIELD_CHR: str(chromosome).replace('chr', '')})
        self.filters = f.filters

    def __repr__(self):
        return '<GenomicFilter {0}>'.format(self.filters)

class Query(object):
    """
    A Query API request wrapper that generates a request from Filter objects,
    and can iterate through streaming result sets.
    """
    # The maximum number of results fetched in one go. Note however
    # that iterating over a query can cause more fetches.
    DEFAULT_PAGE_SIZE = 100

    # Special case for Query class to pre-set SolveClient
    _client = None

    def __init__(
            self,
            dataset_id,
            query=None,
            genome_build=None,
            filters=None,
            fields=None,
            exclude_fields=None,
            entities=None,
            ordering=None,
            limit=float('inf'),
            page_size=DEFAULT_PAGE_SIZE,
            result_class=dict,
            debug=False,
            error=None,
            **kwargs):
        """
        Creates a new Query object.

        :Parameters:
          - `dataset_id`: Unique ID of dataset to query.
          - `query` (optional): An optional query string.
          - `genome_build`: The genome build to use for the query.
          - `result_class` (optional): Class of object returned by query.
          - `fields` (optional): List of specific fields to retrieve.
          - `exclude_fields` (optional): List of specific fields to exclude.
          - `entities` (optional): List of entity tuples to filter on.
          - `ordering` (optional): List of fields to order the results by.
          - `filters` (optional): Filter or List of filter objects.
          - `limit` (optional): Maximum number of query results to return.
          - `page_size` (optional): Number of results to fetch per query page.
          - `debug` (optional): Sends debug information to the API.
        """
        self._dataset_id = dataset_id
        self._data_url = '/v2/datasets/{0}/data'.format(dataset_id)
        self._query = query
        self._genome_build = genome_build
        self._result_class = result_class
        self._fields = fields
        self._exclude_fields = exclude_fields
        self._entities = entities
        self._ordering = ordering
        self._debug = debug
        self._error = error
        if filters:
            if isinstance(filters, Filter):
                filters = [filters]
        else:
            filters = []
        self._filters = filters

        # init response and cursor
        self._response = None
        # Limit defines the total number of results that will be returned
        # from a query involving 1 or more pagination requests.
        self._limit = limit
        # Page size/offset are the low level API limit and offset params.
        self._page_size = int(page_size)
        # Page offset can only be set by execute(). It is always set to the
        # current absolute offset contained in the buffer.
        self._page_offset = None
        # slice is set when the Query is being sliced.
        # In this case, __iter__() and next() will not
        # reset the page_offset to 0 before iterating.
        self._slice = None

        # parameter error checking
        if self._limit < 0:
            raise Exception('\'limit\' parameter must be >= 0')

        if self._page_size <= 0:
            raise Exception('\'page_size\' parameter must be > 0')

        # Set up the SolveClient
        # (kwargs overrides pre-set, which overrides global)
        self._client = kwargs.get('client') or self._client or client

    def _clone(self, filters=None, limit=None):
        new = self.__class__(self._dataset_id,
                             query=self._query,
                             genome_build=self._genome_build,
                             limit=self._limit,
                             fields=self._fields,
                             exclude_fields=self._exclude_fields,
                             entities=self._entities,
                             ordering=self._ordering,
                             page_size=self._page_size,
                             result_class=self._result_class,
                             debug=self._debug,
                             client=self._client)
        new._filters += self._filters

        if filters:
            new._filters += filters

        if limit:
            new._limit = limit

        return new

    def limit(self, limit):
        """
        Returns a new Query instance with the new
        limit values.
        """
        return self._clone(limit=limit)

    def filter(self, *filters, **kwargs):
        """
        Returns this Query instance with the query args combined with
        existing set with AND.

        kwargs are simply passed to a new Filter object and combined to any
        other filters with AND.

        By default, everything is combined using AND. If you provide
        multiple filters in a single filter call, those are ANDed
        together. If you provide multiple filters in multiple filter
        calls, those are ANDed together.

        If you want something different, use the F class which supports
        ``&`` (and), ``|`` (or) and ``~`` (not) operators. Then call
        filter once with the resulting Filter instance.
        """
        f = list(filters)

        if kwargs:
            f += [Filter(**kwargs)]

        return self._clone(filters=f)

    def range(self, chromosome, start, stop, exact=False):
        """
        Shortcut to do range filters on genomic datasets.
        """
        return self._clone(
            filters=[GenomicFilter(chromosome, start, stop, exact)])

    def position(self, chromosome, position, exact=False):
        """
        Shortcut to do a single position filter on genomic datasets.
        """
        return self._clone(
            filters=[GenomicFilter(chromosome, position, exact=exact)])

    def count(self):
        """
        Returns the total number of results returned by a query.
        The count is dependent on the filters, but independent of any limit.
        It is like SQL:
           SELECT COUNT(*) FROM <table> [WHERE condition].
        See also __len__ for a function that is dependent on limit.
        """
        # self.total will warm up the response if it needs to
        return self.total

    def facets(self, *args, **kwargs):
        """
        Returns a dictionary with the requested facets.

        The facets function supports string args, and keyword
        args.

        q.facets('field_1', 'field_2') will return facets for
        field_1 and field_2.
        q.facets(field_1={'limit': 0}, field_2={'limit': 10})
        will return all facets for field_1 and 10 facets for field_2.
        """
        # Combine args and kwargs into facet format.
        facets = dict((a, {}) for a in args)
        facets.update(kwargs)

        if not facets:
            raise AttributeError('Faceting requires at least one field')

        for f in facets.keys():
            if not isinstance(f, six.string_types):
                raise AttributeError('Facet field arguments must be strings')

        q = self._clone()
        q._limit = 0
        q.execute(offset=0, facets=facets)
        return q._response.get('facets')

    def __len__(self):
        """
        Returns the total number of results returned in a query. It is the
        number of items you can iterate over.

        In contrast to count(), the result does take into account any limit
        given. In SQL it is like:

              SELECT COUNT(*) FROM (
                 SELECT * FROM <table> [WHERE condition] [LIMIT number]
              )
        """
        return min(self._limit, self.count())

    def __nonzero__(self):
        return bool(len(self))

    @property
    def _buffer(self):
        if self._response is None:
            logger.debug('warmup (buffer)')
            self.execute(self._slice.start if self._slice else 0)
        return self._response['results']

    @classmethod
    def _process_filters(cls, filters):
        """Takes a list of filters and returns JSON

        :Parameters:
        - `filters`: List of Filters, (key, val) tuples, or dicts

        Returns: List of JSON API filters
        """
        data = []

        # Filters should always be a list
        for f in filters:
            if isinstance(f, Filter):
                if f.filters:
                    data.extend(cls._process_filters(f.filters))
            elif isinstance(f, dict):
                key = list(f.keys())[0]
                val = f[key]

                if isinstance(val, dict):
                    # pass val (a dict) as list
                    # so that it gets processed properly
                    filter_filters = cls._process_filters([val])
                    if len(filter_filters) == 1:
                        filter_filters = filter_filters[0]
                    data.append({key: filter_filters})
                else:
                    data.append({key: cls._process_filters(val)})
            else:
                data.extend((f,))

        return data

    def __repr__(self):
        # Check that Query object does not have any previous errors
        # otherwise, raise the error.
        if self._error:
            raise self._error

        if len(self) == 0:
            return 'Query returned 0 results.'

        return '\n%s\n\n... %s more results.' % (
            tabulate(list(self._buffer[0].items()), ['Fields', 'Data'],
                     aligns=['right', 'left'], sort=True),
            pretty_int(len(self) - 1))

    def __getattr__(self, key):
        if self._response is None:
            logger.debug('warmup (__getattr__: %s)' % key)
            self.execute(self._slice.start if self._slice else 0)

        # Check that Query object does not have any previous errors
        # otherwise, raise the error.
        # execute() sets the error, so the check is placed after it.
        if self._error:
            raise self._error

        if key in self._response:
            return self._response[key]

        raise AttributeError(
            '\'%s\' object has no attribute \'%s\'' %
            (self.__class__.__name__, key))

    @staticmethod
    def bounded_slice(_slice):
        return slice(
            _slice.start if _slice.start is not None else 0,
            _slice.stop if _slice.stop is not None else float('inf')
        )

    @staticmethod
    def as_slice(slice_or_idx):
        if isinstance(slice_or_idx, slice):
            return Query.bounded_slice(slice_or_idx)
        return slice(slice_or_idx, slice_or_idx + 1)

    def __getitem__(self, key):
        """
        Retrieve an item or slice from the result set.

        Query's do not support negative indexing.

        :Parameters:
        - `key`: The requested slice range or index.
        """
        if not isinstance(key, (slice,) + six.integer_types):
            raise TypeError

        if isinstance(key, slice):
            key = Query.bounded_slice(key)
            start = 0 if key.start is None else key.start
            stop = float('inf') if key.stop is None else key.stop

            if start < 0 or stop < 0 or start > stop:
                raise ValueError('Negative indexing is not supported')

            # If a slice is already set, the new slice should be relative
            if self._slice:
                start += self._slice.start
                stop = min(self._slice.start + stop, self._slice.stop)
                # Make sure the new relative start position is within
                # the previous slice.
                if start >= self._slice.stop:
                    return []

            # We need to make a few requests to get the data.
            # We should respect the user's limit if it is smaller than slice.
            # To prevent the state of page_size/offset from being stored,
            # we'll clone this object first.
            q = self._clone()
            q._limit = min(stop - start, self._limit)
            # Setting slice will signal to the iter methods the page_offset.
            q._slice = slice(start, stop)
            return q

        # Not a slice (key is an int)
        if key < 0:
            raise ValueError('Negative indexing is not supported')

        # If a slice already exists, the key is relative to that slice
        if self._slice:
            key = key + self._slice.start
            if key >= self._slice.stop:
                raise IndexError('list index out of range')

        # Use key as the new page_offset and fetch a new page of results
        q = self._clone()
        q._limit = min(1, self._limit)  # Limit may be 0
        q.execute(key)
        return q._buffer[0]

    def __iter__(self):
        # e.g. [r for r in results] will NOT call __getitem__ and
        # requires that we start iteration from the 0th element
        self.execute(self._slice.start if self._slice else 0)

        # Reset the cursor
        self._cursor = 0  # Count the number of results returned
        self._buffer_idx = 0  # The current position within the buffer

        return self

    def __next__(self):
        """Python 3"""
        return self.next()

    def next(self):
        """
        Allows the Query object to be an iterable.

        This method will iterate through a cached result set
        and fetch successive pages as required.

        A `StopIteration` exception will be raised when there aren't
        any more results available or when the requested result
        slice range or limit has been fetched.

        Returns: The next result.
        """
        if not hasattr(self, '_cursor'):
            # Iterator not initialized yet
            self.__iter__()

        # len(self) returns `min(limit, total)` results
        if self._cursor == len(self):
            raise StopIteration()

        if self._buffer_idx == len(self._buffer):
            self.execute(self._page_offset + self._buffer_idx)
            self._buffer_idx = 0

        self._cursor += 1
        self._buffer_idx += 1
        return self._buffer[self._buffer_idx - 1]

    def _build_query(self, **kwargs):
        q = {}

        if self._query:
            q['query'] = self._query

        if self._filters:
            filters = Query._process_filters(self._filters)
            if len(filters) > 1:
                q['filters'] = [{'and': filters}]
            else:
                q['filters'] = filters

        if self._fields is not None:
            q['fields'] = self._fields

        if self._exclude_fields is not None:
            q['exclude_fields'] = self._exclude_fields

        if self._entities is not None:
            q['entities'] = self._entities

        if self._ordering is not None:
            q['ordering'] = self._ordering

        if self._genome_build is not None:
            q['genome_build'] = self._genome_build

        if self._debug:
            q['debug'] = 'True'

        # Add or modify query parameters
        # (used by BatchQuery and facets)
        q.update(**kwargs)

        return q

    def execute(self, offset=0, **query):
        """
        Executes a query. Additional query parameters can be passed
        as keyword arguments.

        Returns: The request parameters and the raw query response.
        """
        _params = self._build_query(**query)
        self._page_offset = offset

        _params.update(
            offset=self._page_offset,
            limit=min(self._page_size, self._limit)
        )

        logger.debug('executing query. from/limit: %6d/%d' %
                     (_params['offset'], _params['limit']))

        # If the request results in a SolveError (ie bad filter) set the error.
        try:
            self._response = self._client.post(self._data_url, _params)
        except SolveError as e:
            self._error = e
            raise

        logger.debug('query response took: %(took)d ms, total: %(total)d'
                     % self._response)
        return _params, self._response

    def export(self, format='json', follow=True, limit=None):
        from solvebio import DatasetExport

        params = self._build_query()
        params.pop('offset', None)
        params.pop('ordering', None)

        # if limit is not set use max limit
        if not limit and self._limit < float('inf'):
            limit = self._limit

        if limit:
            params['limit'] = limit

        export = DatasetExport.create(
            dataset_id=self._dataset_id,
            format=format,
            params=params,
            client=self._client
        )

        if follow:
            export.follow()

        return export

    def migrate(self, target, follow=True, **kwargs):
        """
        Migrate the data from the Query to a target dataset.

        Valid optional kwargs include:

        * target_fields
        * include_errors
        * validation_params
        * metadata
        * commit_mode

        """
        from solvebio import Dataset
        from solvebio import DatasetMigration

        # Target can be provided as a Dataset, or as an ID.
        if isinstance(target, Dataset):
            target_id = target.id
        else:
            target_id = target

        # If a limit is set in the Query and not overridden here, use it.
        limit = kwargs.pop('limit', None)
        if not limit and self._limit < float('inf'):
            limit = self._limit

        # Build the source_params
        params = self._build_query(limit=limit)
        params.pop('offset', None)
        params.pop('ordering', None)

        migration = DatasetMigration.create(
            source_id=self._dataset_id,
            target_id=target_id,
            source_params=params,
            client=self._client,
            **kwargs)

        if follow:
            migration.follow()

        return migration

    def annotate(self, fields, **kwargs):
        from solvebio.annotate import Annotator

        return Annotator(fields, client=self._client, **kwargs).annotate(self)

class BatchQuery(object):
    """
    BatchQuery accepts a list of Query objects and executes them
    in a single request to /v2/batch_query.
    """
    # Allows pre-setting a SolveClient
    _client = None

    def __init__(self, queries, **kwargs):
        """
        Expects a list of Query objects.
        """
        if not isinstance(queries, list):
            queries = [queries]

        self._queries = queries
        self._client = kwargs.get('client') or self._client or client

    def _build_query(self):
        query = {'queries': []}

        for i in self._queries:
            _params = i._build_query(
                dataset=i._dataset_id,
                offset=i._page_offset or 0,
                limit=min(
                    i._page_size,
                    i._limit
                ),
            )
            query['queries'].append(_params)

        return query

    def execute(self, **params):
        _params = self._build_query()
        _params.update(**params)
        response = self._client.post('/v2/batch_query', _params)
        return response


# -*- coding: utf-8 -*-
"""
SolveBio Python Client
~~~~~~~~~~~~~~~~~~~~~~

This is the Python client & library for the SolveBio API.

Have questions or comments? email us at: support@solvebio.com
"""
from __future__ import absolute_import
from __future__ import print_function
__docformat__ = 'restructuredtext'

import os as _os
import logging as _logging
from .help import open_help as _open_help

# Capture warnings (specifically from urllib3)
try:
    _logging.captureWarnings(True)
except:
    # Python 2.6 doesn't support this
    pass

# Read/Write API key
api_key = _os.environ.get('SOLVEBIO_API_KEY', None)
# OAuth2 access tokens
access_token = _os.environ.get('SOLVEBIO_ACCESS_TOKEN', None)
api_host = _os.environ.get('SOLVEBIO_API_HOST', 'https://api.solvebio.com')

def help():
    _open_help('/docs')

def _init_logging():
    loglevel_base = _os.environ.get('SOLVEBIO_LOGLEVEL', 'WARN')
    loglevel_stream = _os.environ.get('SOLVEBIO_LOGLEVEL', 'WARN')
    logfile = _os.environ.get('SOLVEBIO_LOGFILE', '~/.solvebio/solvebio.log')
    loglevel_file = _os.environ.get('SOLVEBIO_LOGLEVEL', 'DEBUG')

    base_logger = _logging.getLogger("solvebio")
    base_logger.setLevel(loglevel_base)

    # clear handlers if any exist
    handlers = base_logger.handlers[:]
    for handler in handlers:
        base_logger.removeHandler(handler)
        handler.close()

    if loglevel_stream:
        stream_handler = _logging.StreamHandler()
        stream_handler.setLevel(loglevel_stream)
        stream_fmt = _logging.Formatter('[SolveBio] %(message)s')
        stream_handler.setFormatter(stream_fmt)
        base_logger.addHandler(stream_handler)

    if logfile:
        logfile_path = _os.path.expanduser(logfile)
        logdir = _os.path.dirname(logfile_path)

        if not _os.path.isdir(logdir):
            # Handle a race condition here when running
            # multiple services that import this package.
            try:
                _os.makedirs(logdir)
            except OSError as err:
                # Re-raise anything other than 'File exists'.
                if err[1] != 'File exists':
                    raise err

        file_handler = _logging.FileHandler(logfile_path)
        file_handler.setLevel(loglevel_file)
        file_fmt = _logging.Formatter(
            '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
        file_handler.setFormatter(file_fmt)
        base_logger.addHandler(file_handler)

    try:
        base_logger.addHandler(_logging.NullHandler())
    except:
        # supports Python < 2.7
        class NullHandler(_logging.Handler):
            def emit(self, record):
                pass

        base_logger.addHandler(NullHandler())

_init_logging()

from .version import VERSION  # noqa
from .errors import SolveError
from .query import Query, BatchQuery, Filter, GenomicFilter
from .annotate import Annotator, Expression
from .client import SolveClient
from .resource import (
    Application,
    Beacon,
    BeaconSet,
    Dataset,
    DatasetCommit,
    DatasetExport,
    DatasetField,
    DatasetImport,
    DatasetMigration,
    DatasetTemplate,
    Group,
    Manifest,
    Object,
    User,
    Vault,
    VaultSyncTask,
    ObjectCopyTask,
    SavedQuery,
    Task
)

def login(**kwargs):
    """
    Sets up the auth credentials using the provided key/token,
    or checks the credentials file (if no token provided).

    Lookup order:
        1. access_token
        2. api_key
        3. local credentials

    No errors are raised if no key is found.
    """
    from .cli.auth import get_credentials
    global access_token, api_key, api_host

    # Clear any existing auth keys
    access_token, api_key = None, None
    # Update the host
    api_host = kwargs.get('api_host') or api_host

    if kwargs.get('access_token'):
        access_token = kwargs.get('access_token')
    elif kwargs.get('api_key'):
        api_key = kwargs.get('api_key')
    else:
        api_key = get_credentials()

    if not (api_key or access_token):
        print('No credentials found. Requests to SolveBio may fail.')
    else:
        from solvebio.client import client
        # Update the client host and token
        client.set_host()
        client.set_token()

__all__ = [
    'Annotator',
    'Application',
    'Expression',
    'BatchQuery',
    'Beacon',
    'BeaconSet',
    'Dataset',
    'DatasetCommit',
    'DatasetField',
    'DatasetExport',
    'DatasetImport',
    'DatasetMigration',
    'DatasetTemplate',
    'Filter',
    'GenomicFilter',
    'Group',
    'Manifest',
    'Object',
    'ObjectCopyTask',
    'Query',
    'SavedQuery',
    'SolveClient',
    'SolveError',
    'VaultSyncTask',
    'Task',
    'Vault',
    'User',
    'VERSION'
]


#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import
import os
import sys
import copy
import argparse

import solvebio

from . import auth
from . import data
from .tutorial import print_tutorial
from .ipython import launch_ipython_shell
from ..utils.validators import validate_api_host_url
from ..utils.files import get_home_dir
from ..client import client

class TildeFixStoreAction(argparse._StoreAction):
    """A special "store" action for argparse that replaces
    any detected home directory with a tilde.
    (reverses bash's built-in ~ expansion).
    """
    def __call__(self, parser, namespace, values, option_string=None):
        home = get_home_dir()
        if values and values.startswith(home):
            values = values.replace(home, '~', 1)
        setattr(namespace, self.dest, values)

class SolveArgumentParser(argparse.ArgumentParser):
    """
    Main parser for the SolveBio command line client.
    """
    subcommands = {
        'login': {
            'func': auth.login,
            'help': 'Login and save credentials'
        },
        'logout': {
            'func': auth.logout,
            'help': 'Logout and delete saved credentials'
        },
        'whoami': {
            'func': auth.whoami,
            'help': 'Show your SolveBio email address'
        },
        'tutorial': {
            'func': print_tutorial,
            'help': 'Show the SolveBio Python Tutorial',
        },
        'shell': {
            'func': launch_ipython_shell,
            'help': 'Open the SolveBio Python shell'
        },
        'import': {
            'func': data.import_file,
            'help': 'Import a local file into a SolveBio dataset',
            'arguments': [
                {
                    'flags': '--create-dataset',
                    'action': 'store_true',
                    'help': 'Create the dataset if it doesn\'t exist',
                },
                {
                    'flags': '--create-vault',
                    'action': 'store_true',
                    'help': 'Create the vault if it doesn\'t exist',
                },
                {
                    'flags': '--template-id',
                    'help': 'The template ID used when '
                            'creating a new dataset (via --create-dataset)',
                },
                {
                    'flags': '--template-file',
                    'help': 'A local template file to be used when '
                            'creating a new dataset (via --create-dataset)',
                },
                {
                    'flags': '--genome-build',
                    'help': 'If the dataset template is genomic, provide a '
                            'genome build for your data (i.e. GRCh37)'
                },
                {
                    'flags': '--capacity',
                    'default': 'small',
                    'help': 'Specifies the capacity of the created dataset: '
                            'small (default, <100M records), '
                            'medium (<500M), large (>=500M)'
                },
                {
                    'flags': '--follow',
                    'action': 'store_true',
                    'default': False,
                    'help': 'Follow the import\'s progress until it completes'
                },
                {
                    'flags': '--commit-mode',
                    'default': 'append',
                    'help': 'Commit mode to use when importing data. '
                            'Options are "append" (default) or "overwrite".'
                },
                {
                    'flags': '--vault',
                    'help': 'The vault containing the dataset. '
                    'Defaults to your personal vault. '
                    'Overrides the vault component of --full-path',
                    'action': TildeFixStoreAction
                },
                {
                    'flags': '--path',
                    'help': 'The path to the dataset (relative to a vault). '
                    'Overrides the path component of --full-path'
                },
                {
                    'name': 'full_path',
                    'help': 'The full path to the dataset in the format: '
                    '"domain:vault:/path/dataset". ',
                    'action': TildeFixStoreAction
                },
                {
                    'name': 'file',
                    'help': 'One or more local files to import',
                    'nargs': '+'
                },
            ]
        },
        'create-dataset': {
            'func': data.create_dataset,
            'help': 'Create a SolveBio dataset',
            'arguments': [
                {
                    'flags': '--create-vault',
                    'action': 'store_true',
                    'help': 'Create the vault if it doesn\'t exist',
                },
                {
                    'flags': '--template-id',
                    'help': 'The template ID used when '
                            'creating a new dataset (via --create-dataset)',
                },
                {
                    'flags': '--template-file',
                    'help': 'A local template file to be used when '
                            'creating a new dataset (via --create-dataset)',
                },
                {
                    'flags': '--capacity',
                    'default': 'small',
                    'help': 'Specifies the capacity of the dataset: '
                            'small (default, <100M records), '
                            'medium (<500M), large (>=500M)'
                },
                {
                    'flags': '--vault',
                    'help':
                    'The vault containing the dataset. '
                    'Overrides the vault component of the full path argument',
                    'action': TildeFixStoreAction
                },
                {
                    'flags': '--path',
                    'help': 'The path to the dataset (relative to the vault). '
                    'Overrides the path component of the full path argument'
                },
                {
                    'name': 'full_path',
                    'help': 'The full path to the dataset in the format: '
                    '"domain:vault:/path/dataset". '
                    'Defaults to your personal vault if no vault is provided. '
                    'Defaults to the vault root if no path is provided. '
                    'Override the vault with --vault '
                    'and/or the path with --path',
                    'action': TildeFixStoreAction
                },
            ]
        },
        'upload': {
            'func': data.upload,
            'help': 'Upload a file or directory to a SolveBio Vault',
            'arguments': [
                {
                    'flags': '--full-path',
                    'required': True,
                    'help': 'The full path where the files and folders should '
                    'be created, defaults to the root of your personal vault',
                    'action': TildeFixStoreAction
                },
                {
                    'flags': '--vault',
                    'help': 'The vault where the files will be uploaded. '
                    'Defaults to your personal vault. '
                    'Overrides the vault component of --full-path',
                    'action': TildeFixStoreAction
                },
                {
                    'flags': '--path',
                    'help': 'The path (relative to a vault) '
                    'where the files will be uploaded. '
                    'Overrides the path component of --full-path'
                },
                {
                    'name': 'local_path',
                    'help': 'The path to the local file or directory '
                            'to upload',
                    'nargs': '+'
                }
            ]
        },
    }

    def __init__(self, *args, **kwargs):
        super(SolveArgumentParser, self).__init__(*args, **kwargs)
        self._optionals.title = 'SolveBio Options'
        self.add_argument(
            '--version',
            action='version',
            version=solvebio.version.VERSION)
        self.add_argument(
            '--api-host',
            help='Override the default SolveBio API host',
            type=self.api_host_url)
        self.add_argument(
            '--api-key',
            help='Manually provide a SolveBio API key')
        self.add_argument(
            '--api-token',
            help='Manually provide a SolveBio OAuth API token')

    def _add_subcommands(self):
        """
            The _add_subcommands method must be separate from the __init__
            method, as infinite recursion will occur otherwise, due to the fact
            that the __init__ method itself will be called when instantiating
            a subparser, as we do below
        """
        subcmd_params = {
            'title': 'SolveBio Commands',
            'dest': 'subcommands'
        }
        subcmd = self.add_subparsers(
            **subcmd_params)  # pylint: disable=star-args

        subcommands = copy.deepcopy(self.subcommands)
        for name, params in subcommands.items():
            p = subcmd.add_parser(name, help=params['help'])
            p.set_defaults(func=params['func'])
            for arg in params.get('arguments', []):
                name_or_flags = arg.pop('name', None) or arg.pop('flags', None)
                p.add_argument(name_or_flags, **arg)

    def parse_solvebio_args(self, args=None, namespace=None):
        """
            Try to parse the args first, and then add the subparsers. We want
            to do this so that we can check to see if there are any unknown
            args. We can assume that if, by this point, there are no unknown
            args, we can append shell to the unknown args as a default.
            However, to do this, we have to suppress stdout/stderr during the
            initial parsing, in case the user calls the help method (in which
            case we want to add the additional arguments and *then* call the
            help method. This is a hack to get around the fact that argparse
            doesn't allow default subcommands.
        """
        try:
            sys.stdout = sys.stderr = open(os.devnull, 'w')
            _, unknown_args = self.parse_known_args(args, namespace)
            if not unknown_args:
                args.insert(0, 'shell')
        except SystemExit:
            pass
        finally:
            sys.stdout.flush()
            sys.stderr.flush()
            sys.stdout, sys.stderr = sys.__stdout__, sys.__stderr__
        self._add_subcommands()
        return super(SolveArgumentParser, self).parse_args(args, namespace)

    def api_host_url(self, value):
        validate_api_host_url(value)
        return value

def main(argv=sys.argv[1:]):
    """ Main entry point for SolveBio CLI """
    parser = SolveArgumentParser()
    args = parser.parse_solvebio_args(argv)

    if args.api_host:
        solvebio.api_host = args.api_host

    if args.api_key:
        solvebio.api_key = args.api_key

    if not solvebio.api_key:
        # If nothing is set (via command line or environment)
        # look in local credentials
        try:
            from .credentials import get_credentials
            solvebio.api_key = get_credentials()
        except:
            pass

    # Update the client host and token
    client.set_host()
    client.set_token()

    return args.func(args)

if __name__ == '__main__':
    main()


import os
import sys
import solvebio

def download_vault_folder(remote_path, local_path, dry_run=False, force=False):
    """Recursively downloads a folder in a vault to a local directory.
    Only downloads files, not datasets."""

    local_path = os.path.normpath(os.path.expanduser(local_path))
    if not os.access(local_path, os.W_OK):
        raise Exception(
            'Write access to local path ({}) is required'
            .format(local_path))

    full_path, path_dict = solvebio.Object.validate_full_path(remote_path)
    vault = solvebio.Vault.get_by_full_path(path_dict['vault'])
    print('Downloading all files from {} to {}'.format(full_path, local_path))

    if path_dict['path'] == '/':
        parent_object_id = None
    else:
        parent_object = solvebio.Object.get_by_full_path(
            remote_path, assert_type='folder')
        parent_object_id = parent_object.id

    # Scan the folder for all sub-folders and create them locally
    print('Creating local directory structure at: {}'.format(local_path))
    if not os.path.exists(local_path):
        if not dry_run:
            os.makedirs(local_path)

    folders = vault.folders(parent_object_id=parent_object_id)
    for f in folders:
        path = os.path.normpath(local_path + f.path)
        if not os.path.exists(path):
            print('Creating folder: {}'.format(path))
            if not dry_run:
                os.makedirs(path)

    files = vault.files(parent_object_id=parent_object_id)
    for f in files:
        path = os.path.normpath(local_path + f.path)
        if os.path.exists(path):
            if force:
                # Delete the local copy
                print('Deleting local file (force download): {}'.format(path))
                if not dry_run:
                    os.remove(path)
            else:
                print('Skipping file (already exists): {}'.format(path))
                continue

        print('Downloading file: {}'.format(path))
        if not dry_run:
            f.download(path)

def main():
    if len(sys.argv) < 3:
        print('Usage: {} <vault path> <local path>'.format(sys.argv[0]))
        sys.exit(1)

    solvebio.login()
    download_vault_folder(sys.argv[1], sys.argv[2], dry_run=True)

if __name__ == '__main__':
    main()


# -*- coding: utf-8 -*-
from __future__ import absolute_import
import six

import sys

from ..client import client
from .util import json

def convert_to_solve_object(resp, **kwargs):
    from . import types

    _client = kwargs.pop('client', None)

    if isinstance(resp, list):
        return [convert_to_solve_object(i, client=_client) for i in resp]
    elif isinstance(resp, dict) and not isinstance(resp, SolveObject):
        resp = resp.copy()
        klass_name = resp.get('class_name')
        if isinstance(klass_name, six.string_types):
            klass = types.get(klass_name, SolveObject)
        else:
            klass = SolveObject
        return klass.construct_from(resp, client=_client)
    else:
        return resp

class SolveObject(dict):
    """Base class for all SolveBio API resource objects"""
    ID_ATTR = 'id'

    # Allows pre-setting a SolveClient
    _client = None

    def __init__(self, id=None, **params):
        super(SolveObject, self).__init__()

        self._client = params.pop('client', self._client or client)

        # store manually updated values for partial updates
        self._unsaved_values = set()

        if id:
            self[self.ID_ATTR] = id

    def __setattr__(self, k, v):
        if k[0] == '_' or k in self.__dict__:
            return super(SolveObject, self).__setattr__(k, v)
        else:
            self[k] = v

    def __getattr__(self, k):
        if k[0] == '_':
            raise AttributeError(k)

        try:
            return self[k]
        except KeyError as err:
            raise AttributeError(*err.args)

    def __setitem__(self, k, v):
        super(SolveObject, self).__setitem__(k, v)
        self._unsaved_values.add(k)

    @classmethod
    def construct_from(cls, values, **kwargs):
        """Used to create a new object from an HTTP response"""
        instance = cls(values.get(cls.ID_ATTR), **kwargs)
        instance.refresh_from(values)
        return instance

    def refresh_from(self, values):
        self.clear()
        self._unsaved_values = set()

        for k, v in six.iteritems(values):
            super(SolveObject, self).__setitem__(
                k, convert_to_solve_object(v, client=self._client))

    def request(self, method, url, **kwargs):
        response = self._client.request(method, url, **kwargs)
        return convert_to_solve_object(response, client=self._client)

    def __repr__(self):
        if isinstance(self.get('class_name'), six.string_types):
            ident_parts = [self.get('class_name')]
        else:
            ident_parts = [type(self).__name__]

        if isinstance(self.get(self.ID_ATTR), int):
            ident_parts.append(
                '%s=%d' % (self.ID_ATTR, self.get(self.ID_ATTR),))

        _repr = '<%s at %s> JSON: %s' % (
            ' '.join(ident_parts), hex(id(self)), str(self))

        if sys.version_info[0] < 3:
            return _repr.encode('utf-8')

        return _repr

    def __str__(self):
        return json.dumps(self, sort_keys=True, indent=2)

    @property
    def solvebio_id(self):
        return self.id


from __future__ import absolute_import

import json
import flask
import requests
import os

from six.moves.urllib.parse import urljoin

from dash_auth.oauth import OAuthBase

import solvebio

class SolveBioAuth(OAuthBase):
    """Handles OAuth2 flows with the SolveBio API."""
    AUTH_COOKIE_NAME = 'dash_solvebio_auth'
    TOKEN_COOKIE_NAME = 'solvebio_oauth_token'

    DEFAULT_TOKEN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
    DEFAULT_SOLVEBIO_URL = 'https://my.solvebio.com'
    DEFAULT_GRANT_TYPE = 'authorization_code'

    OAUTH2_TOKEN_PATH = '/v1/oauth2/token'
    OAUTH2_REVOKE_TOKEN_PATH = '/v1/oauth2/revoke_token'

    def __init__(self, app, app_url, client_id, **kwargs):
        secret_key = kwargs.get('secret_key') or app.server.secret_key
        super(SolveBioAuth, self).__init__(
            app,
            app_url,
            client_id,
            secret_key=secret_key,
            salt=kwargs.get('salt'))

        # Add logout URL
        app.server.add_url_rule(
            '{}_dash-logout'.format(app.config['routes_pathname_prefix']),
            view_func=self.logout,
            methods=['get']
        )

        self._oauth_redirect_uri = urljoin(
            self._app_url,
            '{}_oauth-redirect'.format(
                self._app.config['requests_pathname_prefix']))

        # Handle optional parameters
        self._solvebio_url = \
            kwargs.get('solvebio_url') or self.DEFAULT_SOLVEBIO_URL
        self._api_host = kwargs.get('api_host') or solvebio.api_host
        self._oauth_client_secret = kwargs.get('client_secret')
        self._oauth_grant_type = \
            kwargs.get('grant_type') or self.DEFAULT_GRANT_TYPE
        self.token_cookie_max_age = kwargs.get(
            'max_age', self.DEFAULT_TOKEN_COOKIE_MAX_AGE)

        if self._oauth_grant_type == 'implicit':
            self._oauth_response_type = 'token'
        elif self._oauth_grant_type == 'authorization_code':
            self._oauth_response_type = 'code'
        else:
            raise Exception(
                'SolveBioAuth Error: Unsupported grant type "{}"'
                .format(self._oauth_grant_type))

        _current_path = os.path.dirname(os.path.abspath(__file__))
        with open(os.path.join(_current_path, 'oauth-redirect.js'), 'r') as f:
            self.oauth_redirect_bundle = f.read()

        with open(os.path.join(_current_path, 'login.js'), 'r') as f:
            self.login_bundle = f.read()

    def auth_wrapper(self, f):
        def wrap(*args, **kwargs):
            if not self.is_authorized():
                # NOTE: We always show the login view instead of
                #       returning 403.
                # TODO - Add a list of valid app paths so we don't
                #        show the login page on asset URLs and Dash endpoints.
                return self.login_request()

            try:
                response = f(*args, **kwargs)
            except Exception as err:
                # Clear the cookie if auth fail
                if getattr(err, 'status_code', None) in [401, 403]:
                    response = flask.Response(status=403)
                    self.clear_cookies(response)
                    return response
                else:
                    raise

            # TODO - should set secure in this cookie, not exposed in flask
            # TODO - should set path or domain
            return self.add_access_token_to_response(response)

        # Support for dash-auth >= 1.0.0
        if hasattr(self, 'add_access_token_to_response'):
            return wrap
        else:
            return super(SolveBioAuth, self).auth_wrapper(f)

    def html(self, script):
        return ('''
            <!doctype html>

                <meta charset="UTF-8">
                <title>Log In</title>

        '''.format(json.dumps({
            'oauth_client_id': self._oauth_client_id,
            'oauth_response_type': self._oauth_response_type,
            'solvebio_url': self._solvebio_url,
            'requests_pathname_prefix':
            self._app.config['requests_pathname_prefix']
        }), script))

    def check_view_access(self, oauth_token):
        client = solvebio.SolveClient(token=oauth_token, token_type='Bearer')
        try:
            client.User.retrieve()
            return True
        except:
            return False

    def login_api(self):
        oauth_data = flask.request.get_json()

        if self._oauth_grant_type == 'authorization_code':
            # Authorization Code flow
            auth_code = oauth_data['code']

            # Request the access token with the auth code
            oauth_data = requests.post(
                urljoin(self._api_host, self.OAUTH2_TOKEN_PATH),
                data={
                    'client_id': self._oauth_client_id,
                    'grant_type': self._oauth_grant_type,
                    'redirect_uri': self._oauth_redirect_uri,
                    'code': auth_code
                }).json()
        else:
            raise Exception(
                'SolveBio Auth Error: unsupported grant type "{}"'
                .format(self._oauth_grant_type))

        # Implicit flow returns the access_token in the initial redirect.
        # TODO: Support refresh tokens for authorization code flow.
        oauth_token = oauth_data.get('access_token')
        if not oauth_token:
            # Return the error response to the frontend view
            return flask.Response(
                json.dumps(oauth_data),
                mimetype='application/json',
                status=500
            )

        client = solvebio.SolveClient(token=oauth_token, token_type='Bearer')
        user = client.User.retrieve()
        response = flask.Response(
            json.dumps(user),
            mimetype='application/json',
            status=200
        )

        self.set_cookie(
            response=response,
            name=self.TOKEN_COOKIE_NAME,
            value=oauth_token,
            max_age=self.token_cookie_max_age
        )

        return response

    def logout(self):
        """Revoke the token and remove the cookie."""
        if self._oauth_client_secret:
            try:
                oauth_token = flask.request.cookies[self.TOKEN_COOKIE_NAME]
                # Revoke the token
                requests.post(
                    urljoin(self._api_host, self.OAUTH2_REVOKE_TOKEN_PATH),
                    data={
                        'client_id': self._oauth_client_id,
                        'client_secret': self._oauth_client_secret,
                        'token': oauth_token
                    })
            except:
                pass

        response = flask.redirect('/')
        self.clear_cookies(response)
        return response


from __future__ import absolute_import
import sys
import os

def _print(msg):
    """
    Handle Python 2 interactive shells without requiring
    new print() format.
    """
    sys.stdout.write(msg + '\n')

def launch_ipython_shell(args):  # pylint: disable=unused-argument
    """Open the SolveBio shell (IPython wrapper)"""
    try:
        import IPython  # noqa
    except ImportError:
        _print("The SolveBio Python shell requires IPython.\n"
               "To install, type: 'pip install ipython'")
        return False

    if hasattr(IPython, "version_info"):
        if IPython.version_info > (5, 0, 0, ''):
            return launch_ipython_5_shell(args)

    _print("WARNING: Please upgrade IPython (you are running version: {})"
           .format(IPython.__version__))
    return launch_ipython_legacy_shell(args)

def launch_ipython_5_shell(args):
    """Open the SolveBio shell (IPython wrapper) with IPython 5+"""
    import IPython  # noqa
    from traitlets.config import Config

    c = Config()
    path = os.path.dirname(os.path.abspath(__file__))

    try:
        # see if we're already inside IPython
        get_ipython  # pylint: disable=undefined-variable
        _print("WARNING: Running IPython within IPython.")
    except NameError:
        c.InteractiveShell.banner1 = 'SolveBio Python shell started.\n'

    c.InteractiveShellApp.exec_files = ['{}/ipython_init.py'.format(path)]
    IPython.start_ipython(argv=[], config=c)

def launch_ipython_legacy_shell(args):  # pylint: disable=unused-argument
    """Open the SolveBio shell (IPython wrapper) for older IPython versions"""
    try:
        from IPython.config.loader import Config
    except ImportError:
        _print("The SolveBio Python shell requires IPython.\n"
               "To install, type: 'pip install ipython'")
        return False

    try:
        # see if we're already inside IPython
        get_ipython  # pylint: disable=undefined-variable
    except NameError:
        cfg = Config()
        prompt_config = cfg.PromptManager
        prompt_config.in_template = '[SolveBio] In <\\#>: '
        prompt_config.in2_template = '   .\\D.: '
        prompt_config.out_template = 'Out<\\#>: '
        banner1 = '\nSolveBio Python shell started.'

        exit_msg = 'Quitting SolveBio shell.'
    else:
        _print("Running nested copies of IPython.")
        cfg = Config()
        banner1 = exit_msg = ''

    # First import the embeddable shell class
    try:
        from IPython.terminal.embed import InteractiveShellEmbed
    except ImportError:
        # pylint: disable=import-error,no-name-in-module
        from IPython.frontend.terminal.embed import InteractiveShellEmbed

    path = os.path.dirname(os.path.abspath(__file__))
    init_file = '{}/ipython_init.py'.format(path)
    exec(compile(open(init_file).read(), init_file, 'exec'),
         globals(), locals())

    InteractiveShellEmbed(config=cfg, banner1=banner1, exit_msg=exit_msg)()


# -*- coding: utf-8 -*-
from __future__ import absolute_import

import json
import time
import inspect

import solvebio

from .version import VERSION
from .errors import SolveError
from .utils.validators import validate_api_host_url

import platform
import requests
import textwrap
import logging

from requests import Session
from requests import codes
from requests.auth import AuthBase
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry

from six.moves.urllib.parse import urljoin

# Try using pyopenssl if available.
# Requires: pip install pyopenssl ndg-httpsclient pyasn1
# See http://urllib3.readthedocs.org/en/latest/contrib.html#module-urllib3.contrib.pyopenssl  # noqa
try:
    import urllib3.contrib.pyopenssl
    urllib3.contrib.pyopenssl.inject_into_urllib3()
except ImportError:
    pass

logger = logging.getLogger('solvebio')

def _handle_api_error(response):
    if response.status_code not in [400, 401, 403, 404]:
        logger.info('API Error: %d' % response.status_code)
    raise SolveError(response=response)

def _handle_request_error(e):
    if isinstance(e, requests.exceptions.RequestException):
        msg = SolveError.default_message
        err = "%s: %s" % (type(e).__name__, str(e))
    else:
        msg = ("Unexpected error communicating with SolveBio.\n"
               "It looks like there's probably a configuration "
               "issue locally.\nIf this problem persists, let us "
               "know at support@solvebio.com.")
        err = "A %s was raised" % (type(e).__name__,)
        if str(e):
            err += " with error message %s" % (str(e),)
        else:
            err += " with no error message"
    msg = textwrap.fill(msg) + "\n\n(Network error: %s)" % (err,)
    raise SolveError(message=msg)

class SolveTokenAuth(AuthBase):
    """Custom auth handler for SolveBio API token authentication"""

    def __init__(self, token=None, token_type='Token'):
        self.token = token
        self.token_type = token_type

        if not self.token:
            # Prefer the OAuth2 access token over the API key.
            if solvebio.access_token:
                self.token_type = 'Bearer'
                self.token = solvebio.access_token
            elif solvebio.api_key:
                self.token_type = 'Token'
                self.token = solvebio.api_key

    def __call__(self, r):
        if self.token:
            r.headers['Authorization'] = '{0} {1}'.format(self.token_type,
                                                          self.token)
        return r

    def __repr__(self):
        if self.token:
            return self.token_type
        else:
            return 'Anonymous'

class SolveClient(object):
    """A requests-based HTTP client for SolveBio API resources"""

    def __init__(self, host=None, token=None, token_type='Token',
                 include_resources=True):
        self.set_host(host)
        self.set_token(token, token_type)
        self._headers = {
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            'Accept-Encoding': 'gzip,deflate',
            'User-Agent': 'SolveBio Python Client %s [Python %s/%s]' % (
                VERSION,
                platform.python_implementation(),
                platform.python_version()
            )
        }

        # Use a session with a retry policy to handle
        # intermittent connection errors.
        retries = Retry(
            total=5,
            backoff_factor=0.1,
            status_forcelist=[
                codes.bad_gateway,
                codes.service_unavailable,
                codes.gateway_timeout
            ])
        adapter = HTTPAdapter(max_retries=retries)
        self._session = Session()
        self._session.mount(self._host, adapter)

        # Import all resources into the client
        if include_resources:
            skip = ('SolveError', 'SolveClient',)
            for name, class_ in inspect.getmembers(solvebio, inspect.isclass):
                if name in skip:
                    continue
                subclass = type(name, (class_,), {'_client': self})
                setattr(self, name, subclass)

    def set_host(self, host=None):
        self._host = host or solvebio.api_host
        validate_api_host_url(self._host)

    def set_token(self, token=None, token_type='Token'):
        self._auth = SolveTokenAuth(token, token_type)

    def whoami(self):
        try:
            return self.get('/v1/user', {})
        except:
            return None

    def get(self, url, params, **kwargs):
        """Issues an HTTP GET across the wire via the Python requests
        library. See *request()* for information on keyword args."""
        kwargs['params'] = params
        return self.request('GET', url, **kwargs)

    def post(self, url, data, **kwargs):
        """Issues an HTTP POST across the wire via the Python requests
        library. See *request* for information on keyword args."""
        kwargs['data'] = data
        return self.request('POST', url, **kwargs)

    def delete(self, url, data, **kwargs):
        """Issues an HTTP DELETE across the wire via the Python requests
        library. See *request* for information on keyword args."""
        kwargs['data'] = data
        return self.request('DELETE', url, **kwargs)

    def request(self, method, url, **kwargs):
        """
        Issues an HTTP Request across the wire via the Python requests
        library.

        Parameters
        ----------

        method : str
           an HTTP method: GET, PUT, POST, DELETE, ...

        url : str
           the place to connect to. If the url doesn't start
           with a protocol (https:// or http://), we'll slap
                solvebio.api_host in the front.

        allow_redirects: bool, optional
           set *False* we won't follow any redirects

        headers: dict, optional

          Custom headers can be provided here; generally though this
          will be set correctly by default dependent on the
          method type. If the content type is JSON, we'll
          JSON-encode params.

        param : dict, optional
           passed as *params* in the requests.request

        timeout : int, optional
          timeout value in seconds for the request

        raw: bool, optional
          unless *True* the response encoded to json

        files: file
          File content in the form of a file handle which is to be
          uploaded. Files are passed in POST requests

        Returns
        -------
        response object. If *raw* is not *True* and
        repsonse if valid the object will be JSON encoded. Otherwise
        it will be the request.reposne object.
        """

        opts = {
            'allow_redirects': True,
            'auth': self._auth,
            'data': {},
            'files': None,
            'headers': dict(self._headers),
            'params': {},
            'timeout': 80,
            'verify': True
        }

        raw = kwargs.pop('raw', False)
        debug = kwargs.pop('debug', False)
        opts.update(kwargs)
        method = method.upper()

        if opts['files']:
            # Don't use application/json for file uploads or GET requests
            opts['headers'].pop('Content-Type', None)
        else:
            opts['data'] = json.dumps(opts['data'])

        if not url.startswith(self._host):
            url = urljoin(self._host, url)

        logger.debug('API %s Request: %s' % (method, url))

        if debug:
            self._log_raw_request(method, url, **opts)

        try:
            response = self._session.request(method, url, **opts)
        except Exception as e:
            _handle_request_error(e)

        if 429 == response.status_code:
            delay = int(response.headers['retry-after']) + 1
            logger.warn('Too many requests. Retrying in {0}s.'.format(delay))
            time.sleep(delay)
            return self.request(method, url, **kwargs)

        if not (200 <= response.status_code < 400):
            _handle_api_error(response)

        # 204 is used on deletion. There is no JSON here.
        if raw or response.status_code in [204, 301, 302]:
            return response

        return response.json()

    def _log_raw_request(self, method, url, **kwargs):
        from requests import Request, Session
        req = Request(method=method.upper(), url=url,
                      data=kwargs['data'], params=kwargs['params'])
        prepped = Session().prepare_request(req, )
        logger.debug(prepped.headers)
        logger.debug(prepped.body)

    def __repr__(self):
        return '<SolveClient {0} {1}>'.format(self._host, self._auth)

client = SolveClient(include_resources=False)


"""Solvebio Task API Resource"""
from .apiresource import ListableAPIResource
from .apiresource import UpdateableAPIResource

class Task(ListableAPIResource, UpdateableAPIResource):
    """
    Tasks are operations on datasets or vaults.
    """
    RESOURCE_VERSION = 2

    LIST_FIELDS = (
        ('id', 'ID'),
        ('task_display_name', 'Task Type'),
        ('task_id', 'Task ID'),
        ('description', 'Description'),
        ('status', 'Status'),
        ('created_at', 'Created'),
    )

    @property
    def child_object(self):
        """ Get Task child object class """
        from . import types
        child_klass = types.get(self.task_type.split('.')[1])
        return child_klass.retrieve(self.task_id, client=self._client)

    def follow(self):
        """ Follow the child object but do not loop """
        self.child_object.follow(loop=False)

    def cancel(self):
        """ Cancel a task """
        _status = self.status
        self.status = "canceled"
        try:
            self.save()
        except:
            # Reset status to what it was before
            # status update failure
            self.status = _status
            raise


# -*- coding: utf-8 -*-
from __future__ import absolute_import
from __future__ import print_function

from vcf.parser import Reader
from vcf.parser import RESERVED_INFO

class VCFReader(Reader):
    """
    Subclasses the standard PyVCF VCFReader to fix issues with
    parsing quoted INFO fields, as well as issues with NaN fields in VCFs.
    """

    def _parse_info(self, info_str):
        if info_str == '.':
            return {}

        entries = info_str.split(';')
        retdict = {}

        for entry in entries:
            entry = entry.split('=', 1)
            _id = entry[0]
            try:
                entry_type = self.infos[_id].type
            except KeyError:
                try:
                    entry_type = RESERVED_INFO[_id]
                except KeyError:
                    if entry[1:]:
                        entry_type = 'String'
                    else:
                        entry_type = 'Flag'

            if entry_type == 'Integer':
                vals = entry[1].split(',')
                try:
                    val = self._map(int, vals)
                # Allow specified integers to be flexibly parsed as floats.
                # Handles cases with incorrectly specified header types.
                except ValueError:
                    val = self._map(float, vals)
                    # Convert NaN to None. NaN breaks JSON
                    # convertion code downstream.
                    val = map(lambda x: x if x == x else None, val)
            elif entry_type == 'Float':
                vals = entry[1].split(',')
                val = self._map(float, vals)
                # Convert NaN to None. NaN breaks JSON
                # convertion code downstream.
                val = map(lambda x: x if x == x else None, val)
            elif entry_type == 'Flag':
                val = True
            elif entry_type in ('String', 'Character'):
                try:
                    # Support quoted strings (test if string is quoted).
                    if entry[1][0] + entry[1][-1] in ['""', "''"]:
                        val = [entry[1]]
                    else:
                        # Commas are reserved characters indicating
                        # multiple values.
                        vals = entry[1].split(',')
                        val = self._map(str, vals)
                except IndexError:
                    entry_type = 'Flag'
                    val = True
            try:
                if self.infos[_id].num == 1 and entry_type not in ('Flag',):
                    val = val[0]
            except KeyError:
                pass

            retdict[_id] = val

        return retdict

class ExpandingVCFParser(object):
    """
    Expands multiple alleles in a VCF record to one row per allele.

    Please note that this parser DOES NOT include the reference allele
    as a record. Also, no validation/conversion is done for chromosome,
    or allele fields.

    Requires PyVCF 0.6.8+ (pip install PyVCF).
    """
    DEFAULT_BUILD = 'GRCh37'

    def __init__(self, fsock=None, **kwargs):
        self._reader = None
        self._line_number = -1
        self._next = []
        # Default INFO field parser is pass-through
        self._parse_info = lambda x: x
        self.genome_build = kwargs.pop('genome_build', 'GRCh37')
        self.reader_class = kwargs.pop('reader_class', VCFReader)

        self.reader_kwargs = kwargs
        # Set default reader kwargs
        if fsock:
            self.reader_kwargs['fsock'] = fsock
        if 'strict_whitespace' not in self.reader_kwargs:
            self.reader_kwargs['strict_whitespace'] = True

    @property
    def reader(self):
        if not self._reader:
            # Add 'strict_whitespace' kwarg to force PyVCF to split
            # on '\t' only. This enables proper handling
            # of INFO fields with spaces.
            self._reader = self.reader_class(
                **self.reader_kwargs
            )

            # Setup extra INFO field parsing
            if self._reader.metadata.get('SnpEffCmd'):
                # Only proceed if ANN description exists (ANN fields)
                # The field keys may vary between SnpEff versions:
                # http://snpeff.sourceforge.net/VCFannotationformat_v1.0.pdf
                # Here we find them dynamically in the VCF header:
                ann_info = self._reader.infos.get('ANN')
                if ann_info:
                    self._parse_info = self._parse_info_snpeff
                    # The infos['ANN'] description looks like:
                    #    Functional annotations: 'A | B | C'
                    # where A, B, and C are ANN keys.
                    self._snpeff_ann_fields = []
                    for field in ann_info.desc.split('\'')[1].split('|'):
                        # Field names should not contain [. /]
                        self._snpeff_ann_fields.append(
                            field.strip()
                            .replace('.', '_')
                            .replace('/', '_')
                            .replace(' ', ''))

        return self._reader

    def _parse_info_snpeff(self, info):
        """
        Specialized INFO field parser for SnpEff ANN fields.
        Requires self._snpeff_ann_fields to be set.
        """
        ann = info.pop('ANN', []) or []
        # Overwrite the existing ANN with something parsed
        # Split on '|', merge with the ANN keys parsed above.
        # Ensure empty values are None rather than empty string.
        items = []
        for a in ann:
            # For multi-allelic records, we may have already
            # processed ANN. If so, quit now.
            if isinstance(a, dict):
                info['ANN'] = ann
                return info

            values = [i or None for i in a.split('|')]
            item = dict(zip(self._snpeff_ann_fields, values))

            # Further split the Annotation field by '&'
            if item.get('Annotation'):
                item['Annotation'] = item['Annotation'].split('&')

            items.append(item)

        info['ANN'] = items
        return info

    @property
    def file(self):
        return self.reader._reader

    def close(self):
        self.file.close()

    def __enter__(self):
        """For use as a context manager"""
        return self

    def __exit__(self, *args):
        self.close()
        return False

    def __iter__(self):
        return self

    def __next__(self):
        return self.next()

    def next(self):
        """
        Expands multiple alleles into one record each
        using an internal buffer (_next).
        """

        def _alt(alt):
            """Parses the VCF row ALT object."""
            # If alt is '.' in VCF, PyVCF returns None, convert back to '.'
            if not alt:
                return '.'
            else:
                return str(alt)

        if not self._next:
            row = next(self.reader)
            alternate_alleles = list(map(_alt, row.ALT))

            for allele in alternate_alleles:
                self._next.append(
                    self.row_to_dict(
                        row,
                        allele=allele,
                        alternate_alleles=alternate_alleles))

            # Source line number, only increment if reading a new row.
            self._line_number += 1

        return self._next.pop()

    def row_to_dict(self, row, allele, alternate_alleles):
        """Return a parsed dictionary for JSON."""

        def _variant_sbid(**kwargs):
            """Generates a SolveBio variant ID (SBID)."""
            return '{build}-{chromosome}-{start}-{stop}-{allele}'\
                .format(**kwargs).upper()

        if allele == '.':
            # Try to use the ref, if '.' is supplied for alt.
            allele = row.REF or allele

        genomic_coordinates = {
            'build': self.genome_build,
            'chromosome': row.CHROM,
            'start': row.POS,
            'stop': row.POS + len(row.REF) - 1
        }

        # SolveBio standard variant format
        variant_sbid = _variant_sbid(allele=allele,
                                     **genomic_coordinates)

        return {
            'genomic_coordinates': genomic_coordinates,
            'variant': variant_sbid,
            'allele': allele,
            'row_id': row.ID,
            'reference_allele': row.REF,
            'alternate_alleles': alternate_alleles,
            'info': self._parse_info(row.INFO),
            'qual': row.QUAL,
            'filter': row.FILTER
        }

if __name__ == '__main__':
    import sys
    import json

    if len(sys.argv) < 2:
        print("Usage: python {0} sample.vcf".format(sys.argv[0]))
        sys.exit(1)

    parser = ExpandingVCFParser(filename=sys.argv[1], genome_build='GRCh37')
    for row in parser:
        print(json.dumps(row))

    parser.close()
    sys.exit(0)


# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from __future__ import absolute_import
import six

import solvebio

from netrc import netrc as _netrc, NetrcParseError
import os

from six.moves.urllib.parse import urlparse

class netrc(_netrc):
    """
    Adds a save() method to netrc
    """
    @staticmethod
    def path():
        if os.name == 'nt':
            # Windows
            path = '~\\_solvebio\\credentials'
        else:
            # *nix
            path = '~/.solvebio/credentials'

        try:
            path = os.path.expanduser(path)
        except KeyError:
            # os.path.expanduser can fail when $HOME is undefined and
            # getpwuid fails. See http://bugs.python.org/issue20164
            raise IOError(
                "Could not find any home directory for '{0}'"
                .format(path))

        if not os.path.isdir(os.path.dirname(path)):
            os.makedirs(os.path.dirname(path))

        # create an empty credentials file if it doesn't exist
        if not os.path.exists(path):
            try:
                open(path, 'a').close()
            except IOError:
                raise Exception("Could not create a SolveBio credentials file"
                                " at '%s', permission denied." % path)
        return path

    def save(self, path):
        """Dump the class data in the format of a .netrc file."""
        rep = ""
        for host in self.hosts.keys():
            attrs = self.hosts[host]
            rep = rep + "machine " + host + "\n\tlogin " \
                + six.text_type(attrs[0]) + "\n"
            if attrs[1]:
                rep = rep + "account " + six.text_type(attrs[1])
            rep = rep + "\tpassword " + six.text_type(attrs[2]) + "\n"
        for macro in self.macros.keys():
            rep = rep + "macdef " + macro + "\n"
            for line in self.macros[macro]:
                rep = rep + line
            rep = rep + "\n"

        f = open(path, 'w')
        f.write(rep)
        f.close()

class CredentialsError(BaseException):
    """
    Raised if the credentials are not found.
    """
    pass

def get_credentials():
    """
    Returns the user's stored API key if a valid credentials file is found.
    Raises CredentialsError if no valid credentials file is found.
    """
    try:
        netrc_path = netrc.path()
        auths = netrc(netrc_path).authenticators(
            urlparse(solvebio.api_host).netloc)
    except (IOError, TypeError, NetrcParseError) as e:
        raise CredentialsError(
            'Could not open credentials file: ' + str(e))

    if auths:
        # auths = (login, account, password)
        return auths[2]
    else:
        return None

def delete_credentials():
    try:
        netrc_path = netrc.path()
        rc = netrc(netrc_path)
    except (IOError, TypeError, NetrcParseError) as e:
        raise CredentialsError('Could not open netrc file: ' + str(e))

    try:
        del rc.hosts[urlparse(solvebio.api_host).netloc]
    except KeyError:
        pass
    else:
        rc.save(netrc_path)

def save_credentials(email, api_key):
    try:
        netrc_path = netrc.path()
        rc = netrc(netrc_path)
    except (IOError, TypeError, NetrcParseError) as e:
        raise CredentialsError('Could not open netrc file: ' + str(e))

    # Overwrites any existing credentials
    rc.hosts[urlparse(solvebio.api_host).netloc] = (email, None, api_key)
    rc.save(netrc_path)