Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BUG: oauth2client deprecated, use google-auth instead. #39

Merged
merged 6 commits into from Jun 16, 2017

Conversation

tswast
Copy link
Collaborator

@tswast tswast commented May 18, 2017

Remove the use of oauth2client and use google-auth library, instead.

Rather than check for multiple versions of the libraries, use the
setup.py to specify compatible versions. I believe this is safe since
Pandas checks for the pandas_gbq package.

Since google-auth does not use the argparse module to override user
authentication flow settings, add a parameter to choose between the web
and console flow.

Closes #37.

@tswast
Copy link
Collaborator Author

tswast commented May 18, 2017

Travis build w/ integration tests: https://travis-ci.org/tswast/pandas-gbq/builds/233446493

/cc @jonparrott

@codecov-io
Copy link

codecov-io commented May 18, 2017

Codecov Report

Merging #39 into master will decrease coverage by 46.17%.
The diff coverage is 14.18%.

Impacted file tree graph

@@             Coverage Diff             @@
##           master      #39       +/-   ##
===========================================
- Coverage   74.42%   28.24%   -46.18%     
===========================================
  Files           4        4               
  Lines        1552     1540       -12     
===========================================
- Hits         1155      435      -720     
- Misses        397     1105      +708
Impacted Files Coverage Δ
pandas_gbq/gbq.py 19.35% <16.16%> (-59.21%) ⬇️
pandas_gbq/tests/test_gbq.py 27.94% <9.52%> (-54.4%) ⬇️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update c210de1...d554d67. Read the comment docs.

@parthea parthea added this to the 0.1.7 milestone May 18, 2017
@parthea parthea added the type: bug Error or flaw in code with unintended results or allowing sub-optimal usage patterns. label May 18, 2017
python-gflags==2.0
oauth2client==1.5.0
httplib2>=0.9.1
google-api-python-client>=1.6.2, <2.0.0dev
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason you are putting the <2.0.0.dev, ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, because if there is a 2.0 release by semantic versioning that is a breaking change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks is not common practice and just hides errors

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I can drop it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

httplib2
google-api-python-client
oauth2client
httplib2>=0.9.1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the idea of the different pip files would be to different versions, so for sure these should NOT have an upper bound

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove httplib2 from setup.py altogether and use from googleapiclient.http import build_http in the code?

http = httplib2.Http()

becomes

http = build_http()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we explicitly need httplib2 transport for google-auth-httplib2 to work, I'd prefer to keep this httplib2.Http().

Also, it's the way recommended here: https://github.com/GoogleCloudPlatform/google-auth-library-python-httplib2/blob/587e34ea4c9d331422e40ba8a55e9a8b8c946778/google_auth_httplib2.py#L71

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, Thanks for clarifying.

@@ -4,7 +4,8 @@ Changelog
0.1.7 / 2017-??-??
------------------

- Resolve issue where the optional ``--noauth_local_webserver`` command line argument would not be propagated during the authentication process.
- Use the `google-auth <https://google-auth.readthedocs.io/en/latest/>`__ library for authentication because oauth2client is deprecated. :issue:`37`
- ``read_gbq`` now has a ``auth_local_webserver`` boolean argument for controlling whether to use web server or console flow when getting user credentials.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use the PR number as the issue number and (use parens)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -6,57 +6,19 @@
import time
import sys

import google.auth
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, you have to have a _try_imports to guard against these missing. The parent package (pandas) only checks that gbq itself can be imported, w/o a helpful message (hey you are missing a dependency).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I included google-auth in the dependencies in setup.py? What case would the try imports protect against?

gbq is an optional dependency, but once you install it, pip will install all of its dependencies.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because u get unintelligible import exceptions otherwise

