Skip to content

Commit 3d5ac5d

Browse files
refactor: align plugins list with gateway API
- Use case-insensitive PluginMode enum for --mode - Assume /admin/plugins response shape ({"plugins": [...]}) - Update tests to use invoke_typer_command for accurate option defaults/coverage Signed-off-by: Matthew Grigsby <38010437+MatthewGrigsby@users.noreply.github.com>
1 parent 431fc2b commit 3d5ac5d

2 files changed

Lines changed: 51 additions & 27 deletions

File tree

cforge/commands/resources/plugins.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"""
1717

1818
# Standard
19+
from enum import Enum
1920
from typing import Any, Dict, Optional
2021

2122
# Third-Party
@@ -33,6 +34,28 @@
3334
)
3435

3536

37+
class _CaseInsensitiveEnum(str, Enum):
38+
"""Enum that supports case-insensitive parsing for CLI options."""
39+
40+
@classmethod
41+
def _missing_(cls, value: object) -> Optional["_CaseInsensitiveEnum"]:
42+
if not isinstance(value, str):
43+
return None
44+
value_folded = value.casefold()
45+
for member in cls:
46+
if member.value.casefold() == value_folded:
47+
return member
48+
return None
49+
50+
51+
class PluginMode(_CaseInsensitiveEnum):
52+
"""Valid plugin mode filters supported by the gateway admin API."""
53+
54+
ENFORCE = "enforce"
55+
PERMISSIVE = "permissive"
56+
DISABLED = "disabled"
57+
58+
3659
def _handle_plugins_exception(exception: Exception) -> None:
3760
"""Provide plugin-specific hints and raise a CLI error."""
3861
console = get_console()
@@ -47,7 +70,7 @@ def _handle_plugins_exception(exception: Exception) -> None:
4770

4871
def plugins_list(
4972
search: Optional[str] = typer.Option(None, "--search", help="Search by plugin name, description, or author"),
50-
mode: Optional[str] = typer.Option(None, "--mode", help="Filter by mode (enforce, permissive, disabled)"),
73+
mode: Optional[PluginMode] = typer.Option(None, "--mode", help="Filter by mode"),
5174
hook: Optional[str] = typer.Option(None, "--hook", help="Filter by hook type"),
5275
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by plugin tag"),
5376
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
@@ -60,7 +83,7 @@ def plugins_list(
6083
if search:
6184
params["search"] = search
6285
if mode:
63-
params["mode"] = mode
86+
params["mode"] = mode.value
6487
if hook:
6588
params["hook"] = hook
6689
if tag:
@@ -71,18 +94,7 @@ def plugins_list(
7194
if json_output:
7295
print_json(result, "Plugins")
7396
else:
74-
plugins: list[dict[str, Any]] = []
75-
if isinstance(result, dict):
76-
if "plugins" in result:
77-
raw_plugins = result.get("plugins", [])
78-
if isinstance(raw_plugins, list):
79-
plugins = raw_plugins
80-
elif isinstance(raw_plugins, dict):
81-
plugins = [raw_plugins]
82-
else:
83-
plugins = [result]
84-
elif isinstance(result, list):
85-
plugins = result
97+
plugins: list[dict[str, Any]] = result["plugins"]
8698

8799
if plugins:
88100
print_table(plugins, "Plugins", ["name", "version", "author", "mode", "status", "priority", "hooks", "tags"])

tests/commands/resources/test_plugins.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,26 @@
1212
import typer
1313

1414
# First-Party
15-
from cforge.commands.resources.plugins import plugins_get, plugins_list, plugins_stats
15+
from cforge.commands.resources.plugins import PluginMode, plugins_get, plugins_list, plugins_stats
1616
from cforge.common import AuthenticationError, CLIError
17-
from tests.conftest import patch_functions
17+
from tests.conftest import invoke_typer_command, patch_functions
1818

1919

2020
class TestPluginCommands:
2121
"""Tests for plugins commands."""
2222

23+
def test_plugin_mode_enum_is_case_insensitive(self) -> None:
24+
"""Typer Enum choices should accept case-insensitive values."""
25+
assert PluginMode("EnFoRcE") == PluginMode.ENFORCE
26+
27+
def test_plugin_mode_enum_missing_non_string(self) -> None:
28+
"""Non-string values should not be coerced into Enum members."""
29+
assert PluginMode._missing_(123) is None
30+
31+
def test_plugin_mode_enum_missing_unknown_value(self) -> None:
32+
"""Unknown strings should not be coerced into Enum members."""
33+
assert PluginMode._missing_("nope") is None
34+
2335
def test_plugins_list_success(self, mock_console) -> None:
2436
"""Test plugins list command with table output."""
2537
mock_response = {
@@ -37,7 +49,7 @@ def test_plugins_list_success(self, mock_console) -> None:
3749
make_authenticated_request={"return_value": mock_response},
3850
print_table=None,
3951
) as mocks:
40-
plugins_list(json_output=False)
52+
invoke_typer_command(plugins_list)
4153
mocks.print_table.assert_called_once()
4254

4355
def test_plugins_list_json_output(self, mock_console) -> None:
@@ -49,14 +61,14 @@ def test_plugins_list_json_output(self, mock_console) -> None:
4961
make_authenticated_request={"return_value": mock_response},
5062
print_json=None,
5163
) as mocks:
52-
plugins_list(json_output=True)
64+
invoke_typer_command(plugins_list, json_output=True)
5365
mocks.print_json.assert_called_once()
5466

5567
def test_plugins_list_no_results(self, mock_console) -> None:
5668
"""Test plugins list with no results."""
5769
mock_response = {"plugins": [], "total": 0, "enabled_count": 0, "disabled_count": 0}
5870
with patch_functions("cforge.commands.resources.plugins", get_console=mock_console, make_authenticated_request={"return_value": mock_response}):
59-
plugins_list(json_output=False)
71+
invoke_typer_command(plugins_list)
6072

6173
assert any("No plugins found" in str(call) for call in mock_console.print.call_args_list)
6274

@@ -68,7 +80,7 @@ def test_plugins_list_with_filters(self, mock_console) -> None:
6880
make_authenticated_request={"return_value": {"plugins": [], "total": 0, "enabled_count": 0, "disabled_count": 0}},
6981
print_table=None,
7082
) as mocks:
71-
plugins_list(search="pii", mode="enforce", hook="tool_pre_invoke", tag="security", json_output=False)
83+
invoke_typer_command(plugins_list, search="pii", mode=PluginMode.ENFORCE, hook="tool_pre_invoke", tag="security")
7284

7385
call_args = mocks.make_authenticated_request.call_args
7486
assert call_args[0][0] == "GET"
@@ -79,7 +91,7 @@ def test_plugins_list_error(self, mock_console) -> None:
7991
"""Test plugins list error handling."""
8092
with patch_functions("cforge.commands.resources.plugins", get_console=mock_console, make_authenticated_request={"side_effect": Exception("API error")}):
8193
with pytest.raises(typer.Exit):
82-
plugins_list(json_output=False)
94+
invoke_typer_command(plugins_list)
8395

8496
def test_plugins_get_success(self, mock_console) -> None:
8597
"""Test plugins get command."""
@@ -90,14 +102,14 @@ def test_plugins_get_success(self, mock_console) -> None:
90102
make_authenticated_request={"return_value": mock_plugin},
91103
print_json=None,
92104
) as mocks:
93-
plugins_get(name="pii_filter")
105+
invoke_typer_command(plugins_get, name="pii_filter")
94106
mocks.print_json.assert_called_once()
95107

96108
def test_plugins_get_error(self, mock_console) -> None:
97109
"""Test plugins get error handling."""
98110
with patch_functions("cforge.commands.resources.plugins", get_console=mock_console, make_authenticated_request={"side_effect": Exception("API error")}):
99111
with pytest.raises(typer.Exit):
100-
plugins_get(name="pii_filter")
112+
invoke_typer_command(plugins_get, name="pii_filter")
101113

102114
def test_plugins_stats_success(self, mock_console) -> None:
103115
"""Test plugins stats command."""
@@ -108,14 +120,14 @@ def test_plugins_stats_success(self, mock_console) -> None:
108120
make_authenticated_request={"return_value": mock_stats},
109121
print_json=None,
110122
) as mocks:
111-
plugins_stats()
123+
invoke_typer_command(plugins_stats)
112124
mocks.print_json.assert_called_once()
113125

114126
def test_plugins_stats_error(self, mock_console) -> None:
115127
"""Test plugins stats error handling."""
116128
with patch_functions("cforge.commands.resources.plugins", get_console=mock_console, make_authenticated_request={"side_effect": Exception("API error")}):
117129
with pytest.raises(typer.Exit):
118-
plugins_stats()
130+
invoke_typer_command(plugins_stats)
119131

120132
def test_plugins_list_forbidden_shows_permission_hint(self, mock_console) -> None:
121133
"""Test plugins list shows a targeted hint on forbidden/admin failures."""
@@ -125,7 +137,7 @@ def test_plugins_list_forbidden_shows_permission_hint(self, mock_console) -> Non
125137
make_authenticated_request={"side_effect": AuthenticationError("Authentication required but not configured")},
126138
):
127139
with pytest.raises(typer.Exit):
128-
plugins_list(json_output=False)
140+
invoke_typer_command(plugins_list)
129141

130142
assert any("Requires admin.plugins permission" in str(call) for call in mock_console.print.call_args_list)
131143

@@ -137,6 +149,6 @@ def test_plugins_list_not_found_shows_admin_api_hint(self, mock_console) -> None
137149
make_authenticated_request={"side_effect": CLIError("API request failed (404): Not Found")},
138150
):
139151
with pytest.raises(typer.Exit):
140-
plugins_list(json_output=False)
152+
invoke_typer_command(plugins_list)
141153

142154
assert any("Admin plugin API unavailable" in str(call) for call in mock_console.print.call_args_list)

0 commit comments

Comments
 (0)