Skip to content

Commit cc4e23d

Browse files
authored
refactor: consolidate BigQuery client creation and set user-agent (#100)
* refactor: consolidate BigQuery client creation and set user-agent * add tests for default project logic * use google-auth >=1.2.0 for AnonymousCredentials support * bump minimum google-cloud-bigquery version
1 parent 93ec6ab commit cc4e23d

File tree

6 files changed

+219
-63
lines changed

6 files changed

+219
-63
lines changed

pybigquery/_helpers.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Copyright 2021 The PyBigQuery Authors
2+
#
3+
# Use of this source code is governed by an MIT-style
4+
# license that can be found in the LICENSE file or at
5+
# https://opensource.org/licenses/MIT.
6+
7+
from google.api_core import client_info
8+
import google.auth
9+
from google.cloud import bigquery
10+
from google.oauth2 import service_account
11+
import sqlalchemy
12+
13+
14+
USER_AGENT_TEMPLATE = "sqlalchemy/{}"
15+
SCOPES = (
16+
"https://www.googleapis.com/auth/bigquery",
17+
"https://www.googleapis.com/auth/cloud-platform",
18+
"https://www.googleapis.com/auth/drive",
19+
)
20+
21+
22+
def google_client_info():
23+
user_agent = USER_AGENT_TEMPLATE.format(sqlalchemy.__version__)
24+
return client_info.ClientInfo(user_agent=user_agent)
25+
26+
27+
def create_bigquery_client(
28+
credentials_info=None,
29+
credentials_path=None,
30+
default_query_job_config=None,
31+
location=None,
32+
project_id=None,
33+
):
34+
default_project = None
35+
36+
if credentials_path:
37+
credentials = service_account.Credentials.from_service_account_file(
38+
credentials_path
39+
)
40+
credentials = credentials.with_scopes(SCOPES)
41+
default_project = credentials.project
42+
elif credentials_info:
43+
credentials = service_account.Credentials.from_service_account_info(
44+
credentials_info
45+
)
46+
credentials = credentials.with_scopes(SCOPES)
47+
default_project = credentials.project
48+
else:
49+
credentials, default_project = google.auth.default(scopes=SCOPES)
50+
51+
if project_id is None:
52+
project_id = default_project
53+
54+
return bigquery.Client(
55+
client_info=google_client_info(),
56+
project=project_id,
57+
credentials=credentials,
58+
location=location,
59+
default_query_job_config=default_query_job_config,
60+
)

pybigquery/api.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,18 @@
2222
from __future__ import absolute_import
2323
from __future__ import unicode_literals
2424

25-
from google.cloud.bigquery import Client, QueryJobConfig
25+
from google.cloud.bigquery import QueryJobConfig
26+
27+
from pybigquery import _helpers
2628

2729

2830
class ApiClient(object):
2931
def __init__(self, credentials_path=None, location=None):
3032
self.credentials_path = credentials_path
3133
self.location = location
32-
if self.credentials_path:
33-
self.client = Client.from_service_account_json(
34-
self.credentials_path, location=self.location
35-
)
36-
else:
37-
self.client = Client(location=self.location)
34+
self.client = _helpers.create_bigquery_client(
35+
credentials_path=credentials_path, location=location
36+
)
3837

3938
def dry_run_query(self, query):
4039
job_config = QueryJobConfig()

pybigquery/sqlalchemy_bigquery.py

Lines changed: 11 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,9 @@
2525
import operator
2626

2727
from google import auth
28-
from google.cloud import bigquery
2928
from google.cloud.bigquery import dbapi
3029
from google.cloud.bigquery.schema import SchemaField
3130
from google.cloud.bigquery.table import TableReference
32-
from google.oauth2 import service_account
3331
from google.api_core.exceptions import NotFound
3432
from sqlalchemy.exc import NoSuchTableError
3533
from sqlalchemy import types, util
@@ -46,6 +44,7 @@
4644
import re
4745

4846
from .parse_url import parse_url
47+
from pybigquery import _helpers
4948

5049
FIELD_ILLEGAL_CHARACTERS = re.compile(r"[^\w]+")
5150

@@ -342,30 +341,6 @@ def _add_default_dataset_to_job_config(job_config, project_id, dataset_id):
342341

343342
job_config.default_dataset = "{}.{}".format(project_id, dataset_id)
344343

345-
def _create_client_from_credentials(
346-
self, credentials, default_query_job_config, project_id
347-
):
348-
if project_id is None:
349-
project_id = credentials.project_id
350-
351-
scopes = (
352-
"https://www.googleapis.com/auth/bigquery",
353-
"https://www.googleapis.com/auth/cloud-platform",
354-
"https://www.googleapis.com/auth/drive",
355-
)
356-
credentials = credentials.with_scopes(scopes)
357-
358-
self._add_default_dataset_to_job_config(
359-
default_query_job_config, project_id, self.dataset_id
360-
)
361-
362-
return bigquery.Client(
363-
project=project_id,
364-
credentials=credentials,
365-
location=self.location,
366-
default_query_job_config=default_query_job_config,
367-
)
368-
369344
def create_connect_args(self, url):
370345
(
371346
project_id,
@@ -380,34 +355,16 @@ def create_connect_args(self, url):
380355
self.location = location or self.location
381356
self.credentials_path = credentials_path or self.credentials_path
382357
self.dataset_id = dataset_id
383-
384-
if self.credentials_path:
385-
credentials = service_account.Credentials.from_service_account_file(
386-
self.credentials_path
387-
)
388-
client = self._create_client_from_credentials(
389-
credentials, default_query_job_config, project_id
390-
)
391-
392-
elif self.credentials_info:
393-
credentials = service_account.Credentials.from_service_account_info(
394-
self.credentials_info
395-
)
396-
client = self._create_client_from_credentials(
397-
credentials, default_query_job_config, project_id
398-
)
399-
400-
else:
401-
self._add_default_dataset_to_job_config(
402-
default_query_job_config, project_id, dataset_id
403-
)
404-
405-
client = bigquery.Client(
406-
project=project_id,
407-
location=self.location,
408-
default_query_job_config=default_query_job_config,
409-
)
410-
358+
self._add_default_dataset_to_job_config(
359+
default_query_job_config, project_id, dataset_id
360+
)
361+
client = _helpers.create_bigquery_client(
362+
credentials_path=self.credentials_path,
363+
credentials_info=self.credentials_info,
364+
project_id=project_id,
365+
location=self.location,
366+
default_query_job_config=default_query_job_config,
367+
)
411368
return ([client], {})
412369

413370
def _json_deserializer(self, row):

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ def readme():
6565
platforms="Posix; MacOS X; Windows",
6666
install_requires=[
6767
"sqlalchemy>=1.1.9,<1.4.0dev",
68-
"google-cloud-bigquery>=1.6.0",
68+
"google-auth>=1.2.0,<2.0dev",
69+
"google-cloud-bigquery>=1.12.0",
6970
"future",
7071
],
7172
python_requires=">=3.6, <3.10",

testing/constraints-3.6.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
#
66
# e.g., if setup.py has "foo >= 1.14.0, < 2.0.0dev",
77
sqlalchemy==1.1.9
8-
google-cloud-bigquery==1.6.0
8+
google-auth==1.2.0
9+
google-cloud-bigquery==1.12.0

tests/unit/test_helpers.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Copyright 2021 The PyBigQuery Authors
2+
#
3+
# Use of this source code is governed by an MIT-style
4+
# license that can be found in the LICENSE file or at
5+
# https://opensource.org/licenses/MIT.
6+
7+
from unittest import mock
8+
9+
import google.auth
10+
import google.auth.credentials
11+
from google.oauth2 import service_account
12+
import pytest
13+
14+
15+
class AnonymousCredentialsWithProject(google.auth.credentials.AnonymousCredentials):
16+
"""Fake credentials to trick isinstance"""
17+
18+
def __init__(self, project):
19+
super().__init__()
20+
self.project = project
21+
22+
def with_scopes(self, scopes):
23+
return self
24+
25+
26+
@pytest.fixture(scope="session")
27+
def module_under_test():
28+
from pybigquery import _helpers
29+
30+
return _helpers
31+
32+
33+
def test_create_bigquery_client_with_credentials_path(monkeypatch, module_under_test):
34+
mock_service_account = mock.create_autospec(service_account.Credentials)
35+
mock_service_account.from_service_account_file.return_value = AnonymousCredentialsWithProject(
36+
"service-account-project"
37+
)
38+
monkeypatch.setattr(service_account, "Credentials", mock_service_account)
39+
40+
bqclient = module_under_test.create_bigquery_client(
41+
credentials_path="path/to/key.json",
42+
)
43+
44+
assert bqclient.project == "service-account-project"
45+
46+
47+
def test_create_bigquery_client_with_credentials_path_respects_project(
48+
monkeypatch, module_under_test
49+
):
50+
"""Test that project_id is used, even when there is a default project.
51+
52+
https://github.com/googleapis/python-bigquery-sqlalchemy/issues/48
53+
"""
54+
mock_service_account = mock.create_autospec(service_account.Credentials)
55+
mock_service_account.from_service_account_file.return_value = AnonymousCredentialsWithProject(
56+
"service-account-project"
57+
)
58+
monkeypatch.setattr(service_account, "Credentials", mock_service_account)
59+
60+
bqclient = module_under_test.create_bigquery_client(
61+
credentials_path="path/to/key.json", project_id="connection-url-project",
62+
)
63+
64+
assert bqclient.project == "connection-url-project"
65+
66+
67+
def test_create_bigquery_client_with_credentials_info(monkeypatch, module_under_test):
68+
mock_service_account = mock.create_autospec(service_account.Credentials)
69+
mock_service_account.from_service_account_info.return_value = AnonymousCredentialsWithProject(
70+
"service-account-project"
71+
)
72+
monkeypatch.setattr(service_account, "Credentials", mock_service_account)
73+
74+
bqclient = module_under_test.create_bigquery_client(
75+
credentials_info={
76+
"type": "service_account",
77+
"project_id": "service-account-project",
78+
},
79+
)
80+
81+
assert bqclient.project == "service-account-project"
82+
83+
84+
def test_create_bigquery_client_with_credentials_info_respects_project(
85+
monkeypatch, module_under_test
86+
):
87+
"""Test that project_id is used, even when there is a default project.
88+
89+
https://github.com/googleapis/python-bigquery-sqlalchemy/issues/48
90+
"""
91+
mock_service_account = mock.create_autospec(service_account.Credentials)
92+
mock_service_account.from_service_account_info.return_value = AnonymousCredentialsWithProject(
93+
"service-account-project"
94+
)
95+
monkeypatch.setattr(service_account, "Credentials", mock_service_account)
96+
97+
bqclient = module_under_test.create_bigquery_client(
98+
credentials_info={
99+
"type": "service_account",
100+
"project_id": "service-account-project",
101+
},
102+
project_id="connection-url-project",
103+
)
104+
105+
assert bqclient.project == "connection-url-project"
106+
107+
108+
def test_create_bigquery_client_with_default_credentials(
109+
monkeypatch, module_under_test
110+
):
111+
def mock_default_credentials(*args, **kwargs):
112+
return (google.auth.credentials.AnonymousCredentials(), "default-project")
113+
114+
monkeypatch.setattr(google.auth, "default", mock_default_credentials)
115+
116+
bqclient = module_under_test.create_bigquery_client()
117+
118+
assert bqclient.project == "default-project"
119+
120+
121+
def test_create_bigquery_client_with_default_credentials_respects_project(
122+
monkeypatch, module_under_test
123+
):
124+
"""Test that project_id is used, even when there is a default project.
125+
126+
https://github.com/googleapis/python-bigquery-sqlalchemy/issues/48
127+
"""
128+
129+
def mock_default_credentials(*args, **kwargs):
130+
return (google.auth.credentials.AnonymousCredentials(), "default-project")
131+
132+
monkeypatch.setattr(google.auth, "default", mock_default_credentials)
133+
134+
bqclient = module_under_test.create_bigquery_client(
135+
project_id="connection-url-project",
136+
)
137+
138+
assert bqclient.project == "connection-url-project"

0 commit comments

Comments
 (0)