Skip to content

Commit 5d885f0

Browse files
authored
Merge pull request #329 from raballew/001-noun-aliases
feat: accept both singular and plural noun forms in CLI commands
2 parents 25f334f + 8cfc4af commit 5d885f0

File tree

10 files changed

+169
-19
lines changed

10 files changed

+169
-19
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from click.testing import CliRunner
2+
3+
from jumpstarter_cli_admin import admin
4+
5+
6+
class TestAdminGetNounAliases:
7+
def test_get_clients_resolves_to_get_client(self):
8+
runner = CliRunner()
9+
result_singular = runner.invoke(admin, ["get", "client", "--help"])
10+
result_plural = runner.invoke(admin, ["get", "clients", "--help"])
11+
assert result_singular.exit_code == 0
12+
assert result_plural.exit_code == 0
13+
assert result_singular.output == result_plural.output
14+
15+
def test_get_exporters_resolves_to_get_exporter(self):
16+
runner = CliRunner()
17+
result_singular = runner.invoke(admin, ["get", "exporter", "--help"])
18+
result_plural = runner.invoke(admin, ["get", "exporters", "--help"])
19+
assert result_singular.exit_code == 0
20+
assert result_plural.exit_code == 0
21+
assert result_singular.output == result_plural.output
22+
23+
def test_get_cluster_and_clusters_remain_distinct(self):
24+
runner = CliRunner()
25+
result_singular = runner.invoke(admin, ["get", "cluster", "--help"])
26+
result_plural = runner.invoke(admin, ["get", "clusters", "--help"])
27+
assert result_singular.exit_code == 0
28+
assert result_plural.exit_code == 0
29+
assert result_singular.output != result_plural.output
30+
31+
32+
class TestAdminCreateNounAliases:
33+
def test_create_clusters_resolves_to_create_cluster(self):
34+
runner = CliRunner()
35+
result_singular = runner.invoke(admin, ["create", "cluster", "--help"])
36+
result_plural = runner.invoke(admin, ["create", "clusters", "--help"])
37+
assert result_singular.exit_code == 0
38+
assert result_plural.exit_code == 0
39+
assert result_singular.output == result_plural.output
40+
41+
42+
class TestAdminDeleteNounAliases:
43+
def test_delete_clusters_resolves_to_delete_cluster(self):
44+
runner = CliRunner()
45+
result_singular = runner.invoke(admin, ["delete", "cluster", "--help"])
46+
result_plural = runner.invoke(admin, ["delete", "clusters", "--help"])
47+
assert result_singular.exit_code == 0
48+
assert result_plural.exit_code == 0
49+
assert result_singular.output == result_plural.output

python/packages/jumpstarter-cli-common/jumpstarter_cli_common/alias.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,13 @@ class AliasedGroup(click.Group):
2121
"delete": ["del", "d"],
2222
"shell": ["sh", "s"],
2323
"exporter": ["exporters", "e"],
24+
"exporters": ["exporter"],
2425
"client": ["clients", "c"],
26+
"clients": ["client"],
2527
"lease": ["leases", "l"],
28+
"leases": ["lease"],
29+
"cluster": ["clusters"],
30+
"clusters": ["cluster"],
2631
"version": ["ver", "v"],
2732
}
2833

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from click.testing import CliRunner
2+
3+
from .jmp import jmp
4+
5+
6+
class TestUserCliNounAliases:
7+
def test_get_exporter_resolves_to_get_exporters(self):
8+
runner = CliRunner()
9+
result_plural = runner.invoke(jmp, ["get", "exporters", "--help"])
10+
result_singular = runner.invoke(jmp, ["get", "exporter", "--help"])
11+
assert result_singular.exit_code == 0
12+
assert result_plural.exit_code == 0
13+
assert result_singular.output == result_plural.output
14+
15+
def test_get_lease_resolves_to_get_leases(self):
16+
runner = CliRunner()
17+
result_plural = runner.invoke(jmp, ["get", "leases", "--help"])
18+
result_singular = runner.invoke(jmp, ["get", "lease", "--help"])
19+
assert result_singular.exit_code == 0
20+
assert result_plural.exit_code == 0
21+
assert result_singular.output == result_plural.output
22+
23+
def test_delete_lease_resolves_to_delete_leases(self):
24+
runner = CliRunner()
25+
result_plural = runner.invoke(jmp, ["delete", "leases", "--help"])
26+
result_singular = runner.invoke(jmp, ["delete", "lease", "--help"])
27+
assert result_singular.exit_code == 0
28+
assert result_plural.exit_code == 0
29+
assert result_singular.output == result_plural.output
30+
31+
def test_create_leases_resolves_to_create_lease(self):
32+
runner = CliRunner()
33+
result_singular = runner.invoke(jmp, ["create", "lease", "--help"])
34+
result_plural = runner.invoke(jmp, ["create", "leases", "--help"])
35+
assert result_plural.exit_code == 0
36+
assert result_singular.exit_code == 0
37+
assert result_singular.output == result_plural.output
38+
39+
def test_update_leases_resolves_to_update_lease(self):
40+
runner = CliRunner()
41+
result_singular = runner.invoke(jmp, ["update", "lease", "--help"])
42+
result_plural = runner.invoke(jmp, ["update", "leases", "--help"])
43+
assert result_plural.exit_code == 0
44+
assert result_singular.exit_code == 0
45+
assert result_singular.output == result_plural.output

