Skip to content

[ENH] cache credentials in-memory #208

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

Merged
merged 3 commits into from
Sep 4, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ API Reference

read_gbq
to_gbq
context
Context

.. autofunction:: read_gbq
.. autofunction:: to_gbq
.. autodata:: context
.. autoclass:: Context
8 changes: 8 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Changelog
=========

.. _changelog-0.7.0:

0.7.0 / [unreleased]
--------------------

- Add :class:`pandas_gbq.Context` to cache credentials in-memory, across
calls to ``read_gbq`` and ``to_gbq``. (:issue:`198`, :issue:`208`)

.. _changelog-0.6.1:

0.6.1 / [unreleased]
Expand Down
2 changes: 1 addition & 1 deletion pandas_gbq/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .gbq import to_gbq, read_gbq # noqa
from .gbq import to_gbq, read_gbq, Context, context # noqa

from ._version import get_versions

Expand Down
94 changes: 87 additions & 7 deletions pandas_gbq/gbq.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,72 @@ class TableCreationError(ValueError):
pass


class Context(object):
"""Storage for objects to be used throughout a session.

A Context object is initialized when the ``pandas_gbq`` module is
imported, and can be found at :attr:`pandas_gbq.context`.
"""

def __init__(self):
self._credentials = None
self._project = None

@property
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need the getters and setters? Are they doing anything beyond attributes? It's not bloating the interface, so I don't have a strong view.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

My thought is that these can be used to provide documentation about the settable properties.

def credentials(self):
"""google.auth.credentials.Credentials: Credentials to use for Google
APIs.

Note:
These credentials are automatically cached in memory by calls to
:func:`pandas_gbq.read_gbq` and :func:`pandas_gbq.to_gbq`. To
manually set the credentials, construct an
:class:`google.auth.credentials.Credentials` object and set it as
the context credentials as demonstrated in the example below. See
`auth docs`_ for more information on obtaining credentials.

Example:
Manually setting the context credentials:
>>> import pandas_gbq
>>> from google.oauth2 import service_account
>>> credentials = (service_account
... .Credentials.from_service_account_file(
... '/path/to/key.json'))
>>> pandas_gbq.context.credentials = credentials
.. _auth docs: http://google-auth.readthedocs.io
/en/latest/user-guide.html#obtaining-credentials
"""
return self._credentials

@credentials.setter
def credentials(self, value):
self._credentials = value

@property
def project(self):
"""str: Default project to use for calls to Google APIs.

Example:
Manually setting the context project:
>>> import pandas_gbq
>>> pandas_gbq.context.project = 'my-project'
"""
return self._project

@project.setter
def project(self, value):
self._project = value


# Create an empty context, used to cache credentials.
context = Context()
"""A :class:`pandas_gbq.Context` object used to cache credentials.

Credentials automatically are cached in-memory by :func:`pandas_gbq.read_gbq`
and :func:`pandas_gbq.to_gbq`.
"""


