Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-cliff for openSUSE:Factory 
checked in at 2026-01-22 15:18:05
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-cliff (Old)
 and      /work/SRC/openSUSE:Factory/.python-cliff.new.1928 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-cliff"

Thu Jan 22 15:18:05 2026 rev:49 rq:1328626 version:4.13.1

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-cliff/python-cliff.changes        
2025-11-26 17:18:59.483187181 +0100
+++ /work/SRC/openSUSE:Factory/.python-cliff.new.1928/python-cliff.changes      
2026-01-22 15:19:34.963740820 +0100
@@ -1,0 +2,12 @@
+Thu Jan 22 08:41:56 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 4.13.1:
+  * Remove use of ABCMeta for formatters
+  * Run mypy from tox
+  * Deprecate CommandManager namespace argument
+  * Warn on duplicate commands in the same namespace
+  * Revert "Implement conflict resolution"
+  * Implement conflict resolution
+  * typing: Fixups for typed stevedore
+
+-------------------------------------------------------------------

Old:
----
  cliff-4.12.0.tar.gz

New:
----
  cliff-4.13.1.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-cliff.spec ++++++
--- /var/tmp/diff_new_pack.u8aCNj/_old  2026-01-22 15:19:35.683770771 +0100
+++ /var/tmp/diff_new_pack.u8aCNj/_new  2026-01-22 15:19:35.687770937 +0100
@@ -1,7 +1,7 @@
 #
 # spec file for package python-cliff
 #
-# Copyright (c) 2025 SUSE LLC
+# Copyright (c) 2026 SUSE LLC and contributors
 #
 # All modifications and additions to the file contributed by third parties
 # remain the property of their copyright owners, unless otherwise agreed
@@ -18,7 +18,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-cliff
-Version:        4.12.0
+Version:        4.13.1
 Release:        0
 Summary:        Command Line Interface Formulation Framework
 License:        Apache-2.0

++++++ cliff-4.12.0.tar.gz -> cliff-4.13.1.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/.pre-commit-config.yaml 
new/cliff-4.13.1/.pre-commit-config.yaml
--- old/cliff-4.12.0/.pre-commit-config.yaml    2025-11-20 15:59:28.000000000 
+0100
+++ new/cliff-4.13.1/.pre-commit-config.yaml    2025-12-18 15:10:43.000000000 
+0100
@@ -15,24 +15,8 @@
         files: .*\.(yaml|yml)$
         exclude: '^zuul.d/.*$'
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.14.5
+    rev: v0.14.8
     hooks:
       - id: ruff-check
         args: ['--fix', '--unsafe-fixes']
       - id: ruff-format
-  - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v1.18.2
-    hooks:
-      - id: mypy
-        additional_dependencies:
-          - autopage
-          - cmd2
-          - types-docutils
-          - types-PyYAML
-        # keep this in-sync with '[mypy] exclude' in 'pyproject.toml'
-        exclude: |
-          (?x)(
-            demoapp/.*
-            | doc/.*
-            | releasenotes/.*
-          )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/ChangeLog new/cliff-4.13.1/ChangeLog
--- old/cliff-4.12.0/ChangeLog  2025-11-20 16:00:03.000000000 +0100
+++ new/cliff-4.13.1/ChangeLog  2025-12-18 15:11:35.000000000 +0100
@@ -1,6 +1,21 @@
 CHANGES
 =======
 
+4.13.1
+------
+
+* Remove use of ABCMeta for formatters
+* Run mypy from tox
+
+4.13.0
+------
+
+* Deprecate CommandManager namespace argument
+* Warn on duplicate commands in the same namespace
+* Revert "Implement conflict resolution"
+* Implement conflict resolution
+* typing: Fixups for typed stevedore
+
 4.12.0
 ------
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/PKG-INFO new/cliff-4.13.1/PKG-INFO
--- old/cliff-4.12.0/PKG-INFO   2025-11-20 16:00:03.748392600 +0100
+++ new/cliff-4.13.1/PKG-INFO   2025-12-18 15:11:35.985460800 +0100
@@ -1,6 +1,6 @@
-Metadata-Version: 2.1
+Metadata-Version: 2.4
 Name: cliff
-Version: 4.12.0
+Version: 4.13.1
 Summary: Command Line Interface Formulation Framework
 Author-email: OpenStack <[email protected]>
 License: Apache-2.0
@@ -23,8 +23,10 @@
 Requires-Dist: autopage>=0.4.0
 Requires-Dist: cmd2>=1.0.0
 Requires-Dist: PrettyTable>=0.7.2
-Requires-Dist: stevedore>=2.0.1
+Requires-Dist: stevedore>=5.6.0
 Requires-Dist: PyYAML>=3.12
+Dynamic: license-file
+Dynamic: requires-dist
 
 ========================
 Team and repository tags
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/columns.py 
new/cliff-4.13.1/cliff/columns.py
--- old/cliff-4.12.0/cliff/columns.py   2025-11-20 15:59:28.000000000 +0100
+++ new/cliff-4.13.1/cliff/columns.py   2025-12-18 15:10:43.000000000 +0100
@@ -12,13 +12,12 @@
 
 """Formattable column tools."""
 
-import abc
 import typing as ty
 
 _T = ty.TypeVar('_T')
 
 
-class FormattableColumn(ty.Generic[_T], metaclass=abc.ABCMeta):
+class FormattableColumn(ty.Generic[_T]):
     def __init__(self, value: _T) -> None:
         self._value = value
 
@@ -40,9 +39,9 @@
     def __repr__(self) -> str:
         return f'{self.__class__.__name__}({self.machine_readable()!r})'
 
-    @abc.abstractmethod
     def human_readable(self) -> str:
         """Return a basic human readable version of the data."""