python/packages/jumpstarter-cli/jumpstarter_cli/create.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from datetime import datetime, timedelta
22

33
import click
4+
from jumpstarter_cli_common.alias import AliasedGroup
45
from jumpstarter_cli_common.config import opt_config
56
from jumpstarter_cli_common.exceptions import handle_exceptions_with_reauthentication
67
from jumpstarter_cli_common.opt import OutputType, opt_output_all
@@ -10,7 +11,7 @@
1011
from .login import relogin_client
1112

1213

13-
@click.group()
14+
@click.group(cls=AliasedGroup)
1415
def create():
1516
"""
1617
Create a resource

python/packages/jumpstarter-cli/jumpstarter_cli/create_test.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import inspect
12
from datetime import timedelta
23
from unittest.mock import Mock, patch
34

@@ -14,7 +15,7 @@ def test_create_lease_passes_exporter_name_to_config():
1415

1516
with patch("jumpstarter_cli.create.model_print") as model_print:
1617
# Skip Click config loading wrapper and call the command body directly.
17-
create_lease.callback.__wrapped__.__wrapped__(
18+
inspect.unwrap(create_lease.callback)(
1819
config=config,
1920
selector=None,
2021
exporter_name="laptop-test-exporter",
@@ -36,7 +37,7 @@ def test_create_lease_passes_exporter_name_to_config():
3637

3738
def test_create_lease_requires_selector_or_name():
3839
with pytest.raises(click.UsageError, match="one of --selector/-l or --name/-n is required"):
39-
create_lease.callback.__wrapped__.__wrapped__(
40+
inspect.unwrap(create_lease.callback)(
4041
config=Mock(),
4142
selector=None,
4243
exporter_name=None,

python/packages/jumpstarter-cli/jumpstarter_cli/delete.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import click
2+
from jumpstarter_cli_common.alias import AliasedGroup
23
from jumpstarter_cli_common.config import opt_config
34
from jumpstarter_cli_common.exceptions import handle_exceptions_with_reauthentication
45
from jumpstarter_cli_common.opt import OutputMode, OutputType, opt_output_name_only
@@ -7,7 +8,7 @@
78
from .login import relogin_client
89

910

10-
@click.group()
11+
@click.group(cls=AliasedGroup)
1112
def delete():
1213
"""
1314
Delete resources
@@ -16,36 +17,35 @@ def delete():
1617

1718
@delete.command(name="leases")
1819
@opt_config(exporter=False)
19-
@click.argument("name", required=False)
20+
@click.argument("names", nargs=-1)
2021
@opt_selector
2122
@click.option("--all", "all", is_flag=True)
2223
@opt_output_name_only
2324
@handle_exceptions_with_reauthentication(relogin_client)
24-
def delete_leases(config, name: str, selector: str | None, all: bool, output: OutputType):
25+
def delete_leases(config, names: tuple[str, ...], selector: str | None, all: bool, output: OutputType):
2526
"""
2627
Delete leases
2728
"""
2829

29-
names = []
30+
resolved_names = list(names)
3031

31-
if name is not None:
32-
names.append(name)
32+
if resolved_names:
33+
pass
3334
elif selector:
3435
leases = config.list_leases(filter=selector)
35-
# Client-side filtering for matchExpressions (server only filters matchLabels)
3636
leases = leases.filter_by_selector(selector)
3737
for lease in leases.leases:
3838
if lease.client == config.metadata.name:
39-
names.append(lease.name)
39+
resolved_names.append(lease.name)
4040
elif all:
4141
leases = config.list_leases(filter=None)
4242
for lease in leases.leases:
4343
if lease.client == config.metadata.name:
44-
names.append(lease.name)
44+
resolved_names.append(lease.name)
4545
else:
46-
raise click.ClickException("One of NAME, --selector or --all must be specified")
46+
raise click.ClickException("One of NAME(S), --selector or --all must be specified")
4747

