Skip to content

Commit 61f7d71

Browse files
Support for extending cfn cli with custom commands (#1020)
* code: testing command extension * code: finishing ExtensionPlugin base class * code: removing unnecessary abstract class * code: renaming parameter * test: added unit tests for cli and extensions * refactor: renaming parameter * test: testing ExtensionPlugin * test: testing PluginRegistry * code: updated extension plugin to provider parser to plugin instead * isort: fixing isort * format: fixing black formatting * code: safety check for collisions --------- Co-authored-by: Adrian Chouza <[email protected]>
1 parent 74ed3d9 commit 61f7d71

File tree

8 files changed

+181
-17
lines changed

8 files changed

+181
-17
lines changed

src/rpdk/core/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .build_image import setup_subparser as build_image_setup_subparser
1313
from .data_loaders import resource_yaml
1414
from .exceptions import DownstreamError, SysExitRecommendedError
15+
from .extensions import setup_subparsers as extensions_setup_subparser
1516
from .generate import setup_subparser as generate_setup_subparser
1617
from .init import setup_subparser as init_setup_subparser
1718
from .invoke import setup_subparser as invoke_setup_subparser
@@ -88,6 +89,7 @@ def no_command(args):
8889
invoke_setup_subparser(subparsers, parents)
8990
unittest_patch_setup_subparser(subparsers, parents)
9091
build_image_setup_subparser(subparsers, parents)
92+
extensions_setup_subparser(subparsers, parents)
9193
args = parser.parse_args(args=args_in)
9294

9395
setup_logging(args.verbose)

src/rpdk/core/extensions.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from .plugin_registry import get_extensions
2+
3+
4+
def _check_command_name_collision(subparsers, command_name):
5+
if command_name in subparsers.choices:
6+
raise RuntimeError(
7+
f'"{command_name}" is already registered as an extension. Please use a different name.'
8+
)
9+
10+
11+
def setup_subparsers(subparsers, parents):
12+
extensions = get_extensions()
13+
14+
for extension_cls in extensions.values():
15+
extension = extension_cls()()
16+
_check_command_name_collision(subparsers, extension.command_name)
17+
parser = subparsers.add_parser(extension.command_name, parents=parents)
18+
extension.setup_parser(parser)

src/rpdk/core/plugin_base.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,19 @@ def generate(self, project):
5555
@abstractmethod
5656
def package(self, project, zip_file):
5757
pass
58+
59+
60+
class ExtensionPlugin(ABC):
61+
COMMAND_NAME = None
62+
63+
@property
64+
def command_name(self):
65+
if not self.COMMAND_NAME:
66+
raise RuntimeError(
67+
"Set COMMAND_NAME to the command you want to extend cfn with: `cfn COMMAND_NAME`."
68+
)
69+
return self.COMMAND_NAME
70+
71+
@abstractmethod
72+
def setup_parser(self, parser):
73+
pass

src/rpdk/core/plugin_registry.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,14 @@ def get_parsers():
2323
return parsers
2424

2525

26+
def get_extensions():
27+
extensions = {
28+
entry_point.name: entry_point.load
29+
for entry_point in pkg_resources.iter_entry_points("rpdk.v1.extensions")
30+
}
31+
32+
return extensions
33+
34+
2635
def load_plugin(language):
2736
return PLUGIN_REGISTRY[language]()()

tests/test_cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,15 @@ def test_main_no_args_prints_help(capsys):
7777
assert "--help" in out
7878

7979

80+
def test_main_setup_extensions():
81+
with patch(
82+
"rpdk.core.cli.extensions_setup_subparser"
83+
) as extensions_setup_subparser:
84+
main(args_in=[])
85+
86+
extensions_setup_subparser.assert_called_once()
87+
88+
8089
def test_main_version_arg_prints_version(capsys):
8190
main(args_in=["--version"])
8291
out, err = capsys.readouterr()

tests/test_extensions.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import argparse
2+
from unittest import TestCase
3+
from unittest.mock import MagicMock, patch
4+
5+
from rpdk.core.extensions import setup_subparsers
6+
7+
8+
class ExtensionTest(TestCase):
9+
def test_setup_subparsers(self): # pylint: disable=no-self-use
10+
expeted_command_name = "expected-command-name"
11+
12+
mock_extension = MagicMock()
13+
mock_extension.command_name = expeted_command_name
14+
15+
mock_extension_entry_point = MagicMock()
16+
mock_extension_entry_point.return_value.return_value = mock_extension
17+
18+
mock_extension_entry_points = {"key": mock_extension_entry_point}
19+
20+
subparsers, parents, parser = MagicMock(), MagicMock(), MagicMock()
21+
subparsers.add_parser.return_value = parser
22+
23+
with patch("rpdk.core.extensions.get_extensions") as mock_get_extensions:
24+
mock_get_extensions.return_value = mock_extension_entry_points
25+
setup_subparsers(subparsers, parents)
26+
27+
mock_extension.setup_parser.assert_called_once_with(parser)
28+
subparsers.add_parser.assert_called_with(expeted_command_name, parents=parents)
29+
30+
def test_setup_subparsers_should_raise_error_when_collision_occur(self):
31+
command_name = "command-name"
32+
33+
mock_extension_1, mock_extension_2 = MagicMock(), MagicMock()
34+
mock_extension_1.command_name = command_name
35+
mock_extension_2.command_name = command_name
36+
37+
mock_extension_1_entry_point = MagicMock()
38+
mock_extension_1_entry_point.return_value.return_value = mock_extension_1
39+
40+
mock_extension_2_entry_point = MagicMock()
41+
mock_extension_2_entry_point.return_value.return_value = mock_extension_2
42+
43+
mock_extension_entry_points = {
44+
"key_1": mock_extension_1_entry_point,
45+
"key_2": mock_extension_2_entry_point,
46+
}
47+
48+
parser = argparse.ArgumentParser()
49+
subparsers = parser.add_subparsers()
50+
51+
with patch(
52+
"rpdk.core.extensions.get_extensions"
53+
) as mock_get_extensions, self.assertRaises(RuntimeError) as context:
54+
mock_get_extensions.return_value = mock_extension_entry_points
55+
setup_subparsers(subparsers, [])
56+
57+
assert (
58+
str(context.exception)
59+
== '"command-name" is already registered as an extension. Please use a different name.'
60+
)

tests/test_plugin_base.py

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
import pytest
66

77
from rpdk.core.filters import FILTER_REGISTRY
8-
from rpdk.core.plugin_base import LanguagePlugin, __name__ as plugin_base_name
8+
from rpdk.core.plugin_base import (
9+
ExtensionPlugin,
10+
LanguagePlugin,
11+
__name__ as plugin_base_name,
12+
)
913

1014

1115
class TestLanguagePlugin(LanguagePlugin):
@@ -22,7 +26,7 @@ def package(self, project, zip_file):
2226

2327

2428
@pytest.fixture
25-
def plugin():
29+
def language_plugin():
2630
return TestLanguagePlugin()
2731

2832

@@ -34,20 +38,20 @@ def test_language_plugin_module_not_set():
3438
plugin._module_name # pylint: disable=pointless-statement
3539

3640

37-
def test_language_plugin_init_no_op(plugin):
38-
plugin.init(None)
41+
def test_language_plugin_init_no_op(language_plugin):
42+
language_plugin.init(None)
3943

4044

41-
def test_language_plugin_generate_no_op(plugin):
42-
plugin.generate(None)
45+
def test_language_plugin_generate_no_op(language_plugin):
46+
language_plugin.generate(None)
4347

4448

45-
def test_language_plugin_package_no_op(plugin):
46-
plugin.package(None, None)
49+
def test_language_plugin_package_no_op(language_plugin):
50+
language_plugin.package(None, None)
4751

4852

49-
def test_language_plugin_setup_jinja_env_defaults(plugin):
50-
env = plugin._setup_jinja_env()
53+
def test_language_plugin_setup_jinja_env_defaults(language_plugin):
54+
env = language_plugin._setup_jinja_env()
5155
assert env.loader
5256
assert env.autoescape
5357

@@ -57,28 +61,56 @@ def test_language_plugin_setup_jinja_env_defaults(plugin):
5761
assert env.get_template("test.txt")
5862

5963

60-
def test_language_plugin_setup_jinja_env_overrides(plugin):
64+
def test_language_plugin_setup_jinja_env_overrides(language_plugin):
6165
loader = object()
6266
autoescape = object()
63-
env = plugin._setup_jinja_env(autoescape=autoescape, loader=loader)
67+
env = language_plugin._setup_jinja_env(autoescape=autoescape, loader=loader)
6468
assert env.loader is loader
6569
assert env.autoescape is autoescape
6670

6771
for name in FILTER_REGISTRY:
6872
assert name in env.filters
6973

7074

71-
def test_language_plugin_setup_jinja_env_no_spec(plugin):
75+
def test_language_plugin_setup_jinja_env_no_spec(language_plugin):
7276
with patch(
7377
"rpdk.core.plugin_base.importlib.util.find_spec", return_value=None
7478
) as mock_spec, patch("rpdk.core.plugin_base.PackageLoader") as mock_loader:
75-
env = plugin._setup_jinja_env()
79+
env = language_plugin._setup_jinja_env()
7680

77-
mock_spec.assert_called_once_with(plugin._module_name)
78-
mock_loader.assert_has_calls([call(plugin._module_name), call(plugin_base_name)])
81+
mock_spec.assert_called_once_with(language_plugin._module_name)
82+
mock_loader.assert_has_calls(
83+
[call(language_plugin._module_name), call(plugin_base_name)]
84+
)
7985

8086
assert env.loader
8187
assert env.autoescape
8288

8389
for name in FILTER_REGISTRY:
8490
assert name in env.filters
91+
92+
93+
class TestExtensionPlugin(ExtensionPlugin):
94+
COMMAND_NAME = "test-extension"
95+
96+
def setup_parser(self, parser):
97+
super().setup_parser(parser)
98+
99+
100+
@pytest.fixture
101+
def extension_plugin():
102+
return TestExtensionPlugin()
103+
104+
105+
def test_extension_plugin_command_name(extension_plugin):
106+
assert extension_plugin.command_name == "test-extension"
107+
108+
109+
def test_extension_plugin_command_name_error(extension_plugin):
110+
extension_plugin.COMMAND_NAME = None
111+
with pytest.raises(RuntimeError):
112+
extension_plugin.command_name # pylint: disable=pointless-statement
113+
114+
115+
def test_extension_plugin_setup_parser_no_op(extension_plugin):
116+
extension_plugin.setup_parser(None)

tests/test_plugin_registry.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from unittest.mock import Mock, patch
22

3-
from rpdk.core.plugin_registry import load_plugin
3+
from rpdk.core.plugin_registry import get_extensions, load_plugin
44

55

66
def test_load_plugin():
@@ -11,3 +11,21 @@ def test_load_plugin():
1111
load_plugin("test")
1212
plugin.assert_called_once_with()
1313
plugin.return_value.assert_called_once_with()
14+
15+
16+
def test_get_extensions():
17+
mock_entrypoint_1 = Mock()
18+
mock_entrypoint_2 = Mock()
19+
20+
patch_iter_entry_points = patch(
21+
"rpdk.core.plugin_registry.pkg_resources.iter_entry_points"
22+
)
23+
with patch_iter_entry_points as mock_iter_entry_points:
24+
mock_iter_entry_points.return_value = [mock_entrypoint_1, mock_entrypoint_2]
25+
26+
extensions = get_extensions()
27+
28+
assert extensions == {
29+
mock_entrypoint_1.name: mock_entrypoint_1.load,
30+
mock_entrypoint_2.name: mock_entrypoint_2.load,
31+
}

0 commit comments

Comments
 (0)