diff --git a/docs/cli.md b/docs/cli.md index 22ba75b..8e97055 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -90,7 +90,9 @@ $ manifestoo list [OPTIONS] **Options**: -* `--separator TEXT`: Separator charater to use (by default, print one item per line). +* `--format [names|json]`: Output format [default: names] +* `--separator TEXT`: Separator character to use for the 'names' format (by + default, print one item per line). * `--help`: Show this message and exit. ## `manifestoo list-depends` @@ -105,7 +107,9 @@ $ manifestoo list-depends [OPTIONS] **Options**: -* `--separator TEXT`: Separator charater to use (by default, print one item per line). +* `--format [names|json]`: Output format [default: names] +* `--separator TEXT`: Separator character to use for the 'names' format (by + default, print one item per line). * `--transitive`: Print all transitive dependencies. * `--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. diff --git a/news/11.feature b/news/11.feature new file mode 100644 index 0000000..a0e25a4 --- /dev/null +++ b/news/11.feature @@ -0,0 +1 @@ +Add rich `json` output format to `list` and `list-depends` commands. diff --git a/src/manifestoo/addon.py b/src/manifestoo/addon.py index 4d4c44b..9e2af1e 100644 --- a/src/manifestoo/addon.py +++ b/src/manifestoo/addon.py @@ -1,6 +1,21 @@ +import sys from pathlib import Path -from .manifest import InvalidManifest, Manifest +from .manifest import InvalidManifest, Manifest, ManifestDict + +if sys.version_info >= (3, 8): + from typing import TypedDict + + class AddonDict(TypedDict): + manifest: ManifestDict + manifest_path: str + path: str + + +else: + from typing import Any, Dict + + AddonDict = Dict[str, Any] class AddonNotFound(Exception): @@ -52,3 +67,11 @@ def from_addon_dir( except InvalidManifest as e: raise AddonNotFoundInvalidManifest(str(e)) from e return cls(manifest) + + def as_dict(self) -> AddonDict: + """Convert to a dictionary suitable for json output.""" + return dict( + manifest=self.manifest.manifest_dict, + manifest_path=str(self.manifest_path), + path=str(self.path), + ) diff --git a/src/manifestoo/commands/tree.py b/src/manifestoo/commands/tree.py index 98e86c6..e251e79 100644 --- a/src/manifestoo/commands/tree.py +++ b/src/manifestoo/commands/tree.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional, Set +from typing import Dict, List, Optional, Set, cast import typer @@ -57,7 +57,8 @@ def _print(indent: List[str], node: Node) -> None: def sversion(self, odoo_series: OdooSeries) -> Optional[str]: if not self.addon: - return typer.style("✘ not installed", fg=typer.colors.RED) + # typer.style (from click, actually) miss a type annotation + return cast(str, typer.style("✘ not installed", fg=typer.colors.RED)) elif is_core_ce_addon(self.addon_name, odoo_series): return f"{odoo_series}+{OdooEdition.CE}" elif is_core_ee_addon(self.addon_name, odoo_series): diff --git a/src/manifestoo/main.py b/src/manifestoo/main.py index b52af54..64dc529 100644 --- a/src/manifestoo/main.py +++ b/src/manifestoo/main.py @@ -1,3 +1,4 @@ +from enum import Enum from pathlib import Path from typing import List, Optional @@ -13,7 +14,7 @@ from .core_addons import get_core_addons from .odoo_series import OdooSeries, detect_from_addons_set from .options import MainOptions -from .utils import ensure_odoo_series, not_implemented, print_list +from .utils import ensure_odoo_series, not_implemented, print_addons_as_json, print_list from .version import __version__ app = typer.Typer() @@ -185,26 +186,48 @@ def callback( ctx.obj = main_options +class Format(str, Enum): + names = "names" + json = "json" + + @app.command() def list( ctx: typer.Context, + format: Format = typer.Option( + Format.names, + help="Output format", + ), separator: Optional[str] = typer.Option( None, - help="Separator charater to use (by default, print one item per line).", + help=( + "Separator character to use for the 'names' format " + "(by default, print one item per line)." + ), ), ) -> None: """Print the selected addons.""" main_options: MainOptions = ctx.obj - result = list_command(main_options.addons_selection) - print_list(result, separator or main_options.separator or "\n") + names = list_command(main_options.addons_selection) + if format == Format.names: + print_list(names, separator or main_options.separator or "\n") + else: + print_addons_as_json(names, main_options.addons_set) @app.command() def list_depends( ctx: typer.Context, + format: Format = typer.Option( + Format.names, + help="Output format", + ), separator: Optional[str] = typer.Option( None, - help="Separator charater to use (by default, print one item per line).", + help=( + "Separator character to use for the 'names' format " + "(by default, print one item per line)." + ), ), transitive: bool = typer.Option( False, @@ -238,7 +261,7 @@ def list_depends( main_options: MainOptions = ctx.obj if as_pip_requirements: not_implemented("--as-pip-requirement") - result, missing = list_depends_command( + names, missing = list_depends_command( main_options.addons_selection, main_options.addons_set, transitive, @@ -247,10 +270,13 @@ def list_depends( if missing and not ignore_missing: echo.error("not found in addons path: " + ",".join(sorted(missing))) raise typer.Abort() - print_list( - result, - separator or main_options.separator or "\n", - ) + if format == Format.names: + print_list( + names, + separator or main_options.separator or "\n", + ) + else: + print_addons_as_json(names, main_options.addons_set) @app.command() diff --git a/src/manifestoo/manifest.py b/src/manifestoo/manifest.py index 81c58ac..bb72df8 100644 --- a/src/manifestoo/manifest.py +++ b/src/manifestoo/manifest.py @@ -56,8 +56,11 @@ class InvalidManifest(Exception): pass +ManifestDict = Dict[Any, Any] + + class Manifest: - def __init__(self, manifest_path: Path, manifest_dict: Dict[Any, Any]) -> None: + def __init__(self, manifest_path: Path, manifest_dict: ManifestDict) -> None: self.manifest_path = manifest_path self.manifest_dict = manifest_dict diff --git a/src/manifestoo/utils.py b/src/manifestoo/utils.py index 51dbcf8..603d877 100644 --- a/src/manifestoo/utils.py +++ b/src/manifestoo/utils.py @@ -1,9 +1,12 @@ +import json import sys -from typing import Iterable, List, Optional +from typing import Any, Dict, Iterable, List, Optional import typer from . import echo +from .addon import AddonDict +from .addons_set import AddonsSet from .odoo_series import OdooSeries @@ -29,6 +32,21 @@ def print_list(lst: Iterable[str], separator: str) -> None: sys.stdout.write("\n") +def print_json(obj: Any) -> None: + json.dump(obj, sys.stdout) + sys.stdout.write("\n") + + +def print_addons_as_json(names: Iterable[str], addons_set: AddonsSet) -> None: + d: Dict[str, Optional[AddonDict]] = {} + for name in names: + if name in addons_set: + d[name] = addons_set[name].as_dict() + else: + d[name] = None + print_json(d) + + def notice_or_abort(msg: str, abort: bool) -> None: if abort: echo.error(msg) diff --git a/tests/test_cmd_list.py b/tests/test_cmd_list.py index d6270ad..a5eb00c 100644 --- a/tests/test_cmd_list.py +++ b/tests/test_cmd_list.py @@ -1,3 +1,5 @@ +import json + from typer.testing import CliRunner from manifestoo.commands.list import list_command @@ -26,3 +28,26 @@ def test_integration(tmp_path): assert not result.exception assert result.exit_code == 0, result.stderr assert result.stdout == "a\nb\n" + + +def test_integration_json(tmp_path): + addons = { + "a": {"name": "A"}, + "b": {}, + } + populate_addons_dir(tmp_path, addons) + runner = CliRunner(mix_stderr=False) + result = runner.invoke( + app, + [f"--select-addons-dir={tmp_path}", "list", "--format=json"], + catch_exceptions=False, + ) + assert not result.exception + assert result.exit_code == 0, result.stderr + json_output = json.loads(result.stdout) + assert "a" in json_output + assert "b" in json_output + assert "manifest" in json_output["a"] + assert "manifest_path" in json_output["a"] + assert "path" in json_output["a"] + assert json_output["a"]["manifest"] == addons["a"] diff --git a/tests/test_cmd_list_depends.py b/tests/test_cmd_list_depends.py index e48000e..e51d9e4 100644 --- a/tests/test_cmd_list_depends.py +++ b/tests/test_cmd_list_depends.py @@ -1,3 +1,5 @@ +import json + from typer.testing import CliRunner from manifestoo.commands.list_depends import list_depends_command @@ -159,3 +161,28 @@ def test_integration(tmp_path): assert not result.exception assert result.exit_code == 0, result.stderr assert result.stdout == "b\n" + + +def test_integration_json(tmp_path): + addons = { + "a": {"depends": ["b"]}, + "b": {"name": "B"}, + } + populate_addons_dir(tmp_path, addons) + runner = CliRunner(mix_stderr=False) + result = runner.invoke( + app, + [ + f"--addons-path={tmp_path}", + "--select-include", + "a", + "list-depends", + "--format=json", + ], + catch_exceptions=False, + ) + assert not result.exception + assert result.exit_code == 0, result.stderr + json_output = json.loads(result.stdout) + assert len(json_output) == 1 + assert json_output["b"]["manifest"] == addons["b"] diff --git a/tests/test_utils.py b/tests/test_utils.py index 2f18cf3..f2bd476 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,13 @@ import pytest import typer -from manifestoo.utils import comma_split, not_implemented, notice_or_abort, print_list +from manifestoo.utils import ( + comma_split, + not_implemented, + notice_or_abort, + print_json, + print_list, +) @pytest.mark.parametrize( @@ -24,6 +30,11 @@ def test_print_list(capsys): assert capsys.readouterr().out == "b,a\n" +def test_print_json(capsys): + print_json(["b", "a"]) + assert capsys.readouterr().out == '["b", "a"]\n' + + def test_print_empty_list(capsys): print_list([], ",") assert capsys.readouterr().out == ""