Repository: googleapis/google-auth-library-python

Issue:
Stop reading Cloud SDK's configuration to obtain the project ID
Instead, we should subprocess out to `gcloud config config-helper --format json`

Example output:

```
{
  "configuration": {
    "active_configuration": "default",
    "properties": {
      "core": {
        "account": "[ELIDED]",
        "disable_usage_reporting": "False",
        "project": "[ELIDED]"
      }
    }
  },
  "credential": {
    "access_token": "[ELIDED]",
    "token_expiry": "2017-03-23T23:09:49Z"
  },
  "sentinels": {
    "config_sentinel": "/Users/jonwayne/.config/gcloud/config_sentinel"
  }
}
```

Note: we should *not* use the auth token provided here at all. We should keep our existing logic of reading `~/.config/gcloud/application_default_credentials.json` because those credentials are populated exclusively by `gcloud auth application-default login` and intended for Application Default Credentials.


Discussion:
/cc @markpell @bshaffer

Resolving patch:
diff --git a/google/auth/_cloud_sdk.py b/google/auth/_cloud_sdk.py
--- a/google/auth/_cloud_sdk.py
+++ b/google/auth/_cloud_sdk.py
@@ -14,11 +14,11 @@
 
 """Helpers for reading the Google Cloud SDK's configuration."""
 
-import io
+import json
 import os
+import subprocess
 
 import six
-from six.moves import configparser
 
 from google.auth import environment_vars
 import google.oauth2.credentials
@@ -33,9 +33,9 @@
 # The name of the file in the Cloud SDK config that contains default
 # credentials.
 _CREDENTIALS_FILENAME = 'application_default_credentials.json'
-# The config section and key for the project ID in the cloud SDK config.
-_PROJECT_CONFIG_SECTION = 'core'
-_PROJECT_CONFIG_KEY = 'project'
+# The command to get the Cloud SDK configuration
+_CLOUD_SDK_CONFIG_COMMAND = (
+    'gcloud', 'config', 'config-helper', '--format', 'json')
 
 
 def get_config_path():
@@ -80,66 +80,6 @@ def get_application_default_credentials_path():
     return os.path.join(config_path, _CREDENTIALS_FILENAME)
 
 
-def _get_active_config(config_path):
-    """Gets the active config for the Cloud SDK.
-
-    Args:
-        config_path (str): The Cloud SDK's config path.
-
-    Returns:
-        str: The active configuration name.
-    """
-    active_config_filename = os.path.join(config_path, 'active_config')
-
-    if not os.path.isfile(active_config_filename):
-        return 'default'
-
-    with io.open(active_config_filename, 'r', encoding='utf-8') as file_obj:
-        active_config_name = file_obj.read().strip()
-
-    return active_config_name
-
-
-def _get_config_file(config_path, config_name):
-    """Returns the full path to a configuration's config file.
-
-    Args:
-        config_path (str): The Cloud SDK's config path.
-        config_name (str): The configuration name.
-
-    Returns:
-        str: The config file path.
-    """
-    return os.path.join(
-        config_path, 'configurations', 'config_{}'.format(config_name))
-
-
-def get_project_id():
-    """Gets the project ID from the Cloud SDK's configuration.
-
-    Returns:
-        Optional[str]: The project ID.
-    """
-    config_path = get_config_path()
-    active_config = _get_active_config(config_path)
-    config_file = _get_config_file(config_path, active_config)
-
-    if not os.path.isfile(config_file):
-        return None
-
-    config = configparser.RawConfigParser()
-
-    try:
-        config.read(config_file)
-
-        if config.has_section(_PROJECT_CONFIG_SECTION):
-            return config.get(
-                _PROJECT_CONFIG_SECTION, _PROJECT_CONFIG_KEY)
-
-    except configparser.Error:
-        return None
-
-
 def load_authorized_user_credentials(info):
     """Loads an authorized user credential.
 
@@ -166,3 +106,28 @@ def load_authorized_user_credentials(info):
         token_uri=_GOOGLE_OAUTH2_TOKEN_ENDPOINT,
         client_id=info['client_id'],
         client_secret=info['client_secret'])
+
+
+def get_project_id():
+    """Gets the project ID from the Cloud SDK.
+
+    Returns:
+        Optional[str]: The project ID.
+    """
+
+    try:
+        output = subprocess.check_output(
+            _CLOUD_SDK_CONFIG_COMMAND,
+            stderr=subprocess.STDOUT)
+    except (subprocess.CalledProcessError, OSError, IOError):
+        return None
+
+    try:
+        configuration = json.loads(output.decode('utf-8'))
+    except ValueError:
+        return None
+
+    try:
+        return configuration['configuration']['properties']['core']['project']
+    except KeyError:
+        return None


Test patch:
diff --git a/system_tests/nox.py b/system_tests/nox.py
--- a/system_tests/nox.py
+++ b/system_tests/nox.py
@@ -85,6 +85,11 @@ def install_cloud_sdk(session):
     session.env[CLOUD_SDK_CONFIG_ENV] = str(CLOUD_SDK_ROOT)
     # This tells gcloud which Python interpreter to use (always use 2.7)
     session.env[CLOUD_SDK_PYTHON_ENV] = CLOUD_SDK_PYTHON
+    # This set the $PATH for the subprocesses so they can find the gcloud
+    # executable.
+    session.env['PATH'] = (
+        str(CLOUD_SDK_INSTALL_DIR.join('bin')) + os.pathsep +
+        os.environ['PATH'])
 
     # If gcloud cli executable already exists, just update it.
     if py.path.local(GCLOUD).exists():
@@ -130,6 +135,14 @@ def configure_cloud_sdk(
     """
     install_cloud_sdk(session)
 
