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:
BaseContext— CombinesOutputFormatMixinandRichHelpersMixin. All commands receive it as their Click context and use it to dispatch output.Response— A standardized dataclass for structuring command results. Carriessuccess,message,data,error_code, and an optionalrenderer.BaseRenderer— Controls how aResponseis displayed for every output format. Subclass it and set class attributes; the framework calls the right method based on--output-format.- Mixins —
OutputFormatMixinhandles format dispatch;RichHelpersMixinprovides 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.
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},
])