Skip to content

Commit ff224f7

Browse files
authored
Add Support for Additional Environment Variables (googleads#346)
1 parent 7ff6d09 commit ff224f7

9 files changed

Lines changed: 607 additions & 55 deletions

File tree

google-ads.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@ login_customer_id: INSERT_LOGIN_CUSTOMER_ID_HERE
2323
###############################################################################
2424
# To authenticate with a service account add the appropriate values to the #
2525
# below configuration parameters and remove the four OAuth credentials above. #
26-
# The "path_to_private_key_file" value should be a path to your local private #
27-
# key json file, and "delegated_account" should be the email address that is #
26+
# The "json_key_file_path" value should be a path to your local private #
27+
# key json file, and "impersonated_email" should be the email address that is #
2828
# being used to impersonate the credentials making requests. for more #
2929
# information on service accounts, see: #
3030
# https://developers.google.com/google-ads/api/docs/oauth/service-accounts #
3131
###############################################################################
32-
# path_to_private_key_file: INSERT_PATH_TO_JSON_KEY_FILE_HERE
33-
# delegated_account: INSERT_DOMAIN_WIDE_DELEGATION_ACCOUNT
32+
# json_key_file_path: INSERT_PATH_TO_JSON_KEY_FILE_HERE
33+
# impersonated_email: INSERT_DOMAIN_WIDE_DELEGATION_ACCOUNT
3434

3535
# Logging configuration
3636
###############################################################################

google/ads/google_ads/client.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def _get_client_kwargs(cls, config_data):
7171
"endpoint": config_data.get("endpoint"),
7272
"login_customer_id": config_data.get("login_customer_id"),
7373
"logging_config": config_data.get("logging"),
74+
"linked_customer_id": config_data.get("linked_customer_id"),
7475
}
7576

