Skip to content

Commit 117a70f

Browse files
authored
Add gcloud utility module (#5000)
This PR introduces `casp.utils.gcloud` module, designed to handle Google Cloud authentication for the upcoming init command. Key Changes: * Interactive Credential Discovery: The get_credentials_path function orchestrates the authentication process: 1. It first checks the default gcloud ADC path for valid credentials. 2. If credentials are not found or are invalid, it prompts the user to run gcloud auth application-default login. 3. If the user declines or the login fails, it provides a final option to enter a custom path to a credentials file. * Unit testing.
1 parent 57724c1 commit 117a70f

File tree

5 files changed

+372
-0
lines changed

5 files changed

+372
-0
lines changed

cli/casp/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,11 @@ For example, to add a `my-command` command, follow these steps:
6363

6464
Once you have completed these steps, the new command will be available as
6565
`casp my-command`.
66+
67+
## Running Tests
68+
69+
To run all unit tests for the `casp` CLI, use the following command from the root of the project:
70+
71+
```bash
72+
python -m unittest discover -s cli/casp/src/casp/tests -v
73+
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Tests for the gcloud utility functions.
15+
16+
For running, use (from the root of the project):
17+
python -m unittest discover -s cli/casp/src/casp/tests -p test_gcloud.py -v
18+
"""
19+
20+
import subprocess
21+
import unittest
22+
from unittest.mock import Mock
23+
from unittest.mock import patch
24+
25+
from casp.utils import gcloud
26+
27+
28+
class IsValidCredentialsTest(unittest.TestCase):
29+
"""Tests for _is_valid_credentials."""
30+
31+
@patch('os.path.exists', return_value=True, autospec=True)
32+
@patch(
33+
'google.oauth2.credentials.Credentials.from_authorized_user_file',
34+
autospec=True)
35+
def test_valid_credentials(self, mock_from_file, mock_exists):
36+
"""Tests with a valid credentials file."""
37+
mock_from_file.return_value = Mock()
38+
self.assertTrue(gcloud._is_valid_credentials('valid/path')) # pylint: disable=protected-access
39+
mock_exists.assert_called_once_with('valid/path')
40+
mock_from_file.assert_called_once_with('valid/path')
41+
42+
@patch('os.path.exists', return_value=False, autospec=True)
43+
def test_path_does_not_exist(self, mock_exists):
44+
"""Tests with a non-existent path."""
45+
self.assertFalse(gcloud._is_valid_credentials('invalid/path')) # pylint: disable=protected-access
46+
mock_exists.assert_called_once_with('invalid/path')
47+
48+
@patch('os.path.exists', return_value=True, autospec=True)
49+
@patch(
50+
'google.oauth2.credentials.Credentials.from_authorized_user_file',
51+
autospec=True)
52+
def test_auth_error(self, mock_from_file, mock_exists):
53+
"""Tests with an auth exception."""
54+
mock_from_file.side_effect = ValueError
55+
self.assertFalse(gcloud._is_valid_credentials('path')) # pylint: disable=protected-access
56+
mock_exists.assert_called_once_with('path')
57+
mock_from_file.assert_called_once_with('path')
58+
59+
def test_empty_path(self):
60+
"""Tests with an empty path string."""
61+
self.assertFalse(gcloud._is_valid_credentials('')) # pylint: disable=protected-access
62+
63+
def test_none_path(self):
64+
"""Tests with a None path."""
65+
self.assertFalse(gcloud._is_valid_credentials(None)) # pylint: disable=protected-access
66+
67+
68+
class RunGcloudLoginTest(unittest.TestCase):
69+
"""Tests for _run_gcloud_login."""
70+
71+
@patch(
72+
'casp.utils.gcloud._is_valid_credentials',
73+
return_value=True,
74+
autospec=True)
75+
@patch('subprocess.run', autospec=True)
76+
def test_login_success(self, mock_run, mock_is_valid):
77+
"""Tests successful gcloud login."""
78+
self.assertTrue(gcloud._run_gcloud_login()) # pylint: disable=protected-access
79+
mock_run.assert_called_once_with(
80+
['gcloud', 'auth', 'application-default', 'login'], check=True)
81+
mock_is_valid.assert_called_once_with(
82+
gcloud.DEFAULT_GCLOUD_CREDENTIALS_PATH)
83+
84+
@patch('subprocess.run', autospec=True)
85+
@patch('click.secho', autospec=True)
86+
def test_gcloud_not_found(self, mock_secho, mock_run):
87+
"""Tests with gcloud command not found."""
88+
mock_run.side_effect = FileNotFoundError
89+
self.assertFalse(gcloud._run_gcloud_login()) # pylint: disable=protected-access
90+
mock_secho.assert_called_once()
91+
args, _ = mock_secho.call_args
92+
self.assertIn('gcloud command not found', args[0])
93+
94+
@patch('subprocess.run', autospec=True)
95+
@patch('click.secho', autospec=True)
96+
def test_login_failed(self, mock_secho, mock_run):
97+
"""Tests with a failed login command."""
98+
mock_run.side_effect = subprocess.CalledProcessError(1, 'cmd')
99+
self.assertFalse(gcloud._run_gcloud_login()) # pylint: disable=protected-access
100+
mock_secho.assert_called_once()
101+
args, _ = mock_secho.call_args
102+
self.assertIn('gcloud login failed', args[0])
103+
104+
105+
class PromptForCustomPathTest(unittest.TestCase):
106+
"""Tests for _prompt_for_custom_path."""
107+
108+
@patch('click.prompt', autospec=True)
109+
@patch(
110+
'casp.utils.gcloud._is_valid_credentials',
111+
return_value=True,
112+
autospec=True)
113+
def test_valid_path(self, mock_is_valid, mock_prompt):
114+
"""Tests with a valid custom path."""
115+
mock_prompt.return_value = '/valid/path'
116+
self.assertEqual(gcloud._prompt_for_custom_path(), '/valid/path') # pylint: disable=protected-access
117+
mock_is_valid.assert_called_once_with('/valid/path')
118+
119+
@patch('click.prompt', autospec=True)
120+
@patch(
121+
'casp.utils.gcloud._is_valid_credentials',
122+
return_value=False,
123+
autospec=True)
124+
@patch('click.secho', autospec=True)
125+
def test_invalid_path(self, mock_secho, mock_is_valid, mock_prompt):
126+
"""Tests with an invalid custom path."""
127+
mock_prompt.return_value = '/invalid/path'
128+
self.assertIsNone(gcloud._prompt_for_custom_path()) # pylint: disable=protected-access
129+
mock_is_valid.assert_called_once_with('/invalid/path')
130+
mock_secho.assert_called_once_with(
131+
'Error: The provided credentials file is not valid.', fg='red')
132+
133+
@patch('click.prompt', autospec=True)
134+
def test_empty_path(self, mock_prompt):
135+
"""Tests with empty input from prompt."""
136+
mock_prompt.return_value = ''
137+
self.assertIsNone(gcloud._prompt_for_custom_path()) # pylint: disable=protected-access
138+
139+
140+
class GetCredentialsPathTest(unittest.TestCase):
141+
"""Tests for get_credentials_path."""
142+
143+
@patch(
144+
'casp.utils.gcloud._is_valid_credentials',
145+
return_value=True,
146+
autospec=True)
147+
def test_default_path_valid(self, mock_is_valid):
148+
"""Tests when the default credentials path is valid."""
149+
self.assertEqual(gcloud.get_credentials_path(),
150+
gcloud.DEFAULT_GCLOUD_CREDENTIALS_PATH)
151+
mock_is_valid.assert_called_once_with(
152+
gcloud.DEFAULT_GCLOUD_CREDENTIALS_PATH)
153+
154+
@patch('casp.utils.gcloud._prompt_for_custom_path', autospec=True)
155+
@patch(
156+
'casp.utils.gcloud._run_gcloud_login', return_value=True, autospec=True)
157+
@patch('click.confirm', return_value=True, autospec=True)
158+
@patch(
159+
'casp.utils.gcloud._is_valid_credentials',
160+
return_value=False,
161+
autospec=True)
162+
def test_login_success(self, mock_is_valid, mock_confirm, mock_login,
163+
mock_prompt):
164+
"""Tests successful login after default path fails."""
165+
self.assertEqual(gcloud.get_credentials_path(),
166+
gcloud.DEFAULT_GCLOUD_CREDENTIALS_PATH)
167+
mock_is_valid.assert_called_once_with(
168+
gcloud.DEFAULT_GCLOUD_CREDENTIALS_PATH)
169+
mock_confirm.assert_called_once()
170+
mock_login.assert_called_once()
171+
mock_prompt.assert_not_called()
172+
173+
@patch(
174+
'casp.utils.gcloud._prompt_for_custom_path',
175+
return_value='/custom/path',
176+
autospec=True)
177+
@patch(
178+
'casp.utils.gcloud._run_gcloud_login', return_value=False, autospec=True)
179+
@patch('click.confirm', return_value=True, autospec=True)
180+
@patch(
181+
'casp.utils.gcloud._is_valid_credentials',
182+
return_value=False,
183+
autospec=True)
184+
def test_login_fail_then_custom_path(self, mock_is_valid, mock_confirm,
185+
mock_login, mock_prompt):
186+
"""Tests providing a custom path after a failed login."""
187+
self.assertEqual(gcloud.get_credentials_path(), '/custom/path')
188+
mock_is_valid.assert_called_once_with(
189+
gcloud.DEFAULT_GCLOUD_CREDENTIALS_PATH)
190+
mock_confirm.assert_called_once()
191+
mock_login.assert_called_once()
192+
mock_prompt.assert_called_once()
193+
194+
@patch(
195+
'casp.utils.gcloud._prompt_for_custom_path',
196+
return_value='/custom/path',
197+
autospec=True)
198+
@patch('casp.utils.gcloud._run_gcloud_login', autospec=True)
199+
@patch('click.confirm', return_value=False, autospec=True)
200+
@patch(
201+
'casp.utils.gcloud._is_valid_credentials',
202+
return_value=False,
203+
autospec=True)
204+
def test_decline_login_then_custom_path(self, mock_is_valid, mock_confirm,
205+
mock_login, mock_prompt):
206+
"""Tests providing a custom path after declining to log in."""
207+
self.assertEqual(gcloud.get_credentials_path(), '/custom/path')
208+
mock_is_valid.assert_called_once_with(
209+
gcloud.DEFAULT_GCLOUD_CREDENTIALS_PATH)
210+
mock_confirm.assert_called_once()
211+
mock_login.assert_not_called()
212+
mock_prompt.assert_called_once()
213+
214+
@patch(
215+
'casp.utils.gcloud._prompt_for_custom_path',
216+
return_value=None,
217+
autospec=True)
218+
@patch('click.confirm', return_value=False, autospec=True)
219+
@patch(
220+
'casp.utils.gcloud._is_valid_credentials',
221+
return_value=False,
222+
autospec=True)
223+
def test_all_fail(self, mock_is_valid, mock_confirm, mock_prompt):
224+
"""Tests when all methods to get credentials fail."""
225+
self.assertIsNone(gcloud.get_credentials_path())
226+
mock_is_valid.assert_called_once_with(
227+
gcloud.DEFAULT_GCLOUD_CREDENTIALS_PATH)
228+
mock_confirm.assert_called_once()
229+
mock_prompt.assert_called_once()
230+
231+
232+
if __name__ == '__main__':
233+
unittest.main()

cli/casp/src/casp/utils/gcloud.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""gcloud utility functions."""
15+
16+
import os
17+
import subprocess
18+
19+
import click
20+
from google.oauth2 import credentials
21+
22+
DEFAULT_GCLOUD_CREDENTIALS_PATH = os.path.expanduser(
23+
'~/.config/gcloud/application_default_credentials.json')
24+
25+
26+
def _is_valid_credentials(path: str) -> bool:
27+
"""Returns True if the path points to a valid credentials file."""
28+
if not path or not os.path.exists(path):
29+
click.secho('Error: No valid credentials file found.', fg='red')
30+
return False
31+
try:
32+
credentials.Credentials.from_authorized_user_file(path)
33+
return True
34+
except ValueError as e:
35+
click.secho(f'Error when checking for valid credentials: {e}', fg='red')
36+
return False
37+
38+
39+
def _run_gcloud_login() -> bool:
40+
"""
41+
Runs the gcloud login command and returns True on success.
42+
"""
43+
try:
44+
subprocess.run(
45+
['gcloud', 'auth', 'application-default', 'login'], check=True)
46+
# After login, re-validate the default file.
47+
return _is_valid_credentials(DEFAULT_GCLOUD_CREDENTIALS_PATH)
48+
except FileNotFoundError:
49+
click.secho(
50+
'Error: gcloud command not found. Please ensure it is installed and '
51+
'in your PATH. '
52+
'Or you can mannually run '
53+
'`gcloud auth application-default login`',
54+
fg='red')
55+
return False
56+
except subprocess.CalledProcessError:
57+
click.secho(
58+
'Error: gcloud login failed. '
59+
'You can mannually run '
60+
'`gcloud auth application-default login`',
61+
fg='red')
62+
return False
63+
64+
65+
def _prompt_for_custom_path() -> str | None:
66+
"""
67+
Prompts the user for a custom credentials path and returns it if valid.
68+
"""
69+
path = click.prompt(
70+
'Enter path to your credentials file (or press Ctrl+C to cancel)',
71+
default='',
72+
show_default=False,
73+
type=click.Path(exists=True, dir_okay=False, resolve_path=True))
74+
75+
if not path:
76+
return None
77+
78+
if _is_valid_credentials(path):
79+
return path
80+
81+
click.secho('Error: The provided credentials file is not valid.', fg='red')
82+
return None
83+
84+
85+
def get_credentials_path() -> str | None:
86+
"""
87+
Finds a valid gcloud credentials path, prompting the user if needed.
88+
89+
Returns:
90+
The path to a valid credentials file, or None if one cannot be found.
91+
"""
92+
if _is_valid_credentials(DEFAULT_GCLOUD_CREDENTIALS_PATH):
93+
return DEFAULT_GCLOUD_CREDENTIALS_PATH
94+
95+
click.secho(
96+
'Default gcloud credentials not found or are invalid.', fg='yellow')
97+
98+
if click.confirm('Do you want to log in with gcloud now?'):
99+
if _run_gcloud_login():
100+
return DEFAULT_GCLOUD_CREDENTIALS_PATH
101+
102+
click.secho(
103+
'\nLogin was skipped or failed. You can provide a direct path instead.',
104+
fg='yellow')
105+
return _prompt_for_custom_path()

0 commit comments

Comments
 (0)