credentials do not have access to the project (self.project_id)
on BigQuery.
"""
with open('bigquery_credentials.dat') as credentials_file:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will hard fail with an odd message if this file doesn't exist. is there a reason this is hardcoded? (not to mention pathed), better to have its location specified by argument.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file path was already hard-coded with the previous oauth2client.fileStorage('bigquery_credentials.dat') call.

I've filed #41 to track adding a parameter to override this. Since this would be a new feature, I'd prefer to do this in a separate pull request.

I have added a check for if this file is unable to be opened/parsed plus an integration test for this case.

"""
Saves user account credentials to a local file.
"""
with open('bigquery_credentials.dat', 'wb') as credentials_file:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method will now log a message if the file cannot be saved. Since this doesn't prevent the credentials being used, it no longer throws an exception.

I've added a test for the case.

client_secret='kOc9wMptUtxkcIFbtZCcrEAc',
scope=self.scope,
redirect_uri='urn:ietf:wg:oauth:2.0:oob')
def get_user_account_credentials(self, auth_local_webserver=True):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you expecting a user to call this method directly? should this not be in the read_/to_ API?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_user_account_credentials method was already there, I'm just swapping the implementation for google-auth instead of oauth2client.


client_config = {
'installed': {
'client_id': ('495642085510-k0tmvj2m941jhre2nbqka17vqpjfddtd'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this hardcoded?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it was hard coded previously. I just converted the variable to the expected format. This config variable is how Google knows to say Pandas GBQ is requesting access versus some other project.

@@ -83,100 +83,7 @@ def _get_private_key_contents():
return f.read()


def _test_imports():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pls don't remove things like this. just change to use the new deps. This is going to hard fail if deps are not installed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

furthermore the point of this is to have informative messages.

setup.py Outdated
'pandas',
'httplib2>=0.9.1',
'google-api-python-client>=1.6.2, <2.0.0dev',
'google-auth>=1.0.0, <2.0.0dev',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you need to verify whether these dep packages are avail on conda-forge as well (and if not, prod to have them built there). furthermore unless there is a really really good reason don't put an upper bound on deps.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They aren't, yet. I'm prodding.

@parthea
Copy link
Contributor

parthea commented May 18, 2017

@tswast Thanks for the PR! I'm going to try to test this tonight. I'm hitting this error in my local testing. Have you encountered it before?

    test_gbq.py::TestGBQConnectorIntegrationWithLocalUserAccountAuth::test_should_be_able_to_make_a_connector ERROR

==================================== ERRORS ====================================
 ERROR at setup of TestGBQConnectorIntegrationWithLocalUserAccountAuth.test_should_be_able_to_make_a_connector 

self = <pandas_gbq.tests.test_gbq.TestGBQConnectorIntegrationWithLocalUserAccountAuth object at 0x7f3cc9ec3110>
method = <bound method TestGBQConnectorIntegrationWithLocalUserAccountAuth.test_should_....TestGBQConnectorIntegrationWithLocalUserAccountAuth object at 0x7f3cc9ec3110>>

    def setup_method(self, method):
        _setup_common()
        _skip_if_no_project_id()
        _skip_local_auth_if_in_travis_env()
    
>       self.sut = gbq.GbqConnector(_get_project_id())

test_gbq.py:180: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../gbq.py:120: in __init__
    auth_local_webserver=auth_local_webserver)
../gbq.py:128: in get_credentials
    credentials = self.get_application_default_credentials()
../gbq.py:175: in get_application_default_credentials
    credentials, _ = google.auth.default(scopes=[self.scope])
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

scopes = ['https://www.googleapis.com/auth/bigquery'], request = None

    def default(scopes=None, request=None):
        """Gets the default credentials for the current environment.
    
        `Application Default Credentials`_ provides an easy way to obtain
        credentials to call Google APIs for server-to-server or local applications.
        This function acquires credentials from the environment in the following
        order:
    
        1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set
           to the path of a valid service account JSON private key file, then it is
           loaded and returned. The project ID returned is the project ID defined
           in the service account file if available (some older files do not
           contain project ID information).
        2. If the `Google Cloud SDK`_ is installed and has application default
           credentials set they are loaded and returned.
    
           To enable application default credentials with the Cloud SDK run::
    
                gcloud auth application-default login
    
           If the Cloud SDK has an active project, the project ID is returned. The
           active project can be set using::
    
                gcloud config set project
    
        3. If the application is running in the `App Engine standard environment`_
           then the credentials and project ID from the `App Identity Service`_
           are used.
        4. If the application is running in `Compute Engine`_ or the
           `App Engine flexible environment`_ then the credentials and project ID
           are obtained from the `Metadata Service`_.
        5. If no credentials are found,
           :class:`~google.auth.exceptions.DefaultCredentialsError` will be raised.
    
        .. _Application Default Credentials: https://developers.google.com\
                /identity/protocols/application-default-credentials
        .. _Google Cloud SDK: https://cloud.google.com/sdk
        .. _App Engine standard environment: https://cloud.google.com/appengine
        .. _App Identity Service: https://cloud.google.com/appengine/docs/python\
                /appidentity/
        .. _Compute Engine: https://cloud.google.com/compute
        .. _App Engine flexible environment: https://cloud.google.com\
                /appengine/flexible
        .. _Metadata Service: https://cloud.google.com/compute/docs\
                /storing-retrieving-metadata
    
        Example::
    
            import google.auth
    
            credentials, project_id = google.auth.default()
    
        Args:
            scopes (Sequence[str]): The list of scopes for the credentials. If
                specified, the credentials will automatically be scoped if
                necessary.
            request (google.auth.transport.Request): An object used to make
                HTTP requests. This is used to detect whether the application
                is running on Compute Engine. If not specified, then it will
                use the standard library http client to make requests.
    
        Returns:
            Tuple[~google.auth.credentials.Credentials, Optional[str]]:
                the current environment's credentials and project ID. Project ID
                may be None, which indicates that the Project ID could not be
                ascertained from the environment.
    
        Raises:
            ~google.auth.exceptions.DefaultCredentialsError:
                If no credentials were found, or if the credentials found were
                invalid.
        """
        from google.auth.credentials import with_scopes_if_required
    
        explicit_project_id = os.environ.get(
            environment_vars.PROJECT,
            os.environ.get(environment_vars.LEGACY_PROJECT))
    
        checkers = (
            _get_explicit_environ_credentials,
            _get_gcloud_sdk_credentials,
            _get_gae_credentials,
            lambda: _get_gce_credentials(request))
    
        for checker in checkers:
            credentials, project_id = checker()
            if credentials is not None:
                credentials = with_scopes_if_required(credentials, scopes)
                return credentials, explicit_project_id or project_id
    
>       raise exceptions.DefaultCredentialsError(_HELP_MESSAGE)
E       DefaultCredentialsError: Could not automatically determine credentials. Please set GOOGLE_APPLICATION_CREDENTIALS or
E       explicitly create credential and re-run the application. For more
E       information, please see
E       https://developers.google.com/accounts/docs/application-default-credentials.

../../../miniconda2/lib/python2.7/site-packages/google/auth/_default.py:282: DefaultCredentialsError

EDIT:
I was able to resolve the error by setting GOOGLE_APPLICATION_CREDENTIALS but that should not be required for the unit test which is testing local user account authentication

test_gbq.py::TestGBQConnectorIntegrationWithLocalUserAccountAuth::test_should_be_able_to_make_a_connector

I am also seeing the following error. Could you try deleting your 'bigquery_credentials.dat' file to see if you also get the error? Previously, the authentication flow would start if 'bigquery_credentials.dat' was missing.

ERROR at setup of TestGBQConnectorIntegrationWithLocalUserAccountAuth.test_should_be_able_to_get_schema_from_query 

self = <pandas_gbq.tests.test_gbq.TestGBQConnectorIntegrationWithLocalUserAccountAuth object at 0x7f42e0309750>
method = <bound method TestGBQConnectorIntegrationWithLocalUserAccountAuth.test_should_....TestGBQConnectorIntegrationWithLocalUserAccountAuth object at 0x7f42e0309750>>

    def setup_method(self, method):
        _setup_common()
        _skip_if_no_project_id()
        _skip_local_auth_if_in_travis_env()
    
>       self.sut = gbq.GbqConnector(_get_project_id())

test_gbq.py:180: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../gbq.py:120: in __init__
    auth_local_webserver=auth_local_webserver)
../gbq.py:131: in get_credentials
    auth_local_webserver=auth_local_webserver)
../gbq.py:245: in get_user_account_credentials
    credentials = self.load_user_account_credentials()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <pandas_gbq.gbq.GbqConnector object at 0x7f42e0309690>

    def load_user_account_credentials(self):
        """
            Loads user account credentials from a local file.
    
            Parameters
            ----------
            None
    
            Returns
            -------
            - GoogleCredentials,
                If the credentials can loaded. The retrieved credentials should
                also have access to the project (self.project_id) on BigQuery.
            - OR None,
                If credentials can not be loaded from a file. Or, the retrieved
                credentials do not have access to the project (self.project_id)
                on BigQuery.
            """
>       with open('bigquery_credentials.dat') as credentials_file:
E       IOError: [Errno 2] No such file or directory: 'bigquery_credentials.dat'

@tswast
Copy link
Collaborator Author

tswast commented May 18, 2017

You're right, I forgot to check for if the token file exists, and I didn't fail gracefully if application default credentials were not set.

I'll add some tests for those cases tomorrow.

@jreback jreback modified the milestones: 0.1.7, 0.2.0 May 18, 2017
Copy link
Collaborator Author

@tswast tswast left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've addressed most of your review comments and added a couple tests for things that the test suite and I missed the first time.

Building at:
https://travis-ci.org/tswast/pandas-gbq/builds/233838814

I'll push the google-auth maintainers to make a conda package.

python-gflags==2.0
oauth2client==1.5.0
httplib2>=0.9.1
google-api-python-client>=1.6.2, <2.0.0dev
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -4,7 +4,8 @@ Changelog
0.1.7 / 2017-??-??
------------------

- Resolve issue where the optional ``--noauth_local_webserver`` command line argument would not be propagated during the authentication process.
- Use the `google-auth <https://google-auth.readthedocs.io/en/latest/>`__ library for authentication because oauth2client is deprecated. :issue:`37`
- ``read_gbq`` now has a ``auth_local_webserver`` boolean argument for controlling whether to use web server or console flow when getting user credentials.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

httplib2
google-api-python-client
oauth2client
httplib2>=0.9.1
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we explicitly need httplib2 transport for google-auth-httplib2 to work, I'd prefer to keep this httplib2.Http().

Also, it's the way recommended here: https://github.com/GoogleCloudPlatform/google-auth-library-python-httplib2/blob/587e34ea4c9d331422e40ba8a55e9a8b8c946778/google_auth_httplib2.py#L71

setup.py Outdated
'pandas',
'httplib2>=0.9.1',
'google-api-python-client>=1.6.2, <2.0.0dev',
'google-auth>=1.0.0, <2.0.0dev',
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They aren't, yet. I'm prodding.

client_secret='kOc9wMptUtxkcIFbtZCcrEAc',
scope=self.scope,
redirect_uri='urn:ietf:wg:oauth:2.0:oob')
def get_user_account_credentials(self, auth_local_webserver=True):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_user_account_credentials method was already there, I'm just swapping the implementation for google-auth instead of oauth2client.

credentials do not have access to the project (self.project_id)
on BigQuery.
"""
with open('bigquery_credentials.dat') as credentials_file:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file path was already hard-coded with the previous oauth2client.fileStorage('bigquery_credentials.dat') call.

