Skip to content

Commit 38c55df

Browse files
authored
Implement Application Default Credentials (ADC) functionality. (#1035)
1 parent df5094d commit 38c55df

File tree

6 files changed

+89
-31
lines changed

6 files changed

+89
-31
lines changed

README.rst

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,6 @@ Documentation
2828
Please refer to our `Developer Site`_ for documentation on how to install,
2929
configure, and use this client library.
3030

31-
Protobuf Messages
32-
-----------------
33-
Version `14.0.0`_ of this library introduced the **required** `use_proto_plus`
34-
configuration option that specifies which type of protobuf message to use. For
35-
information on why this flag is important and what the differences are between
36-
the two message types, see the `Protobuf Messages`_ guide.
37-
38-
Minimum Dependency Versions
39-
---------------------------
40-
Version `21.2.0`_ of this library *lowered* the minimum version for some
41-
dependencies in order to improve compatibility with other applications and
42-
packages that rely on `protobuf`_ version 3.
43-
44-
Note that using protobuf 3 will cause performance degradations in this library,
45-
so you may experience slower response times. For optimal performance we
46-
recommend using protobuf versions 4.21.5 or higher.
47-
4831
Miscellaneous
4932
-------------
5033

@@ -73,7 +56,3 @@ Authors
7356
.. _Andrew Burke: https://github.com/AndrewMBurke
7457
.. _Laura Chevalier: https://github.com/laurachevalier4
7558
.. _Bob Hancock: https://github.com/bobhancock
76-
.. _14.0.0: https://pypi.org/project/google-ads/14.0.0/
77-
.. _21.2.0: https://pypi.org/project/google-ads/21.2.0/
78-
.. _Protobuf Messages: https://developers.google.com/google-ads/api/docs/client-libs/python/protobuf-messages
79-
.. _protobuf: https://pypi.org/project/protobuf/

google-ads.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,11 @@ login_customer_id: INSERT_LOGIN_CUSTOMER_ID_HERE
8181
# don't have username and password, just specify host and port. #
8282
# ########################################################################################
8383
# http_proxy: http://user:password@localhost:8000
84+
85+
# Application default credentials configuration
86+
##########################################################################################
87+
# Below you can specify whether to initialize the library using Application Default #
88+
# Credentials (ADC). To understand how ADC discovers credentials in a given environment, #
89+
# see: https://developers.google.com/identity/protocols/application-default-credentials #
90+
# ########################################################################################
91+
# use_application_default_credentials: True

google/ads/googleads/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"linked_customer_id",
3535
"http_proxy",
3636
"use_cloud_org_for_api_access",
37+
"use_application_default_credentials"
3738
)
3839
_CONFIG_FILE_PATH_KEY = ("configuration_file_path",)
3940
_OAUTH2_INSTALLED_APP_KEYS = ("client_id", "client_secret", "refresh_token")
@@ -159,6 +160,16 @@ def parser_wrapper(*args: Any, **kwargs: Any) -> Any:
159160
value: Union[str, bool] = parsed_config.get("use_proto_plus", False)
160161
parsed_config["use_proto_plus"]: bool = disambiguate_string_bool(value)
161162

163+
if "use_application_default_credentials" in config_keys:
164+
# When loaded from YAML, YAML string or a dict, this value is
165+
# evaluated as a bool. If it's loaded from an environment variable
166+
# it's evaluated as a string. If set to "False" as an environment
167+
# variable we need to manually change it to the bool False because
168+
# the string "False" is truthy and can easily be incorrectly
169+
# converted to the boolean True.
170+
value: Union[str, bool] = parsed_config.get("use_application_default_credentials", False)
171+
parsed_config["use_application_default_credentials"]: bool = disambiguate_string_bool(value)
172+
162173
return parsed_config
163174

164175
return parser_wrapper

google/ads/googleads/oauth2.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
from google.oauth2.service_account import Credentials as ServiceAccountCreds
2121
from google.oauth2.credentials import Credentials as InstalledAppCredentials
22+
from google.auth import default as ApplicationDefaultCredentials
23+
from google.auth.credentials import Credentials as CredentialsBaseClass
2224
from google.auth.transport.requests import Request
2325

2426
from google.ads.googleads import config
@@ -109,9 +111,23 @@ def get_service_account_credentials(
109111
)
110112

111113

