From e2d774dba91a0763e177c39fb39ddc6367486207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Wygoda?= Date: Wed, 12 Jun 2019 14:00:26 +0200 Subject: [PATCH] Allow loading client configuration from env (#70) --- README.rst | 45 +++++++- google/ads/google_ads/client.py | 90 +++++++++++++-- tests/client_test.py | 191 ++++++++++++++++++++------------ 3 files changed, 244 insertions(+), 82 deletions(-) diff --git a/README.rst b/README.rst index d3f4cea9d..6489d522e 100644 --- a/README.rst +++ b/README.rst @@ -50,6 +50,9 @@ please refer to `this page of the wiki`_. Create a GoogleAdsClient ######################## +Using YAML file +*************** + You can run the following to retrieve a `GoogleAdsClient` instance using a configuration file named **google-ads.yaml** stored in your home directory: @@ -57,6 +60,43 @@ configuration file named **google-ads.yaml** stored in your home directory: client = google.ads.google_ads.client.GoogleAdsClient.load_from_storage() +Using environment variables +*************************** + +You can also retrieve it exporting environment variables. + +* Required: + +.. code-block:: bash + + export GOOGLE_ADS_CLIENT_ID=INSERT_OAUTH2_CLIENT_ID_HERE + export GOOGLE_ADS_CLIENT_SECRET=INSERT_OAUTH2_CLIENT_SECRET_HERE + export GOOGLE_ADS_REFRESH_TOKEN=INSERT_REFRESH_TOKEN_HERE + export GOOGLE_ADS_DEVELOPER_TOKEN=INSERT_DEVELOPER_TOKEN_HERE + +* Optional: + +.. code-block:: bash + + export GOOGLE_ADS_LOGIN_CUSTOMER_ID=INSERT_LOGIN_CUSTOMER_ID_HERE + export GOOGLE_ADS_LOGGING=INSERT_GOOGLE_ADS_LOGGING + +.. _GOOGLE_ADS_LOGGING: + +GOOGLE_ADS_LOGGING should be a JSON with logging configuration. Example: + +.. code-block:: json + + {"version": 1, "disable_existing_loggers": false, "formatters": {"default_fmt": {"format": "[%(asctime)s - %(levelname)s] %(message).5000s", "datefmt": "%Y-%m-%d %H:%M:%S"}}, "handlers": {"default_handler": {"class": "logging.StreamHandler", "formatter": "default_fmt"}}, "loggers": {"": {"handlers": ["default_handler"], "level": "INFO"}}} + + +Then run the following to retrieve a GoogleAdsClient instance: + +.. code-block:: python + + client = google.ads.google_ads.client.GoogleAdsClient.load_from_env() + + Get types and service clients ############################# You can use a `GoogleAdsClient` instance to retrieve any type or service used @@ -94,8 +134,9 @@ The currently available list of versions is: Enabling and Configuring logging ################################ The library uses Python's built in logging framework. You can specify your -configuration via the configuration file; see `google-ads.yaml`_ -for an example. The library logs to ``stderr`` by default. You can easily pipe +configuration via the configuration file (see `google-ads.yaml`_ +for an example) or GOOGLE_ADS_LOGGING_ environment variable. +The library logs to ``stderr`` by default. You can easily pipe log messages to a file; when running an example: .. code-block:: bash diff --git a/google/ads/google_ads/client.py b/google/ads/google_ads/client.py index a43c91b63..3ca899d17 100644 --- a/google/ads/google_ads/client.py +++ b/google/ads/google_ads/client.py @@ -38,16 +38,24 @@ _VALID_API_VERSIONS = ['v1'] _DEFAULT_VERSION = _VALID_API_VERSIONS[0] _REQUEST_ID_KEY = 'request-id' +_ENV_PREFIX = 'GOOGLE_ADS_' +_KEYS_ENV_VARIABLES_MAP = { + key: _ENV_PREFIX + key.upper() for key in + list(_REQUIRED_KEYS) + ['login_customer_id', 'endpoint', 'logging'] +} class GoogleAdsClient(object): """Google Ads client used to configure settings and fetch services.""" @classmethod - def _get_client_kwargs_from_yaml(cls, yaml_str): - """Utility function used to load client kwargs from YAML string. + def _get_client_kwargs(cls, config_data, error_message): + """Utility function used to load client kwargs from configuration data + dictionary. Args: - yaml_str: a str containing client configuration in YAML format. + config_data: a dict containing client configuration. + error_message: error message raised when config_data lacks a + required key Returns: A dict containing configuration data that will be provided to the @@ -56,8 +64,6 @@ def _get_client_kwargs_from_yaml(cls, yaml_str): Raises: ValueError: If the configuration lacks a required field. """ - config_data = yaml.safe_load(yaml_str) or {} - if all(required_key in config_data for required_key in _REQUIRED_KEYS): credentials = Credentials( None, @@ -77,9 +83,64 @@ def _get_client_kwargs_from_yaml(cls, yaml_str): 'login_customer_id': login_customer_id, 'logging_config': config_data.get('logging')} else: - raise ValueError('A required field in the configuration data was' - 'not found. The required fields are: %s' - % str(_REQUIRED_KEYS)) + raise ValueError(error_message) + + @classmethod + def _get_client_kwargs_from_env(cls): + """Utility function used to load client kwargs from env variables. + + Returns: + A dict containing configuration data that will be provided to the + GoogleAdsClient initializer as keyword arguments. + + Raises: + ValueError: If the environment lacks a required field or + GOOGLE_ADS_LOGGING env variable is not in JSON format. + """ + config_data = { + key: os.environ[env_variable] + for key, env_variable in _KEYS_ENV_VARIABLES_MAP.items() + if env_variable in os.environ + } + if 'logging' in config_data.keys(): + try: + config_data['logging'] = json.loads(config_data['logging']) + except json.JSONDecodeError: + raise ValueError( + 'GOOGLE_ADS_LOGGING env variable should be in JSON format.' + ) + return cls._get_client_kwargs( + config_data, + 'A required variable was not found in the environment. The ' + 'required environment variables are: %s' + % str( + tuple( + v for k, v in _KEYS_ENV_VARIABLES_MAP.items() + if k in _REQUIRED_KEYS + ) + ) + ) + + @classmethod + def _get_client_kwargs_from_yaml(cls, yaml_str): + """Utility function used to load client kwargs from YAML string. + + Args: + yaml_str: a str containing client configuration in YAML format. + + Returns: + A dict containing configuration data that will be provided to the + GoogleAdsClient initializer as keyword arguments. + + Raises: + ValueError: If the configuration lacks a required field. + """ + config_data = yaml.safe_load(yaml_str) or {} + return cls._get_client_kwargs( + config_data, + 'A required field in the configuration data was not found. The ' + 'required fields are: %s' % str(_REQUIRED_KEYS) + ) @classmethod def get_type(cls, name, version=_DEFAULT_VERSION): @@ -106,6 +167,19 @@ def get_type(cls, name, version=_DEFAULT_VERSION): 'API %s.' % (name, version)) return message_type() + @classmethod + def load_from_env(cls): + """Creates a GoogleAdsClient with data stored in the env variables. + + Returns: + A GoogleAdsClient initialized with the values specified in the + env variables. + + Raises: + ValueError: If the configuration lacks a required field. + """ + return cls(**cls._get_client_kwargs_from_env()) + @classmethod def load_from_string(cls, yaml_str): """Creates a GoogleAdsClient with data stored in the YAML string. diff --git a/tests/client_test.py b/tests/client_test.py index ef861ecfa..8cf32e626 100644 --- a/tests/client_test.py +++ b/tests/client_test.py @@ -39,6 +39,18 @@ class ModuleLevelTest(TestCase): + def test_validate_login_customer_id_invalid(self): + self.assertRaises( + ValueError, + Client._validate_login_customer_id, + '123-456-7890') + + def test_validate_login_customer_id_too_short(self): + self.assertRaises( + ValueError, + Client._validate_login_customer_id, + '123') + def test_parse_metadata_to_json(self): mock_metadata = [ ('x-goog-api-client', @@ -97,7 +109,7 @@ def setUp(self): self.refresh_token = 'refresh' self.login_customer_id = '1234567890' - def test_load_from_storage_login_customer_id(self): + def test_get_client_kwargs_login_customer_id(self): config = { 'developer_token': self.developer_token, 'client_id': self.client_id, @@ -106,25 +118,23 @@ def test_load_from_storage_login_customer_id(self): 'login_customer_id': self.login_customer_id } - file_path = os.path.join(os.path.expanduser('~'), 'google-ads.yaml') - self.fs.create_file(file_path, contents=yaml.safe_dump(config)) - - with mock.patch('google.ads.google_ads.client.GoogleAdsClient' - '.__init__') as mock_client_init, \ - mock.patch( + with mock.patch( 'google.ads.google_ads.client.Credentials') as mock_credentials: - mock_client_init.return_value = None mock_credentials_instance = mock.Mock() mock_credentials.return_value = mock_credentials_instance - Client.GoogleAdsClient.load_from_storage() - mock_client_init.assert_called_once_with( - credentials=mock_credentials_instance, - developer_token=self.developer_token, - endpoint=None, - login_customer_id=self.login_customer_id, - logging_config=None) - - def test_load_from_storage_login_customer_id_as_None(self): + result = (Client.GoogleAdsClient. + _get_client_kwargs(config, '')) + self.assertEqual( + result, + { + 'credentials': mock_credentials_instance, + 'developer_token': self.developer_token, + 'endpoint': None, + 'login_customer_id': self.login_customer_id, + 'logging_config': None + }) + + def test_get_client_kwargs_login_customer_id_as_None(self): config = { 'developer_token': self.developer_token, 'client_id': self.client_id, @@ -133,67 +143,98 @@ def test_load_from_storage_login_customer_id_as_None(self): 'login_customer_id': None } - file_path = os.path.join(os.path.expanduser('~'), 'google-ads.yaml') - self.fs.create_file(file_path, contents=yaml.safe_dump(config)) - - with mock.patch('google.ads.google_ads.client.GoogleAdsClient' - '.__init__') as mock_client_init, \ - mock.patch( + with mock.patch( 'google.ads.google_ads.client.Credentials') as mock_credentials: - mock_client_init.return_value = None mock_credentials_instance = mock.Mock() mock_credentials.return_value = mock_credentials_instance - Client.GoogleAdsClient.load_from_storage() - mock_client_init.assert_called_once_with( - credentials=mock_credentials_instance, - developer_token=self.developer_token, - endpoint=None, - login_customer_id=None, - logging_config=None) - - def test_load_from_storage_invalid_login_customer_id(self): + result = (Client.GoogleAdsClient. + _get_client_kwargs(config, '')) + self.assertEqual( + result, + { + 'credentials': mock_credentials_instance, + 'developer_token': self.developer_token, + 'endpoint': None, + 'login_customer_id': None, + 'logging_config': None + }) + + def test_get_client_kwargs(self): config = { 'developer_token': self.developer_token, 'client_id': self.client_id, 'client_secret': self.client_secret, - 'refresh_token': self.refresh_token, - 'login_customer_id': '123-456-7890' + 'refresh_token': self.refresh_token } - file_path = os.path.join(os.path.expanduser('~'), 'google-ads.yaml') - self.fs.create_file(file_path, contents=yaml.safe_dump(config)) - with mock.patch( 'google.ads.google_ads.client.Credentials') as mock_credentials: mock_credentials_instance = mock.Mock() mock_credentials.return_value = mock_credentials_instance - self.assertRaises( - ValueError, - Client.GoogleAdsClient - .load_from_storage) - - def test_load_from_storage_too_short_login_customer_id(self): + result = (Client.GoogleAdsClient._get_client_kwargs(config, '')) + self.assertEqual( + result, + { + 'credentials': mock_credentials_instance, + 'developer_token': self.developer_token, + 'endpoint': None, + 'login_customer_id': None, + 'logging_config': None + }) + + def test_get_client_kwargs_custom_endpoint(self): + endpoint = 'alt.endpoint.com' config = { 'developer_token': self.developer_token, 'client_id': self.client_id, 'client_secret': self.client_secret, 'refresh_token': self.refresh_token, - 'login_customer_id': '123' + 'endpoint': endpoint } - file_path = os.path.join(os.path.expanduser('~'), 'google-ads.yaml') - self.fs.create_file(file_path, contents=yaml.safe_dump(config)) - with mock.patch( 'google.ads.google_ads.client.Credentials') as mock_credentials: mock_credentials_instance = mock.Mock() mock_credentials.return_value = mock_credentials_instance - self.assertRaises( - ValueError, - Client.GoogleAdsClient - .load_from_storage) - - def test_load_from_storage(self): + result = (Client.GoogleAdsClient. + _get_client_kwargs(config, '')) + self.assertEqual( + result, + { + 'credentials': mock_credentials_instance, + 'developer_token': self.developer_token, + 'endpoint': endpoint, + 'login_customer_id': None, + 'logging_config': None + }) + + def test_get_client_kwargs_from_env(self): + environ = { + 'GOOGLE_ADS_DEVELOPER_TOKEN': self.developer_token, + 'GOOGLE_ADS_CLIENT_ID': self.client_id, + 'GOOGLE_ADS_CLIENT_SECRET': self.client_secret, + 'GOOGLE_ADS_REFRESH_TOKEN': self.refresh_token, + 'GOOGLE_ADS_LOGGING': '{"test": true}' + } + with mock.patch('os.environ', environ): + with mock.patch( + 'google.ads.google_ads.client.Credentials' + ) as mock_credentials: + mock_credentials_instance = mock.Mock() + mock_credentials.return_value = mock_credentials_instance + result = (Client.GoogleAdsClient. + _get_client_kwargs_from_env()) + self.assertEqual( + result, + { + 'credentials': mock_credentials_instance, + 'developer_token': self.developer_token, + 'endpoint': None, + 'login_customer_id': None, + 'logging_config': {'test': True} + }) + + def test_get_client_kwargs_from_yaml(self): config = { 'developer_token': self.developer_token, 'client_id': self.client_id, @@ -201,32 +242,30 @@ def test_load_from_storage(self): 'refresh_token': self.refresh_token } - file_path = os.path.join(os.path.expanduser('~'), 'google-ads.yaml') - self.fs.create_file(file_path, contents=yaml.safe_dump(config)) + yaml_str = yaml.safe_dump(config) - with mock.patch('google.ads.google_ads.client.GoogleAdsClient' - '.__init__') as mock_client_init, \ - mock.patch( + with mock.patch( 'google.ads.google_ads.client.Credentials') as mock_credentials: - mock_client_init.return_value = None mock_credentials_instance = mock.Mock() mock_credentials.return_value = mock_credentials_instance - Client.GoogleAdsClient.load_from_storage() - mock_client_init.assert_called_once_with( - credentials=mock_credentials_instance, - developer_token=self.developer_token, - endpoint=None, - login_customer_id=None, - logging_config=None) + result = (Client.GoogleAdsClient._get_client_kwargs_from_yaml( + yaml_str)) + self.assertEqual( + result, + { + 'credentials': mock_credentials_instance, + 'developer_token': self.developer_token, + 'endpoint': None, + 'login_customer_id': None, + 'logging_config': None + }) - def test_load_from_storage_custom_endpoint(self): - endpoint = 'alt.endpoint.com' + def test_load_from_storage(self): config = { 'developer_token': self.developer_token, 'client_id': self.client_id, 'client_secret': self.client_secret, - 'refresh_token': self.refresh_token, - 'endpoint': endpoint + 'refresh_token': self.refresh_token } file_path = os.path.join(os.path.expanduser('~'), 'google-ads.yaml') @@ -243,7 +282,7 @@ def test_load_from_storage_custom_endpoint(self): mock_client_init.assert_called_once_with( credentials=mock_credentials_instance, developer_token=self.developer_token, - endpoint=endpoint, + endpoint=None, login_customer_id=None, logging_config=None) @@ -296,6 +335,14 @@ def test_load_from_storage_required_config_missing(self): Client.GoogleAdsClient.load_from_storage, path=file_path) + def test_init_validate_login_customer_id(self): + with mock.patch( + 'google.ads.google_ads.client._validate_login_customer_id' + ) as f: + Client.GoogleAdsClient( + None, None, login_customer_id='1234567890') + self.assertTrue(f.called) + def test_get_service(self): # Retrieve service names for all defined service clients. for ver in valid_versions: