diff --git a/docs/cli.md b/docs/cli.md index e80ba99..036a06d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -26,7 +26,7 @@ $ manifestoo [OPTIONS] COMMAND [ARGS]... * `--addons-path-from-import-odoo / --no-addons-path-from-import-odoo`: Expand addons path by trying to `import odoo` and looking at `odoo.addons.__path__`. This option is useful when addons have been installed with pip. [default: addons-path-from-import-odoo] * `--addons-path-python PYTHON`: The python executable to use when importing `odoo.addons.__path__`. Defaults to the `python` executable found in PATH. * `--addons-path-from-odoo-cfg FILE`: Expand addons path by looking into the provided Odoo configuration file. [env var: ODOO_RC] -* `--odoo-series [8.0|9.0|10.0|11.0|12.0|13.0|14.0|15.0|16.0]`: Odoo series to use, in case it is not autodetected from addons version. [env var: ODOO_VERSION, ODOO_SERIES] +* `--odoo-series [8.0|9.0|10.0|11.0|12.0|13.0|14.0|15.0|16.0|17.0]`: Odoo series to use, in case it is not autodetected from addons version. [env var: ODOO_VERSION, ODOO_SERIES] * `-v, --verbose` * `-q, --quiet` * `--version` @@ -95,6 +95,7 @@ $ manifestoo list [OPTIONS] **Options**: * `--separator TEXT`: Separator character to use (by default, print one item per line). +* `--sorting, --sort TEXT`: Choice between 'alphabetical' and 'topological'.Topological sorting is useful when seeking a migration order. [default: alphabetical] * `--help`: Show this message and exit. ## `manifestoo list-codepends` @@ -115,6 +116,7 @@ $ manifestoo list-codepends [OPTIONS] * `--separator TEXT`: Separator character to use (by default, print one item per line). * `--transitive / --no-transitive`: Print all transitive co-dependencies. [default: transitive] * `--include-selected / --no-include-selected`: Print the selected addons along with their co-dependencies. [default: include-selected] +* `--sorting, --sort TEXT`: Choice between 'alphabetical' and 'topological'.Topological sorting is useful when seeking a migration order. [default: alphabetical] * `--help`: Show this message and exit. ## `manifestoo list-depends` @@ -134,6 +136,7 @@ $ manifestoo list-depends [OPTIONS] * `--include-selected`: Print the selected addons along with their dependencies. * `--ignore-missing`: Do not fail if dependencies are not found in addons path. This only applies to top level (selected) addons and transitive dependencies. * `--as-pip-requirements` +* `--sorting, --sort TEXT`: Choice between 'alphabetical' and 'topological'.Topological sorting is useful when seeking a migration order. [default: alphabetical] * `--help`: Show this message and exit. ## `manifestoo list-external-dependencies` diff --git a/news/62.feature b/news/62.feature new file mode 100644 index 0000000..adf63c6 --- /dev/null +++ b/news/62.feature @@ -0,0 +1 @@ +Add ``--sorting`` option on list, list-depends and list-codepends. diff --git a/src/manifestoo/addon_sorter.py b/src/manifestoo/addon_sorter.py new file mode 100644 index 0000000..d596dee --- /dev/null +++ b/src/manifestoo/addon_sorter.py @@ -0,0 +1,70 @@ +import sys +from typing import Dict, Iterable, Set + +import typer + +from manifestoo_core.addons_set import AddonsSet + +from . import echo +from .exceptions import CycleErrorExit + + +class AddonSorter: + @staticmethod + def from_name(name: str) -> "AddonSorter": + if name == "alphabetical": + return AddonSorterAlphabetical() + elif name == "topological": + if sys.version_info < (3, 9): + echo.error( + "The 'topological' sorter requires Python 3.9 or later", + err=False, + ) + raise typer.Exit(1) + return AddonSorterTopological() + else: + echo.error(f"Unknown sorter {name}", err=False) + raise typer.Exit(1) + + def sort( + self, addons_selection: Iterable[str], addon_set: AddonsSet + ) -> Iterable[str]: + raise NotImplementedError() + + +class AddonSorterAlphabetical(AddonSorter): + def sort( + self, addons_selection: Iterable[str], addon_set: AddonsSet + ) -> Iterable[str]: + return sorted(addons_selection) + + +class AddonSorterTopological(AddonSorter): + def sort( + self, addons_selection: Iterable[str], addon_set: AddonsSet + ) -> Iterable[str]: + result_dict: Dict[str, Set[str]] = {} + for addon_name in addons_selection: + try: + addon = addon_set[addon_name] + result_dict[addon_name] = set( + [ + depend + for depend in addon.manifest.depends + if depend in addons_selection + ] + ) + except KeyError: + echo.debug(f"Addon {addon_name} not found in addon set") + from graphlib import ( # type: ignore[import-not-found] + CycleError, + TopologicalSorter, + ) + + topological_sorted_res = TopologicalSorter(result_dict) + try: + res = list(topological_sorted_res.static_order()) + except CycleError as e: + echo.error("Cycle detected in dependencies", err=False) + raise CycleErrorExit(1) from e + return res diff --git a/src/manifestoo/commands/list.py b/src/manifestoo/commands/list.py index f0dca52..2b94236 100644 --- a/src/manifestoo/commands/list.py +++ b/src/manifestoo/commands/list.py @@ -1,7 +1,16 @@ -from typing import Iterable +from typing import Iterable, Optional +from manifestoo_core.addons_set import AddonsSet + +from ..addon_sorter import AddonSorter, AddonSorterAlphabetical from ..addons_selection import AddonsSelection -def list_command(addons_selection: AddonsSelection) -> Iterable[str]: - return sorted(addons_selection) +def list_command( + addons_selection: AddonsSelection, + addons_set: AddonsSet, + addon_sorter: Optional[AddonSorter] = None, +) -> Iterable[str]: + if not addon_sorter: + addon_sorter = AddonSorterAlphabetical() + return addon_sorter.sort(addons_selection, addons_set) diff --git a/src/manifestoo/commands/list_codepends.py b/src/manifestoo/commands/list_codepends.py index 801160d..75c0928 100644 --- a/src/manifestoo/commands/list_codepends.py +++ b/src/manifestoo/commands/list_codepends.py @@ -1,7 +1,8 @@ -from typing import Iterable, Set +from typing import Iterable, Optional, Set from manifestoo_core.addons_set import AddonsSet +from ..addon_sorter import AddonSorter, AddonSorterAlphabetical from ..addons_selection import AddonsSelection @@ -10,14 +11,18 @@ def list_codepends_command( addons_set: AddonsSet, transitive: bool = True, include_selected: bool = True, + addon_sorter: Optional[AddonSorter] = None, ) -> Iterable[str]: + if not addon_sorter: + addon_sorter = AddonSorterAlphabetical() result: Set[str] = set(addons_selection) if include_selected else set() codeps = direct_codependencies(addons_selection, addons_set, result) result |= codeps while transitive and codeps: codeps = direct_codependencies(codeps, addons_set, result) result |= codeps - return result if include_selected else result - addons_selection + res = result if include_selected else result - set(addons_selection) + return set(addon_sorter.sort(res, addons_set)) def direct_codependencies( diff --git a/src/manifestoo/commands/list_depends.py b/src/manifestoo/commands/list_depends.py index 5cfc0c1..169c24c 100644 --- a/src/manifestoo/commands/list_depends.py +++ b/src/manifestoo/commands/list_depends.py @@ -1,7 +1,8 @@ -from typing import Iterable, Set, Tuple +from typing import Iterable, Optional, Set, Tuple from manifestoo_core.addons_set import AddonsSet +from ..addon_sorter import AddonSorter, AddonSorterAlphabetical from ..addons_selection import AddonsSelection from ..dependency_iterator import dependency_iterator @@ -11,7 +12,10 @@ def list_depends_command( addons_set: AddonsSet, transitive: bool = False, include_selected: bool = False, + addon_sorter: Optional[AddonSorter] = None, ) -> Tuple[Iterable[str], Iterable[str]]: + if not addon_sorter: + addon_sorter = AddonSorterAlphabetical() result: Set[str] = set() missing: Set[str] = set() for addon_name, addon in dependency_iterator( @@ -23,4 +27,4 @@ def list_depends_command( missing.add(addon_name) else: result.update(set(addon.manifest.depends) - set(addons_selection)) - return sorted(result), missing + return addon_sorter.sort(result, addons_set), missing diff --git a/src/manifestoo/exceptions.py b/src/manifestoo/exceptions.py new file mode 100644 index 0000000..e255265 --- /dev/null +++ b/src/manifestoo/exceptions.py @@ -0,0 +1,5 @@ +from typer import Exit + + +class CycleErrorExit(Exit): + pass diff --git a/src/manifestoo/main.py b/src/manifestoo/main.py index 60199fd..52a01fe 100644 --- a/src/manifestoo/main.py +++ b/src/manifestoo/main.py @@ -7,6 +7,7 @@ from manifestoo_core.odoo_series import OdooSeries, detect_from_addons_set from . import echo +from .addon_sorter import AddonSorter from .commands.check_dev_status import check_dev_status_command from .commands.check_licenses import check_licenses_command from .commands.interactive_tree import interactive_tree_command @@ -225,10 +226,23 @@ def list( None, help="Separator character to use (by default, print one item per line).", ), + sorting: str = typer.Option( + "alphabetical", + "--sorting", + "--sort", + help=( + "Choice between 'alphabetical' and 'topological'." + "Topological sorting is useful when seeking a migration order." + ), + show_default=True, + ), ) -> None: """Print the selected addons.""" main_options: MainOptions = ctx.obj - result = list_command(main_options.addons_selection) + addon_sorter = AddonSorter.from_name(sorting) + result = list_command( + main_options.addons_selection, main_options.addons_set, addon_sorter + ) print_list(result, separator or main_options.separator or "\n") @@ -266,16 +280,28 @@ def list_depends( "--as-pip-requirements", show_default=False, ), + sorting: str = typer.Option( + "alphabetical", + "--sorting", + "--sort", + help=( + "Choice between 'alphabetical' and 'topological'." + "Topological sorting is useful when seeking a migration order." + ), + show_default=True, + ), ) -> None: """Print the dependencies of selected addons.""" main_options: MainOptions = ctx.obj if as_pip_requirements: not_implemented("--as-pip-requirement") + addon_sorter = AddonSorter.from_name(sorting) result, missing = list_depends_command( main_options.addons_selection, main_options.addons_set, transitive, include_selected, + addon_sorter, ) if missing and not ignore_missing: echo.error("not found in addons path: " + ",".join(sorted(missing))) @@ -301,6 +327,16 @@ def list_codepends( True, help="Print the selected addons along with their co-dependencies.", ), + sorting: str = typer.Option( + "alphabetical", + "--sorting", + "--sort", + help=( + "Choice between 'alphabetical' and 'topological'." + "Topological sorting is useful when seeking a migration order." + ), + show_default=True, + ), ) -> None: """Print the co-dependencies of selected addons. @@ -308,11 +344,13 @@ def list_codepends( addons. """ main_options: MainOptions = ctx.obj + addon_sorter = AddonSorter.from_name(sorting) result = list_codepends_command( main_options.addons_selection, main_options.addons_set, transitive, include_selected, + addon_sorter, ) print_list(result, separator or main_options.separator or "\n") diff --git a/tests/test_cmd_list.py b/tests/test_cmd_list.py index 1a1170a..66b6490 100644 --- a/tests/test_cmd_list.py +++ b/tests/test_cmd_list.py @@ -5,12 +5,18 @@ from manifestoo.commands.list import list_command from manifestoo.main import app -from .common import mock_addons_selection, populate_addons_dir +from .common import mock_addons_selection, mock_addons_set, populate_addons_dir def test_basic(): addons_selection = mock_addons_selection("b,a") - assert list_command(addons_selection) == ["a", "b"] + addons_set = mock_addons_set( + { + "a": {}, + "b": {}, + } + ) + assert list_command(addons_selection, addons_set) == ["a", "b"] def test_integration(tmp_path): diff --git a/tests/test_cmd_list_codepends.py b/tests/test_cmd_list_codepends.py index c4d2945..0cb5ef0 100644 --- a/tests/test_cmd_list_codepends.py +++ b/tests/test_cmd_list_codepends.py @@ -1,5 +1,9 @@ +import sys + +import pytest from typer.testing import CliRunner +from manifestoo.addon_sorter import AddonSorterTopological from manifestoo.commands.list_codepends import list_codepends_command from manifestoo.main import app @@ -33,6 +37,32 @@ def test_transitive(): ) == {"b"} assert list_codepends_command( addons_selection, addons_set, transitive=True, include_selected=False + ) == {"a", "b"} + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires python3.9 or higher") +def test_transitive_topological(): + addons_set = mock_addons_set( + { + "a": {"depends": ["b"]}, + "b": {"depends": ["c"]}, + "c": {}, + } + ) + addons_selection = mock_addons_selection("c") + assert list_codepends_command( + addons_selection, + addons_set, + transitive=False, + include_selected=False, + addon_sorter=AddonSorterTopological(), + ) == {"b"} + assert list_codepends_command( + addons_selection, + addons_set, + transitive=True, + include_selected=False, + addon_sorter=AddonSorterTopological(), ) == {"b", "a"} @@ -87,6 +117,29 @@ def test_include_selected(): ) == {"a", "b"} +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires python3.9 or higher") +def test_include_selected_topological(): + addons_set = mock_addons_set( + { + "a": {"depends": ["b"]}, + "b": {}, + } + ) + addons_selection = mock_addons_selection("b") + assert list_codepends_command( + addons_selection, + addons_set, + include_selected=False, + addon_sorter=AddonSorterTopological(), + ) == {"a"} + assert list_codepends_command( + addons_selection, + addons_set, + include_selected=True, + addon_sorter=AddonSorterTopological(), + ) == {"b", "a"} + + def test_integration(tmp_path): addons = { "a": {"depends": ["b"]}, diff --git a/tests/test_cmd_list_depends.py b/tests/test_cmd_list_depends.py index e48000e..42914d9 100644 --- a/tests/test_cmd_list_depends.py +++ b/tests/test_cmd_list_depends.py @@ -1,6 +1,11 @@ +import sys + +import pytest from typer.testing import CliRunner +from manifestoo.addon_sorter import AddonSorterTopological from manifestoo.commands.list_depends import list_depends_command +from manifestoo.exceptions import CycleErrorExit from manifestoo.main import app from .common import mock_addons_selection, mock_addons_set, populate_addons_dir @@ -70,6 +75,26 @@ def test_loop(): ) +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires python3.9 or higher") +def test_loop_topological(): + addons_set = mock_addons_set( + { + "a": {"depends": ["b"]}, + "b": {"depends": ["c"]}, + "c": {"depends": ["a"]}, + } + ) + addons_selection = mock_addons_selection("a") + with pytest.raises(CycleErrorExit): + list_depends_command( + addons_selection, + addons_set, + include_selected=True, + transitive=True, + addon_sorter=AddonSorterTopological(), + ) + + def test_missing(): addons_set = mock_addons_set( { @@ -108,6 +133,25 @@ def test_missing(): ) +@pytest.mark.skipif(sys.version_info < (3, 9), reason="Requires python3.9 or higher") +def test_missing_topological(): + addons_set = mock_addons_set( + { + "a": {"depends": ["b"]}, + } + ) + assert list_depends_command( + mock_addons_selection("a,c"), + addons_set, + include_selected=True, + transitive=True, + addon_sorter=AddonSorterTopological(), + ) == ( + ["b", "a"], + {"b", "c"}, + ) + + def test_include_selected_not_included(): """Dependencies that are part of the selection are not returned.""" addons_set = mock_addons_set(