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
