Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion craft_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -51,4 +52,6 @@
"HIDDEN",
"ProvideHelpException",
"emit",
"complete",
"Emitter",
]
3 changes: 2 additions & 1 deletion craft_cli/completion/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
2 changes: 2 additions & 0 deletions craft_cli/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 6 additions & 5 deletions craft_cli/helptexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:"]
Expand Down
115 changes: 114 additions & 1 deletion craft_cli/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,93 @@
import enum
import functools
import getpass
import json
import logging
import os
import pathlib
import select
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})
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example code is missing a closing bracket ] and has unbalanced parentheses. Should be formatter.format([{\"name\":\"Alice\",\"age\":30}]).

Suggested change
>>> formatter.format([{"name":"Alice","age":30})
>>> formatter.format([{"name":"Alice","age":30}])

Copilot uses AI. Check for mistakes.
'{"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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]:
Expand Down
3 changes: 2 additions & 1 deletion craft_cli/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
8 changes: 4 additions & 4 deletions craft_cli/pytest_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__))
Expand Down
13 changes: 13 additions & 0 deletions examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions tests/integration/test_messages_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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}"
Expand Down
7 changes: 5 additions & 2 deletions tests/unit/test_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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 += (
Expand Down
Loading
Loading