Skip to content

Commit a0eee2b

Browse files
committed
Warn on duplicate commands in the same namespace
We intentionally do not use stevedore's conflict resolution functionality here because, unlikely stevedore, we don't care what extension namespace a command is defined in: if it's got the same command name then it's a duplicate regardless. Change-Id: I20f4c75fc6538615e7d1be5fc4e91568d4e8b193 Signed-off-by: Stephen Finucane <sfinucan@redhat.com>
1 parent 37edae7 commit a0eee2b

4 files changed

Lines changed: 189 additions & 26 deletions

File tree

cliff/commandmanager.py

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ def __init__(
5353
def load(self) -> type[command.Command]:
5454
return self.command_class
5555

56+
@property
57+
def value(self) -> str:
58+
# fake entry point target for logging purposes
59+
return ':'.join(
60+
[self.command_class.__module__, self.command_class.__name__]
61+
)
62+
5663

5764
EntryPointT: TypeAlias = EntryPointWrapper | importlib.metadata.EntryPoint
5865

@@ -64,15 +71,23 @@ class CommandManager:
6471
plugins to be loaded. For example, ``'cliff.formatter.list'``.
6572
:param convert_underscores: Whether cliff should convert underscores to
6673
spaces in entry_point commands.
74+
:param ignored_modules: A list of module names to ignore when loading
75+
commands. This will be matched partial, so be as specific as needed.
6776
"""
6877

6978
def __init__(
70-
self, namespace: str, convert_underscores: bool = True
79+
self,
80+
namespace: str | None,
81+
convert_underscores: bool = True,
82+
*,
83+
ignored_modules: collections.abc.Iterable[str] | None = None,
7184
) -> None:
72-
self.commands: dict[str, EntryPointT] = {}
73-
self._legacy: dict[str, str] = {}
7485
self.namespace = namespace
7586
self.convert_underscores = convert_underscores
87+
self.ignored_modules = ignored_modules or ()
88+
89+
self.commands: dict[str, EntryPointT] = {}
90+
self._legacy: dict[str, str] = {}
7691
self.group_list: list[str] = []
7792
self._load_commands()
7893

@@ -81,19 +96,79 @@ def _load_commands(self) -> None:
8196
if self.namespace:
8297
self.load_commands(self.namespace)
8398

99+
@staticmethod
100+
def _is_module_ignored(
101+
module_name: str, ignored_modules: collections.abc.Iterable[str]
102+
) -> bool:
103+
# given module_name = 'foo.bar.baz:wow', we expect to match any of
104+
# the following ignores: foo.bar.baz:wow, foo.bar.baz, foo.bar, foo
105+
while True:
106+
if module_name in ignored_modules:
107+
return True
108+
109+
split_index = max(module_name.rfind(':'), module_name.rfind('.'))
110+
if split_index == -1:
111+
break
112+
113+
module_name = module_name[:split_index]
114+
115+
return False
116+
84117
def load_commands(self, namespace: str) -> None:
85-
"""Load all the commands from an entrypoint"""
118+
"""Load all the commands from an entrypoint
119+
120+
:param namespace: The namespace to load commands from.
121+
:returns: None
122+
"""
86123
self.group_list.append(namespace)
87124
em: stevedore.ExtensionManager[command.Command]
125+
# note that we don't invoke stevedore's conflict resolver functionality
126+
# because that is namespace specific and we care about conflicts
127+
# regardless of the namespace
88128
em = stevedore.ExtensionManager(namespace)
89-
for ep in em:
90-
LOG.debug('found command %r', ep.name)
129+
for ext in em:
130+
LOG.debug('found command %r', ext.name)
131+
132+
if self._is_module_ignored(ext.module_name, self.ignored_modules):
133+
LOG.debug(
134+
'extension found in ignored module %r: skipping',
135+
ext.module_name,
136+
)
137+
continue
138+
91139
cmd_name = (
92-
ep.name.replace('_', ' ')
140+
ext.name.replace('_', ' ')
93141
if self.convert_underscores
94-
else ep.name
142+
else ext.name
95143
)
96-
self.commands[cmd_name] = ep.entry_point
144+
145+
if cmd_name in self.commands:
146+
# Attention, programmers: If you arrived here attempting to
147+
# resolve a warning in your application then you have a command
148+
# with the same name either defined more than once in the same
149+
# application (a typo?) or in multiple packages (for example,
150+
# a package that adds plugins to your applications). The latter
151+
# can often happen if you e.g. move a command from a plugin to
152+
# the core application. In this situation, you should add the
153+
# old location to 'ignored_modules' and the plugin will now be
154+
# ignored.
155+
LOG.warning(
156+
'found duplicate implementations of the %(name)r command '
157+
'in the following modules: %(modules)s: this is likely '
158+
'programmer error and should be reported as a bug to the '
159+
'relevant project(s)',
160+
{
161+
'name': cmd_name,
162+
'modules': ', '.join(
163+
[
164+
self.commands[cmd_name].value,
165+
ext.entry_point.value,
166+
]
167+
),
168+
},
169+
)
170+
171+
self.commands[cmd_name] = ext.entry_point
97172

98173
def __iter__(
99174
self,

cliff/tests/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,13 @@
1919
class TestBase(testtools.TestCase):
2020
def setUp(self):
2121
super().setUp()
22+
2223
self._stdout_fixture = fixtures.StringStream('stdout')
2324
self.stdout = self.useFixture(self._stdout_fixture).stream
2425
self.useFixture(fixtures.MonkeyPatch('sys.stdout', self.stdout))
26+
2527
self._stderr_fixture = fixtures.StringStream('stderr')
2628
self.stderr = self.useFixture(self._stderr_fixture).stream
2729
self.useFixture(fixtures.MonkeyPatch('sys.stderr', self.stderr))
30+
31+
self.logger = self.useFixture(fixtures.FakeLogger('cliff'))

cliff/tests/test_commandmanager.py

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
# License for the specific language governing permissions and limitations
1111
# under the License.
1212

13-
import testscenarios
1413
from unittest import mock
1514

15+
import testscenarios
16+
1617
from cliff import command
1718
from cliff import commandmanager
1819
from cliff.tests import base
@@ -113,20 +114,23 @@ def test_intersected_commands(self):
113114

114115
class TestLoad(base.TestBase):
115116
def test_load_commands(self):
116-
testcmd = mock.Mock(name='testcmd')
117-
testcmd.name.replace.return_value = 'test'
117+
testcmd = mock.Mock()
118+
testcmd.name = 'test_cmd'
119+
testcmd.module_name = 'module.a'
118120
mock_get_group_all = mock.Mock(return_value=[testcmd])
119121
with mock.patch(
120122
'stevedore.ExtensionManager', mock_get_group_all
121123
) as mock_manager:
122124
mgr = commandmanager.CommandManager('test')
123-
mock_manager.assert_called_once_with('test')
124-
names = [n for n, v in mgr]
125-
self.assertEqual(['test'], names)
125+
126+
mock_manager.assert_called_once_with('test')
127+
names = [n for n, v in mgr]
128+
self.assertEqual(['test cmd'], names)
126129

127130
def test_load_commands_keep_underscores(self):
128131
testcmd = mock.Mock()
129132
testcmd.name = 'test_cmd'
133+
testcmd.module_name = 'module.a'
130134
mock_get_group_all = mock.Mock(return_value=[testcmd])
131135
with mock.patch(
132136
'stevedore.ExtensionManager', mock_get_group_all
@@ -135,24 +139,58 @@ def test_load_commands_keep_underscores(self):
135139
'test',
136140
convert_underscores=False,
137141
)
138-
mock_manager.assert_called_once_with('test')
139-
names = [n for n, v in mgr]
140-
self.assertEqual(['test_cmd'], names)
141142

142-
def test_load_commands_replace_underscores(self):
143-
testcmd = mock.Mock()
144-
testcmd.name = 'test_cmd'
145-
mock_get_group_all = mock.Mock(return_value=[testcmd])
143+
mock_manager.assert_called_once_with('test')
144+
names = [n for n, v in mgr]
145+
self.assertEqual(['test_cmd'], names)
146+
147+
def test_load_commands_ignore_modules(self):
148+
testcmd_normal = mock.Mock()
149+
testcmd_normal.name = 'normal_cmd'
150+
testcmd_normal.module_name = 'normal.module'
151+
testcmd_ignored = mock.Mock()
152+
testcmd_ignored.name = 'ignored_cmd'
153+
testcmd_ignored.module_name = 'ignored.module'
154+
mock_get_group_all = mock.Mock(
155+
return_value=[testcmd_normal, testcmd_ignored]
156+
)
146157
with mock.patch(
147158
'stevedore.ExtensionManager', mock_get_group_all
148159
) as mock_manager:
149160
mgr = commandmanager.CommandManager(
150161
'test',
151-
convert_underscores=True,
162+
ignored_modules=['ignored.module'],
152163
)
153-
mock_manager.assert_called_once_with('test')
154-
names = [n for n, v in mgr]
155-
self.assertEqual(['test cmd'], names)
164+
165+
mock_manager.assert_called_once_with('test')
166+
names = [n for n, v in mgr]
167+
self.assertEqual(['normal cmd'], names)
168+
169+
def test_load_commands_duplicates(self):
170+
testcmd_a = mock.Mock()
171+
testcmd_a.name = 'test_cmd'
172+
testcmd_a.module_name = 'module.a'
173+
testcmd_a.entry_point.value = 'module.a:TestCmd'
174+
testcmd_b = mock.Mock()
175+
testcmd_b.name = 'test_cmd'
176+
testcmd_b.module_name = 'module.b'
177+
testcmd_b.entry_point.value = 'module.b:TestCmd'
178+
mock_get_group_all = mock.Mock(return_value=[testcmd_a, testcmd_b])
179+
with mock.patch(
180+
'stevedore.ExtensionManager', mock_get_group_all
181+
) as mock_manager:
182+
mgr = commandmanager.CommandManager('test')
183+
184+
mock_manager.assert_called_once_with('test')
185+
names = [n for n, v in mgr]
186+
commands = [v for _, v in mgr]
187+
self.assertEqual(['test cmd'], names)
188+
# we should have ended up with the second command
189+
self.assertEqual([testcmd_b.entry_point], commands)
190+
self.assertIn(
191+
"found duplicate implementations of the 'test cmd' command",
192+
self.logger.output,
193+
)
156194

157195

158196
class FauxCommand(command.Command):
@@ -216,6 +254,34 @@ def test(self):
216254
self.assertFalse(remaining)
217255

218256

257+
class TestIsModuleIgnored(base.TestBase):
258+
def test_match(self):
259+
result = commandmanager.CommandManager._is_module_ignored(
260+
'foo.bar.baz', ['foo.bar.baz', 'other.module']
261+
)
262+
self.assertTrue(result)
263+
result = commandmanager.CommandManager._is_module_ignored(
264+
'foo.bar.baz', ['foo.bar', 'other.module']
265+
)
266+
self.assertTrue(result)
267+
result = commandmanager.CommandManager._is_module_ignored(
268+
'foo.bar.baz', ['foo', 'other.module']
269+
)
270+
self.assertTrue(result)
271+
272+
def test_no_match(self):
273+
result = commandmanager.CommandManager._is_module_ignored(
274+
'foo.bar.baz', ['other.module', 'another.package']
275+
)
276+
self.assertFalse(result)
277+
278+
def test_no_ignores(self):
279+
result = commandmanager.CommandManager._is_module_ignored(
280+
'foo.bar.baz', []
281+
)
282+
self.assertFalse(result)
283+
284+
219285
class TestGetByPartialName(base.TestBase):
220286
def setUp(self):
221287
super().setUp()
@@ -334,8 +400,10 @@ def test_get_command_groups(self):
334400
def test_get_command_names(self):
335401
mock_cmd_one = mock.Mock()
336402
mock_cmd_one.name = 'one'
403+
mock_cmd_one.module_name = 'module.a'
337404
mock_cmd_two = mock.Mock()
338405
mock_cmd_two.name = 'cmd two'
406+
mock_cmd_two.module_name = 'module.b'
339407
mock_get_group_all = mock.Mock(
340408
return_value=[mock_cmd_one, mock_cmd_two],
341409
)
@@ -345,5 +413,9 @@ def test_get_command_names(self):
345413
) as mock_manager:
346414
mgr = commandmanager.CommandManager('test')
347415
mock_manager.assert_called_once_with('test')
416+
348417
cmds = mgr.get_command_names('test')
418+
mock_manager.assert_has_calls(
419+
[mock.call('test'), mock.call('test')]
420+
)
349421
self.assertEqual(['one', 'cmd two'], cmds)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
features:
3+
- |
4+
``cliff.commandmanager.CommandManager`` now accepts an optional
5+
``ignored_modules`` argument, which can be used to indicate modules that
6+
should be skipped when loading commands. This can be useful if e.g. moving
7+
a command from a plugin to the core application, to ensure the version in
8+
the core application is always preferred.
9+
upgrade:
10+
- |
11+
cliff will now warn if duplicates command implementations are found, rather
12+
than silently ignoring them.

0 commit comments

Comments
 (0)