Skip to content

Output Formatting and Responses

pyclif provides a built-in system to standardize and format CLI output. It supports JSON, YAML, interactive tables, Rich text, plain text, and raw values — all driven by BaseContext, the Response dataclass, and BaseRenderer.

Core Concepts

The output system is built around four parts:

  1. BaseContext — Combines OutputFormatMixin and RichHelpersMixin. All commands receive it as their Click context and use it to dispatch output.
  2. Response — A standardized dataclass for structuring command results. Carries success, message, data, error_code, and an optional renderer.
  3. BaseRenderer — Controls how a Response is displayed for every output format. Subclass it and set class attributes; the framework calls the right method based on --output-format.
  4. MixinsOutputFormatMixin handles format dispatch; RichHelpersMixin provides Rich console helpers.

Automatic Output Format Option

@app_group and @group automatically add --output-format / -o to the CLI. It is propagated globally to all subcommands. The selected format is stored in ctx.meta['pyclif.output_format'] and read automatically by BaseContext — you do not need to declare an output_format parameter in your functions.

Automatic Response Dispatch

pyclif provides three complementary mechanisms to print a Response automatically when a command returns one, without manually calling ctx.print_result_based_on_format(response).

Level 1 — App-wide: @app_group(handle_response=True)

from pyclif import app_group, option, Response
import click


@app_group(handle_response=True)
@click.pass_context
def app(ctx):
    """My CLI."""
    pass


@app.command()
@option("--name", default="world")
@click.pass_context
def hello(ctx, name):
    """Greet someone."""
    return Response(success=True, message=f"Hello {name}!", data={"name": name})

Per-command override:

@app.command(handle_response=False)
@click.pass_context
def raw_cmd(ctx):
    """This command manages its own output."""
    click.echo("custom output")

Level 2 — Standalone command: @command(handle_response=True)

from pyclif import command, option, Response
import click


@command(handle_response=True)
@option("--name", default="world")
@click.pass_context
def hello(ctx, name):
    """Greet someone."""
    return Response(success=True, message=f"Hello {name}!", data={"name": name})


app.add_command(hello)

Level 3 — Explicit decorator: @returns_response

from pyclif import returns_response, Response
import click


@app.command()
@returns_response
@click.pass_context
def hello(ctx):
    """Greet someone."""
    return Response(success=True, message="Hello!", data={})

Output Format Resolution

All three mechanisms read the format from ctx.meta['pyclif.output_format'], set by --output-format. Explicit values (command-line or environment variable) always take precedence.

myapp --output-format json hello --name Alice   # JSON output
myapp -o yaml hello                             # YAML output

Filtering Output — @output_filter_option()

Add @output_filter_option() to expose --output-filter / -f. Works with raw, json, and yaml. Accepts a dotted key path — single keys and nested traversal both work.

from pyclif import app_group, output_filter_option, Response
import click


@app_group(handle_response=True)
@click.pass_context
def app(ctx):
    """My CLI."""
    pass


@app.command()
@output_filter_option()
@click.pass_context
def list_articles(ctx):
    """List articles."""
    return Response(
        success=True,
        message="2 articles retrieved",
        data={
            "results": [
                {"id": 1, "title": "Hello pyclif", "author": "Alice"},
                {"id": 2, "title": "Advanced usage", "author": "Bob"},
            ]
        },
    )

Resolution order: data["data"] first, then top-level response fields (success, message, error_code). Numeric path segments are treated as list indices.

The output format determines how the extracted value is printed:

# raw: value as-is — best for shell scripting and piping
myapp -o raw list-articles -f results           # [{"id": 1, ...}, {"id": 2, ...}]
myapp -o raw list-articles -f results.0.title   # Hello pyclif
myapp -o raw list-articles -f message           # 2 articles retrieved

# json: value re-serialized as valid JSON
myapp -o json list-articles -f results.0        # {"id": 1, "title": "Hello pyclif", ...}
myapp -o json list-articles -f results.0.id     # 1

