Skip to content

Commit 8130a39

Browse files
dharapandya85dharapandya85
authored andcommitted
feat(emitter):add structured data output with JSON and table formatting
1 parent d38e0f4 commit 8130a39

File tree

6 files changed

+126
-6
lines changed

6 files changed

+126
-6
lines changed

craft_cli/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@
2929

3030
# names included here only to be exposed as external API; the particular order of imports
3131
# is to break cyclic dependencies
32-
from .messages import EmitterMode, emit # isort:skip
32+
from .messages import EmitterMode, emit,Emitter # isort:skip
3333
from .dispatcher import BaseCommand, CommandGroup, Dispatcher, GlobalArgument
34+
from .completion import complete
3435
from .errors import (
3536
ArgumentParsingError,
3637
CraftError,
@@ -51,4 +52,6 @@
5152
"HIDDEN",
5253
"ProvideHelpException",
5354
"emit",
55+
"complete",
56+
"Emitter"
5457
]

craft_cli/dispatcher.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,12 @@ def fill_parser(self, parser: _CustomArgumentParser) -> None:
179179
180180
:param parser: The object to fill with this command's parameters.
181181
"""
182+
parser.add_argument(
183+
"--format",
184+
choices=["json","table"],
185+
default="table",
186+
help="Format for structured output",
187+
)
182188

183189
# NOTE: run() returns `Optional[int]` instead of `int | None` as the latter would
184190
# be a breaking change for subclasses that override this with just `None` and
@@ -192,7 +198,10 @@ def run(self, parsed_args: argparse.Namespace) -> int | None:
192198
:param parsed_args: The parsed arguments that were defined in :meth:`fill_parser`.
193199
:return: This method should return ``None`` or the desired process' return code.
194200
"""
195-
raise NotImplementedError
201+
emit=Emitter()
202+
sample_data=[{"name":"App A","version":"1.0.1"},{"name":"App B","version":"2.3.0"}]
203+
emit.data(sample_data,format=parsed_args.format)
204+
return 0
196205

197206

198207
class _CustomArgumentParser(argparse.ArgumentParser):

craft_cli/messages.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,67 @@
3636
from collections.abc import Callable, Generator
3737
from contextlib import contextmanager
3838
from datetime import datetime
39-
from typing import TYPE_CHECKING, Any, Literal, TextIO, TypeVar, cast
40-
39+
from typing import TYPE_CHECKING, Any, Literal, TextIO, TypeVar, cast, Union, List, Dict
40+
import json
4141
import platformdirs
4242

4343
from craft_cli import errors
4444
from craft_cli.printer import Printer
45+
from abc import ABC, abstractmethod
46+
#from .formatters import BaseFormatter
47+
48+
TabularData=Union[List[Dict[str,Any]],Dict[str,Any]]
49+
50+
class BaseFormatter(ABC):
51+
@abstractmethod
52+
def format(self,data:TabularData,headers:dict[str,str] | None=None)->str:
53+
pass
54+
class JSONFormatter(BaseFormatter):
55+
"""
56+
Format data into JSON.
4557
58+
Example
59+
-------
60+
>>> formatter=JsonFormatter()
61+
>>> formatter.format([{"name":"Alice","age":30})
62+
'{"name":"Alice","age":30}'
63+
"""
64+
def format(self,data: TabularData,headers: dict[str,str] | None=None)->str:
65+
return json.dumps(data,indent=2,default=str)
66+
class TableFormatter(BaseFormatter):
67+
"""
68+
Format data into a pretty table.
69+
70+
Example
71+
-------
72+
>>> formatter=TableFormatter()
73+
>>> formatter.format([["Name","Age"],["Alice",30]])
74+
'Name Age\nAlice 30'
75+
"""
76+
def format(self,data:TabularData,headers:dict[str,str] | None=None)->str:
77+
"""Format a list of rows into a table string."""
78+
if not data:
79+
return "[no data]"
80+
table_headers:list[str]
81+
if isinstance(data,list):
82+
#list_data=cast(List[Dict[str,Any]],data)
83+
all_keys: set[str]=set().union(*(row.keys() for row in data))
84+
table_headers:list[str]=sorted(list(all_keys))
85+
rows=[[str(row.get(h,"")) for h in table_headers] for row in data]
86+
else:
87+
table_headers=["Key", "Value"]
88+
#data_dict=cast(Dict[str,Any],data)
89+
rows=[[str(k),str(v)] for k,v in data.items()]
90+
cols_widths=[max(len(str(item)) for item in col) for col in zip(*([table_headers]+rows))]
91+
92+
def format_row(row: list[str])->str:
93+
return " | ".join(str(cell).ljust(width) for cell, width in zip(row,cols_widths))
94+
table=[format_row(table_headers)]
95+
table.append("-" * sum(cols_widths)+"---"*(len(table_headers)-1))
96+
table.extend(format_row(row) for row in rows)
97+
return "\n".join(table)
98+
99+
46100
if TYPE_CHECKING:
47101
from types import TracebackType
48102