I've filed #41 to track adding a parameter to override this. Since this would be a new feature, I'd prefer to do this in a separate pull request.

I have added a check for if this file is unable to be opened/parsed plus an integration test for this case.

"""
Saves user account credentials to a local file.
"""
with open('bigquery_credentials.dat', 'wb') as credentials_file:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method will now log a message if the file cannot be saved. Since this doesn't prevent the credentials being used, it no longer throws an exception.

I've added a test for the case.

@parthea
Copy link
Contributor

parthea commented May 19, 2017

@tswast At your earliest convenience, please could you rebase to resolve conflicts? Also, please run flake8 pandas_gbq -v to resolve flake8 errors.

pandas_gbq/gbq.py:46:9: F401 'google.auth' imported but unused
pandas_gbq/gbq.py:46:27: E261 at least two spaces before inline comment
pandas_gbq/gbq.py:46:28: E262 inline comment should start with '# '
pandas_gbq/gbq.py:47:9: F401 'google_auth_oauthlib.flow.InstalledAppFlow' imported but unused
pandas_gbq/gbq.py:47:63: E261 at least two spaces before inline comment
pandas_gbq/gbq.py:47:64: E262 inline comment should start with '# '
pandas_gbq/gbq.py:48:9: F401 'google_auth_httplib2' imported but unused
pandas_gbq/gbq.py:48:36: E261 at least two spaces before inline comment
pandas_gbq/gbq.py:48:37: E262 inline comment should start with '# '
pandas_gbq/tests/test_gbq.py:92:41: E241 multiple spaces after ','
pandas_gbq/tests/test_gbq.py:120:13: F401 'google.auth.default' imported but unused
pandas_gbq/tests/test_gbq.py:120:44: E261 at least two spaces before inline comment
pandas_gbq/tests/test_gbq.py:120:45: E262 inline comment should start with '# '
pandas_gbq/tests/test_gbq.py:121:13: F401 'google.auth.exceptions.DefaultCredentialsError' imported but unused
pandas_gbq/tests/test_gbq.py:121:71: E261 at least two spaces before inline comment
pandas_gbq/tests/test_gbq.py:121:72: E262 inline comment should start with '# '
pandas_gbq/tests/test_gbq.py:122:62: E261 at least two spaces before inline comment
pandas_gbq/tests/test_gbq.py:122:63: E262 inline comment should start with '# '
pandas_gbq/tests/test_gbq.py:123:13: F811 redefinition of unused 'Credentials' from line 122
pandas_gbq/tests/test_gbq.py:123:13: F401 'google.oauth2.service_account.Credentials' imported but unused
pandas_gbq/tests/test_gbq.py:123:66: E261 at least two spaces before inline comment
pandas_gbq/tests/test_gbq.py:123:67: E262 inline comment should start with '# '
pandas_gbq/tests/test_gbq.py:129:13: F401 'google_auth_httplib2.AuthorizedHttp' imported but unused
pandas_gbq/tests/test_gbq.py:129:60: E261 at least two spaces before inline comment
pandas_gbq/tests/test_gbq.py:129:61: E262 inline comment should start with '# '
pandas_gbq/tests/test_gbq.py:130:13: F401 'google_auth_httplib2.Request' imported but unused
pandas_gbq/tests/test_gbq.py:130:53: E261 at least two spaces before inline comment
pandas_gbq/tests/test_gbq.py:130:54: E262 inline comment should start with '# '
pandas_gbq/tests/test_gbq.py:136:13: F401 'google_auth_oauthlib.flow.InstalledAppFlow' imported but unused
pandas_gbq/tests/test_gbq.py:136:67: E261 at least two spaces before inline comment
pandas_gbq/tests/test_gbq.py:136:68: E262 inline comment should start with '# '
pandas_gbq/tests/test_gbq.py:221:57: F841 local variable 'e' is assigned to but never used
pandas_gbq/tests/test_gbq.py:232:17: E129 visually indented line with same indent as next logical line
pandas_gbq/tests/test_gbq.py:296:32: F841 local variable 'auth_mock' is assigned to but never used

