Skip to content

Commit 5ff6ae5

Browse files
authored
Add init command and config util (#5004)
This PR introduces the `casp init` command, which performs the essential first-time setup for the CLI tool. Key Changes: * Environment Validation: The command checks for a valid Docker setup and `gcloud application-default` credentials, exiting with a clear error message if either is missing. * Configuration: It automatically creates a configuration file at `~/.casp/config.json`, saving the path to the gcloud credentials and an optional custom config path provided by the user. * Dependency Fetching: It pulls the required Docker image needed for subsequent operations. * Unit Testing.
1 parent 73d44ec commit 5ff6ae5

File tree

5 files changed

+450
-3
lines changed

5 files changed

+450
-3
lines changed

cli/casp/src/casp/commands/init.py

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,115 @@
77
# http://www.apache.org/licenses/LICENSE-2.0
88
#
99
# Unless required by applicable law or agreed to in writing, software
10-
# distributed under the License is is "AS IS" BASIS,
10+
# distributed under the License is distributed on an "AS IS" BASIS,
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
"""Init command."""
1515

16+
import os
17+
import sys
18+
from typing import Any
19+
from typing import Dict
20+
21+
from casp.utils import config
22+
from casp.utils import docker_utils
23+
from casp.utils import gcloud
1624
import click
1725

1826

27+
def _setup_docker():
28+
"""Sets up Docker."""
29+
click.echo('Checking Docker setup...')
30+
if not docker_utils.check_docker_setup():
31+
click.secho(
32+
'Docker setup check failed. Please resolve the issues above.', fg='red')
33+
sys.exit(1)
34+
click.secho('Docker setup is correct.', fg='green')
35+
36+
37+
def _setup_gcloud_credentials(cfg: Dict[str, Any]):
38+
""""Setup gcloud credentials. Prompts the user if not found.
39+
40+
Args:
41+
cfg: The configuration dictionary to update.
42+
"""
43+
click.echo('Checking gcloud authentication...')
44+
credentials_path = gcloud.get_credentials_path()
45+
46+
if not credentials_path:
47+
click.secho('gcloud authentication check failed.', fg='red')
48+
sys.exit(1)
49+
50+
click.echo(f'Using credentials found in {credentials_path}')
51+
cfg['gcloud_credentials_path'] = credentials_path
52+
click.secho('gcloud authentication is configured correctly.', fg='green')
53+
54+
55+
def _setup_custom_config(cfg: Dict[str, Any]):
56+
"""Sets up optional custom configuration directory path.
57+
58+
Args:
59+
cfg: The configuration dictionary to update.
60+
"""
61+
custom_config_path = click.prompt(
62+
'Enter path to custom config directory (optional)',
63+
default='',
64+
show_default=False,
65+
type=click.Path())
66+
67+
if not custom_config_path:
68+
# Handle case where user wants to clear the path
69+
if 'custom_config_path' in cfg:
70+
del cfg['custom_config_path']
71+
click.echo('Cleared custom config path.')
72+
return
73+
74+
if not os.path.exists(custom_config_path):
75+
click.secho(
76+
f'Custom config path "{custom_config_path}" does not exist. '
77+
'Skipping.',
78+
fg='yellow')
79+
return
80+
81+
cfg['custom_config_path'] = custom_config_path
82+
click.secho(f'Custom config path set to: {custom_config_path}', fg='green')
83+
84+
85+
def _pull_image():
86+
"""Pulls the docker image."""
87+
click.echo(f'Pulling Docker image: {docker_utils.DOCKER_IMAGE}...')
88+
if not docker_utils.pull_image():
89+
click.secho(
90+
f'\nError: Failed to pull Docker image {docker_utils.DOCKER_IMAGE}.',
91+
fg='red')
92+
click.secho('Initialization failed.', fg='red')
93+
sys.exit(1)
94+
95+
1996
@click.command(name='init', help='Initializes the CLI')
2097
def cli():
21-
"""Initializes the CLI"""
22-
click.echo('To be implemented...')
98+
"""Initializes the CASP CLI.
99+
100+
This is done by:
101+
1. Checking the Docker setup
102+
2. Setting up the gcloud credentials for later use
103+
3. Optionally setting up a custom configuration directory path
104+
4. Saving the configuration to the config file
105+
5. Pulling the Docker image
106+
"""
107+
_setup_docker()
108+
109+
cfg = config.load_config()
110+
if not cfg:
111+
click.echo('Config file not found, creating a new one...')
112+
cfg = {}
113+
114+
_setup_gcloud_credentials(cfg)
115+
_setup_custom_config(cfg)
116+
117+
config.save_config(cfg)
118+
click.secho(f'Configuration saved to {config.CONFIG_FILE}.', fg='green')
119+
120+
_pull_image()
121+
click.secho('Initialization complete.', fg='green')
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: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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 init command.
15+
16+
For running all the tests, use (from the root of the project):
17+
python -m unittest discover -s cli/casp/src/casp/tests -p test_init.py -v
18+
"""
19+
20+
import os
21+
import unittest
22+
from unittest.mock import patch
23+
24+
from casp.commands import init
25+
from click.testing import CliRunner
26+
27+
28+
class InitCliTest(unittest.TestCase):
29+
"""Tests for the init command."""
30+
31+
def setUp(self):
32+
super().setUp()
33+
self.runner = CliRunner()
34+
35+
# Patch dependencies
36+
self.mock_docker_utils = self.enterContext(
37+
patch.object(init, 'docker_utils', autospec=True))
38+
self.mock_gcloud = self.enterContext(
39+
patch.object(init, 'gcloud', autospec=True))
40+
self.mock_config = self.enterContext(
41+
patch.object(init, 'config', autospec=True))
42+
self.mock_os_path_exists = self.enterContext(
43+
patch.object(os.path, 'exists', autospec=True))
44+
45+
# Default mock behaviors for success paths
46+
self.mock_docker_utils.check_docker_setup.return_value = True
47+
self.mock_docker_utils.pull_image.return_value = True
48+
self.mock_docker_utils.DOCKER_IMAGE = 'gcr.io/casp/runner:latest'
49+
credentials_path = '/fake/path/credentials.json'
50+
self.mock_gcloud.get_credentials_path.return_value = credentials_path
51+
self.mock_config.load_config.return_value = {}
52+
self.mock_config.CONFIG_FILE = '~/.casp/config.json'
53+
54+
def test_init_success_all_steps(self):
55+
"""Tests successful initialization through all steps, no custom config."""
56+
result = self.runner.invoke(
57+
init.cli, input='\n') # Enter for optional prompt
58+
59+
self.assertEqual(0, result.exit_code)
60+
self.assertIn('Docker setup is correct.', result.output)
61+
self.assertIn('gcloud authentication is configured correctly.',
62+
result.output)
63+
self.assertIn(
64+
f'Pulling Docker image: {self.mock_docker_utils.DOCKER_IMAGE}',
65+
result.output)
66+
self.assertIn('Initialization complete.', result.output)
67+
68+
self.mock_docker_utils.check_docker_setup.assert_called_once()
69+
self.mock_gcloud.get_credentials_path.assert_called_once()
70+
expected_config = {'gcloud_credentials_path': '/fake/path/credentials.json'}
71+
self.mock_config.save_config.assert_called_once_with(expected_config)
72+
self.mock_docker_utils.pull_image.assert_called_once()
73+
74+
def test_init_docker_setup_fails(self):
75+
"""Tests when Docker setup check fails."""
76+
self.mock_docker_utils.check_docker_setup.return_value = False
77+
result = self.runner.invoke(init.cli)
78+
79+
self.assertNotEqual(0, result.exit_code) # Should indicate failure
80+
self.assertIn('Docker setup check failed.', result.output)
81+
self.assertNotIn('Initialization complete.', result.output)
82+
self.mock_gcloud.get_credentials_path.assert_not_called()
83+
84+
def test_init_gcloud_auth_fails(self):
85+
"""Tests when gcloud authentication fails."""
86+
self.mock_gcloud.get_credentials_path.return_value = None
87+
result = self.runner.invoke(init.cli)
88+
89+
self.assertNotEqual(0, result.exit_code)
90+
self.assertIn('gcloud authentication check failed.', result.output)
91+
self.assertNotIn('Initialization complete.', result.output)
92+
self.mock_config.save_config.assert_not_called()
93+
self.mock_docker_utils.pull_image.assert_not_called()
94+
95+
def test_init_docker_pull_fails(self):
96+
"""Tests when Docker image pull fails."""
97+
self.mock_docker_utils.pull_image.return_value = False
98+
result = self.runner.invoke(init.cli, input='\n')
99+
100+
self.assertNotEqual(0, result.exit_code)
101+
self.assertIn('Error: Failed to pull Docker image', result.output)
102+
self.assertIn('Initialization failed.', result.output)
103+
self.assertNotIn('Initialization complete.', result.output)
104+
105+
def test_init_with_existing_config(self):
106+
"""Tests that existing config is loaded and updated."""
107+
self.mock_config.load_config.return_value = {
108+
'existing_key': 'existing_value'
109+
}
110+
result = self.runner.invoke(init.cli, input='\n')
111+
112+
self.assertEqual(0, result.exit_code)
113+
expected_config = {
114+
'existing_key': 'existing_value',
115+
'gcloud_credentials_path': '/fake/path/credentials.json'
116+
}
117+
self.mock_config.save_config.assert_called_once_with(expected_config)
118+
119+
def test_init_custom_config_path_success(self):
120+
"""Tests providing a valid custom config path."""
121+
custom_path = '/my/custom/config/dir'
122+
self.mock_os_path_exists.return_value = True # Path exists
123+
self.mock_config.load_config.return_value = {}
124+
125+
result = self.runner.invoke(init.cli, input=f'{custom_path}\n')
126+
127+
self.assertEqual(0, result.exit_code)
128+
self.mock_os_path_exists.assert_any_call(custom_path)
129+
expected_config = {
130+
'gcloud_credentials_path': '/fake/path/credentials.json',
131+
'custom_config_path': custom_path
132+
}
133+
self.mock_config.save_config.assert_called_once_with(expected_config)
134+
self.assertIn(f'Custom config path set to: {custom_path}', result.output)
135+
self.assertIn('Initialization complete.', result.output)
136+
137+
def test_init_custom_config_path_not_exists(self):
138+
"""Tests providing a custom config path that does not exist."""
139+
custom_path = '/non/existent/dir'
140+
self.mock_os_path_exists.return_value = False # Path does not exist
141+
self.mock_config.load_config.return_value = {}
142+
143+
result = self.runner.invoke(init.cli, input=f'{custom_path}\n')
144+
145+
self.assertEqual(0, result.exit_code)
146+
self.mock_os_path_exists.assert_any_call(custom_path)
147+
expected_config = {'gcloud_credentials_path': '/fake/path/credentials.json'}
148+
self.mock_config.save_config.assert_called_once_with(expected_config)
149+
self.assertIn(f'Custom config path "{custom_path}" does not exist.',
150+
result.output)
151+
self.assertIn('Skipping.', result.output)
152+
self.assertNotIn('Custom config path set to', result.output)
153+
self.assertIn('Initialization complete.', result.output)
154+
155+
def test_init_custom_config_path_empty(self):
156+
"""Tests providing an empty custom config path (skipping)."""
157+
self.mock_config.load_config.return_value = {}
158+
result = self.runner.invoke(init.cli, input='\n') # Just press Enter
159+
160+
self.assertEqual(0, result.exit_code)
161+
expected_config = {'gcloud_credentials_path': '/fake/path/credentials.json'}
162+
self.mock_config.save_config.assert_called_once_with(expected_config)
163+
self.assertNotIn('Custom config path set to', result.output)
164+
self.assertNotIn('Cleared custom config path', result.output)
165+
self.assertIn('Initialization complete.', result.output)
166+
167+
def test_init_custom_config_path_empty_clears_existing(self):
168+
"""Tests that an empty input for custom
169+
config path clears an existing one."""
170+
self.mock_config.load_config.return_value = {
171+
'custom_config_path': '/my/old/path'
172+
}
173+
result = self.runner.invoke(init.cli, input='\n')
174+
175+
self.assertEqual(0, result.exit_code)
176+
expected_config = {'gcloud_credentials_path': '/fake/path/credentials.json'}
177+
self.mock_config.save_config.assert_called_once_with(expected_config)
178+
self.assertIn('Cleared custom config path.', result.output)
179+
180+
181+
if __name__ == '__main__':
182+
unittest.main()

0 commit comments

Comments
 (0)