48-
for name in names:
48+
for name in resolved_names:
4949
config.delete_lease(name=name)
5050
match output:
5151
case OutputMode.NAME:
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from unittest.mock import Mock
2+
3+
import click
4+
import pytest
5+
6+
from jumpstarter_cli.delete import delete_leases
7+
8+
9+
class TestBatchDeleteLeases:
10+
def test_delete_multiple_leases(self):
11+
config = Mock()
12+
delete_leases.callback.__wrapped__.__wrapped__(
13+
config=config,
14+
names=("lease1", "lease2", "lease3"),
15+
selector=None,
16+
all=False,
17+
output=None,
18+
)
19+
assert config.delete_lease.call_count == 3
20+
config.delete_lease.assert_any_call(name="lease1")
21+
config.delete_lease.assert_any_call(name="lease2")
22+
config.delete_lease.assert_any_call(name="lease3")
23+
24+
def test_delete_zero_names_no_flags_raises_error(self):
25+
config = Mock()
26+
with pytest.raises(click.ClickException, match="must be specified"):
27+
delete_leases.callback.__wrapped__.__wrapped__(
28+
config=config,
29+
names=(),
30+
selector=None,
31+
all=False,
32+
output=None,
33+
)
34+
35+
def test_delete_with_output_name(self):
36+
from jumpstarter_cli_common.opt import OutputMode
37+
38+
config = Mock()
39+
delete_leases.callback.__wrapped__.__wrapped__(
40+
config=config,
41+
names=("lease1", "lease2"),
42+
selector=None,
43+
all=False,
44+
output=OutputMode.NAME,
45+
)
46+
assert config.delete_lease.call_count == 2

python/packages/jumpstarter-cli/jumpstarter_cli/get.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import click
2+
from jumpstarter_cli_common.alias import AliasedGroup
23
from jumpstarter_cli_common.config import opt_config
34
from jumpstarter_cli_common.exceptions import handle_exceptions_with_reauthentication
45
from jumpstarter_cli_common.opt import OutputType, opt_comma_separated, opt_output_all
@@ -8,7 +9,7 @@
89
from .login import relogin_client
910

1011

11-
@click.group()
12+
@click.group(cls=AliasedGroup)
1213
def get():
1314
"""
1415
Display one or many resources

python/packages/jumpstarter-cli/jumpstarter_cli/shell_test.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import inspect
12
from contextlib import asynccontextmanager
23
from datetime import timedelta
34
from unittest.mock import AsyncMock, Mock, patch
@@ -49,7 +50,7 @@ def test_shell_passes_exporter_name_to_lease_async():
4950

5051
def test_shell_requires_selector_or_name():
5152
with pytest.raises(click.UsageError, match="one of --selector/-l or --name/-n is required"):
52-
shell.callback.__wrapped__.__wrapped__(
53+
inspect.unwrap(shell.callback)(
5354
config=Mock(spec=ClientConfigV1Alpha1),
5455
command=(),
5556
lease_name=None,
@@ -66,7 +67,7 @@ def test_shell_allows_existing_lease_name_without_selector_or_name():
6667
patch("jumpstarter_cli.shell.anyio.run", return_value=0),
6768
patch("jumpstarter_cli.shell.sys.exit") as mock_exit,
6869
):
69-
shell.callback.__wrapped__.__wrapped__(
70+
inspect.unwrap(shell.callback)(
7071
config=Mock(spec=ClientConfigV1Alpha1),
7172
command=(),
7273
lease_name="existing-lease",
@@ -86,7 +87,7 @@ def test_shell_allows_env_lease_without_selector_or_name():
8687
patch("jumpstarter_cli.shell.sys.exit") as mock_exit,
8788
patch.dict("os.environ", {JMP_LEASE: "existing-lease"}, clear=False),
8889
):
89-
shell.callback.__wrapped__.__wrapped__(
90+
inspect.unwrap(shell.callback)(
9091
config=Mock(spec=ClientConfigV1Alpha1),
9192
command=(),
9293
lease_name=None,

python/packages/jumpstarter-cli/jumpstarter_cli/update.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from datetime import datetime, timedelta
22

33
import click
4+
from jumpstarter_cli_common.alias import AliasedGroup
45
from jumpstarter_cli_common.config import opt_config
56
from jumpstarter_cli_common.exceptions import handle_exceptions_with_reauthentication
67
from jumpstarter_cli_common.opt import OutputType, opt_output_all
@@ -10,7 +11,7 @@
1011
from .login import relogin_client
1112

1213

13-
@click.group()
14+
@click.group(cls=AliasedGroup)
1415
def update():
1516
"""
1617
Update a resource

0 commit comments

Comments
 (0)