112-
def get_credentials(
113-
config_data: dict[str, Any]
114-
) -> Union[InstalledAppCredentials, ServiceAccountCreds]:
114+
@_initialize_credentials_decorator
115+
def get_application_default_credentials(
116+
scopes: list[str] = _SERVICE_ACCOUNT_SCOPES
117+
) -> CredentialsBaseClass:
118+
"""Loads Application Default Credentials as returns them.
119+
120+
Args:
121+
scopes: A list of additional scopes.
122+
123+
Returns:
124+
An instance of auth.credentials.Credentials
125+
"""
126+
(credentials, _) = ApplicationDefaultCredentials(scopes=scopes)
127+
return credentials
128+
129+
130+
def get_credentials(config_data: dict[str, Any]) -> CredentialsBaseClass:
115131
"""Decides which type of credentials to return based on the given config.
116132
117133
Args:
@@ -127,20 +143,23 @@ def get_credentials(
127143
str, ...
128144
] = config.get_oauth2_required_service_account_keys()
129145

146+
if config_data.get("use_application_default_credentials"):
147+
# Using Application Default Credentials
148+
return get_application_default_credentials()
130149
if all(key in config_data for key in required_installed_app_keys):
131150
# Using the Installed App Flow
132151
return get_installed_app_credentials(
133-
config_data.get("client_id"), # type: ignore[arg-type]
134-
config_data.get("client_secret"), # type: ignore[arg-type]
135-
config_data.get("refresh_token"), # type: ignore[arg-type]
136-
http_proxy=config_data.get("http_proxy"), # type: ignore[arg-type]
152+
config_data.get("client_id"),
153+
config_data.get("client_secret"),
154+
config_data.get("refresh_token"),
155+
http_proxy=config_data.get("http_proxy"),
137156
)
138157
elif all(key in config_data for key in required_service_account_keys):
139158
# Using the Service Account Flow
140159
return get_service_account_credentials(
141-
config_data.get("json_key_file_path"), # type: ignore[arg-type]
142-
config_data.get("impersonated_email"), # type: ignore[arg-type]
143-
http_proxy=config_data.get("http_proxy"), # type: ignore[arg-type]
160+
config_data.get("json_key_file_path"),
161+
config_data.get("impersonated_email"),
162+
http_proxy=config_data.get("http_proxy"),
144163
)
145164
else:
146165
raise ValueError(

tests/config_test.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def setUp(self):
3838
self.impersonated_email = "[email protected]"
3939
self.use_proto_plus = False
4040
self.use_cloud_org_for_api_access = False
41+
self.use_application_default_credentials = True
4142
# The below fields are defaults that include required keys.
4243
# They are merged with other keys in individual tests, and isolated
4344
# here so that new required keys don't need to be added to each test.
@@ -355,6 +356,7 @@ def test_load_from_env(self, config_spy):
355356
"GOOGLE_ADS_LINKED_CUSTOMER_ID": self.linked_customer_id,
356357
"GOOGLE_ADS_JSON_KEY_FILE_PATH": self.json_key_file_path,
357358
"GOOGLE_ADS_IMPERSONATED_EMAIL": self.impersonated_email,
359+
"GOOGLE_ADS_USE_APPLICATION_DEFAULT_CREDENTIALS": self.use_application_default_credentials,
358360
},
359361
}
360362

@@ -374,6 +376,7 @@ def test_load_from_env(self, config_spy):
374376
"linked_customer_id": self.linked_customer_id,
375377
"json_key_file_path": self.json_key_file_path,
376378
"impersonated_email": self.impersonated_email,
379+
"use_application_default_credentials": self.use_application_default_credentials
377380
},
378381
)
379382
config_spy.assert_called_once()
@@ -689,3 +692,16 @@ def test_load_from_yaml_file_use_cloud_org_for_api_access_not_set(self):
689692
self._create_mock_yaml({})
690693
result = config.load_from_yaml_file()
691694
self.assertEqual(result.get("use_cloud_org_for_api_access"), None)
695+
696+
def test_load_from_yaml_file_use_account_default_credentials(self):
697+
"""Should load "use_account_default_credentials" config from a yaml."""
698+
self._create_mock_yaml({"use_account_default_credentials": True})
699+
700+
result = config.load_from_yaml_file()
701+
self.assertEqual(result["use_account_default_credentials"], True)
702+
703+
def test_load_from_yaml_file_use_account_default_credentials_not_set(self):
704+
"""Should set "use_account_default_credentials" as None when not set."""
705+
self._create_mock_yaml({})
706+
result = config.load_from_yaml_file()
707+
self.assertEqual(result.get("use_account_default_credentials"), None)

tests/oauth2_test.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,28 @@ def test_get_installed_app_credentials_without_proxy(self):
173173
self.client_id, self.client_secret, self.refresh_token
174174
)
175175
mock_request_class.assert_called_once_with()
176+
177+
def test_get_application_default_credentials(self):
178+
mock_credentials = mock.Mock()
179+
mock_request = mock.Mock()
180+
with mock.patch.object(
181+
oauth2, "ApplicationDefaultCredentials", return_value=(mock_credentials, None)
182+
) as mock_initializer, mock.patch.object(
183+
oauth2, "Request", return_value=mock_request
184+
) as mock_request_class:
185+
result = oauth2.get_application_default_credentials(self.scopes)
186+
187+
mock_initializer.assert_called_once_with(scopes=self.scopes)
188+
mock_request_class.assert_called_once()
189+
result.refresh.assert_called_once_with(mock_request)
190+
191+
def test_get_credentials_application_default(self):
192+
mock_config = {
193+
"use_application_default_credentials": True
194+
}
195+
196+
with mock.patch.object(
197+
oauth2, "get_application_default_credentials", return_value=None
198+
) as mock_initializer:
199+
oauth2.get_credentials(mock_config)
200+
mock_initializer.assert_called_once()

0 commit comments

Comments
 (0)