diff --git a/craft_cli/__init__.py b/craft_cli/__init__.py index b1cfb5d4..fc870e6c 100644 --- a/craft_cli/__init__.py +++ b/craft_cli/__init__.py @@ -29,8 +29,9 @@ # names included here only to be exposed as external API; the particular order of imports # is to break cyclic dependencies -from .messages import EmitterMode, emit # isort:skip +from .messages import EmitterMode, emit, Emitter # isort:skip from .dispatcher import BaseCommand, CommandGroup, Dispatcher, GlobalArgument +from .completion import complete from .errors import ( ArgumentParsingError, CraftError, @@ -51,4 +52,6 @@ "HIDDEN", "ProvideHelpException", "emit", + "complete", + "Emitter", ] diff --git a/craft_cli/completion/completion.py b/craft_cli/completion/completion.py index 59a95486..43f0a52e 100644 --- a/craft_cli/completion/completion.py +++ b/craft_cli/completion/completion.py @@ -247,11 +247,12 @@ def complete(shell_cmd: str, get_app_info: Callable[[], DispatcherAndConfig]) -> if arg.type == "option" ] - return template.render( + result: str = template.render( shell_cmd=shell_cmd, commands=command_map, global_opts=global_opts, ) + return result def _validate_app_info(raw_ref: str) -> Callable[[], DispatcherAndConfig]: diff --git a/craft_cli/dispatcher.py b/craft_cli/dispatcher.py index 283d2ac5..39bae2cd 100644 --- a/craft_cli/dispatcher.py +++ b/craft_cli/dispatcher.py @@ -391,6 +391,8 @@ def _get_requested_help( # noqa: PLR0912 (too many branches) # store the different options if present, otherwise it's just the dest help_text = "" if action.help is None else action.help if action.option_strings: + if "--format" in action.option_strings: + continue command_options.append((", ".join(action.option_strings), help_text)) else: if action.metavar is None: diff --git a/craft_cli/helptexts.py b/craft_cli/helptexts.py index 86859a6a..3dcc2310 100644 --- a/craft_cli/helptexts.py +++ b/craft_cli/helptexts.py @@ -328,7 +328,7 @@ def _build_plain_command_help( textblocks.append(f"Summary:{overview}") # column alignment is dictated by longest options title - max_title_len = max(len(title) for title, _ in options) + max_title_len = max((len(title) for title, _ in options), default=0) if parameters: # command positional arguments @@ -344,10 +344,11 @@ def _build_plain_command_help( ) # command options - option_lines = ["Options:"] - for title, text in options: - option_lines.extend(_build_item_plain(title, text, max_title_len)) - textblocks.append("\n".join(option_lines)) + if options: + option_lines = ["Options:"] + for title, text in options: + option_lines.extend(_build_item_plain(title, text, max_title_len)) + textblocks.append("\n".join(option_lines)) if other_command_names: see_also_block = ["See also:"] diff --git a/craft_cli/messages.py b/craft_cli/messages.py index 76dfc4c1..2c5b0343 100644 --- a/craft_cli/messages.py +++ b/craft_cli/messages.py @@ -26,6 +26,7 @@ import enum import functools import getpass +import json import logging import os import pathlib @@ -33,16 +34,85 @@ import sys import threading import traceback +from abc import ABC, abstractmethod from collections.abc import Callable, Generator from contextlib import contextmanager from datetime import datetime from typing import TYPE_CHECKING, Any, Literal, TextIO, TypeVar, cast -import platformdirs +import platformdirs # type: ignore [import-not-found] from craft_cli import errors from craft_cli.printer import Printer +TabularData = list[dict[str, Any]] | dict[str, Any] + + +class BaseFormatter(ABC): + @abstractmethod + def format(self, data: TabularData, headers: dict[str, str] | None = None) -> str: + pass + + +class JSONFormatter(BaseFormatter): + """Format data into JSON. + + Example: + ------- + >>> formatter = JSONFormatter() + >>> formatter.format([{"name":"Alice","age":30}) + '{"name":"Alice","age":30}' + + """ + + def format(self, data: TabularData, headers: dict[str, str] | None = None) -> str: + _ = headers + return json.dumps(data, indent=2, default=str) + + +class TableFormatter(BaseFormatter): + r"""Format data into a pretty table. + + Example: + ------- + >>> formatter = TableFormatter() + >>> formatter.format([["Name", "Age"], ["Alice", 30]]) + 'Name Age\nAlice 30' + + """ + + def format(self, data: TabularData, headers: dict[str, str] | None = None) -> str: + """Format a list of rows into a table string.""" + _ = headers + if not data: + return "[no data]" + if isinstance(data, list): + all_keys: set[str] = set() + for row in data: + all_keys.update(str(k) for k in row) + table_headers = sorted(all_keys) + rows = [[str(row.get(h, "")) for h in table_headers] for row in data] + else: + table_headers = ["Key", "Value"] + rows = [[str(k), str(v)] for k, v in data.items()] + cols_widths = [ + max(len(str(item)) for item in col) for col in zip(table_headers, *rows) + ] + + def format_row(row: list[str]) -> str: + return " | ".join( + cell.ljust(width) for cell, width in zip(row, cols_widths) + ) + + separator = "-+-".join("-" * width for width in cols_widths) + table = [ + format_row(table_headers), + separator, + *(format_row(row) for row in rows), + ] + return "\n".join(table) + + if TYPE_CHECKING: from types import TracebackType @@ -462,6 +532,10 @@ def __init__(self) -> None: self._log_handler: _Handler = None # type: ignore[assignment] self._streaming_brief = False self._docs_base_url: str | None = None + self._formatters = { + "json": JSONFormatter(), + "table": TableFormatter(), + } def init( self, @@ -727,6 +801,45 @@ def open_stream(self, text: str | None = None) -> _StreamContextManager: ephemeral_mode=ephemeral, ) + @_active_guard() + def data( + self, + data: TabularData, + output_format: Literal["json", "table"] = "table", + headers: dict[str, str] | None = None, + ) -> None: + """Output structured data to the terminal in a specific format. + + :param data: The structured data to output( list of dicts or a single dict). + :param format: The format( defaults to 'table') + :param headers: Optional dictionary to map internal data keys to displayed header. + """ + formatter = self._formatters.get(output_format) + if not formatter: + raise ValueError(f"Unsupported format: {output_format}") + + formatted_data = formatter.format(data, headers) + stream = None if self._mode == EmitterMode.QUIET else sys.stdout + self._printer.show(stream, formatted_data, raw_output=True) + + @_active_guard() + def table( + self, + data: TabularData, + headers: dict[str, str] | None = None, + ) -> None: + """Output data as table.""" + self.data(data, output_format="table", headers=headers) + + @_active_guard() + def json( + self, + data: TabularData, + headers: dict[str, str] | None = None, + ) -> None: + """Output data as JSON.""" + self.data(data, output_format="json", headers=headers) + @_active_guard() @contextmanager def pause(self) -> Generator[None, None, None]: diff --git a/craft_cli/printer.py b/craft_cli/printer.py index de4aa18b..bb05ab07 100644 --- a/craft_cli/printer.py +++ b/craft_cli/printer.py @@ -444,13 +444,14 @@ def show( use_timestamp: bool = False, end_line: bool = False, avoid_logging: bool = False, + raw_output: bool = False, ) -> None: """Show a text to the given stream if not stopped.""" if self.stopped: return text = self._apply_secrets(text) - + _ = raw_output msg = _MessageInfo( stream=stream, text=text.rstrip(), diff --git a/craft_cli/pytest_plugin.py b/craft_cli/pytest_plugin.py index b6cd5851..a7b72350 100644 --- a/craft_cli/pytest_plugin.py +++ b/craft_cli/pytest_plugin.py @@ -25,7 +25,7 @@ from typing import TYPE_CHECKING, Any, Literal from unittest.mock import call -import pytest +import pytest # type: ignore[import-not-found] from typing_extensions import Self from craft_cli import messages, printer @@ -34,8 +34,8 @@ from unittest.mock import _Call # type: ignore[reportPrivateUsage] -@pytest.fixture(autouse=True) -def init_emitter(monkeypatch: pytest.MonkeyPatch) -> Generator[None]: +@pytest.fixture(autouse=True) # type: ignore[misc] +def init_emitter(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]: """Ensure ``emit`` is always clean, and initiated (in test mode). Note that the ``init`` is done in the current instance that all modules already @@ -243,7 +243,7 @@ def advance(self, *a: Any, **k: Any) -> None: self.recording_emitter.record("advance", a, k) -@pytest.fixture +@pytest.fixture # type: ignore[misc] def emitter(monkeypatch: pytest.MonkeyPatch) -> RecordingEmitter: """Provide a helper to test everything that was shown using the Emitter.""" recording_emitter = RecordingEmitter() diff --git a/docs/conf.py b/docs/conf.py index f911de7b..edd5a0e9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,7 +46,7 @@ def run_apidoc(_): import os import sys - from sphinx.ext.apidoc import main + from sphinx.ext.apidoc import main # type: ignore[reportMissingImports] sys.path.append(os.path.join(os.path.dirname(__file__), "..")) cur_dir = os.path.abspath(os.path.dirname(__file__)) diff --git a/examples.py b/examples.py index 8d090b1a..c0638c8e 100755 --- a/examples.py +++ b/examples.py @@ -595,6 +595,19 @@ def example_35() -> None: emit.message("Takeover complete. Have a nice day!") +def example_36(*args: str) -> None: + """Demonstrate structured output formats.""" + print(">>> Running example_36 with args:", args) + sample_data = [ + {"name": "App A", "version": "1.0.1", "status": "active"}, + {"name": "App B", "version": "2.3.0", "status": "inactive"}, + ] + fmt = args[0] if args else "table" + emit.message(f"{fmt.upper()} output:") + + emit.data(sample_data, output_format=fmt) + + # -- end of test cases if len(sys.argv) < 2: # noqa: PLR2004, magic value diff --git a/tests/integration/test_messages_integration.py b/tests/integration/test_messages_integration.py index d9074df9..6d2b048d 100644 --- a/tests/integration/test_messages_integration.py +++ b/tests/integration/test_messages_integration.py @@ -840,9 +840,10 @@ def test_third_party_output_quiet(capsys, tmp_path): @pytest.mark.parametrize("output_is_terminal", [True]) -def test_third_party_output_brief_terminal(capsys, tmp_path): +def test_third_party_output_brief_terminal(capsys, tmp_path, monkeypatch): """Manage the streams produced for sub-executions, brief mode, to the terminal.""" # something to execute + monkeypatch.setattr(printer, "_SPINNER_THRESHOLD", 100) script = tmp_path / "script.py" script.write_text( textwrap.dedent( @@ -1484,7 +1485,7 @@ def test_capture_delays(tmp_path, loops, sleep, max_repetitions): # are slower (a limit that is still useful: when subprocess Python # is run without the `-u` option average delays are around 500 ms. delays = [t_outside - t_inside for t_outside, t_inside in timestamps] - too_big = [delay for delay in delays if delay > 0.050] + too_big = [delay for delay in delays if delay > 0.100] if len(too_big) > loops / 20: pytest.fail( f"Delayed capture: {too_big} avg delay is {sum(delays) / len(delays):.3f}" diff --git a/tests/unit/test_help.py b/tests/unit/test_help.py index 5bee65c4..46564e9e 100644 --- a/tests/unit/test_help.py +++ b/tests/unit/test_help.py @@ -462,8 +462,9 @@ def test_command_help_text_no_parameters(docs_url, output_format): help_builder = HelpBuilder("testapp", "general summary", command_groups, docs_url) text = help_builder.get_command_help(cmd1(None), options, output_format) - expected_plain = textwrap.dedent( - """\ + expected_plain = ( + textwrap.dedent( + """\ Usage: testapp somecommand [options] @@ -484,6 +485,8 @@ def test_command_help_text_no_parameters(docs_url, output_format): For a summary of all commands, run 'testapp help --all'. """ + ).rstrip() + + "\n" ) if docs_url: expected_plain += ( diff --git a/tests/unit/test_messages_emitter.py b/tests/unit/test_messages_emitter.py index fa537eca..d12b52a1 100644 --- a/tests/unit/test_messages_emitter.py +++ b/tests/unit/test_messages_emitter.py @@ -16,6 +16,7 @@ """Tests that check the whole Emitter machinery.""" +import json import logging import sys from collections.abc import Callable @@ -27,7 +28,13 @@ import pytest_mock from craft_cli import messages from craft_cli.errors import CraftCommandError, CraftError -from craft_cli.messages import Emitter, EmitterMode, _Handler +from craft_cli.messages import ( + Emitter, + EmitterMode, + JSONFormatter, + TableFormatter, + _Handler, +) FAKE_LOG_NAME = "fakelog.log" @@ -263,17 +270,21 @@ def test_init_receiving_logfile(tmp_path, monkeypatch): def test_init_double_regular_mode(tmp_path, monkeypatch): """Double init in regular usage mode.""" # ensure it's not using the standard log filepath provider (that pollutes user dirs) - monkeypatch.setattr( - messages, "_get_log_filepath", lambda appname: tmp_path / FAKE_LOG_NAME - ) - + monkeypatch.setattr(messages, "_get_log_filepath", None) + fake_logpath = tmp_path / FAKE_LOG_NAME emitter = Emitter() - with patch("craft_cli.messages.Printer"): - emitter.init(EmitterMode.VERBOSE, "testappname", "greeting") - - with pytest.raises(RuntimeError, match="Double Emitter init detected!"): - emitter.init(EmitterMode.VERBOSE, "testappname", "greeting") + # with patch("craft_cli.messages.Printer"): + emitter.init( + EmitterMode.BRIEF, "testappname", "first greeting", log_filepath=fake_logpath + ) + with pytest.raises(RuntimeError, match="Double Emitter init detected"): + emitter.init( + EmitterMode.QUIET, + "newappname", + "second greeting", + log_filepath=fake_logpath, + ) def test_init_double_tests_mode(tmp_path, monkeypatch): @@ -1532,3 +1543,45 @@ def test_prompt_does_not_allow_empty_input( with pytest.raises(CraftError, match="input cannot be empty"): initiated_emitter.prompt("prompt") + + +def test_table_formatter_simple_dict_list(): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + fmt = TableFormatter() + out = fmt.format(data) + expected = """\ +a | b +--+-- +1 | 2 +3 | 4""" + + assert out.strip() == expected.strip() + + +def test_table_formatter_empty_list(): + fmt = TableFormatter() + out = fmt.format([]) + assert out == "[no data]" + + +def test_table_formatter_dict_input(): + fmt = TableFormatter() + out = fmt.format({"foo": "bar"}) + + # Define exact expected structure + # This depends on TableFormatter's specific style + + expected = """\ +Key | Value +----+------ +foo | bar""" + assert out.strip() == expected.strip() + + +def test_json_formatter_dict_list(): + data = [{"x": 1}, {"x": 2}] + fmt = JSONFormatter() + out = fmt.format(data) + parsed = json.loads(out) + assert isinstance(parsed, list) + assert parsed[0]["x"] == 1