+        raise NotImplementedError()
 
     def machine_readable(self) -> _T:
         """Return a raw data structure using only Python built-in types.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/command.py 
new/cliff-4.13.1/cliff/command.py
--- old/cliff-4.12.0/cliff/command.py   2025-11-20 15:59:28.000000000 +0100
+++ new/cliff-4.13.1/cliff/command.py   2025-12-18 15:10:43.000000000 +0100
@@ -23,6 +23,8 @@
 
 if ty.TYPE_CHECKING:
     from . import app as _app
+    from . import hooks
+    from typing_extensions import Never
 
 _T = ty.TypeVar('_T')
 _dists_by_mods = None
@@ -96,6 +98,9 @@
                 self.app.command_manager.namespace,
                 self.cmd_name.replace(' ', '_'),
             )
+            self._hooks: (
+                extension.ExtensionManager[hooks.CommandHook] | list[Never]
+            )
             self._hooks = extension.ExtensionManager(
                 namespace=namespace,
                 invoke_on_load=True,
@@ -136,7 +141,9 @@
         # replace a None in self._epilog with an empty string
         parts = [self._epilog or '']
         hook_epilogs = [
-            e for h in self._hooks if (e := h.obj.get_epilog()) is not None
+            e
+            for h in self._hooks
+            if (h.obj is not None and (e := h.obj.get_epilog()) is not None)
         ]
         parts.extend(hook_epilogs)
         app_dist_name = getattr(
@@ -161,6 +168,8 @@
             conflict_handler=self.conflict_handler,
         )
         for hook in self._hooks:
+            if hook.obj is None:
+                continue
             hook.obj.get_parser(parser)
         return parser
 
@@ -201,6 +210,8 @@
         hook processing behavior.
         """
         for hook in self._hooks:
+            if hook.obj is None:
+                continue
             ret = hook.obj.before(parsed_args)
             # If the return is None do not change parsed_args, otherwise
             # set up to pass it to the next hook
@@ -221,6 +232,9 @@
         hook processing behavior.
         """
         for hook in self._hooks:
+            if hook.obj is None:
+                continue
+
             ret = hook.obj.after(parsed_args, return_code)
             # If the return is None do not change return_code, otherwise
             # set up to pass it to the next hook
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/commandmanager.py 
new/cliff-4.13.1/cliff/commandmanager.py
--- old/cliff-4.12.0/cliff/commandmanager.py    2025-11-20 15:59:28.000000000 
+0100
+++ new/cliff-4.13.1/cliff/commandmanager.py    2025-12-18 15:10:43.000000000 
+0100
@@ -13,7 +13,10 @@
 """Discover and lookup command plugins."""
 
 import collections.abc
+import importlib.metadata
 import logging
+from typing import TypeAlias
+import warnings
 
 import stevedore
 
@@ -51,46 +54,140 @@
     def load(self) -> type[command.Command]:
         return self.command_class
 
+    @property
+    def value(self) -> str:
+        # fake entry point target for logging purposes
+        return ':'.join(
+            [self.command_class.__module__, self.command_class.__name__]
+        )
+
+
+EntryPointT: TypeAlias = EntryPointWrapper | importlib.metadata.EntryPoint
+
 
 class CommandManager:
     """Discovers commands and handles lookup based on argv data.
 
-    :param namespace: String containing the entrypoint namespace for the
-        plugins to be loaded. For example, ``'cliff.formatter.list'``.
+    :param namespace: **DEPRECATED** String containing the entrypoint namespace
+        for the plugins to be loaded from by default. For example,
+        ``'cliff.formatter.list'``. :meth:`CommandManager.load_commands` should
+        be preferred.
     :param convert_underscores: Whether cliff should convert underscores to
         spaces in entry_point commands.
