Skip to content

Commit cdfc1aa

Browse files
committed
[ENH] cache credentials in-memory
This commit adds a global pandas_gbq.context variable which caches the project ID and credentials across calls to read_gbq and to_gbq.
1 parent 3f3192f commit cdfc1aa

File tree

6 files changed

+170
-32
lines changed

6 files changed

+170
-32
lines changed

docs/source/api.rst

+4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ API Reference
1414

1515
read_gbq
1616
to_gbq
17+
context
18+
Context
1719

1820
.. autofunction:: read_gbq
1921
.. autofunction:: to_gbq
22+
.. autodata:: context
23+
.. autoclass:: Context

pandas_gbq/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .gbq import to_gbq, read_gbq # noqa
1+
from .gbq import to_gbq, read_gbq, Context, context # noqa
22

33
from ._version import get_versions
44

pandas_gbq/gbq.py

+87-7
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,72 @@ class TableCreationError(ValueError):
162162
pass
163163

164164

165+
class Context(object):
166+
"""Storage for objects to be used throughout a session.
167+
168+
A Context object is initialized when the ``pandas_gbq`` module is
169+
imported, and can be found at :attr:`pandas_gbq.context`.
170+
"""
171+
172+
def __init__(self):
173+
self._credentials = None
174+
self._project = None
175+
176+
@property
177+
def credentials(self):
178+
"""google.auth.credentials.Credentials: Credentials to use for Google
179+
APIs.
180+
181+
Note:
182+
These credentials are automatically cached in memory by calls to
183+
:func:`pandas_gbq.read_gbq` and :func:`pandas_gbq.to_gbq`. To
184+
manually set the credentials, construct an
185+
:class:`google.auth.credentials.Credentials` object and set it as
186+
the context credentials as demonstrated in the example below. See
187+
`auth docs`_ for more information on obtaining credentials.
188+
189+
Example:
190+
Manually setting the context credentials:
191+
>>> import pandas_gbq
192+
>>> from google.oauth2 import service_account
193+
>>> credentials = (service_account
194+
... .Credentials.from_service_account_file(
195+
... '/path/to/key.json'))
196+
>>> pandas_gbq.context.credentials = credentials
197+
.. _auth docs: http://google-auth.readthedocs.io
198+
/en/latest/user-guide.html#obtaining-credentials
199+
"""
200+
return self._credentials
201+
202+
@credentials.setter
203+
def credentials(self, value):
204+
self._credentials = value
205+
206+
@property
207+
def project(self):
208+
"""str: Default project to use for calls to Google APIs.
209+
210+
Example:
211+
Manually setting the context project:
212+
>>> import pandas_gbq
213+
>>> pandas_gbq.context.project = 'my-project'
214+
"""
215+
return self._project
216+
217+
@project.setter
218+
def project(self, value):
219+
self._project = value
220+
221+
222+
# Create an empty context, used to cache credentials.
223+
context = Context()
224+
"""A :class:`pandas_gbq.Context` object used to cache credentials.
225+
226+
Credentials automatically are cached in-memory by :func:`pandas_gbq.read_gbq`
227+
and :func:`pandas_gbq.to_gbq`.
228+
"""
229+
230+
165231
class GbqConnector(object):
166232
def __init__(
167233
self,
@@ -173,6 +239,7 @@ def __init__(
173239
location=None,
174240
try_credentials=None,
175241
):
242+
global context
176243
from google.api_core.exceptions import GoogleAPIError
177244
from google.api_core.exceptions import ClientError
178245
from pandas_gbq import auth
@@ -185,13 +252,20 @@ def __init__(
185252
self.auth_local_webserver = auth_local_webserver
186253
self.dialect = dialect
187254
self.credentials_path = _get_credentials_file()
188-
self.credentials, default_project = auth.get_credentials(
189-
private_key=private_key,
190-
project_id=project_id,
191-
reauth=reauth,
192-
auth_local_webserver=auth_local_webserver,
193-
try_credentials=try_credentials,
194-
)
255+
256+
# Load credentials from cache.
257+
self.credentials = context.credentials
258+
default_project = context.project
259+
260+
# Credentials were explicitly asked for, so don't use the cache.
261+
if private_key or reauth or not self.credentials:
262+
self.credentials, default_project = auth.get_credentials(
263+
private_key=private_key,
264+
project_id=project_id,
265+
reauth=reauth,
266+
auth_local_webserver=auth_local_webserver,
267+
try_credentials=try_credentials,
268+
)
195269

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

278+
# Cache the credentials if they haven't been set yet.
279+
if context.credentials is None:
280+
context.credentials = self.credentials
281+
if context.project is None:
282+
context.project = self.project_id
283+
204284
self.client = self.get_client()
205285

206286
# BQ Queries costs $5 per TB. First 1 TB per month is free

tests/unit/conftest.py

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# -*- coding: utf-8 -*-
2+
3+
try:
4+
from unittest import mock
5+
except ImportError: # pragma: NO COVER
6+
import mock
7+
8+
import pytest
9+
10+
11+
@pytest.fixture(autouse=True, scope='function')
12+
def reset_context():
13+
import pandas_gbq
14+
pandas_gbq.context.credentials = None
15+
pandas_gbq.context.project = None
16+
17+
18+
@pytest.fixture(autouse=True)
19+
def mock_bigquery_client(monkeypatch):
20+
from pandas_gbq import gbq
21+
from google.api_core.exceptions import NotFound
22+
import google.cloud.bigquery
23+
import google.cloud.bigquery.table
24+
25+
mock_client = mock.create_autospec(google.cloud.bigquery.Client)
26+
mock_schema = [google.cloud.bigquery.SchemaField("_f0", "INTEGER")]
27+
# Mock out SELECT 1 query results.
28+
mock_query = mock.create_autospec(google.cloud.bigquery.QueryJob)
29+
mock_query.job_id = "some-random-id"
30+
mock_query.state = "DONE"
31+
mock_rows = mock.create_autospec(google.cloud.bigquery.table.RowIterator)
32+
mock_rows.total_rows = 1
33+
mock_rows.schema = mock_schema
34+
mock_rows.__iter__.return_value = [(1,)]
35+
mock_query.result.return_value = mock_rows
36+
mock_client.query.return_value = mock_query
37+
# Mock table creation.
38+
mock_client.get_table.side_effect = NotFound("nope")
39+
monkeypatch.setattr(gbq.GbqConnector, "get_client", lambda _: mock_client)
40+
return mock_client

tests/unit/test_context.py

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# -*- coding: utf-8 -*-
2+
3+
try:
4+
from unittest import mock
5+
except ImportError: # pragma: NO COVER
6+
import mock
7+
8+
import pytest
9+
10+
11+
@pytest.fixture(autouse=True)
12+
def mock_get_credentials(monkeypatch):
13+
from pandas_gbq import auth
14+
import google.auth.credentials
15+
16+
mock_credentials = mock.MagicMock(google.auth.credentials.Credentials)
17+
mock_get_credentials = mock.Mock()
18+
mock_get_credentials.return_value = (mock_credentials, "my-project")
19+
20+
monkeypatch.setattr(auth, "get_credentials", mock_get_credentials)
21+
return mock_get_credentials
22+
23+
24+
def test_read_gbq_should_save_credentials(mock_get_credentials):
25+
import pandas_gbq
26+
27+
assert pandas_gbq.context.credentials is None
28+
assert pandas_gbq.context.project is None
29+
30+
pandas_gbq.read_gbq("SELECT 1", dialect="standard")
31+
32+
mock_get_credentials.assert_called_once()
33+
mock_get_credentials.reset_mock()
34+
assert pandas_gbq.context.credentials is not None
35+
assert pandas_gbq.context.project is not None
36+
37+
pandas_gbq.read_gbq("SELECT 1", dialect="standard")
38+
mock_get_credentials.assert_not_called()

tests/unit/test_gbq.py

-24
Original file line numberDiff line numberDiff line change
@@ -25,30 +25,6 @@ def min_bq_version():
2525
return pkg_resources.parse_version("0.32.0")
2626

2727

28-
@pytest.fixture(autouse=True)
29-
def mock_bigquery_client(monkeypatch):
30-
from google.api_core.exceptions import NotFound
31-
import google.cloud.bigquery
32-
import google.cloud.bigquery.table
33-
34-
mock_client = mock.create_autospec(google.cloud.bigquery.Client)
35-
mock_schema = [google.cloud.bigquery.SchemaField("_f0", "INTEGER")]
36-
# Mock out SELECT 1 query results.
37-
mock_query = mock.create_autospec(google.cloud.bigquery.QueryJob)
38-
mock_query.job_id = "some-random-id"
39-
mock_query.state = "DONE"
40-
mock_rows = mock.create_autospec(google.cloud.bigquery.table.RowIterator)
41-
mock_rows.total_rows = 1
42-
mock_rows.schema = mock_schema
43-
mock_rows.__iter__.return_value = [(1,)]
44-
mock_query.result.return_value = mock_rows
45-
mock_client.query.return_value = mock_query
46-
# Mock table creation.
47-
mock_client.get_table.side_effect = NotFound("nope")
48-
monkeypatch.setattr(gbq.GbqConnector, "get_client", lambda _: mock_client)
49-
return mock_client
50-
51-
5228
def mock_none_credentials(*args, **kwargs):
5329
return None, None
5430

0 commit comments

Comments
 (0)