class GbqConnector(object):
def __init__(
self,
Expand All @@ -173,6 +239,7 @@ def __init__(
location=None,
try_credentials=None,
):
global context
from google.api_core.exceptions import GoogleAPIError
from google.api_core.exceptions import ClientError
from pandas_gbq import auth
Expand All @@ -185,13 +252,20 @@ def __init__(
self.auth_local_webserver = auth_local_webserver
self.dialect = dialect
self.credentials_path = _get_credentials_file()
self.credentials, default_project = auth.get_credentials(
private_key=private_key,
project_id=project_id,
reauth=reauth,
auth_local_webserver=auth_local_webserver,
try_credentials=try_credentials,
)

# Load credentials from cache.
self.credentials = context.credentials
default_project = context.project

# Credentials were explicitly asked for, so don't use the cache.
if private_key or reauth or not self.credentials:
self.credentials, default_project = auth.get_credentials(
private_key=private_key,
project_id=project_id,
reauth=reauth,
auth_local_webserver=auth_local_webserver,
try_credentials=try_credentials,
)

if self.project_id is None:
self.project_id = default_project
Expand All @@ -201,6 +275,12 @@ def __init__(
"Could not determine project ID and one was not supplied."
)

# Cache the credentials if they haven't been set yet.
if context.credentials is None:
context.credentials = self.credentials
if context.project is None:
context.project = self.project_id

self.client = self.get_client()

# BQ Queries costs $5 per TB. First 1 TB per month is free
Expand Down
41 changes: 41 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-

try:
from unittest import mock
except ImportError: # pragma: NO COVER
import mock

import pytest


@pytest.fixture(autouse=True, scope="function")
def reset_context():
import pandas_gbq

pandas_gbq.context.credentials = None
pandas_gbq.context.project = None


@pytest.fixture(autouse=True)
def mock_bigquery_client(monkeypatch):
from pandas_gbq import gbq
from google.api_core.exceptions import NotFound
import google.cloud.bigquery
import google.cloud.bigquery.table

mock_client = mock.create_autospec(google.cloud.bigquery.Client)
mock_schema = [google.cloud.bigquery.SchemaField("_f0", "INTEGER")]
# Mock out SELECT 1 query results.
mock_query = mock.create_autospec(google.cloud.bigquery.QueryJob)
mock_query.job_id = "some-random-id"
mock_query.state = "DONE"
mock_rows = mock.create_autospec(google.cloud.bigquery.table.RowIterator)
mock_rows.total_rows = 1
mock_rows.schema = mock_schema
mock_rows.__iter__.return_value = [(1,)]
mock_query.result.return_value = mock_rows
mock_client.query.return_value = mock_query
# Mock table creation.
mock_client.get_table.side_effect = NotFound("nope")
monkeypatch.setattr(gbq.GbqConnector, "get_client", lambda _: mock_client)
return mock_client
38 changes: 38 additions & 0 deletions tests/unit/test_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-

try:
from unittest import mock
except ImportError: # pragma: NO COVER
import mock

import pytest


@pytest.fixture(autouse=True)
def mock_get_credentials(monkeypatch):
from pandas_gbq import auth
import google.auth.credentials

mock_credentials = mock.MagicMock(google.auth.credentials.Credentials)
mock_get_credentials = mock.Mock()
mock_get_credentials.return_value = (mock_credentials, "my-project")

monkeypatch.setattr(auth, "get_credentials", mock_get_credentials)
return mock_get_credentials


def test_read_gbq_should_save_credentials(mock_get_credentials):
import pandas_gbq

assert pandas_gbq.context.credentials is None
assert pandas_gbq.context.project is None

pandas_gbq.read_gbq("SELECT 1", dialect="standard")

assert mock_get_credentials.call_count == 1
mock_get_credentials.reset_mock()
assert pandas_gbq.context.credentials is not None
assert pandas_gbq.context.project is not None

pandas_gbq.read_gbq("SELECT 1", dialect="standard")
mock_get_credentials.assert_not_called()
24 changes: 0 additions & 24 deletions tests/unit/test_gbq.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,6 @@ def min_bq_version():
return pkg_resources.parse_version("0.32.0")


@pytest.fixture(autouse=True)
def mock_bigquery_client(monkeypatch):
from google.api_core.exceptions import NotFound
import google.cloud.bigquery
import google.cloud.bigquery.table

mock_client = mock.create_autospec(google.cloud.bigquery.Client)
mock_schema = [google.cloud.bigquery.SchemaField("_f0", "INTEGER")]
# Mock out SELECT 1 query results.
mock_query = mock.create_autospec(google.cloud.bigquery.QueryJob)
mock_query.job_id = "some-random-id"
mock_query.state = "DONE"
mock_rows = mock.create_autospec(google.cloud.bigquery.table.RowIterator)
mock_rows.total_rows = 1
mock_rows.schema = mock_schema
mock_rows.__iter__.return_value = [(1,)]
mock_query.result.return_value = mock_rows
mock_client.query.return_value = mock_query
# Mock table creation.
mock_client.get_table.side_effect = NotFound("nope")
monkeypatch.setattr(gbq.GbqConnector, "get_client", lambda _: mock_client)
return mock_client


def mock_none_credentials(*args, **kwargs):
return None, None

Expand Down