+    :param ignored_modules: A list of module names to ignore when loading
+        commands. This will be matched partial, so be as specific as needed.
     """
 
     def __init__(
-        self, namespace: str, convert_underscores: bool = True
+        self,
+        namespace: str | None = None,
+        convert_underscores: bool = True,
+        *,
+        ignored_modules: collections.abc.Iterable[str] | None = None,
     ) -> None:
-        self.commands: dict[str, EntryPointWrapper] = {}
-        self._legacy: dict[str, str] = {}
+        if namespace:
+            # TODO(stephenfin): Remove this functionality in 5.0.0 and make
+            # convert_underscores a kwarg-only argument
+            warnings.warn(
+                f'Initialising {self.__class__!r} with a namespace is '
+                f'deprecated for removal. Prefer loading commands from a '
+                f'given namespace with load_commands instead',
+                DeprecationWarning,
+            )
+
         self.namespace = namespace
         self.convert_underscores = convert_underscores
+        self.ignored_modules = ignored_modules or ()
+
+        self.commands: dict[str, EntryPointT] = {}
+        self._legacy: dict[str, str] = {}
         self.group_list: list[str] = []
         self._load_commands()
 
     def _load_commands(self) -> None:
         # NOTE(jamielennox): kept for compatibility.
+        # TODO(stephenfin): We can remove this when we remove the 'namespace'
+        # argument
         if self.namespace:
             self.load_commands(self.namespace)
 
+    @staticmethod
+    def _is_module_ignored(
+        module_name: str, ignored_modules: collections.abc.Iterable[str]
+    ) -> bool:
+        # given module_name = 'foo.bar.baz:wow', we expect to match any of
+        # the following ignores: foo.bar.baz:wow, foo.bar.baz, foo.bar, foo
+        while True:
+            if module_name in ignored_modules:
+                return True
+
+            split_index = max(module_name.rfind(':'), module_name.rfind('.'))
+            if split_index == -1:
+                break
+
+            module_name = module_name[:split_index]
+
+        return False
+
     def load_commands(self, namespace: str) -> None:
-        """Load all the commands from an entrypoint"""
+        """Load all the commands from an entrypoint
+
+        :param namespace: The namespace to load commands from.
+        :returns: None
+        """
         self.group_list.append(namespace)
-        for ep in stevedore.ExtensionManager(namespace):
-            LOG.debug('found command %r', ep.name)
+        em: stevedore.ExtensionManager[command.Command]
+        # note that we don't invoke stevedore's conflict resolver functionality
+        # because that is namespace specific and we care about conflicts
+        # regardless of the namespace
+        em = stevedore.ExtensionManager(namespace)
+        for ext in em:
+            LOG.debug('found command %r', ext.name)
+
+            if self._is_module_ignored(ext.module_name, self.ignored_modules):
+                LOG.debug(
+                    'extension found in ignored module %r: skipping',
+                    ext.module_name,
+                )
+                continue
+
             cmd_name = (
-                ep.name.replace('_', ' ')
+                ext.name.replace('_', ' ')
                 if self.convert_underscores
-                else ep.name
+                else ext.name
             )
-            self.commands[cmd_name] = ep.entry_point
+
+            if cmd_name in self.commands:
+                # Attention, programmers: If you arrived here attempting to
+                # resolve a warning in your application then you have a command
+                # with the same name either defined more than once in the same
+                # application (a typo?) or in multiple packages (for example,
+                # a package that adds plugins to your applications). The latter
+                # can often happen if you e.g. move a command from a plugin to
+                # the core application. In this situation, you should add the
+                # old location to 'ignored_modules' and the plugin will now be
+                # ignored.
+                LOG.warning(
+                    'found duplicate implementations of the %(name)r command '
+                    'in the following modules: %(modules)s: this is likely '
+                    'programmer error and should be reported as a bug to the '
+                    'relevant project(s)',
+                    {
+                        'name': cmd_name,
+                        'modules': ', '.join(
+                            [
+                                self.commands[cmd_name].value,
+                                ext.entry_point.value,
+                            ]
+                        ),
+                    },
+                )
+
+            self.commands[cmd_name] = ext.entry_point
 
     def __iter__(
         self,
-    ) -> collections.abc.Iterator[tuple[str, EntryPointWrapper]]:
+    ) -> collections.abc.Iterator[tuple[str, EntryPointT]]:
         return iter(self.commands.items())
 
     def add_command(
@@ -163,7 +260,9 @@
         """Returns a list of commands loaded for the specified group"""
         group_list: list[str] = []
         if group is not None:
-            for ep in stevedore.ExtensionManager(group):
+            em: stevedore.ExtensionManager[command.Command]
+            em = stevedore.ExtensionManager(group)
+            for ep in em:
                 cmd_name = (
                     ep.name.replace('_', ' ')
                     if self.convert_underscores
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/complete.py 
new/cliff-4.13.1/cliff/complete.py
--- old/cliff-4.12.0/cliff/complete.py  2025-11-20 15:59:28.000000000 +0100
+++ new/cliff-4.13.1/cliff/complete.py  2025-12-18 15:10:43.000000000 +0100
@@ -191,6 +191,7 @@
     """print bash completion command"""
 
     log = logging.getLogger(__name__ + '.CompleteCommand')
+    _formatters: stevedore.ExtensionManager[CompleteShellBase]
 
     def __init__(
         self,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/display.py 
new/cliff-4.13.1/cliff/display.py
--- old/cliff-4.12.0/cliff/display.py   2025-11-20 15:59:28.000000000 +0100
+++ new/cliff-4.13.1/cliff/display.py   2025-12-18 15:10:43.000000000 +0100
@@ -23,13 +23,21 @@
 from cliff import app
 from cliff import _argparse
 from cliff import command
+from cliff.formatters import base as base_formatters
 
 _T = ty.TypeVar("_T")
 
 
-class DisplayCommandBase(command.Command, metaclass=abc.ABCMeta):
+class DisplayCommandBase(
+    command.Command,
+    ty.Generic[base_formatters.FormatterT],
+    metaclass=abc.ABCMeta,
+):
     """Command base class for displaying data about a single object."""
 
+    _formatter_plugins: stevedore.ExtensionManager[base_formatters.FormatterT]
+    formatter: base_formatters.FormatterT
+
     def __init__(
         self,
         app: app.App,
@@ -49,7 +57,9 @@
     def formatter_default(self) -> str:
         """String specifying the name of the default formatter."""
 
-    def _load_formatter_plugins(self) -> stevedore.ExtensionManager:
+    def _load_formatter_plugins(
+        self,
+    ) -> stevedore.ExtensionManager[base_formatters.FormatterT]:
         # Here so tests can override
         return stevedore.ExtensionManager(
             self.formatter_namespace,
@@ -89,6 +99,7 @@
             ),
         )
         for formatter in self._formatter_plugins:
+            assert formatter.obj is not None  # noqa
             formatter.obj.add_argument_group(parser)
         return parser
 
@@ -97,7 +108,7 @@
         self,
         parsed_args: argparse.Namespace,
         column_names: collections.abc.Sequence[str],
-        data: collections.abc.Iterable[collections.abc.Sequence[ty.Any]],
+        data: ty.Any,
     ) -> int:
         """Use the formatter to generate the output.
 
@@ -144,7 +155,9 @@
 
     def run(self, parsed_args: argparse.Namespace) -> int:
         parsed_args = self._run_before_hooks(parsed_args)