@parthea
Copy link
Contributor

parthea commented May 19, 2017

@tswast It's great to see that most integration tests are passing with the improvements made in this PR. Do you have any thoughts on how to resolve the following integration test failure?

https://travis-ci.org/tswast/pandas-gbq/jobs/233838815#L1293

@parthea
Copy link
Contributor

parthea commented May 19, 2017

Could we change

self.sut = gbq.GbqConnector(_get_project_id())

to

        self.sut = gbq.GbqConnector(_get_project_id(),
                                    auth_local_webserver=True)

in setup_method() in the TestGBQConnectorIntegrationWithLocalUserAccountAuth class to keep the functionality of the integration test consistent with the current master?

Separately, if I click 'Deny' instead of 'Allow' during the local user web authentication flow I receive the following exception. Would it be helpful to provide a better error message than the one provided by oauthlib?

    def setup_method(self, method):
        _setup_common()
        _skip_if_no_project_id()
        _skip_local_auth_if_in_travis_env()
    
>       self.sut = gbq.GbqConnector(_get_project_id(), auth_local_webserver=True)

test_gbq.py:268: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../gbq.py:174: in __init__
    auth_local_webserver=auth_local_webserver)
../gbq.py:185: in get_credentials
    auth_local_webserver=auth_local_webserver)