# yaml: value re-serialized as valid YAML
myapp -o yaml list-articles -f results.0.title  # 'Hello pyclif'\n

The Response Object

Response carries the result of a command: success state, a message, optional structured data, and a renderer that controls the output for every format.

from pyclif import Response

# Minimal response — uses BaseRenderer defaults for all formats
response = Response(
    success=True,
    message="Operation completed successfully",
    data={"id": 1, "status": "active"},
)

Attach a BaseRenderer subclass to control table columns, JSON fields, and Rich display:

from pyclif import Response, BaseRenderer


class ArticleRenderer(BaseRenderer):
    fields = ["id", "title", "author"]   # included in JSON / YAML / raw
    columns = ["id", "title", "author"]  # shown in the table
    rich_title = "Articles"


response = Response(
    success=True,
    message="Articles retrieved",
    data={"results": [...]},
    renderer=ArticleRenderer(),
)

In practice, renderers are attached by BaseInterface.respond() automatically — you rarely need to set renderer= by hand. See Error Handling and Interfaces for the full pattern.

Supported Formats

Format Renderer method called Description Filterable
table renderer.table() Rich table — default format no
rich renderer.rich() / Live hooks Rich panels / live display no
text renderer.text() Plain text — response.message only no
json renderer.serialize() Syntax-highlighted JSON — always valid JSON yes
yaml renderer.serialize() Syntax-highlighted YAML — always valid YAML yes
raw renderer.raw() Compact JSON, no highlighting — machine-readable yes

table is the default format.

--output-filter accepts a single key (checks data sub-dict first, then top-level fields). Its behaviour differs by format:

  • raw --output-filter key — prints the raw extracted value as-is. running, not "running". Use this for shell scripts where the value feeds another command.
  • json --output-filter key — re-serializes the extracted value as valid JSON. Output is always valid JSON: "running", 42, or {"id": 1}.
  • yaml --output-filter key — re-serializes the extracted value as valid YAML. Output is always valid YAML.
myapp status --output-format raw -f status    # running          (raw string)
myapp status --output-format json -f status   # "running"        (valid JSON string)
myapp status --output-format yaml -f status   # 'running'\n      (valid YAML)
myapp status --output-format json -f data     # {"status": "running", ...}  (valid JSON object)

Using BaseRenderer

BaseRenderer is the single source of truth for all output formats. Subclass it and set class attributes — the framework calls the right method automatically based on --output-format.

Declarative renderer

from pyclif import BaseRenderer


class ArticleRenderer(BaseRenderer):
    # Fields included in JSON / YAML / raw serialization
    fields = ["id", "title", "author", "published"]

    # Columns shown in the table (defaults to fields when empty)
    columns = ["id", "title", "author"]

    # Panel title used by the default rich() display
    rich_title = "Articles"

    # Messages used by get_success_message() / get_failure_message()
    success_message = "Articles retrieved."
    failure_message = "Failed to retrieve articles."

serialize() builds a dict from the fields list by reading each result's data payload and OperationResult attributes. table() uses columns to build a CliTable. Both are auto-called by the framework — you only need to override them for genuinely custom behavior.

Overriding individual methods

Override only what the declarative attributes cannot express:

from rich.console import Console
from rich.table import Table as RichTable
from pyclif import BaseRenderer, Response


class UserRenderer(BaseRenderer):
    fields = ["username", "email", "active"]
    columns = ["username", "email", "active"]

    def rich(self, response: Response, console: Console) -> None:
        """Colour active / inactive rows."""
        table = RichTable(title="Users")
        table.add_column("Username")
        table.add_column("Email")
        table.add_column("Active")
        for result in response.data.get("results", []):
            d = result.data or {}
            color = "green" if d.get("active") else "red"
            table.add_row(
                d.get("username", ""),
                d.get("email", ""),
                f"[{color}]{'yes' if d.get('active') else 'no'}[/{color}]",
            )
        console.print(table)

Streaming support