-        self.formatter = self._formatter_plugins[parsed_args.formatter].obj
+        formatter = self._formatter_plugins[parsed_args.formatter].obj
+        assert formatter is not None  # noqa
+        self.formatter = formatter
         column_names, data = self.take_action(parsed_args)
         column_names, data = self._run_after_hooks(
             parsed_args, (column_names, data)
@@ -171,11 +184,15 @@
         hook processing behavior.
         """
         for hook in self._hooks:
-            ret = hook.obj.after(parsed_args, data)
+            # we need to ignore the types since CommandHook states that it
+            # should return an integer, but they'll actually return a tuple of
+            # column names and data when used with DisplayCommandBase
+            # subclasses
+            ret = hook.obj.after(parsed_args, data)  # type: ignore
             # If the return is None do not change return_code, otherwise
             # set up to pass it to the next hook
             if ret is not None:
-                data = ret
+                data = ret  # type: ignore
         return data
 
     @staticmethod
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/formatters/base.py 
new/cliff-4.13.1/cliff/formatters/base.py
--- old/cliff-4.12.0/cliff/formatters/base.py   2025-11-20 15:59:28.000000000 
+0100
+++ new/cliff-4.13.1/cliff/formatters/base.py   2025-12-18 15:10:43.000000000 
+0100
@@ -18,6 +18,9 @@
 import typing as ty
 
 
+FormatterT = ty.TypeVar('FormatterT', bound='Formatter')
+
+
 class Formatter(metaclass=abc.ABCMeta):
     @abc.abstractmethod
     def add_argument_group(self, parser: argparse.ArgumentParser) -> None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/lister.py 
new/cliff-4.13.1/cliff/lister.py
--- old/cliff-4.12.0/cliff/lister.py    2025-11-20 15:59:28.000000000 +0100
+++ new/cliff-4.13.1/cliff/lister.py    2025-12-18 15:10:43.000000000 +0100
@@ -20,9 +20,13 @@
 
 from cliff import _argparse
 from cliff import display
+from cliff.formatters import base as base_formatters
 
 
-class Lister(display.DisplayCommandBase, metaclass=abc.ABCMeta):
+class Lister(
+    display.DisplayCommandBase[base_formatters.ListFormatter],
+    metaclass=abc.ABCMeta,
+):
     """Command base class for providing a list of data as output."""
 
     log = logging.getLogger(__name__)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/show.py 
new/cliff-4.13.1/cliff/show.py
--- old/cliff-4.12.0/cliff/show.py      2025-11-20 15:59:28.000000000 +0100
+++ new/cliff-4.13.1/cliff/show.py      2025-12-18 15:10:43.000000000 +0100
@@ -18,9 +18,13 @@
 import typing as ty
 
 from cliff import display
+from cliff.formatters import base as base_formatters
 
 
-class ShowOne(display.DisplayCommandBase, metaclass=abc.ABCMeta):
+class ShowOne(
+    display.DisplayCommandBase[base_formatters.SingleFormatter],
+    metaclass=abc.ABCMeta,
+):
     """Command base class for displaying data about a single object."""
 
     @property
@@ -47,9 +51,9 @@
         self,
         parsed_args: argparse.Namespace,
         column_names: collections.abc.Sequence[str],
-        data: collections.abc.Iterable[collections.abc.Sequence[ty.Any]],
+        data: collections.abc.Sequence[ty.Any],
     ) -> int:
-        (columns_to_include, selector) = self._generate_columns_and_selector(
+        columns_to_include, selector = self._generate_columns_and_selector(
             parsed_args, column_names
         )
         if selector:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/sphinxext.py 
new/cliff-4.13.1/cliff/sphinxext.py
--- old/cliff-4.12.0/cliff/sphinxext.py 2025-11-20 15:59:28.000000000 +0100
+++ new/cliff-4.13.1/cliff/sphinxext.py 2025-12-18 15:10:43.000000000 +0100
@@ -456,9 +456,9 @@
 
 def setup(app: sphinx.application.Sphinx) -> dict[str, ty.Any]:
     app.add_directive('autoprogram-cliff', AutoprogramCliffDirective)
-    app.add_config_value('autoprogram_cliff_application', '', True)
-    app.add_config_value('autoprogram_cliff_ignored', ['--help'], True)
-    app.add_config_value('autoprogram_cliff_app_dist_name', None, True)
+    app.add_config_value('autoprogram_cliff_application', '', 'env')
+    app.add_config_value('autoprogram_cliff_ignored', ['--help'], 'env')
+    app.add_config_value('autoprogram_cliff_app_dist_name', None, 'env')
 
     return {
         'parallel_read_safe': True,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/tests/base.py 
new/cliff-4.13.1/cliff/tests/base.py
--- old/cliff-4.12.0/cliff/tests/base.py        2025-11-20 15:59:28.000000000 
+0100
+++ new/cliff-4.13.1/cliff/tests/base.py        2025-12-18 15:10:43.000000000 
+0100
@@ -11,7 +11,7 @@
 #  License for the specific language governing permissions and limitations
 #  under the License.
 
-import testtools
+import testtools  # type: ignore
 
 import fixtures
 
@@ -19,9 +19,13 @@
 class TestBase(testtools.TestCase):
     def setUp(self):
         super().setUp()
+
         self._stdout_fixture = fixtures.StringStream('stdout')
         self.stdout = self.useFixture(self._stdout_fixture).stream
         self.useFixture(fixtures.MonkeyPatch('sys.stdout', self.stdout))
+
         self._stderr_fixture = fixtures.StringStream('stderr')
         self.stderr = self.useFixture(self._stderr_fixture).stream
         self.useFixture(fixtures.MonkeyPatch('sys.stderr', self.stderr))
+
+        self.logger = self.useFixture(fixtures.FakeLogger('cliff'))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/tests/test_command_hooks.py 
new/cliff-4.13.1/cliff/tests/test_command_hooks.py
--- old/cliff-4.12.0/cliff/tests/test_command_hooks.py  2025-11-20 
15:59:28.000000000 +0100
+++ new/cliff-4.13.1/cliff/tests/test_command_hooks.py  2025-12-18 
15:10:43.000000000 +0100
@@ -11,6 +11,7 @@
 #  under the License.
 
 import argparse
+import importlib.metadata
 
 from cliff import app as application
 from cliff import command
@@ -24,6 +25,15 @@
 from unittest import mock
 
 
+def make_extension(name, plugin, obj):
+    ep = importlib.metadata.EntryPoint(
+        name=name,
+        value=f'my_module:{name.replace("-", "_")}',
+        group='my_app.plugins',
+    )
+    return extension.Extension(name, ep, plugin, obj)
+
+
 def make_app(**kwargs):
     cmd_mgr = commandmanager.CommandManager('cliff.tests')
 
@@ -221,10 +231,11 @@
         self.cmd = TestCommand(self.app, None, cmd_name='test')
         self.hook_a = TestHook(self.cmd)
         self.hook_b = TestNullHook(self.cmd)
+        self.mgr: extension.ExtensionManager[hooks.CommandHook]
         self.mgr = extension.ExtensionManager.make_test_instance(
             [
-                extension.Extension('parser-hook-a', None, None, self.hook_a),
-                extension.Extension('parser-hook-b', None, None, self.hook_b),
+                make_extension('parser-hook-a', TestHook, self.hook_a),
+                make_extension('parser-hook-b', TestHook, self.hook_b),
             ],
         )
         # Replace the auto-loaded hooks with our explicitly created
@@ -262,8 +273,9 @@
         self.app = make_app()
         self.cmd = TestCommand(self.app, None, cmd_name='test')
         self.hook = TestChangeHook(self.cmd)
+        self.mgr: extension.ExtensionManager[hooks.CommandHook]
         self.mgr = extension.ExtensionManager.make_test_instance(
-            [extension.Extension('parser-hook', None, None, self.hook)],
+            [make_extension('parser-hook', TestChangeHook, self.hook)]
         )
         # Replace the auto-loaded hooks with our explicitly created
         # manager.
@@ -302,8 +314,9 @@
         self.app = make_app()
         self.cmd = TestShowCommand(self.app, None, cmd_name='test')
         self.hook = TestHook(self.cmd)
+        self.mgr: extension.ExtensionManager[hooks.CommandHook]
         self.mgr = extension.ExtensionManager.make_test_instance(
-            [extension.Extension('parser-hook', None, None, self.hook)],
+            [make_extension('parser-hook', TestHook, self.hook)]
         )
         # Replace the auto-loaded hooks with our explicitly created
         # manager.
@@ -339,8 +352,9 @@
         self.app = make_app()
         self.cmd = TestShowCommand(self.app, None, cmd_name='test')
         self.hook = TestDisplayChangeHook(self.cmd)
+        self.mgr: extension.ExtensionManager[hooks.CommandHook]
         self.mgr = extension.ExtensionManager.make_test_instance(
-            [extension.Extension('parser-hook', None, None, self.hook)],
+            [make_extension('parser-hook', TestDisplayChangeHook, self.hook)]
         )
         # Replace the auto-loaded hooks with our explicitly created
         # manager.
@@ -379,8 +393,9 @@
         self.app = make_app()
         self.cmd = TestListerCommand(self.app, None, cmd_name='test')
         self.hook = TestHook(self.cmd)
+        self.mgr: extension.ExtensionManager[hooks.CommandHook]
         self.mgr = extension.ExtensionManager.make_test_instance(
-            [extension.Extension('parser-hook', None, None, self.hook)],
+            [make_extension('parser-hook', TestHook, self.hook)]
         )
         # Replace the auto-loaded hooks with our explicitly created
         # manager.
@@ -416,8 +431,9 @@
         self.app = make_app()
         self.cmd = TestListerCommand(self.app, None, cmd_name='test')
         self.hook = TestListerChangeHook(self.cmd)
+        self.mgr: extension.ExtensionManager[hooks.CommandHook]
         self.mgr = extension.ExtensionManager.make_test_instance(
-            [extension.Extension('parser-hook', None, None, self.hook)],
+            [make_extension('parser-hook', TestListerChangeHook, self.hook)]
         )
         # Replace the auto-loaded hooks with our explicitly created
         # manager.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/tests/test_commandmanager.py 
new/cliff-4.13.1/cliff/tests/test_commandmanager.py
--- old/cliff-4.12.0/cliff/tests/test_commandmanager.py 2025-11-20 
15:59:28.000000000 +0100
+++ new/cliff-4.13.1/cliff/tests/test_commandmanager.py 2025-12-18 
15:10:43.000000000 +0100
@@ -10,9 +10,10 @@
 #  License for the specific language governing permissions and limitations
 #  under the License.
 
-import testscenarios
 from unittest import mock
 
+import testscenarios  # type: ignore
+
 from cliff import command
 from cliff import commandmanager
 from cliff.tests import base
@@ -113,20 +114,23 @@
 
 class TestLoad(base.TestBase):
     def test_load_commands(self):
-        testcmd = mock.Mock(name='testcmd')
-        testcmd.name.replace.return_value = 'test'
+        testcmd = mock.Mock()
+        testcmd.name = 'test_cmd'
+        testcmd.module_name = 'module.a'
         mock_get_group_all = mock.Mock(return_value=[testcmd])
         with mock.patch(
             'stevedore.ExtensionManager', mock_get_group_all
         ) as mock_manager:
             mgr = commandmanager.CommandManager('test')
-            mock_manager.assert_called_once_with('test')
-            names = [n for n, v in mgr]
-            self.assertEqual(['test'], names)
+
+        mock_manager.assert_called_once_with('test')
+        names = [n for n, v in mgr]
+        self.assertEqual(['test cmd'], names)
 
     def test_load_commands_keep_underscores(self):
         testcmd = mock.Mock()
         testcmd.name = 'test_cmd'
+        testcmd.module_name = 'module.a'
         mock_get_group_all = mock.Mock(return_value=[testcmd])
         with mock.patch(
             'stevedore.ExtensionManager', mock_get_group_all
@@ -135,24 +139,58 @@
                 'test',
                 convert_underscores=False,
             )
-            mock_manager.assert_called_once_with('test')
-            names = [n for n, v in mgr]
-            self.assertEqual(['test_cmd'], names)
 
-    def test_load_commands_replace_underscores(self):
-        testcmd = mock.Mock()
-        testcmd.name = 'test_cmd'
-        mock_get_group_all = mock.Mock(return_value=[testcmd])
+        mock_manager.assert_called_once_with('test')
+        names = [n for n, v in mgr]
+        self.assertEqual(['test_cmd'], names)
+
+    def test_load_commands_ignore_modules(self):
+        testcmd_normal = mock.Mock()
+        testcmd_normal.name = 'normal_cmd'
+        testcmd_normal.module_name = 'normal.module'
+        testcmd_ignored = mock.Mock()
+        testcmd_ignored.name = 'ignored_cmd'
+        testcmd_ignored.module_name = 'ignored.module'
+        mock_get_group_all = mock.Mock(
+            return_value=[testcmd_normal, testcmd_ignored]
+        )
         with mock.patch(
             'stevedore.ExtensionManager', mock_get_group_all
         ) as mock_manager:
             mgr = commandmanager.CommandManager(
                 'test',
-                convert_underscores=True,
+                ignored_modules=['ignored.module'],
             )
-            mock_manager.assert_called_once_with('test')
-            names = [n for n, v in mgr]
-            self.assertEqual(['test cmd'], names)
+
+        mock_manager.assert_called_once_with('test')
+        names = [n for n, v in mgr]
+        self.assertEqual(['normal cmd'], names)
+
+    def test_load_commands_duplicates(self):
+        testcmd_a = mock.Mock()
+        testcmd_a.name = 'test_cmd'
+        testcmd_a.module_name = 'module.a'
+        testcmd_a.entry_point.value = 'module.a:TestCmd'
+        testcmd_b = mock.Mock()
+        testcmd_b.name = 'test_cmd'
+        testcmd_b.module_name = 'module.b'
+        testcmd_b.entry_point.value = 'module.b:TestCmd'
+        mock_get_group_all = mock.Mock(return_value=[testcmd_a, testcmd_b])
+        with mock.patch(
+            'stevedore.ExtensionManager', mock_get_group_all
+        ) as mock_manager:
+            mgr = commandmanager.CommandManager('test')
+
+        mock_manager.assert_called_once_with('test')
+        names = [n for n, v in mgr]
+        commands = [v for _, v in mgr]
+        self.assertEqual(['test cmd'], names)
+        # we should have ended up with the second command
+        self.assertEqual([testcmd_b.entry_point], commands)
+        self.assertIn(
+            "found duplicate implementations of the 'test cmd' command",
+            self.logger.output,
+        )
 
 
 class FauxCommand(command.Command):
@@ -216,6 +254,34 @@
         self.assertFalse(remaining)
 
 
+class TestIsModuleIgnored(base.TestBase):
+    def test_match(self):
+        result = commandmanager.CommandManager._is_module_ignored(
+            'foo.bar.baz', ['foo.bar.baz', 'other.module']
+        )
+        self.assertTrue(result)
+        result = commandmanager.CommandManager._is_module_ignored(
+            'foo.bar.baz', ['foo.bar', 'other.module']
+        )
+        self.assertTrue(result)
+        result = commandmanager.CommandManager._is_module_ignored(
+            'foo.bar.baz', ['foo', 'other.module']
+        )
+        self.assertTrue(result)
+
+    def test_no_match(self):
+        result = commandmanager.CommandManager._is_module_ignored(
+            'foo.bar.baz', ['other.module', 'another.package']
+        )
+        self.assertFalse(result)
+
+    def test_no_ignores(self):
+        result = commandmanager.CommandManager._is_module_ignored(
+            'foo.bar.baz', []
+        )
+        self.assertFalse(result)
+
+
 class TestGetByPartialName(base.TestBase):
     def setUp(self):
         super().setUp()
@@ -334,8 +400,10 @@
     def test_get_command_names(self):
         mock_cmd_one = mock.Mock()
         mock_cmd_one.name = 'one'
+        mock_cmd_one.module_name = 'module.a'
         mock_cmd_two = mock.Mock()
         mock_cmd_two.name = 'cmd two'
+        mock_cmd_two.module_name = 'module.b'
         mock_get_group_all = mock.Mock(
             return_value=[mock_cmd_one, mock_cmd_two],
         )
@@ -345,5 +413,9 @@
         ) as mock_manager:
             mgr = commandmanager.CommandManager('test')
             mock_manager.assert_called_once_with('test')
+
             cmds = mgr.get_command_names('test')
+            mock_manager.assert_has_calls(
+                [mock.call('test'), mock.call('test')]
+            )
             self.assertEqual(['one', 'cmd two'], cmds)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/tests/test_lister.py 
new/cliff-4.13.1/cliff/tests/test_lister.py
--- old/cliff-4.12.0/cliff/tests/test_lister.py 2025-11-20 15:59:28.000000000 
+0100
+++ new/cliff-4.13.1/cliff/tests/test_lister.py 2025-12-18 15:10:43.000000000 
+0100
@@ -10,20 +10,25 @@
 #  License for the specific language governing permissions and limitations
 #  under the License.
 
+import argparse
 import typing as ty
 import weakref
 
 from unittest import mock
 
+from cliff.formatters import base as base_formatters
 from cliff import lister
 from cliff.tests import base
 
 
-class FauxFormatter:
+class FauxFormatter(base_formatters.ListFormatter):
     def __init__(self):
         self.args = []
         self.obj = weakref.proxy(self)
 
+    def add_argument_group(self, parser: argparse.ArgumentParser) -> None:
+        return None
+
     def emit_list(self, columns, data, stdout, args):
         self.args.append((columns, data))
 
@@ -65,6 +70,7 @@
         test_lister.run(parsed_args)
 
         f = test_lister._formatter_plugins['test']
+        assert isinstance(f, FauxFormatter)
         self.assertEqual(1, len(f.args))
         args = f.args[0]
         self.assertEqual(list(parsed_args.columns), args[0])
@@ -95,6 +101,7 @@
         test_lister.run(parsed_args)
 
         f = test_lister._formatter_plugins['test']
+        assert isinstance(f, FauxFormatter)
         args = f.args[0]
         data = list(args[1])
         self.assertEqual([['a', 'A'], ['b', 'B'], ['c', 'A']], data)
@@ -109,6 +116,7 @@
         test_lister.run(parsed_args)
 
         f = test_lister._formatter_plugins['test']
+        assert isinstance(f, FauxFormatter)
         args = f.args[0]
         data = list(args[1])
         self.assertEqual([['a', 'A'], ['c', 'A'], ['b', 'B']], data)
@@ -124,6 +132,7 @@
         test_lister.run(parsed_args)
 
         f = test_lister._formatter_plugins['test']
+        assert isinstance(f, FauxFormatter)
         args = f.args[0]
         data = list(args[1])
         self.assertEqual([['b', 'B'], ['c', 'A'], ['a', 'A']], data)
@@ -138,6 +147,7 @@
         test_lister.run(parsed_args)
 
         f = test_lister._formatter_plugins['test']
+        assert isinstance(f, FauxFormatter)
         args = f.args[0]
         data = list(args[1])
         self.assertEqual([['a', 'A'], ['b', 'B'], ['c', 'A']], data)
@@ -152,6 +162,7 @@
         test_lister.run(parsed_args)
 
         f = test_lister._formatter_plugins['test']
+        assert isinstance(f, FauxFormatter)
         args = f.args[0]
         data = list(args[1])
         self.assertEqual(
@@ -169,6 +180,7 @@
             test_lister.run(parsed_args)
 
         f = test_lister._formatter_plugins['test']
+        assert isinstance(f, FauxFormatter)
         args = f.args[0]
         data = list(args[1])
         # The output should be unchanged
@@ -198,6 +210,7 @@
             test_lister.run(parsed_args)
 
         f = test_lister._formatter_plugins['test']
+        assert isinstance(f, FauxFormatter)
         args = f.args[0]
         data = list(args[1])
         self.assertEqual([['a'], ['c'], ['b']], data)
@@ -212,6 +225,7 @@
         test_lister.run(parsed_args)
 
         f = test_lister._formatter_plugins['test']
+        assert isinstance(f, FauxFormatter)
         args = f.args[0]
         data = list(args[1])
         self.assertEqual([['a', 'A'], ['b', 'B'], ['c', 'A']], data)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff/tests/test_show.py 
new/cliff-4.13.1/cliff/tests/test_show.py
--- old/cliff-4.12.0/cliff/tests/test_show.py   2025-11-20 15:59:28.000000000 
+0100
+++ new/cliff-4.13.1/cliff/tests/test_show.py   2025-12-18 15:10:43.000000000 
+0100
@@ -10,19 +10,24 @@
 #  License for the specific language governing permissions and limitations
 #  under the License.
 
+import argparse
 import weakref
 
 from unittest import mock
 
+from cliff.formatters import base as base_formatters
 from cliff import show
 from cliff.tests import base
 
 
-class FauxFormatter:
+class FauxFormatter(base_formatters.SingleFormatter):
     def __init__(self):
         self.args = []
         self.obj = weakref.proxy(self)
 
+    def add_argument_group(self, parser: argparse.ArgumentParser) -> None:
+        return None
+
     def emit_one(self, columns, data, stdout, args):
         self.args.append((columns, data))
 
@@ -51,6 +56,7 @@
 
         test_show.run(parsed_args)
         f = test_show._formatter_plugins['test']
+        assert isinstance(f, FauxFormatter)
         self.assertEqual(1, len(f.args))
         args = f.args[0]
         self.assertEqual(list(parsed_args.columns), args[0])
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff.egg-info/PKG-INFO 
new/cliff-4.13.1/cliff.egg-info/PKG-INFO
--- old/cliff-4.12.0/cliff.egg-info/PKG-INFO    2025-11-20 16:00:03.000000000 
+0100
+++ new/cliff-4.13.1/cliff.egg-info/PKG-INFO    2025-12-18 15:11:35.000000000 
+0100
@@ -1,6 +1,6 @@
-Metadata-Version: 2.1
+Metadata-Version: 2.4
 Name: cliff
-Version: 4.12.0
+Version: 4.13.1
 Summary: Command Line Interface Formulation Framework
 Author-email: OpenStack <[email protected]>
 License: Apache-2.0
@@ -23,8 +23,10 @@
 Requires-Dist: autopage>=0.4.0
 Requires-Dist: cmd2>=1.0.0
 Requires-Dist: PrettyTable>=0.7.2
-Requires-Dist: stevedore>=2.0.1
+Requires-Dist: stevedore>=5.6.0
 Requires-Dist: PyYAML>=3.12
+Dynamic: license-file
+Dynamic: requires-dist
 
 ========================
 Team and repository tags
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff.egg-info/SOURCES.txt 
new/cliff-4.13.1/cliff.egg-info/SOURCES.txt
--- old/cliff-4.12.0/cliff.egg-info/SOURCES.txt 2025-11-20 16:00:03.000000000 
+0100
+++ new/cliff-4.13.1/cliff.egg-info/SOURCES.txt 2025-12-18 15:11:35.000000000 
+0100
@@ -97,6 +97,8 @@
 releasenotes/notes/add-Lister-sort-direction-5f34dba3c9743572.yaml
 releasenotes/notes/command-group-8c00f260340a130c.yaml
 releasenotes/notes/comparable-FormattableColumn-31c0030ced70b7fb.yaml
+releasenotes/notes/conflict-resolution-21bcb5f9a6c19d7f.yaml
+releasenotes/notes/deprecate-commandmanager-namespace-argument-2d5169222402f633.yaml
 releasenotes/notes/drop-python-39-bbeabc19b5143cd3.yaml
 releasenotes/notes/drop-python27-support-b16c9e5a9e2000ef.yaml
 releasenotes/notes/handle-none-values-when-sorting-de40e36c66ad95ca.yaml
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff.egg-info/pbr.json 
new/cliff-4.13.1/cliff.egg-info/pbr.json
--- old/cliff-4.12.0/cliff.egg-info/pbr.json    2025-11-20 16:00:03.000000000 
+0100
+++ new/cliff-4.13.1/cliff.egg-info/pbr.json    2025-12-18 15:11:35.000000000 
+0100
@@ -1 +1 @@
-{"git_version": "12c21fa", "is_release": true}
\ No newline at end of file
+{"git_version": "2bf3144", "is_release": true}
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/cliff.egg-info/requires.txt 
new/cliff-4.13.1/cliff.egg-info/requires.txt
--- old/cliff-4.12.0/cliff.egg-info/requires.txt        2025-11-20 
16:00:03.000000000 +0100
+++ new/cliff-4.13.1/cliff.egg-info/requires.txt        2025-12-18 
15:11:35.000000000 +0100
@@ -1,5 +1,5 @@
 autopage>=0.4.0
 cmd2>=1.0.0
 PrettyTable>=0.7.2
-stevedore>=2.0.1
+stevedore>=5.6.0
 PyYAML>=3.12
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/pyproject.toml 
new/cliff-4.13.1/pyproject.toml
--- old/cliff-4.12.0/pyproject.toml     2025-11-20 15:59:28.000000000 +0100
+++ new/cliff-4.13.1/pyproject.toml     2025-12-18 15:10:43.000000000 +0100
@@ -74,14 +74,7 @@
 show_column_numbers = true
 show_error_context = true
 strict = true
-# keep this in-sync with 'mypy.exclude' in '.pre-commit-config.yaml'
-exclude = '''
-(?x)(
-    doc
-    | demoapp
-    | releasenotes
-  )
-'''
+exclude = '(?x)(doc | demoapp | releasenotes)'
 
 [[tool.mypy.overrides]]
 module = ["cliff.tests.*"]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/cliff-4.12.0/releasenotes/notes/conflict-resolution-21bcb5f9a6c19d7f.yaml 
new/cliff-4.13.1/releasenotes/notes/conflict-resolution-21bcb5f9a6c19d7f.yaml
--- 
old/cliff-4.12.0/releasenotes/notes/conflict-resolution-21bcb5f9a6c19d7f.yaml   
    1970-01-01 01:00:00.000000000 +0100
+++ 
new/cliff-4.13.1/releasenotes/notes/conflict-resolution-21bcb5f9a6c19d7f.yaml   
    2025-12-18 15:10:43.000000000 +0100
@@ -0,0 +1,12 @@
+---
+features:
+  - |
+    ``cliff.commandmanager.CommandManager`` now accepts an optional
+    ``ignored_modules`` argument, which can be used to indicate modules that
+    should be skipped when loading commands. This can be useful if e.g. moving
+    a command from a plugin to the core application, to ensure the version in
+    the core application is always preferred.
+upgrade:
+  - |
+    cliff will now warn if duplicates command implementations are found, rather
+    than silently ignoring them.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/cliff-4.12.0/releasenotes/notes/deprecate-commandmanager-namespace-argument-2d5169222402f633.yaml
 
new/cliff-4.13.1/releasenotes/notes/deprecate-commandmanager-namespace-argument-2d5169222402f633.yaml
--- 
old/cliff-4.12.0/releasenotes/notes/deprecate-commandmanager-namespace-argument-2d5169222402f633.yaml
       1970-01-01 01:00:00.000000000 +0100
+++ 
new/cliff-4.13.1/releasenotes/notes/deprecate-commandmanager-namespace-argument-2d5169222402f633.yaml
       2025-12-18 15:10:43.000000000 +0100
@@ -0,0 +1,13 @@
+---
+deprecations:
+  - |
+    The ``namespace`` argument to ``cliff.commandmanager.CommandManager`` has
+    been deprecated for removal. Users should prefer invoking ``load_commands``
+    instead. For example, instead of::
+
+        cm = commandmanager.CommandManager('foo')
+
+    Do::
+
+        cm = commandmanager.CommandManager()
+        cm.load_plugins('foo')
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/requirements.txt 
new/cliff-4.13.1/requirements.txt
--- old/cliff-4.12.0/requirements.txt   2025-11-20 15:59:28.000000000 +0100
+++ new/cliff-4.13.1/requirements.txt   2025-12-18 15:10:43.000000000 +0100
@@ -1,5 +1,5 @@
 autopage>=0.4.0 # Apache 2.0
 cmd2>=1.0.0 # MIT
 PrettyTable>=0.7.2 # BSD
-stevedore>=2.0.1 # Apache-2.0
+stevedore>=5.6.0 # Apache-2.0
 PyYAML>=3.12 # MIT
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cliff-4.12.0/tox.ini new/cliff-4.13.1/tox.ini
--- old/cliff-4.12.0/tox.ini    2025-11-20 15:59:28.000000000 +0100
+++ new/cliff-4.13.1/tox.ini    2025-12-18 15:10:43.000000000 +0100
@@ -19,11 +19,23 @@
 [testenv:pep8]
 description =
   Run style checks.
-skip_install = true
 deps =
   pre-commit
+  {[testenv:mypy]deps}
 commands =
   pre-commit run --all-files --show-diff-on-failure
+  {[testenv:mypy]commands}
+
+[testenv:mypy]
+description =
+  Run type checks.
+deps =
+  {[testenv]deps}
+  mypy
+  types-docutils
+  types-PyYAML
+commands =
+  mypy --cache-dir="{envdir}/mypy_cache" {posargs:cliff}
 
 [testenv:venv]
 # TODO(modred) remove doc/requirements.txt once the openstack-build-sphinx-docs

Reply via email to