Skip to content

Commit ba2803d

Browse files
add CLI support for Shell driver methods
so that they show up in jmp shell.
1 parent d10f6b5 commit ba2803d

File tree

4 files changed

+193
-1
lines changed

4 files changed

+193
-1
lines changed

packages/jumpstarter-driver-shell/README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,53 @@ methods will be generated dynamically, and they will be available as follows:
6363

6464
:returns: A tuple(stdout, stderr, return_code)
6565
```
66+
67+
## CLI Usage
68+
69+
The shell driver also provides a CLI when using `jmp shell`. All configured methods become available as CLI commands, except for methods starting with `_` which are considered private and hidden from the end user:
70+
71+
```console
72+
$ jmp shell --exporter shell-exporter
73+
$ j shell
74+
Usage: j shell [OPTIONS] COMMAND [ARGS]...
75+
76+
Shell command executor
77+
78+
Commands:
79+
env_var Execute the env_var shell method
80+
ls Execute the ls shell method
81+
method2 Execute the method2 shell method
82+
method3 Execute the method3 shell method
83+
```
84+
85+
### CLI Command Usage
86+
87+
Each configured method becomes a CLI command with the following options:
88+
89+
```console
90+
$ j shell ls --help
91+
Usage: j shell ls [OPTIONS] [ARGS]...
92+
93+
Execute the ls shell method
94+
95+
Options:
96+
-e, --env TEXT Environment variables in KEY=VALUE format
97+
--help Show this message and exit.
98+
```
99+
100+
### Examples
101+
102+
```console
103+
# Execute simple commands
104+
$ j shell ls
105+
file1.txt file2.txt directory/
106+
107+
# Pass arguments to shell methods
108+
$ j shell method3 "first arg" "second arg"
109+
Hello World first arg
110+
Hello World second arg
111+
112+
# Set environment variables
113+
$ j shell env_var arg1 arg2 --env ENV_VAR=myvalue
114+
arg1,arg2,myvalue
115+
```

packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from dataclasses import dataclass
22

3+
import click
4+
35
from jumpstarter.client import DriverClient
46

57

@@ -25,3 +27,52 @@ def _check_method_exists(self, method):
2527
def __getattr__(self, name):
2628
self._check_method_exists(name)
2729
return lambda *args, **kwargs: tuple(self.call("call_method", name, kwargs, *args))
30+
31+
def cli(self):
32+
"""Create CLI interface for dynamically configured shell methods"""
33+
@click.group
34+
def base():
35+
"""Shell command executor"""
36+
pass
37+
38+
# Get available methods from the driver
39+
if self._methods is None:
40+
self._methods = self.call("get_methods")
41+
42+
# Create a command for each configured method (excluding private methods starting with _)
43+
for method_name in self._methods:
44+
self._add_method_command(base, method_name)
45+
46+
return base
47+
48+
def _add_method_command(self, group, method_name):
49+
"""Add a Click command for a specific shell method"""
50+
@group.command(name=method_name)
51+
@click.argument('args', nargs=-1)
52+
@click.option('--env', '-e', multiple=True,
53+
help='Environment variables in KEY=VALUE format')
54+
def method_command(args, env):
55+
# Parse environment variables
56+
env_dict = {}
57+
for env_var in env:
58+
if '=' in env_var:
59+
key, value = env_var.split('=', 1)
60+
env_dict[key] = value
61+
else:
62+
raise click.BadParameter(f"Invalid --env value '{env_var}'. Use KEY=VALUE.")
63+
64+
# Call the method
65+
stdout, stderr, returncode = self.call("call_method", method_name, env_dict, *args)
66+
67+
# Display results
68+
if stdout:
69+
click.echo(stdout, nl=not stdout.endswith("\n"))
70+
if stderr:
71+
click.echo(stderr, err=True, nl=not stderr.endswith("\n"))
72+
73+
# Exit with the same return code as the shell command
74+
if returncode != 0:
75+
raise click.exceptions.Exit(returncode)
76+
77+
# Update the docstring dynamically
78+
method_command.__doc__ = f"Execute the {method_name} shell method"

packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver_test.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,94 @@ def test_unknown_method(client):
4747
assert "method unknown not found in" in str(e)
4848
else:
4949
raise AssertionError("Expected AttributeError")
50+
51+
52+
def test_cli_interface(client):
53+
"""Test that the CLI interface is created with all methods"""
54+
cli = client.cli()
55+
56+
# Check that it's a Click group
57+
assert hasattr(cli, 'commands')
58+
59+
# Check that all configured methods are available as commands
60+
expected_methods = {"echo", "env", "multi_line", "exit1", "stderr"}
61+
available_commands = set(cli.commands.keys())
62+
63+
assert expected_methods == available_commands, f"Expected {expected_methods}, got {available_commands}"
64+
65+
66+
def test_cli_method_execution(client):
67+
"""Test that CLI methods can be executed"""
68+
cli = client.cli()
69+
70+
# Test that we can get the echo command
71+
echo_command = cli.commands.get('echo')
72+
assert echo_command is not None
73+
assert echo_command.name == 'echo'
74+
75+
76+
def test_cli_includes_all_methods():
77+
"""Test that CLI includes all methods, including those starting with _"""
78+
from .driver import Shell
79+
from jumpstarter.common.utils import serve
80+
81+
shell_instance = Shell(
82+
log_level="DEBUG",
83+
methods={
84+
"method1": "echo method1",
85+
"method2": "echo method2",
86+
"method3": "echo method3",
87+
},
88+
)
89+
90+
with serve(shell_instance) as test_client:
91+
cli = test_client.cli()
92+
available_commands = set(cli.commands.keys())
93+
94+
# All methods should be available
95+
expected_methods = {"method1", "method2", "method3"}
96+
assert available_commands == expected_methods, f"Expected {expected_methods}, got {available_commands}"
97+
98+
99+
def test_cli_exit_codes():
100+
"""Test that CLI commands preserve shell command exit codes"""
101+
import click
102+
103+
from .driver import Shell
104+
from jumpstarter.common.utils import serve
105+
106+
# Create a shell instance with methods that have different exit codes
107+
shell_instance = Shell(
108+
log_level="DEBUG",
109+
methods={
110+
"success": "exit 0",
111+
"fail_1": "exit 1",
112+
"fail_42": "exit 42",
113+
},
114+
)
115+
116+
with serve(shell_instance) as test_client:
117+
cli = test_client.cli()
118+
119+
# Test successful command (exit 0) - should not raise
120+
success_cmd = cli.commands['success']
121+
try:
122+
success_cmd.callback([], []) # Call with empty args and env
123+
except click.exceptions.Exit:
124+
raise AssertionError("Success command should not raise Exit exception") from None
125+
126+
# Test command that exits with code 1 - should raise Exit(1)
127+
fail1_cmd = cli.commands['fail_1']
128+
try:
129+
fail1_cmd.callback([], [])
130+
raise AssertionError("Command should have raised Exit exception")
131+
except click.exceptions.Exit as e:
132+
assert e.exit_code == 1
133+
134+
# Test command that exits with code 42 - should raise Exit(42)
135+
fail42_cmd = cli.commands['fail_42']
136+
try:
137+
fail42_cmd.callback([], [])
138+
raise AssertionError("Command should have raised Exit exception")
139+
except click.exceptions.Exit as e:
140+
assert e.exit_code == 42

packages/jumpstarter-driver-shell/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ readme = "README.md"
66
authors = [{ name = "Miguel Angel Ajo", email = "miguelangel@ajo.es" }]
77
requires-python = ">=3.11"
88
license = "Apache-2.0"
9-
dependencies = ["anyio>=4.6.2.post1", "jumpstarter"]
9+
dependencies = ["anyio>=4.6.2.post1", "jumpstarter", "click>=8.1.7.2"]
1010

1111
[project.entry-points."jumpstarter.drivers"]
1212
Shell = "jumpstarter_driver_shell.driver:Shell"

0 commit comments

Comments
 (0)