../gbq.py:316: in get_user_account_credentials
    credentials = app_flow.run_local_server()
../../../miniconda2/lib/python2.7/site-packages/google_auth_oauthlib/flow.py:414: in run_local_server
    self.fetch_token(authorization_response=authorization_response)
../../../miniconda2/lib/python2.7/site-packages/google_auth_oauthlib/flow.py:235: in fetch_token
    **kwargs)
../../../miniconda2/lib/python2.7/site-packages/requests_oauthlib/oauth2_session.py:187: in fetch_token
    state=self._state)
../../../miniconda2/lib/python2.7/site-packages/oauthlib/oauth2/rfc6749/clients/web_application.py:174: in parse_request_uri_response
    response = parse_authorization_code_response(uri, state=state)
 def parse_authorization_code_response(uri, state=None):
        """Parse authorization grant response URI into a dict.
    
        If the resource owner grants the access request, the authorization
        server issues an authorization code and delivers it to the client by
        adding the following parameters to the query component of the
        redirection URI using the ``application/x-www-form-urlencoded`` format:
    
        **code**
                REQUIRED.  The authorization code generated by the
                authorization server.  The authorization code MUST expire
                shortly after it is issued to mitigate the risk of leaks.  A
                maximum authorization code lifetime of 10 minutes is
                RECOMMENDED.  The client MUST NOT use the authorization code
                more than once.  If an authorization code is used more than
                once, the authorization server MUST deny the request and SHOULD
                revoke (when possible) all tokens previously issued based on
                that authorization code.  The authorization code is bound to
                the client identifier and redirection URI.
    
        **state**
                REQUIRED if the "state" parameter was present in the client
                authorization request.  The exact value received from the
                client.
    
        :param uri: The full redirect URL back to the client.
        :param state: The state parameter from the authorization request.
    
        For example, the authorization server redirects the user-agent by
        sending the following HTTP response:
    
        .. code-block:: http
    
            HTTP/1.1 302 Found
            Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA
                    &state=xyz
    
        """
        if not is_secure_transport(uri):
            raise InsecureTransportError()
    
        query = urlparse.urlparse(uri).query
        params = dict(urlparse.parse_qsl(query))
    
        if not 'code' in params:
>           raise MissingCodeError("Missing code parameter in response.")
E           MissingCodeError: (missing_code) Missing code parameter in response.

../../../miniconda2/lib/python2.7/site-packages/oauthlib/oauth2/rfc6749/parameters.py:224: MissingCodeError
``

@parthea
Copy link
Contributor

parthea commented May 19, 2017

I received a resourceInUse exception in my local testing. I believe these exceptions appeared before (intermittently) and they will appear less frequently with the improvements in this PR. Do you have any suggestions to further reduce the number of resourceInUse exceptions?

test_gbq.py::TestToGBQIntegrationWithServiceAccountKeyPath::test_upload_data_if_table_exists_fail ERROR
================================= short test summary info ==================================
SKIP [1] /home/tony/pydata-pandas-gbq/pandas_gbq/tests/test_gbq.py:304: Cannot get default_credentials from the environment!

========================================== ERRORS ==========================================
 ERROR at teardown of TestToGBQIntegrationWithServiceAccountKeyPath.test_upload_data_if_table_exists_fail 

self = <pandas_gbq.tests.test_gbq.TestToGBQIntegrationWithServiceAccountKeyPath object at 0x7f9915c5dad0>
method = <bound method TestToGBQIntegrationWithServiceAccountKeyPath.test_upload_data_i...st_gbq.TestToGBQIntegrationWithServiceAccountKeyPath object at 0x7f9915c5dad0>>

    def teardown_method(self, method):
        # - PER-TEST FIXTURES -
        # put here any instructions you want to be run *AFTER* *EVERY* test is
        # executed.
>       clean_gbq_environment(self.dataset_prefix, _get_private_key_path())

test_gbq.py:1033: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