+    # Setup the service account as the default user account. This is
+    # needed for the project ID detection to work. Note that this doesn't
+    # change the application default credentials file, which is user
+    # credentials instead of service account credentials sometimes.
+    session.run(
+        GCLOUD, 'auth', 'activate-service-account', '--key-file',
+        SERVICE_ACCOUNT_FILE)
+
     if project:
         session.run(GCLOUD, 'config', 'set', 'project', 'example-project')
     else:
diff --git a/tests/data/cloud_sdk.cfg b/tests/data/cloud_sdk.cfg
deleted file mode 100644
--- a/tests/data/cloud_sdk.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[core]
-project = example-project
diff --git a/tests/data/cloud_sdk_config.json b/tests/data/cloud_sdk_config.json
new file mode 100644
--- /dev/null
+++ b/tests/data/cloud_sdk_config.json
@@ -0,0 +1,19 @@
+{
+  "configuration": {
+    "active_configuration": "default",
+    "properties": {
+      "core": {
+        "account": "user@example.com",
+        "disable_usage_reporting": "False",
+        "project": "example-project"
+      }
+    }
+  },
+  "credential": {
+    "access_token": "don't use me",
+    "token_expiry": "2017-03-23T23:09:49Z"
+  },
+  "sentinels": {
+    "config_sentinel": "/Users/example/.config/gcloud/config_sentinel"
+  }
+}
diff --git a/tests/test__cloud_sdk.py b/tests/test__cloud_sdk.py
--- a/tests/test__cloud_sdk.py
+++ b/tests/test__cloud_sdk.py
@@ -12,11 +12,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import io
 import json
 import os
+import subprocess
 
 import mock
-import py
 import pytest
 
 from google.auth import _cloud_sdk
@@ -27,76 +28,52 @@
 DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
 AUTHORIZED_USER_FILE = os.path.join(DATA_DIR, 'authorized_user.json')
 
-with open(AUTHORIZED_USER_FILE) as fh:
+with io.open(AUTHORIZED_USER_FILE) as fh:
     AUTHORIZED_USER_FILE_DATA = json.load(fh)
 
 SERVICE_ACCOUNT_FILE = os.path.join(DATA_DIR, 'service_account.json')
 
-with open(SERVICE_ACCOUNT_FILE) as fh:
+with io.open(SERVICE_ACCOUNT_FILE) as fh:
     SERVICE_ACCOUNT_FILE_DATA = json.load(fh)
 
