diff --git a/src/manifestoo/commands/tree.py b/src/manifestoo/commands/tree.py index 7c515a6..e7959dd 100644 --- a/src/manifestoo/commands/tree.py +++ b/src/manifestoo/commands/tree.py @@ -20,13 +20,19 @@ class Node: def __init__(self, addon_name: str, addon: Optional[Addon]): self.addon_name = addon_name self.addon = addon - self.children = [] # type: List[Node] + self.children: Set[Node] = set() + self.parents: Set[Node] = set() + + def __hash__(self): + return hash(self.addon_name) @staticmethod def key(addon_name: str) -> NodeKey: return addon_name - def print(self, odoo_series: OdooSeries, fold_core_addons: bool) -> None: + def print( + self, odoo_series: OdooSeries, fold_core_addons: bool, inverse: bool + ) -> None: seen: Set[Node] = set() def _print(indent: List[str], node: Node) -> None: @@ -41,22 +47,25 @@ def _print(indent: List[str], node: Node) -> None: return typer.secho(f" ({node.sversion(odoo_series)})", dim=True) seen.add(node) - if not node.children: + sub_nodes = sorted( + # In inverse mode, we iterate over the parents instead of the children + node.parents if inverse else node.children, + key=lambda n: n.addon_name, + ) + if not sub_nodes: return if fold_core_addons and is_core_addon(node.addon_name, odoo_series): return - pointers = [TEE] * (len(node.children) - 1) + [LAST] - for pointer, child in zip( - pointers, sorted(node.children, key=lambda n: n.addon_name) - ): + pointers = [TEE] * (len(sub_nodes) - 1) + [LAST] + for pointer, sub_node in zip(pointers, sub_nodes): if indent: if indent[-1] == TEE: - _print(indent[:-1] + [BRANCH, pointer], child) + _print(indent[:-1] + [BRANCH, pointer], sub_node) else: assert indent[-1] == LAST - _print(indent[:-1] + [SPACE, pointer], child) + _print(indent[:-1] + [SPACE, pointer], sub_node) else: - _print([pointer], child) + _print([pointer], sub_node) _print([], self) @@ -76,6 +85,7 @@ def tree_command( addons_set: AddonsSet, odoo_series: OdooSeries, fold_core_addons: bool, + inverse: bool, ) -> None: nodes: Dict[NodeKey, Node] = {} @@ -92,7 +102,9 @@ def add(addon_name: str) -> Node: for depend in addon.manifest.depends: if depend == "base": continue - node.children.append(add(depend)) + child = add(depend) + node.children.add(child) + child.parents.add(node) return node root_nodes: List[Node] = [] @@ -100,5 +112,9 @@ def add(addon_name: str) -> Node: if addon_name == "base": continue root_nodes.append(add(addon_name)) + if inverse: + # In inverse mode, leaf nodes become root nodes + root_nodes = [node for node in nodes.values() if not node.children] + root_nodes = sorted(root_nodes, key=lambda n: n.addon_name) for root_node in root_nodes: - root_node.print(odoo_series, fold_core_addons) + root_node.print(odoo_series, fold_core_addons, inverse) diff --git a/src/manifestoo/main.py b/src/manifestoo/main.py index a5e84e2..e56a335 100644 --- a/src/manifestoo/main.py +++ b/src/manifestoo/main.py @@ -495,12 +495,22 @@ def tree( help="Display an interactive tree.", show_default=False, ), + inverse: bool = typer.Option( + False, + "--inverse", + help="Display the tree in inverse mode. Not available in interactive mode.", + show_default=False, + ), ) -> None: """Print the dependency tree of selected addons.""" main_options: MainOptions = ctx.obj ensure_odoo_series(main_options.odoo_series) assert main_options.odoo_series if interactive: + if inverse: + raise typer.BadParameter( + "The --inverse option is not available in interactive mode." + ) interactive_tree_command( main_options.addons_selection, main_options.addons_set, @@ -513,4 +523,5 @@ def tree( main_options.addons_set, main_options.odoo_series, fold_core_addons, + inverse, ) diff --git a/tests/test_tree.py b/tests/test_tree.py index 4e3e4d7..eb6f761 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -1,4 +1,5 @@ import textwrap +from pathlib import Path from typer.testing import CliRunner @@ -6,8 +7,7 @@ from .common import populate_addons_dir - -def test_integration(tmp_path): +def _init_test_addons(tmp_path: Path): addons = { "a": {"version": "13.0.1.0.0", "depends": ["b", "c"]}, "b": {"depends": ["base", "mail"]}, @@ -16,6 +16,9 @@ def test_integration(tmp_path): "base": {}, } populate_addons_dir(tmp_path, addons) + +def test_integration(tmp_path: Path): + _init_test_addons(tmp_path) runner = CliRunner(mix_stderr=False) result = runner.invoke( app, @@ -34,3 +37,26 @@ def test_integration(tmp_path): └── b ⬆ """ ) + +def test_integration_inverse(tmp_path: Path): + _init_test_addons(tmp_path) + runner = CliRunner(mix_stderr=False) + result = runner.invoke( + app, + ["--select=a", f"--addons-path={tmp_path}", "tree", "--inverse"], + catch_exceptions=False, + ) + assert not result.exception + assert result.exit_code == 0, result.stderr + assert result.stdout == textwrap.dedent( + """\ + account (13.0+c) + └── c (no version) + └── a (13.0.1.0.0) + mail (✘ not installed) + └── b (no version) + ├── a (13.0.1.0.0) + └── c (no version) + └── a ⬆ + """ + )