For long-running operations that yield results one by one, override the Live context hooks. The framework uses a rich.live.Live context during streaming; rich_on_item() is called after each OperationResult arrives, and rich_summary() is called once the generator is exhausted.

from rich.progress import Progress, SpinnerColumn, TextColumn
from pyclif import BaseRenderer, Response, OperationResult


class DeployRenderer(BaseRenderer):
    fields = ["item", "success"]
    columns = ["item", "success"]
    success_message = "Deployment complete."
    failure_message = "Deployment failed."

    def rich_setup(self):
        """Create the Progress bar shown inside the Live context."""
        self._progress = Progress(SpinnerColumn(), TextColumn("{task.description}"))
        self._task = self._progress.add_task("Deploying…")
        return self._progress

    def rich_on_item(self, result: OperationResult, all_so_far: list) -> None:
        """Update the progress bar after each result."""
        icon = "✓" if result.success else "✗"
        self._progress.update(self._task, description=f"{icon} {result.item}")

    def rich_summary(self, response: Response, console) -> None:
        """Print a summary panel after the Live context closes."""
        status = "Success" if response.success else "Failed"
        console.print(f"[bold]{status}[/bold]: {response.message}")

Use Response.from_stream() in the command to wire a generator to this renderer:

from pyclif import Response, command, pass_context


@command()
@pass_context
def deploy(ctx) -> Response:
    """Deploy the application."""
    return Response.from_stream(
        MyInterface(ctx).deploy(),
        renderer=DeployRenderer(),
    )

Unhandled Exceptions

Any exception that escapes a command is caught by the framework before output is produced. You do not need try/except in your commands for expected failures — use OperationResult.error() in the interface layer instead (see Error Handling).

For truly unexpected exceptions (programming errors, broken invariants), the framework:

  • Prints a formatted Response(success=False, message=str(e)) to stdout, respecting --output-format (JSON, table, rich, raw…)
  • Logs the traceback to stderr at the configured log level

The log level is configured on @app_group:

@app_group(
    handle_response=True,
    unhandled_exception_log_level="error",  # default — traceback always visible
)
def main():
    """My CLI."""

# Use "debug" for clean end-user output; full traces only with --log-level debug
@app_group(
    handle_response=True,
    unhandled_exception_log_level="debug",
)
def main():
    """My CLI."""

BaseContext and Mixins

Commands can print a Response manually by calling ctx.print_result_based_on_format():

import click
from pyclif import app_group, BaseContext, Response


@app_group()
@click.pass_context
def cli(ctx):
    """CLI with output management."""
    pass


@cli.command()
@click.pass_context
def get_users(ctx):
    """List users."""
    response = Response(
        success=True,
        message="Users retrieved",
        data={"results": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]},
    )
    ctx.obj.print_result_based_on_format(response)

In practice you rarely call this directly — handle_response=True or @returns_response handle it automatically.

Rich Helpers

RichHelpersMixin (included in BaseContext) gives easy access to Rich console interactions:

@cli.command()
@click.pass_context
def interactive_command(ctx):
    """Interactive command."""
    ctx.obj.rich_panel("Welcome to interactive mode!", title="Hello")

    name = ctx.obj.ask_user("What is your name?", default="User")

    with ctx.obj.show_status("Processing..."):
        import time
        time.sleep(2)

Table Utilities

pyclif provides built-in table components used internally by BaseRenderer.table(). You can also construct them directly for custom renderers:

  • CliTable: Pre-styled Rich table for structured data.
  • CliTableColumn: Column descriptor with header, style, and justify options.
  • ExceptionTable: Specialized table for displaying formatted exception details.
from pyclif import CliTable, CliTableColumn

fields = {
    "id": CliTableColumn(header="ID", justify="right"),
    "name": CliTableColumn(header="Name"),
    "active": CliTableColumn(header="Active"),
}

table = CliTable(fields=fields, rows=[
    {"id": 1, "name": "Alice", "active": True},
    {"id": 2, "name": "Bob", "active": False},
])

See Also