-with open(os.path.join(DATA_DIR, 'cloud_sdk.cfg')) as fh:
-    CLOUD_SDK_CONFIG_DATA = fh.read()
+with io.open(os.path.join(DATA_DIR, 'cloud_sdk_config.json'), 'rb') as fh:
+    CLOUD_SDK_CONFIG_FILE_DATA = fh.read()
 
-CONFIG_PATH_PATCH = mock.patch(
-    'google.auth._cloud_sdk.get_config_path', autospec=True)
-
-
-@pytest.fixture
-def config_dir(tmpdir):
-    config_dir = tmpdir.join(
-        '.config', _cloud_sdk._CONFIG_DIRECTORY)
-
-    with CONFIG_PATH_PATCH as mock_get_config_dir:
-        mock_get_config_dir.return_value = str(config_dir)
-        yield config_dir
 
-
-@pytest.fixture
-def config_file(config_dir):
-    config_file = py.path.local(_cloud_sdk._get_config_file(
-        str(config_dir), 'default'))
-    yield config_file
-
-
-def test_get_project_id(config_file):
-    config_file.write(CLOUD_SDK_CONFIG_DATA, ensure=True)
+@mock.patch(
+    'subprocess.check_output', autospec=True,
+    return_value=CLOUD_SDK_CONFIG_FILE_DATA)
+def test_get_project_id(check_output_mock):
     project_id = _cloud_sdk.get_project_id()
     assert project_id == 'example-project'
 
 
-def test_get_project_id_non_existent(config_file):
+@mock.patch(
+    'subprocess.check_output', autospec=True,
+    side_effect=subprocess.CalledProcessError(-1, None))
+def test_get_project_id_call_error(check_output_mock):
     project_id = _cloud_sdk.get_project_id()
     assert project_id is None
 
 
-def test_get_project_id_bad_file(config_file):
-    config_file.write('<<<badconfig', ensure=True)
+@mock.patch(
+    'subprocess.check_output', autospec=True,
+    return_value=b'I am some bad json')
+def test_get_project_id_bad_json(check_output_mock):
     project_id = _cloud_sdk.get_project_id()
     assert project_id is None
 
 
-def test_get_project_id_no_section(config_file):
-    config_file.write('[section]', ensure=True)
+@mock.patch(
+    'subprocess.check_output', autospec=True,
+    return_value=b'{}')
+def test_get_project_id_missing_value(check_output_mock):
     project_id = _cloud_sdk.get_project_id()
     assert project_id is None
 
 
-def test_get_project_id_non_default_config(config_dir):
-    active_config = config_dir.join('active_config')
-    test_config = py.path.local(_cloud_sdk._get_config_file(
-        str(config_dir), 'test'))
-
-    # Create an active config file that points to the 'test' config.
-    active_config.write('test', ensure=True)
-    test_config.write(CLOUD_SDK_CONFIG_DATA, ensure=True)
-
-    project_id = _cloud_sdk.get_project_id()
-
-    assert project_id == 'example-project'
-
-
-@CONFIG_PATH_PATCH
+@mock.patch(
+    'google.auth._cloud_sdk.get_config_path', autospec=True)
 def test_get_application_default_credentials_path(mock_get_config_dir):
     config_path = 'config_path'
     mock_get_config_dir.return_value = config_path
diff --git a/tests/test__default.py b/tests/test__default.py
--- a/tests/test__default.py
+++ b/tests/test__default.py
@@ -38,9 +38,6 @@
 with open(SERVICE_ACCOUNT_FILE) as fh:
     SERVICE_ACCOUNT_FILE_DATA = json.load(fh)
 
-with open(os.path.join(DATA_DIR, 'cloud_sdk.cfg')) as fh:
-    CLOUD_SDK_CONFIG_DATA = fh.read()
-
 LOAD_FILE_PATCH = mock.patch(
     'google.auth._default._load_credentials_from_file', return_value=(
         mock.sentinel.credentials, mock.sentinel.project_id), autospec=True)