Skip to content

Commit dc14a6e

Browse files
authored
[CLI] Migrate package (#5060)
### Changes This PR adds the new `casp package` command to the `casp` CLI. Which acts as a convenient wrapper for the root `butler.py package` script. ### How it works: - `casp package` packages ClusterFuzz with a staging revision. - `casp package -p <platform>` specifies the target platform (`linux`, `macos`, `windows`, or `all`). - `casp package -r <release>` sets the release channel (options: `prod`, `candidate`, or `chrome-tests-syncer`). - If `-r` is not provided, the release channel defaults to `prod`. ### Changes in butler Regarding the refactor/improvement in `butler.py`, here are some screenshots comparing running `python butler.py package` in this branch and master. They show that the behavior is the same. <img width="1438" height="233" alt="image" src="https://github.com/user-attachments/assets/b33a65ae-e816-4bbe-a531-4c80f3aff62d" /> <img width="1438" height="234" alt="image" src="https://github.com/user-attachments/assets/cef26580-2bdf-416b-8822-25e008ea2893" /> <img width="1438" height="233" alt="image" src="https://github.com/user-attachments/assets/271b09e6-14cd-4122-a72a-e8908557ff6c" /> <img width="1438" height="234" alt="image" src="https://github.com/user-attachments/assets/fa1d3b03-6332-488f-9931-74542d26bdd4" /> ### Demo prints Here are some screenshots of `casp package` in action: * `casp package -p linux` <img width="1438" height="233" alt="image" src="https://github.com/user-attachments/assets/1aa540cf-4211-40a0-b074-4bf6dd7905fd" /> <img width="1438" height="234" alt="image" src="https://github.com/user-attachments/assets/278595f9-b1eb-4cbc-a5bd-58ced2b5816f" />
1 parent 6b10da3 commit dc14a6e

File tree

3 files changed

+136
-13
lines changed

3 files changed

+136
-13
lines changed

butler.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,19 @@ def _setup_args_for_remote(parser):
9898
subparsers.add_parser('reboot', help='Reboot with `sudo reboot`.')
9999

100100

101+
def _add_package_subparser(toplevel_subparsers):
102+
"""Adds a parser for the `package` command."""
103+
parser_package = toplevel_subparsers.add_parser(
104+
'package', help='Package clusterfuzz with a staging revision')
105+
parser_package.add_argument(
106+
'-p', '--platform', choices=['linux', 'macos', 'windows', 'all'])
107+
parser_package.add_argument(
108+
'-r',
109+
'--release',
110+
choices=['prod', 'candidate', 'chrome-tests-syncer'],
111+
default='prod')
112+
113+
101114
def _add_bootstrap_subparser(toplevel_subparsers):
102115
"""Adds a parser for the `bootstrap` command."""
103116
toplevel_subparsers.add_parser(
@@ -334,16 +347,6 @@ def main():
334347
help=('Do not close browser when tests '
335348
'finish. Good for debugging.'))
336349

337-
parser_package = subparsers.add_parser(
338-
'package', help='Package clusterfuzz with a staging revision')
339-
parser_package.add_argument(
340-
'-p', '--platform', choices=['linux', 'macos', 'windows', 'all'])
341-
parser_package.add_argument(
342-
'-r',
343-
'--release',
344-
choices=['prod', 'candidate', 'chrome-tests-syncer'],
345-
default='prod')
346-
347350
parser_deploy = subparsers.add_parser('deploy', help='Deploy to Appengine')
348351
parser_deploy.add_argument(
349352
'-f',
@@ -457,6 +460,7 @@ def main():
457460
default='us-central',
458461
help='Location for App Engine.')
459462

463+
_add_package_subparser(subparsers)
460464
_add_bootstrap_subparser(subparsers)
461465
_add_py_unittest_subparser(subparsers)
462466
_add_lint_subparser(subparsers)

cli/casp/src/casp/commands/package.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,47 @@
1313
# limitations under the License.
1414
"""Package command."""
1515

16+
import subprocess
17+
import sys
18+
19+
from casp.utils import local_butler
1620
import click
1721

1822

1923
@click.command(
20-
name='package', help='Package clusterfuzz with a staging revision')
21-
def cli():
24+
name='package',
25+
help=('Package clusterfuzz with a staging revision. '
26+
'It creates a zip for each platform chosen in '
27+
'the clusterfuzz/deployment directory.'))
28+
@click.option(
29+
'--platform',
30+
'-p',
31+
type=click.Choice(['linux', 'macos', 'windows', 'all']),
32+
help='The platform to package for.')
33+
@click.option(
34+
'--release',
35+
'-r',
36+
type=click.Choice(['prod', 'candidate', 'chrome-tests-syncer']),
37+
default='prod',
38+
show_default=True,
39+
help='The release channel.')
40+
def cli(platform, release):
2241
"""Package clusterfuzz with a staging revision"""
23-
click.echo('To be implemented...')
42+
try:
43+
arguments = {'release': release}
44+
if platform:
45+
arguments['platform'] = platform
46+
47+
command = local_butler.build_command('package', **arguments)
48+
except FileNotFoundError:
49+
click.echo('butler.py not found in this directory.', err=True)
50+
sys.exit(1)
51+
52+
try:
53+
subprocess.run(command, check=True)
54+
except FileNotFoundError:
55+
click.echo('python not found in PATH.', err=True)
56+
sys.exit(1)
57+
except subprocess.CalledProcessError as e:
58+
click.echo(f'Error running butler.py package: {e}', err=True)
59+
sys.exit(1)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 package 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 package_test.py -v
18+
"""
19+
20+
import subprocess
21+
import unittest
22+
from unittest.mock import patch
23+
24+
from casp.commands import package as package_command
25+
from click.testing import CliRunner
26+
27+
28+
class PackageCliTest(unittest.TestCase):
29+
"""Tests for the package command."""
30+
31+
def setUp(self):
32+
self.runner = CliRunner()
33+
self.mock_local_butler = self.enterContext(
34+
patch('casp.commands.package.local_butler', autospec=True))
35+
self.mock_subprocess_run = self.enterContext(
36+
patch('subprocess.run', autospec=True))
37+
38+
def test_package_success_defaults(self):
39+
"""Tests package command with defaults."""
40+
self.mock_local_butler.build_command.return_value = ['cmd']
41+
result = self.runner.invoke(package_command.cli)
42+
self.assertEqual(0, result.exit_code, msg=result.output)
43+
self.mock_local_butler.build_command.assert_called_once_with(
44+
'package', release='prod')
45+
self.mock_subprocess_run.assert_called_once_with(['cmd'], check=True)
46+
47+
def test_package_success_with_args(self):
48+
"""Tests package command with arguments."""
49+
self.mock_local_butler.build_command.return_value = ['cmd']
50+
result = self.runner.invoke(
51+
package_command.cli, ['--platform', 'linux', '--release', 'candidate'])
52+
self.assertEqual(0, result.exit_code, msg=result.output)
53+
self.mock_local_butler.build_command.assert_called_once_with(
54+
'package', platform='linux', release='candidate')
55+
56+
def test_butler_not_found(self):
57+
"""Tests when butler.py is not found."""
58+
self.mock_local_butler.build_command.side_effect = FileNotFoundError
59+
result = self.runner.invoke(package_command.cli)
60+
self.assertNotEqual(0, result.exit_code)
61+
self.assertIn('butler.py not found', result.output)
62+
self.mock_subprocess_run.assert_not_called()
63+
64+
def test_subprocess_run_fails(self):
65+
"""Tests when subprocess.run fails."""
66+
self.mock_local_butler.build_command.return_value = ['cmd']
67+
self.mock_subprocess_run.side_effect = subprocess.CalledProcessError(
68+
1, 'cmd')
69+
result = self.runner.invoke(package_command.cli)
70+
self.assertNotEqual(0, result.exit_code)
71+
self.assertIn('Error running butler.py package', result.output)
72+
73+
def test_python_not_found(self):
74+
"""Tests when python command is not found."""
75+
self.mock_local_butler.build_command.return_value = ['cmd']
76+
self.mock_subprocess_run.side_effect = FileNotFoundError
77+
result = self.runner.invoke(package_command.cli)
78+
self.assertNotEqual(0, result.exit_code)
79+
self.assertIn('python not found in PATH', result.output)
80+
81+
82+
if __name__ == '__main__':
83+
unittest.main()

0 commit comments

Comments
 (0)