diff --git a/resotocore/resotocore/cli/command.py b/resotocore/resotocore/cli/command.py index 032012c853..ade3d49e11 100644 --- a/resotocore/resotocore/cli/command.py +++ b/resotocore/resotocore/cli/command.py @@ -119,6 +119,8 @@ Property, ArrayKind, PropertyPath, + TransformKind, + AnyKind, ) from resotocore.model.resolve_in_graph import NodePath from resotocore.model.typed_model import to_json, to_js, from_js @@ -2553,6 +2555,7 @@ def args_info(self) -> ArgsInfo: return [ ArgInfo("--csv", help_text="format", option_group="format"), ArgInfo("--markdown", help_text="format", option_group="format"), + ArgInfo("--json-table", help_text="format", option_group="format"), ArgInfo( expects_value=True, help_text="comma separated list of properties to show", @@ -2565,6 +2568,7 @@ def parse(self, arg: Optional[str] = None, ctx: CLIContext = EmptyContext, **kwa output_type = parser.add_mutually_exclusive_group() output_type.add_argument("--csv", dest="csv", action="store_true") output_type.add_argument("--markdown", dest="markdown", action="store_true") + output_type.add_argument("--json-table", dest="json_table", action="store_true") parsed, properties_list = parser.parse_known_args(arg.split() if arg else []) properties = " ".join(properties_list) if properties_list else None @@ -2661,6 +2665,38 @@ def to_csv_string(lst: List[Any]) -> str: result.append(value) yield to_csv_string(result) + async def json_table_stream(in_stream: JsStream, model: Model) -> JsGen: + def kind_of(path: List[str]) -> str: + if path[0] in Section.lookup_sections: + return kind_of(path[2:]) + if path[0] in Section.content: + path = path[1:] + kind = model.kind_by_path(".".join(path)) + if isinstance(kind, TransformKind): + return kind.source_kind.fqn if kind.source_kind else AnyKind().fqn + else: + return kind.fqn + + # header columns + yield { + "columns": [ + { + "name": name, + "kind": kind_of(path), + "display": " ".join(word.capitalize() for word in name.split("_")), + } + for path, name in props_to_show + ], + } + # data columns + async with in_stream.stream() as s: + async for elem in s: + if is_node(elem): + yield { + "id": elem["id"], + "row": {name: js_value_at(elem, prop_path) for prop_path, name in props_to_show}, + } + def markdown_stream(in_stream: JsStream) -> JsGen: chunk_size = 500 @@ -2732,6 +2768,12 @@ def fmt(in_stream: JsGen) -> JsGen: return csv_stream(in_stream) elif parsed.markdown: return markdown_stream(in_stream) + elif parsed.json_table: + + async def load_model() -> Model: + return await self.dependencies.model_handler.load_model(ctx.graph_name) + + return stream.flatmap(stream.call(load_model), partial(json_table_stream, in_stream)) else: return stream.map(in_stream, lambda elem: fmt_json(elem) if isinstance(elem, dict) else str(elem)) diff --git a/resotocore/resotocore/model/graph_access.py b/resotocore/resotocore/model/graph_access.py index 0ac69ebb5c..3cbbae4521 100644 --- a/resotocore/resotocore/model/graph_access.py +++ b/resotocore/resotocore/model/graph_access.py @@ -80,6 +80,7 @@ class Section: # The list of all lookup sections lookup_sections_ordered = [ancestors, descendants, usage] + lookup_sections = set(lookup_sections_ordered) # The list of all sections all_ordered = [*content_ordered, *lookup_sections_ordered] diff --git a/resotocore/tests/resotocore/cli/command_test.py b/resotocore/tests/resotocore/cli/command_test.py index 5b146c4905..11ea085804 100644 --- a/resotocore/tests/resotocore/cli/command_test.py +++ b/resotocore/tests/resotocore/cli/command_test.py @@ -573,7 +573,7 @@ async def test_list_command(cli: CLI) -> None: # List is using the correct type props = dict(id="test", a="a", b=True, c=False, d=None, e=12, f=1.234, reported={}) - result = await cli.execute_cli_command(f"json {json.dumps(props)}" " | list a,b,c,d,e,f", stream.list) + result = await cli.execute_cli_command(f"json {json.dumps(props)} | list a,b,c,d,e,f", stream.list) assert result[0] == ["a=a, b=true, c=false, e=12, f=1.234"] # Queries that use the reported section, also interpret the list format in the reported section @@ -585,14 +585,14 @@ async def test_list_command(cli: CLI) -> None: # List supports csv output props = dict(id="test", a="a", b=True, c=False, d=None, e=12, f=1.234, reported={}) result = await cli.execute_cli_command( - f"json {json.dumps(props)}" " | list --csv a,b,c,d,e,f,non_existent", stream.list + f"json {json.dumps(props)} | list --csv a,b,c,d,e,f,non_existent", stream.list ) assert result[0] == ['"a","b","c","d","e","f","non_existent"', '"a",True,False,"",12,1.234,""'] # List supports markdown output props = dict(id="test", a="a", b=True, c=False, d=None, e=12, f=1.234, reported={}) result = await cli.execute_cli_command( - f"json {json.dumps(props)}" " | list --markdown a,b,c,d,e,f,non_existent", stream.list + f"json {json.dumps(props)} | list --markdown a,b,c,d,e,f,non_existent", stream.list ) assert result[0] == [ "|a|b |c |d |e |f |non_existent|", @@ -600,6 +600,20 @@ async def test_list_command(cli: CLI) -> None: "|a|true|false|null|12|1.234|null |", ] + # List supports markdown output + result = await cli.execute_cli_command( + 'json {"id": "foo", "reported":{}, "name": "a", "some_int": 1} | list --json-table name, some_int', stream.list + ) + assert result[0] == [ + { + "columns": [ + {"display": "Name", "kind": "string", "name": "name"}, + {"display": "Some Int", "kind": "int32", "name": "some_int"}, + ], + }, + {"id": "foo", "row": {"name": "a", "some_int": 1}}, + ] + # List supports only markdown or csv, but not both at the same time props = dict(id="test", a="a", b=True, c=False, d=None, e=12, f=1.234, reported={}) with pytest.raises(CLIParseError):