@@ -462,6 +516,10 @@ def __init__(self) -> None:
462516
self._log_handler: _Handler = None # type: ignore[assignment]
463517
self._streaming_brief = False
464518
self._docs_base_url: str | None = None
519+
self._formatters={
520+
"json":JSONFormatter(),
521+
"table":TableFormatter(),
522+
}
465523

466524
def init(
467525
self,
@@ -888,6 +946,14 @@ def prompt(self, prompt_text: str, *, hide: bool = False) -> str:
888946
if not val:
889947
raise errors.CraftError("input cannot be empty")
890948
return val
949+
950+
@_active_guard()
951+
def data(self,records: list[dict], format:str="table")->None:
952+
if format not in self._formatters:
953+
raise ValueError(f"Unsupported format: {format}")
954+
formatter=self._formatters[format]
955+
formatted_output=formatter.format(records)
956+
self.message(formatted_output)
891957

892958
@property
893959
def log_filepath(self) -> pathlib.Path:
@@ -905,3 +971,4 @@ def _format_details(details: str) -> str:
905971
if "\n" in details:
906972
return details if details.startswith("\n") else f"\n{details}"
907973
return details
974+

craft_cli/pytest_plugin.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from typing import TYPE_CHECKING, Any, Literal
2626
from unittest.mock import call
2727

28+
2829
import pytest
2930
from typing_extensions import Self
3031

@@ -43,6 +44,8 @@ def init_emitter(monkeypatch: pytest.MonkeyPatch) -> Generator[None]:
4344
4445
This is an "autouse" fixture, so it just works, no need to declare it in your tests.
4546
"""
47+
# messages.emit._initiated = False
48+
# messages.emit._stopped = False
4649
# initiate with a custom log filepath so user directories are not involved here; note that
4750
# we're not using pytest's standard tmp_path as Emitter would write logs there, and in
4851
# effect we would be polluting that temporary directory (potentially messing with
@@ -59,7 +62,8 @@ def init_emitter(monkeypatch: pytest.MonkeyPatch) -> Generator[None]:
5962
yield
6063
# end machinery (just in case it was not ended before; note it's ok to "double end")
6164
messages.emit.ended_ok()
62-
65+
# messages.emit._initiated = False
66+
# messages.emit._stopped = True
6367

6468
class _RegexComparingText(str):
6569
"""A string that compares for equality using regex.match."""

examples.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,20 @@ def example_35() -> None:
594594

595595
emit.message("Takeover complete. Have a nice day!")
596596

597+
def example_36(*args)->None:
598+
print(">>> Running example_33 with args:", args)
599+
sample_data=[
600+
{"name":"App A","version":"1.0.1","status":"active"},
601+
{"name":"App B","version":"2.3.0","status":"inactive"},
602+
]
603+
fmt=args[0] if args else "table"
604+
emit.message(f"{fmt.upper()} output:")
605+
#emit.message("JSON output:")
606+
emit.data(sample_data,format=fmt)
607+
# emit.message("Table output:")
608+
# emit.data(sample_data,format="table")
609+
610+
# emit.structured(sample_data,fmt=fmt)
597611

598612
# -- end of test cases
599613

@@ -627,3 +641,4 @@ def example_35() -> None:
627641
emit.error(error)
628642
else:
629643
emit.ended_ok()
644+

tests/unit/test_messages_emitter.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import logging
2020
import sys
21+
import json
2122
from collections.abc import Callable
2223
from typing import Any, cast
2324
from unittest import mock
@@ -27,7 +28,7 @@
2728
import pytest_mock
2829
from craft_cli import messages
2930
from craft_cli.errors import CraftCommandError, CraftError
30-
from craft_cli.messages import Emitter, EmitterMode, _Handler
31+
from craft_cli.messages import Emitter, EmitterMode, _Handler,TableFormatter,JSONFormatter
3132

3233
FAKE_LOG_NAME = "fakelog.log"
3334

@@ -1532,3 +1533,24 @@ def test_prompt_does_not_allow_empty_input(
15321533

15331534
with pytest.raises(CraftError, match="input cannot be empty"):
15341535
initiated_emitter.prompt("prompt")
1536+
def test_table_formatter_simple_dict_list():
1537+
data=[{"a":1,"b":2},{"a":3,"b":4}]
1538+
fmt=TableFormatter()
1539+
out=fmt.format(data)
1540+
assert "a" in out and "b" in out
1541+
assert "1" in out and "4" in out
1542+
def test_table_formatter_empty_list():
1543+
fmt=TableFormatter()
1544+
out=fmt.format([])
1545+
assert out=="[no data]"
1546+
def test_table_formatter_dict_input():
1547+
fmt=TableFormatter()
1548+
out=fmt.format({"foo":"bar"})
1549+
assert "foo" in out and "bar" in out
1550+
def test_json_formatter_dict_list():
1551+
data=[{"x":1},{"x":2}]
1552+
fmt=JSONFormatter()
1553+
out=fmt.format(data)
1554+
parsed=json.loads(out)
1555+
assert isinstance(parsed,list)
1556+
assert parsed[0]["x"]==1

0 commit comments

Comments
 (0)