dataset_prefix = 'pandas_gbq_99300', private_key = '.....'

    def clean_gbq_environment(dataset_prefix, private_key=None):
        dataset = gbq._Dataset(_get_project_id(), private_key=private_key)
        all_datasets = dataset.datasets()
    
        retry = 3
        while retry > 0:
            try:
                retry = retry - 1
                for i in range(1, 10):
                    dataset_id = dataset_prefix + str(i)
                    if dataset_id in all_datasets:
                        table = gbq._Table(_get_project_id(), dataset_id,
                                           private_key=private_key)
                        all_tables = dataset.tables(dataset_id)
                        for table_id in all_tables:
                            try:
                                table.delete(table_id)
                            except gbq.NotFoundException as e:
                                pass
    
                        dataset.delete(dataset_id)
                retry = 0
            except gbq.GenericGBQException as ex:
                # Build in retry logic to work around the following errors :
                # An internal error occurred and the request could not be...
                # Dataset ... is still in use
                error_message = str(ex).lower()
                if ('an internal error occurred' in error_message or
                    'still in use' in error_message) and retry > 0:
                    sleep(randint(1, 10))
                else:
>                   raise ex
E                   GenericGBQException: Reason: resourceInUse, Message: Dataset pandas-140401:pandas_gbq_993001 is still in use

test_gbq.py:235: GenericGBQException
!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!

@parthea
Copy link
Contributor

parthea commented May 19, 2017

If I set my GOOGLE_APPLICATION_CREDENTIALS environment variable to an empty string, I receive an exception instead of the local user web authentication flow. Is it expected?

tony@tonypc:~/pydata-pandas-gbq/pandas_gbq/tests$ export GOOGLE_APPLICATION_CREDENTIALS=''
tony@tonypc:~/pydata-pandas-gbq/pandas_gbq/tests$ pytest test_gbq.py::TestGBQConnectorIntegrationWithLocalUserAccountAuth -v -r s . --maxfail=1
============================= test session starts ==============================
platform linux2 -- Python 2.7.12, pytest-3.0.6, py-1.4.32, pluggy-0.4.0 -- /home/tony/miniconda2/bin/python
cachedir: ../../.cache
rootdir: /home/tony/pydata-pandas-gbq, inifile: 
plugins: cov-2.4.0
collected 100 items 

test_gbq.py::TestGBQConnectorIntegrationWithLocalUserAccountAuth::test_should_be_able_to_make_a_connector ERROR

==================================== ERRORS ====================================
 ERROR at setup of TestGBQConnectorIntegrationWithLocalUserAccountAuth.test_should_be_able_to_make_a_connector 

self = <pandas_gbq.tests.test_gbq.TestGBQConnectorIntegrationWithLocalUserAccountAuth object at 0x7f2bbecf6490>
method = <bound method TestGBQConnectorIntegrationWithLocalUserAccountAuth.test_should_....TestGBQConnectorIntegrationWithLocalUserAccountAuth object at 0x7f2bbecf6490>>

    def setup_method(self, method):
        _setup_common()
        _skip_if_no_project_id()
        _skip_local_auth_if_in_travis_env()
    
>       self.sut = gbq.GbqConnector(_get_project_id(), auth_local_webserver=True)

test_gbq.py:268: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../gbq.py:174: in __init__
    auth_local_webserver=auth_local_webserver)
../gbq.py:182: in get_credentials
    credentials = self.get_application_default_credentials()
../gbq.py:212: in get_application_default_credentials
    credentials, _ = google.auth.default(scopes=[self.scope])
../../../miniconda2/lib/python2.7/site-packages/google/auth/_default.py:277: in default
    credentials, project_id = checker()
../../../miniconda2/lib/python2.7/site-packages/google/auth/_default.py:138: in _get_explicit_environ_credentials
    os.environ[environment_vars.CREDENTIALS])
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

filename = ''

    def _load_credentials_from_file(filename):
        """Loads credentials from a file.
    
        The credentials file must be a service account key or stored authorized
        user credentials.
    
        Args:
            filename (str): The full path to the credentials file.
    
        Returns:
            Tuple[google.auth.credentials.Credentials, Optional[str]]: Loaded
                credentials and the project ID. Authorized user credentials do not
                have the project ID information.
    
        Raises:
            google.auth.exceptions.DefaultCredentialsError: if the file is in the
                wrong format.
        """
>       with io.open(filename, 'r') as file_obj:
E       IOError: [Errno 2] No such file or directory: ''

../../../miniconda2/lib/python2.7/site-packages/google/auth/_default.py:63: IOError
!!!!!!!!!!!!!!!!!!!! Interrupted: stopping after 1 failures !!!!!!!!!!!!!!!!!!!!
=========================== 1 error in 0.61 seconds ============================

@tswast
Copy link
Collaborator Author

tswast commented May 19, 2017

Yes, we should set the auth server to true in setup as you suggest. It's a good default to be false so it works in remote Jupyter notebooks, but py.test doesn't like it due to the output capturing.

I think resource in use errors are happening because the table deletes are eventually consistent. I will add a loop to list the tables until they are gone.

The GOOGLE_APPLICATION_CREDENTIALS set to empty string error sounds like a bug in google-auth. Let's file an issue over there for it.

@tswast
Copy link
Collaborator Author

tswast commented May 19, 2017

Okay, I've rebased, modified the integration tests to use local webserver by default, and added a couple more checks for invalid credentials.

Integration tests are running here: https://travis-ci.org/tswast/pandas-gbq/builds/234115597 https://travis-ci.org/tswast/pandas-gbq/jobs/234117128