7677
@classmethod
@@ -207,6 +208,7 @@ def __init__(
207208
endpoint=None,
208209
login_customer_id=None,
209210
logging_config=None,
211+
linked_customer_id=None,
210212
):
211213
"""Initializer for the GoogleAdsClient.
212214
@@ -216,6 +218,7 @@ def __init__(
216218
endpoint: a str specifying an optional alternative API endpoint.
217219
login_customer_id: a str specifying a login customer ID.
218220
logging_config: a dict specifying logging config options.
221+
linked_customer_id: a str specifying a linked customer ID.
219222
"""
220223
if logging_config:
221224
logging.config.dictConfig(logging_config)
@@ -224,6 +227,7 @@ def __init__(
224227
self.developer_token = developer_token
225228
self.endpoint = endpoint
226229
self.login_customer_id = login_customer_id
230+
self.linked_customer_id = linked_customer_id
227231

228232
def get_service(self, name, version=_DEFAULT_VERSION, interceptors=None):
229233
"""Returns a service client instance for the specified service_name.
@@ -278,7 +282,11 @@ def get_service(self, name, version=_DEFAULT_VERSION, interceptors=None):
278282
)
279283

280284
interceptors = interceptors + [
281-
MetadataInterceptor(self.developer_token, self.login_customer_id),
285+
MetadataInterceptor(
286+
self.developer_token,
287+
self.login_customer_id,
288+
self.linked_customer_id,
289+
),
282290
LoggingInterceptor(_logger, version, endpoint),
283291
ExceptionInterceptor(version),
284292
]

google/ads/google_ads/config.py

Lines changed: 158 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,41 @@
1313
# limitations under the License.
1414
"""A set of functions to help load configuration from various locations."""
1515

16-
import json
1716
import functools
17+
import json
18+
import logging.config
1819
import os
1920
import yaml
2021

22+
23+
_logger = logging.getLogger(__name__)
24+
2125
_ENV_PREFIX = "GOOGLE_ADS_"
2226
_REQUIRED_KEYS = ("developer_token",)
23-
_OPTIONAL_KEYS = ("login_customer_id", "endpoint", "logging")
27+
_OPTIONAL_KEYS = (
28+
"login_customer_id",
29+
"endpoint",
30+
"logging",
31+
"linked_customer_id",
32+
)
33+
_CONFIG_FILE_PATH_KEY = ("configuration_file_path",)
2434
_OAUTH2_INSTALLED_APP_KEYS = ("client_id", "client_secret", "refresh_token")
25-
_OAUTH2_SERVICE_ACCOUNT_KEYS = ("path_to_private_key_file", "delegated_account")
35+
_OAUTH2_SERVICE_ACCOUNT_KEYS = ("json_key_file_path", "impersonated_email")
36+
# These keys are deprecated environment variables that can be used in place of
37+
# the primary OAuth2 service account keys for backwards compatibility. They will
38+
# be removed in favor of the primary keys at some point.
39+
_SECONDARY_OAUTH2_SERVICE_ACCOUNT_KEYS = (
40+
"path_to_private_key_file",
41+
"delegated_account",
42+
)
2643
_KEYS_ENV_VARIABLES_MAP = {
2744
key: _ENV_PREFIX + key.upper()
28-
for key in list(_REQUIRED_KEYS)
29-
+ list(_OPTIONAL_KEYS)
30-
+ list(_OAUTH2_INSTALLED_APP_KEYS)
31-
+ list(_OAUTH2_SERVICE_ACCOUNT_KEYS)
45+
for key in _REQUIRED_KEYS
46+
+ _OPTIONAL_KEYS
47+
+ _OAUTH2_INSTALLED_APP_KEYS
48+
+ _CONFIG_FILE_PATH_KEY
49+
+ _OAUTH2_SERVICE_ACCOUNT_KEYS
50+
+ _SECONDARY_OAUTH2_SERVICE_ACCOUNT_KEYS
3251
}
3352

3453

@@ -63,6 +82,61 @@ def _config_parser_decorator(func):
6382
def parser_wrapper(*args, **kwargs):
6483
config_dict = func(*args, **kwargs)
6584
parsed_config = convert_login_customer_id_to_str(config_dict)
85+
parsed_config = convert_linked_customer_id_to_str(parsed_config)
86+
87+
config_keys = parsed_config.keys()
88+
89+
if "logging" in config_keys:
90+
logging_config = parsed_config["logging"]
91+
# If the logging config is a dict then it is already in the format
92+
# that needs to be returned by this method.
93+
if type(logging_config) is not dict:
94+
try:
95+
parsed_config["logging"] = json.loads(logging_config)
96+
# The logger is configured here in case deprecation warnings
97+
# need to be logged further down in this method. The logger
98+
# is otherwise configured by the GoogleAdsClient class.
99+
logging.config.dictConfig(parsed_config["logging"])
100+
except json.JSONDecodeError:
101+
raise ValueError(
102+
"Could not configure the client because the logging "
103+
"configuration defined in the 'logging' key or "
104+
"'GOOGLE_ADS_LOGGING' environment variable is invalid. "
105+
"The configuration value should be a valid JSON string."
106+
)
107+
108+
if "path_to_private_key_file" in config_keys:
109+
_logger.warning(
110+
"The 'path_to_private_key_file' configuration key and "
111+
"'GOOGLE_ADS_PATH_TO_PRIVATE_KEY_FILE' environment variable "
112+
"are deprecated and support will be removed at some point in "
113+
"the future. Please use 'json_key_file_path' configuration key "
114+
"or 'GOOGLE_ADS_JSON_KEY_FILE_PATH' environment variable "
115+
"instead."
116+
)
117+
if "json_key_file_path" not in config_keys:
118+
parsed_config["json_key_file_path"] = parsed_config[
119+
"path_to_private_key_file"
120+
]
121+
122+
del parsed_config["path_to_private_key_file"]
123+
124+
if "delegated_account" in config_keys:
125+
_logger.warning(
126+
"The 'delegated_account' configuration key and "
127+
"'GOOGLE_ADS_DELEGATED_PATH' environment variable are "
128+
"deprecated and support will be removed at some point in "
129+
"the future. Please use 'impersonated_email' configuration key "
130+
"or 'GOOGLE_ADS_IMPERSONATED_EMAIL' environment variable "
131+
"instead."
132+
)
133+
if "impersonated_email" not in config_keys:
134+
parsed_config["impersonated_email"] = parsed_config[
135+
"delegated_account"
136+
]
137+
138+
del parsed_config["delegated_account"]
139+
66140
return parsed_config
67141

68142
return parser_wrapper
@@ -74,6 +148,7 @@ def validate_dict(config_data):
74148
Validations that are performed include:
75149
1. Ensuring all required keys are present.
76150
2. If a login_customer_id is present ensure it's valid
151+
3. If a linked_customer_id is present ensure it's valid
77152
78153
Args:
79154
config_data: a dict with configuration data.
@@ -90,26 +165,57 @@ def validate_dict(config_data):
90165
if "login_customer_id" in config_data:
91166
validate_login_customer_id(config_data["login_customer_id"])
92167

168+
if "linked_customer_id" in config_data:
169+
validate_linked_customer_id(config_data["linked_customer_id"])
93170

94-
def validate_login_customer_id(login_customer_id):
95-
"""Validates a login customer ID.
171+
172+
def _validate_customer_id(customer_id, id_type):
173+
"""Validates a customer ID.
96174
97175
Args:
98-
login_customer_id: a str from config indicating a login customer ID.
176+
customer_id: a str from config indicating a login customer ID or
177+
linked customer ID.
178+
id_type: a str of the type of customer ID, either "login" or "linked".
99179
100180
Raises:
101-
ValueError: If the login customer ID is not an int in the
181+
ValueError: If the customer ID is not an int in the
102182
range 0 - 9999999999.
103183
"""
104-
if login_customer_id is not None:
105-
if not login_customer_id.isdigit() or len(login_customer_id) != 10:
184+
if customer_id is not None:
185+
if not customer_id.isdigit() or len(customer_id) != 10:
106186
raise ValueError(
107-
"The specified login customer ID is invalid. "
187+
f"The specified {id_type} customer ID is invalid. "
108188
"It must be a ten digit number represented "
109189
'as a string, i.e. "1234567890"'
110190
)
111191

112192

193+
def validate_login_customer_id(login_customer_id):
194+
"""Validates a login customer ID.
195+
196+
Args:
197+
login_customer_id: a str from config indicating a login customer ID.
198+
199+
Raises:
200+
ValueError: If the login customer ID is not an int in the
201+
range 0 - 9999999999.
202+
"""
203+
_validate_customer_id(login_customer_id, "login")
204+
205+
206+
def validate_linked_customer_id(linked_customer_id):
207+
"""Validates a linked customer ID.
208+
209+
Args:
210+
linked_customer_id: a str from config indicating a linked customer ID.
211+
212+
Raises:
213+
ValueError: If the linked customer ID is not an int in the
214+
range 0 - 9999999999.
215+
"""
216+
_validate_customer_id(linked_customer_id, "linked")
217+
218+
113219
@_config_validation_decorator
114220
@_config_parser_decorator
115221
def load_from_yaml_file(path=None):
@@ -127,7 +233,17 @@ def load_from_yaml_file(path=None):
127233
IOError: If the configuration file can't be loaded.
128234
"""
129235
if path is None:
130-
path = os.path.join(os.path.expanduser("~"), "google-ads.yaml")
236+
# If no path is specified then we check for the environment variable
237+
# that may define the path. If that is not defined then we use the
238+
# default path.
239+
path_from_env_var = os.environ.get(
240+
_ENV_PREFIX + _CONFIG_FILE_PATH_KEY[0].upper()
241+
)
242+
path = (
243+
path_from_env_var
244+
if path_from_env_var
245+
else os.path.join(os.path.expanduser("~"), "google-ads.yaml")
246+
)
131247

132248
if not os.path.isabs(path):
133249
path = os.path.expanduser(path)
@@ -192,17 +308,15 @@ def load_from_env():
192308
ValueError: If the configuration
193309
"""
194310
config_data = {
195-
key: os.environ[env_variable]
311+
key: os.environ.get(env_variable)
196312
for key, env_variable in _KEYS_ENV_VARIABLES_MAP.items()
197313
if env_variable in os.environ
198314
}
199-
if "logging" in config_data.keys():
200-
try:
201-
config_data["logging"] = json.loads(config_data["logging"])
202-
except json.JSONDecodeError:
203-
raise ValueError(
204-
"GOOGLE_ADS_LOGGING env variable should be in JSON format."
205-
)
315+
316+
# If configuration_file_path is set by the environment then configuration
317+
# is retrieved from the yaml file specified in the given path.
318+
if "configuration_file_path" in config_data.keys():
319+
return load_from_yaml_file(config_data["configuration_file_path"])
206320

207321
return config_data
208322

@@ -244,3 +358,24 @@ def convert_login_customer_id_to_str(config_data):
244358
config_data["login_customer_id"] = str(login_customer_id)
245359

246360
return config_data
361+
362+
363+
def convert_linked_customer_id_to_str(config_data):
364+
"""Parses a config dict's linked_customer_id attr value to a str.
365+
366+
Like many values from YAML it's possible for linked_customer_id to
367+
either be a str or an int. Since we actually run validations on this
368+
value before making requests it's important to parse it to a str.
369+
370+
Args:
371+
config_data: A config dict object.
372+
373+
Returns:
374+
The same config dict object with a mutated linked_customer_id attr.
375+
"""
376+
linked_customer_id = config_data.get("linked_customer_id")
377+
378+
if linked_customer_id:
379+
config_data["linked_customer_id"] = str(linked_customer_id)
380+
381+
return config_data

google/ads/google_ads/interceptors/metadata_interceptor.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,27 @@ class MetadataInterceptor(
2929
):
3030
"""An interceptor that appends custom metadata to requests."""
3131

32-
def __init__(self, developer_token, login_customer_id):
32+
def __init__(
33+
self, developer_token, login_customer_id, linked_customer_id=None
34+
):
35+
"""Initialization method for this class.
36+
37+
Args:
38+
developer_token: a str developer token.
39+
login_customer_id: a str specifying a login customer ID.
40+
linked_customer_id: a str specifying a linked customer ID.
41+
"""
3342
self.developer_token_meta = ("developer-token", developer_token)
3443
self.login_customer_id_meta = (
3544
("login-customer-id", login_customer_id)
3645
if login_customer_id
3746
else None
3847
)
48+
self.linked_customer_id_meta = (
49+
("linked-customer-id", linked_customer_id)
50+
if linked_customer_id
51+
else None
52+
)
3953

4054
def _update_client_call_details_metadata(
4155
self, client_call_details, metadata
@@ -82,6 +96,9 @@ def _intercept(self, continuation, client_call_details, request):
8296
if self.login_customer_id_meta:
8397
metadata.append(self.login_customer_id_meta)
8498

99+
if self.linked_customer_id_meta:
100+
metadata.append(self.linked_customer_id_meta)
101+
85102
client_call_details = self._update_client_call_details_metadata(
86103
client_call_details, metadata
87104
)

google/ads/google_ads/oauth2.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,21 +67,20 @@ def get_installed_app_credentials(
6767

6868
@_initialize_credentials_decorator
6969
def get_service_account_credentials(
70-
path_to_private_key_file, subject, scopes=_SERVICE_ACCOUNT_SCOPES
70+
json_key_file_path, subject, scopes=_SERVICE_ACCOUNT_SCOPES
7171
):
7272
"""Creates and returns an instance of oauth2.service_account.Credentials.
7373
7474
Args:
75-
path_to_private_key_file: A str of the path to the private key file
76-
location.
75+
json_key_file_path: A str of the path to the private key file location.
7776
subject: A str of the email address of the delegated account.
7877
scopes: A list of additional scopes.
7978
8079
Returns:
8180
An instance of oauth2.credentials.Credentials
8281
"""
8382
return ServiceAccountCreds.from_service_account_file(
84-
path_to_private_key_file, subject=subject, scopes=scopes
83+
json_key_file_path, subject=subject, scopes=scopes
8584
)
8685

8786

@@ -107,8 +106,8 @@ def get_credentials(config_data):
107106
elif all(key in config_data for key in required_service_account_keys):
108107
# Using the Service Account Flow
109108
return get_service_account_credentials(
110-
config_data.get("path_to_private_key_file"),
111-
config_data.get("delegated_account"),
109+
config_data.get("json_key_file_path"),
110+
config_data.get("impersonated_email"),
112111
)
113112
else:
114113
raise ValueError(

0 commit comments

Comments
 (0)