Skip to content

Commit 0816668

Browse files
authored
CLN: refactor auth logic to its own module. (googleapis#176)
* CLN: refactor auth logic to its own module. Uses new-style tests for auth-related tests. * Mock new auth module functions in gbq unit tests. * Use monkeypatch in unit tests to not break system tests. * Use private_key and explicit project when default creds not available (such as on Travis) * Use explicit project with default credentials if passed in * Add comment why check default auth credentials. * Skip auth tests when key not available. * Move generate gbq schema test to unit tests. * Use pytest marks to skip local auth tests on Travis. * Skip tests when no private key file. * Use shared test fixtures for auth and gbq system tests.
1 parent f301442 commit 0816668

File tree

10 files changed

+669
-570
lines changed

10 files changed

+669
-570
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ script:
4949
- if [[ $PYTHON == '3.6' ]] && [[ "$PANDAS" == "MASTER" ]]; then nox -s test36master ; fi
5050
- REQ="ci/requirements-${PYTHON}-${PANDAS}" ;
5151
if [ -f "$REQ.conda" ]; then
52-
pytest -v tests/unit tests/system.py ;
52+
pytest --quiet -m 'not local_auth' -v tests ;
5353
fi
5454
- if [[ $COVERAGE == 'true' ]]; then nox -s cover ; fi
5555
- if [[ $LINT == 'true' ]]; then nox -s lint ; fi

nox.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
See: https://nox.readthedocs.io/en/latest/
44
"""
55

6+
import os
67
import os.path
78

89
import nox
@@ -17,16 +18,24 @@
1718
def default(session):
1819
session.install('mock', 'pytest', 'pytest-cov')
1920
session.install('-e', '.')
21+
22+
# Skip local auth tests on Travis.
23+
additional_args = list(session.posargs)
24+
if 'TRAVIS_BUILD_DIR' in os.environ:
25+
additional_args = additional_args + [
26+
'-m',
27+
'not local_auth',
28+
]
29+
2030
session.run(
2131
'pytest',
22-
os.path.join('.', 'tests', 'unit'),
23-
os.path.join('.', 'tests', 'system.py'),
32+
os.path.join('.', 'tests'),
2433
'--quiet',
2534
'--cov=pandas_gbq',
2635
'--cov=tests.unit',
2736
'--cov-report',
2837
'xml:/tmp/pytest-cov.xml',
29-
*session.posargs
38+
*additional_args
3039
)
3140

3241

pandas_gbq/auth.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
"""Private module for fetching Google BigQuery credentials."""
2+
3+
import json
4+
import logging
5+
import os
6+
import os.path
7+
8+
import pandas.compat
9+
10+
import pandas_gbq.exceptions
11+
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
SCOPES = ['https://www.googleapis.com/auth/bigquery']
17+
18+
19+
def get_credentials(
20+
private_key=None, project_id=None, reauth=False,
21+
auth_local_webserver=False):
22+
if private_key:
23+
return get_service_account_credentials(private_key)
24+
25+
# Try to retrieve Application Default Credentials
26+
credentials, default_project = get_application_default_credentials(
27+
project_id=project_id)
28+
29+
if credentials:
30+
return credentials, default_project
31+
32+
credentials = get_user_account_credentials(
33+
project_id=project_id, reauth=reauth,
34+
auth_local_webserver=auth_local_webserver)
35+
return credentials, project_id
36+
37+
38+
def get_service_account_credentials(private_key):
39+
import google.auth.transport.requests
40+
from google.oauth2.service_account import Credentials
41+
42+
try:
43+
if os.path.isfile(private_key):
44+
with open(private_key) as f:
45+
json_key = json.loads(f.read())
46+
else:
47+
# ugly hack: 'private_key' field has new lines inside,
48+
# they break json parser, but we need to preserve them
49+
json_key = json.loads(private_key.replace('\n', ' '))
50+
json_key['private_key'] = json_key['private_key'].replace(
51+
' ', '\n')
52+
53+
if pandas.compat.PY3:
54+
json_key['private_key'] = bytes(
55+
json_key['private_key'], 'UTF-8')
56+
57+
credentials = Credentials.from_service_account_info(json_key)
58+
credentials = credentials.with_scopes(SCOPES)
59+
60+
# Refresh the token before trying to use it.
61+
request = google.auth.transport.requests.Request()
62+
credentials.refresh(request)
63+
64+
return credentials, json_key.get('project_id')
65+
except (KeyError, ValueError, TypeError, AttributeError):
66+
raise pandas_gbq.exceptions.InvalidPrivateKeyFormat(
67+
"Private key is missing or invalid. It should be service "
68+
"account private key JSON (file path or string contents) "
69+
"with at least two keys: 'client_email' and 'private_key'. "
70+
"Can be obtained from: https://console.developers.google."
71+
"com/permissions/serviceaccounts")
72+
73+
74+
def get_application_default_credentials(project_id=None):
75+
"""
76+
This method tries to retrieve the "default application credentials".
77+
This could be useful for running code on Google Cloud Platform.
78+
79+
Parameters
80+
----------
81+
project_id (str, optional): Override the default project ID.
82+
83+
Returns
84+
-------
85+
- GoogleCredentials,
86+
If the default application credentials can be retrieved
87+
from the environment. The retrieved credentials should also
88+
have access to the project (project_id) on BigQuery.
89+
- OR None,
90+
If default application credentials can not be retrieved
91+
from the environment. Or, the retrieved credentials do not
92+
have access to the project (project_id) on BigQuery.
93+
"""
94+
import google.auth
95+
from google.auth.exceptions import DefaultCredentialsError
96+
97+
try:
98+
credentials, default_project = google.auth.default(scopes=SCOPES)
99+
except (DefaultCredentialsError, IOError):
100+
return None, None
101+
102+
# Even though we now have credentials, check that the credentials can be
103+
# used with BigQuery. For example, we could be running on a GCE instance
104+
# that does not allow the BigQuery scopes.
105+
billing_project = project_id or default_project
106+
return _try_credentials(billing_project, credentials), billing_project
107+
108+
109+
def get_user_account_credentials(
110+
project_id=None, reauth=False, auth_local_webserver=False,
111+
credentials_path=None):
112+
"""Gets user account credentials.
113+
114+
This method authenticates using user credentials, either loading saved
115+
credentials from a file or by going through the OAuth flow.
116+
117+
Parameters
118+
----------
119+
None
120+
121+
Returns
122+
-------
123+
GoogleCredentials : credentials
124+
Credentials for the user with BigQuery access.
125+
"""
126+
from google_auth_oauthlib.flow import InstalledAppFlow
127+
from oauthlib.oauth2.rfc6749.errors import OAuth2Error
128+
129+
# Use the default credentials location under ~/.config and the
130+
# equivalent directory on windows if the user has not specified a
131+
# credentials path.
132+
if not credentials_path:
133+
credentials_path = get_default_credentials_path()
134+
135+
# Previously, pandas-gbq saved user account credentials in the
136+
# current working directory. If the bigquery_credentials.dat file
137+
# exists in the current working directory, move the credentials to
138+
# the new default location.
139+
if os.path.isfile('bigquery_credentials.dat'):
140+
os.rename('bigquery_credentials.dat', credentials_path)
141+
142+
credentials = load_user_account_credentials(
143+
project_id=project_id, credentials_path=credentials_path)
144+
145+
client_config = {
146+
'installed': {
147+
'client_id': ('495642085510-k0tmvj2m941jhre2nbqka17vqpjfddtd'
148+
'.apps.googleusercontent.com'),
149+
'client_secret': 'kOc9wMptUtxkcIFbtZCcrEAc',
150+
'redirect_uris': ['urn:ietf:wg:oauth:2.0:oob'],
151+
'auth_uri': 'https://accounts.google.com/o/oauth2/auth',
152+
'token_uri': 'https://accounts.google.com/o/oauth2/token',
153+
}
154+
}
155+
156+
if credentials is None or reauth:
157+
app_flow = InstalledAppFlow.from_client_config(
158+
client_config, scopes=SCOPES)
159+
160+
try:
161+
if auth_local_webserver:
162+
credentials = app_flow.run_local_server()
163+
else:
164+
credentials = app_flow.run_console()
165+
except OAuth2Error as ex:
166+
raise pandas_gbq.exceptions.AccessDenied(
167+
"Unable to get valid credentials: {0}".format(ex))
168+
169+
save_user_account_credentials(credentials, credentials_path)
170+
171+
return credentials
172+
173+
174+
def load_user_account_credentials(project_id=None, credentials_path=None):
175+
"""
176+
Loads user account credentials from a local file.
177+
178+
.. versionadded 0.2.0
179+
180+
Parameters
181+
----------
182+
None
183+
184+
Returns
185+
-------
186+
- GoogleCredentials,
187+
If the credentials can loaded. The retrieved credentials should
188+
also have access to the project (project_id) on BigQuery.
189+
- OR None,
190+
If credentials can not be loaded from a file. Or, the retrieved
191+
credentials do not have access to the project (project_id)
192+
on BigQuery.
193+
"""
194+
import google.auth.transport.requests
195+
from google.oauth2.credentials import Credentials
196+
197+
try:
198+
with open(credentials_path) as credentials_file:
199+
credentials_json = json.load(credentials_file)
200+
except (IOError, ValueError):
201+
return None
202+
203+
credentials = Credentials(
204+
token=credentials_json.get('access_token'),
205+
refresh_token=credentials_json.get('refresh_token'),
206+
id_token=credentials_json.get('id_token'),
207+
token_uri=credentials_json.get('token_uri'),
208+
client_id=credentials_json.get('client_id'),
209+
client_secret=credentials_json.get('client_secret'),
210+
scopes=credentials_json.get('scopes'))
211+
212+
# Refresh the token before trying to use it.
213+
request = google.auth.transport.requests.Request()
214+
credentials.refresh(request)
215+
216+
return _try_credentials(project_id, credentials)
217+
218+
219+
def get_default_credentials_path():
220+
"""
221+
Gets the default path to the BigQuery credentials
222+
223+
.. versionadded 0.3.0
224+
225+
Returns
226+
-------
227+
Path to the BigQuery credentials
228+
"""
229+
if os.name == 'nt':
230+
config_path = os.environ['APPDATA']
231+
else:
232+
config_path = os.path.join(os.path.expanduser('~'), '.config')
233+
234+
config_path = os.path.join(config_path, 'pandas_gbq')
235+
236+
# Create a pandas_gbq directory in an application-specific hidden
237+
# user folder on the operating system.
238+
if not os.path.exists(config_path):
239+
os.makedirs(config_path)
240+
241+
return os.path.join(config_path, 'bigquery_credentials.dat')
242+
243+
244+
def save_user_account_credentials(credentials, credentials_path):
245+
"""
246+
Saves user account credentials to a local file.
247+
248+
.. versionadded 0.2.0
249+
"""
250+
try:
251+
with open(credentials_path, 'w') as credentials_file:
252+
credentials_json = {
253+
'refresh_token': credentials.refresh_token,
254+
'id_token': credentials.id_token,
255+
'token_uri': credentials.token_uri,
256+
'client_id': credentials.client_id,
257+
'client_secret': credentials.client_secret,
258+
'scopes': credentials.scopes,
259+
}
260+
json.dump(credentials_json, credentials_file)
261+
except IOError:
262+
logger.warning('Unable to save credentials.')
263+
264+
265+
def _try_credentials(project_id, credentials):
266+
from google.cloud import bigquery
267+
import google.api_core.exceptions
268+
269+
if not credentials:
270+
return None
271+
if not project_id:
272+
return credentials
273+
274+
try:
275+
client = bigquery.Client(project=project_id, credentials=credentials)
276+
# Check if the application has rights to the BigQuery project
277+
client.query('SELECT 1').result()
278+
return credentials
279+
except google.api_core.exceptions.GoogleAPIError:
280+
return None

pandas_gbq/exceptions.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
2+
3+
class AccessDenied(ValueError):
4+
"""
5+
Raised when invalid credentials are provided, or tokens have expired.
6+
"""
7+
pass
8+
9+
10+
class InvalidPrivateKeyFormat(ValueError):
11+
"""
12+
Raised when provided private key has invalid format.
13+
"""
14+
pass

0 commit comments

Comments
 (0)