@tswast
Copy link
Collaborator Author

tswast commented May 19, 2017

Looks like all the test failures are in the dataset listing

GenericGBQException: Reason: notFound, Message: Not found: Token pandas_gbq_457494

I'll ping the BQ team that this is still an issue. Not sure how to work around that one, since it seems pretty clearly a backend bug (we're getting a token from the API, but then they can't find it when we use it)

@tswast
Copy link
Collaborator Author

tswast commented May 19, 2017

Oh, and latest pandas removed assertRaises, assert_equals.

Tests running here: https://travis-ci.org/tswast/pandas-gbq/builds/234145711 https://travis-ci.org/tswast/pandas-gbq/builds/234153727

Copy link
Contributor

@jreback jreback left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tswast looks pretty good. a few doc comments. also pls add another CI build that works on the current version of deps. The idea is to make sure we fail gracefully, IOW. it is VERY likely that people will upgrade this and NOT there deps. SO need to make sure it still 'work' (in that it gives nice error messages). I don't believe pip will correctly upgrade things on a default installation, though conda will.

google-api-python-client==1.2
python-gflags==2.0
oauth2client==1.5.0
google-api-python-client
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you fix one of these builts to the oldest versions that are possible to make this work (of google-api-python-client at least)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

- Drop support for Python 3.4 (:issue:`40`)
- Use the `google-auth <https://google-auth.readthedocs.io/en/latest/>`__ library for authentication because oauth2client is deprecated. (:issue:`39`)
- ``read_gbq`` now has a ``auth_local_webserver`` boolean argument for controlling whether to use web server or console flow when getting user credentials.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a replacement for 35? if so, pls list that here

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, done.

google_api_minimum_version = '1.2.0'
# Version 1.6.0 is the first version to support google-auth.
# https://github.com/google/google-api-python-client/blob/master/CHANGELOG
google_api_minimum_version = '1.6.0'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, so put this version fixed in the ci

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌

return _try_credentials(self.project_id, credentials)

def save_user_account_credentials(self, credentials):
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for new functions, add a versionadded 0.2.0 in the doc-string

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added.


def get_service_account_credentials(self):
import httplib2
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are any of these new functions user API visible? if so, do a _try_imports at the top of them.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the get_*_credentials functions will be used directly.

I noticed that read_gbq and to_gbq did not have the checks, so I've added it there.

@@ -686,6 +750,9 @@ def read_gbq(query, project_id=None, index_col=None, col_order=None,
Service account private key in JSON format. Can be file path
or string contents. This is useful for remote server
authentication (eg. jupyter iPython notebook on remote host)
auth_local_webserver : boolean (default False)
Use a local webserver when getting user credentials to handle
OAuth authorization flow redirects.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

versionadded tag

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -686,6 +750,9 @@ def read_gbq(query, project_id=None, index_col=None, col_order=None,
Service account private key in JSON format. Can be file path
or string contents. This is useful for remote server
authentication (eg. jupyter iPython notebook on remote host)
auth_local_webserver : boolean (default False)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boolean, default False

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -686,6 +750,9 @@ def read_gbq(query, project_id=None, index_col=None, col_order=None,
Service account private key in JSON format. Can be file path
or string contents. This is useful for remote server
authentication (eg. jupyter iPython notebook on remote host)
auth_local_webserver : boolean (default False)
Use a local webserver when getting user credentials to handle
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you can add a doc-link link on how this works would be great (in addition or just to gbq docs is fine as well)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added links.

The google-auth-oauthlib docs explain it pretty well.

@@ -815,6 +883,9 @@ def to_gbq(dataframe, destination_table, project_id, chunksize=10000,
Service account private key in JSON format. Can be file path
or string contents. This is useful for remote server
authentication (eg. jupyter iPython notebook on remote host)
auth_local_webserver : boolean (default False)
Use a local webserver when getting user credentials to handle
OAuth authorization flow redirects.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

versionadded tag & doc-link

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copy link
Collaborator Author

@tswast tswast left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed you comments. Build seems happier now, too. https://travis-ci.org/tswast/pandas-gbq/builds/234178457

google-api-python-client==1.2
python-gflags==2.0
oauth2client==1.5.0
google-api-python-client
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

- Drop support for Python 3.4 (:issue:`40`)
- Use the `google-auth <https://google-auth.readthedocs.io/en/latest/>`__ library for authentication because oauth2client is deprecated. (:issue:`39`)
- ``read_gbq`` now has a ``auth_local_webserver`` boolean argument for controlling whether to use web server or console flow when getting user credentials.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, done.

google_api_minimum_version = '1.2.0'
# Version 1.6.0 is the first version to support google-auth.
# https://github.com/google/google-api-python-client/blob/master/CHANGELOG
google_api_minimum_version = '1.6.0'
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌

@@ -686,6 +750,9 @@ def read_gbq(query, project_id=None, index_col=None, col_order=None,
Service account private key in JSON format. Can be file path
or string contents. This is useful for remote server
authentication (eg. jupyter iPython notebook on remote host)
auth_local_webserver : boolean (default False)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -686,6 +750,9 @@ def read_gbq(query, project_id=None, index_col=None, col_order=None,
Service account private key in JSON format. Can be file path
or string contents. This is useful for remote server
authentication (eg. jupyter iPython notebook on remote host)
auth_local_webserver : boolean (default False)
Use a local webserver when getting user credentials to handle
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added links.

The google-auth-oauthlib docs explain it pretty well.

@@ -686,6 +750,9 @@ def read_gbq(query, project_id=None, index_col=None, col_order=None,
Service account private key in JSON format. Can be file path
or string contents. This is useful for remote server
authentication (eg. jupyter iPython notebook on remote host)
auth_local_webserver : boolean (default False)
Use a local webserver when getting user credentials to handle
OAuth authorization flow redirects.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -815,6 +883,9 @@ def to_gbq(dataframe, destination_table, project_id, chunksize=10000,
Service account private key in JSON format. Can be file path
or string contents. This is useful for remote server
authentication (eg. jupyter iPython notebook on remote host)
auth_local_webserver : boolean (default False)
Use a local webserver when getting user credentials to handle
OAuth authorization flow redirects.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

return _try_credentials(self.project_id, credentials)

def save_user_account_credentials(self, credentials):
"""
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added.


def get_service_account_credentials(self):
import httplib2
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the get_*_credentials functions will be used directly.

I noticed that read_gbq and to_gbq did not have the checks, so I've added it there.

from googleapiclient.errors import HttpError # noqa
except ImportError as ex:
raise ImportError(
"pandas requires google-api-python-client for Google BigQuery "

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a stray space between Google and BigQuery?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, removed. Thanks.

except ImportError as ex:
raise ImportError(
"pandas requires google-api-python-client for Google BigQuery "
"support: {0}".format(str(ex)))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using .format, you don't need to str, format will call __str__.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.


http = httplib2.Http()
try:
http = AuthorizedHttp(credentials, http=http)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: shadowing variables makes me uncomfortable, I'd prefer you call this one authed_http.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@tswast
Copy link
Collaborator Author

tswast commented Jun 12, 2017

Thanks for patching those in. I've rebased on master.

Remove the use of oauth2client and use google-auth library, instead.
See GH#37.

Rather than check for multiple versions of the libraries, use the
setup.py to specify compatible versions. I believe this is safe since
Pandas checks for the pandas_gbq package.

Since google-auth does not use the argparse module to override user
authentication flow settings, add a parameter to choose between the web
and console flow.

Addresses some eventual consistency issues in table/dataset listing in
the integration tests.
This method was removed in
pandas-dev/pandas#16089 in favor of
pytest.raises.
This method was removed in
pandas-dev/pandas#16017 in favor of
pytest.raises.
@parthea
Copy link
Contributor

parthea commented Jun 16, 2017

@jreback I'm going to merge this now. All review comments have been addressed and integration tests passed.

Copy link
Contributor

@parthea parthea left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks @tswast !

There was a conflict with the change log that was really painless to fix so I just corrected it myself to speed up the process. Authorship should be maintained.

@tswast
Copy link
Collaborator Author

tswast commented Jun 16, 2017

Awesome. Thanks @parthea.

@parthea
Copy link
Contributor

parthea commented Jun 16, 2017

google-auth, google-auth-oauthlib and google-auth-httplib2 are all conda packages.

@theacodes
Copy link

@parthea thanks for helping us get on conda!

@parthea parthea merged commit 852b2a3 into googleapis:master Jun 16, 2017
@parthea
Copy link
Contributor

parthea commented Jun 16, 2017

@jonparrott No problem. It was a great learning experience! Would you like to be added to the maintainer's list for the google-auth* conda packages?

@theacodes
Copy link

theacodes commented Jun 16, 2017 via email

@jreback
Copy link
Contributor

jreback commented Jun 16, 2017

manually check the installation to make sure it's picking things up

may need to update install ibstructions as well

@tswast tswast deleted the gbq-37 branch June 16, 2017 19:37
@jreback
Copy link
Contributor

jreback commented Jun 16, 2017

ok need to change one of the builds to install all the deps via conda rather than pip

maybe the 3.6 build

volunteers?

@tswast
Copy link
Collaborator Author

tswast commented Jun 16, 2017

You mean in Travis? I've been meaning to learn Conda, so I'll volunteer.

@jreback
Copy link
Contributor

jreback commented Jun 16, 2017

yep

@parthea
Copy link
Contributor

parthea commented Jun 17, 2017

The installation of google-auth* conda packages appear to be working correctly with the following commands:

conda install -c conda-forge google-auth
conda install -c conda-forge google-auth-oauthlib
conda install -c conda-forge google-auth-httplib2

Yes, we'll need to update the documentation as well. I'll open a new issue.

@tworec
Copy link
Contributor

tworec commented Jun 19, 2017

great job guys!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: bug Error or flaw in code with unintended results or allowing sub-optimal usage patterns.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants