Using asyoulikeit in your CLI
asyoulikeit is a small library you apply to a Click-based
command-line tool to give your handlers a single source of truth for
structured output. Instead of printing in one hard-coded format, your
handler returns a Reports value and the library
handles rendering it as JSON, TSV, or a rich human-readable display,
according to whatever the user selected with the --as flag.
This page walks through the typical shape of an integration. See Command-line options for what your users will see on the command line, and API reference for the full API reference.
Installation
pip install asyoulikeit
Or with uv:
uv add asyoulikeit
The minimal example
A Click command decorated with report_output()
returns a Reports value. That’s essentially all
there is to it.
import click
from asyoulikeit import Report, Reports, TableContent, report_output
@click.command()
@report_output(reports={"users": "The users of the system."})
def list_users():
"""List the users of the system."""
data = (
TableContent(title="Users")
.add_column("name", "Name")
.add_column("role", "Role")
.add_row(name="Alice", role="admin")
.add_row(name="Bob", role="user")
)
return Reports(users=Report(data=data))
if __name__ == "__main__":
list_users()
Run list_users attached to a terminal and it renders with the
display formatter — borders, a title, legible rows. Pipe the output
into another program and it renders as TSV (# Name\tRole header
then tab-separated values). Pass --as json and it emits structured
JSON. The handler is identical across all three modes; the choice of
format is a runtime concern the decorator handles.
The data model
Three classes carry all the state.
TableContent is a schema-validated table builder.
Add columns first, then rows. Every row must supply exactly the columns
that were declared — missing keys and unexpected keys both raise
ValueError, which catches typos early. TableContent also
holds optional title and description metadata that some
formatters display.
TreeContent is the hierarchical sibling — a
forest of nodes sharing a single column schema. See Tree content
below.
Both TableContent and TreeContent are implementations of
ReportContent. Future shapes (heterogeneous
trees, lists, description lists, …) will slot in as further subclasses
without changing the rest of the API.
Report is a frozen dataclass wrapping one piece
of ReportContent plus formatting preferences (detail_level,
header). These preferences are suggestions — the user can
override them from the command line.
Reports is a validated mapping of
name → Report. A command can return a single named report or
several; both are expressed by the same type. The names become keys in
JSON output and selectors for the user’s --report flag.
Importance and detail level
Not every column is equally important. Some carry core identifying
information (“name”, “id”, “status”); others are supplementary
(“notes”, “last modified”, “description”). Tag this with
Importance on columns, and if needed on
individual rows:
from asyoulikeit import Importance
data = (
TableContent()
.add_column("name", "Name") # ESSENTIAL (the default)
.add_column("notes", "Notes", importance=Importance.DETAIL)
.add_row(name="Alice", notes="Joined 2024-03") # essential row (the default)
.add_row(name="Bob", notes="Deprecated",
_importance=Importance.DETAIL)
)
When the user passes --essential, DETAIL columns and DETAIL rows
drop out. When they pass --detailed, everything stays. The default
is AUTO, which each formatter resolves to whatever makes sense for
itself: TSV picks ESSENTIAL (pipe-friendly), JSON and display pick
DETAILED (self-describing or human-facing).
Multiple reports
A single command can return more than one report. The command’s
default selection policy — set by the default_reports= argument
to report_output() — decides what happens when the
user doesn’t pass --report:
return Reports(
users=Report(data=users_data),
roles=Report(data=roles_data),
)
There are three shapes for default_reports. Pick the one that
fits the command’s purpose:
Show everything by default —
default_reports=ALL_REPORTS(also the default when the argument is omitted). Typical for reporting commands where every report is useful information and the user’s opt-out is explicit. Users narrow the set with--report <name>(repeatable), drop everything with--no-reports, or leave well enough alone to see it all.Show nothing by default —
default_reports=None. Typical for action commands whose reports are useful interactively but noisy when the command is orchestrated. Users opt into specific reports with--report <name>, or grab the full set with--all-reports.Show a specific subset by default —
default_reports=["X", "Y"]. For commands with a natural “most commonly wanted” subset. Users narrow further with--report, suppress with--no-reports, or override to everything with--all-reports.
The names in default_reports must appear in the reports=
declaration (or be admitted by an Ellipsis slot); a typo raises
ReportDeclarationError at decoration time. Action
commands that never return a Reports (always
None) declare reports={}.
All-reports-by-default: the reporting-command shape
@click.command()
@report_output(reports={
"users": "The system's users.",
"groups": "The system's groups.",
})
def status():
return Reports(
users=Report(data=...),
groups=Report(data=...),
)
$ mytool status # users + groups
$ mytool status --report users # just users
$ mytool status --no-reports # nothing (rare; mostly for uniformity)
No-reports-by-default: the action-command shape
@click.command()
@click.argument("path", type=click.Path(exists=True))
@report_output(
reports={
"summary": "Totals of what was imported.",
"rejected": "Rows that couldn't be imported.",
},
default_reports=None,
)
def import_data(path):
do_the_import(path) # side effect
return Reports(
summary=Report(data=_summary_data()),
rejected=Report(data=_rejected_data()),
)
$ mytool import-data file.csv # silent — side effect only
$ mytool import-data file.csv --report summary # just the totals
$ mytool import-data file.csv --all-reports # everything
The handler runs in all three cases — only the rendering differs.
Drift detection and the Reports return-type
check still fire whenever the handler returns something, so a buggy
return isn’t hidden by --no-reports.
CLI selection flags are mutually exclusive
--report, --no-reports, and --all-reports are three
different ways to override the command’s default selection policy.
Any combination of them is incoherent (you can’t ask for “only users”
and “all of them” and “none of them”), so the decorator rejects
the combination with a click.UsageError at the wrapper boundary,
before the handler runs.
Tree content
When your data is hierarchical — a filesystem subtree, an organisation
chart, a syntax tree — return TreeContent from
your handler instead of TableContent. The API is deliberately
parallel: you declare a column schema just like for a table, but every
node in the tree carries values matching that schema (homogeneous
columns across all nodes) and nodes form a parent/child hierarchy
instead of a flat list.
from asyoulikeit import (
Importance, Report, Reports, TreeContent, report_output,
)
@click.command()
@report_output(reports={"fs": "A filesystem subtree."})
def list_usr():
tree = (
TreeContent(title="/usr")
.add_column("name", "Name", header=True)
.add_column("size", "Size")
.add_column("kind", "Kind", importance=Importance.DETAIL)
)
usr = tree.add_root(name="/usr", size=0, kind="dir")
bin_dir = usr.add_child(name="bin", size=4096, kind="dir")
bin_dir.add_child(name="ls", size=150_296, kind="exec")
bin_dir.add_child(name="cat", size=52_024, kind="exec")
return Reports(fs=Report(data=tree))
Key differences from TableContent:
Exactly one column must be marked
header=True. Trees always need a label for each node; that label is what the display formatter draws alongside the ASCII-art connectors.Repeatable roots.
add_rootmay be called once for a single tree, or many times to build a forest — useful when listing several independent top-level items.Return the child, not the parent.
Node.add_child(...)hands back the newly-added child, so you descend by keeping a reference to each level you need. Siblings come from callingadd_childon the shared parent again.Per-node
Importancetagging prunes whole subtrees. A node markedDETAILand all its descendants drop out under--essential, because you cannot show a child while hiding its parent.
The three built-in formatters each render trees in the way best suited to their audience:
displaylays ASCII-art connectors (├──,└──,│) into the first column of a Rich table, with the other columns lining up as usual. When the tree has only the header column and it’s the only report being shown, the bordered-table chrome is dropped and the tree renders as bare ASCII — the connectors already convey the hierarchy, and the box adds no information. Multi-column trees and multi-report outputs keep the chrome (there’s no other way to line up siblings or distinguish reports from one another).tsvflattens the tree in pre-order into fixed-width rows. Column one always carries the node’s own header-column value (the leaf), soawk '{print $1}'yields the node name regardless of depth. Columns two onwards carry the full root-to-node path, one component per cell, left-packed and padded on the right with empty cells so every row has the same number of path cells. The leaf therefore also appears as the last non-empty path cell — intentional duplication that keeps the row human-readable as a full path while giving scripts a fixed leaf column. Path columns are labelledPath1,Path2, … in 1-based style so thatPathKis “the node at depth K” (root = depth 1); the largestPathNin the header row reveals the tree’s maximum visible depth. Non-header data columns follow the path cells with their usual labels.jsonemits a nested{"values": {...}, "children": [...]}structure under a"roots"list, withmetadata.kind = "tree"distinguishing it from table-shaped output.
Scalar content
Some commands produce just a single value — a name, a number, an
address, a status string. Wrapping that in a transposed 1×1 table
reads as ceremony around a single cell, and the JSON output ends up
with "rows": [{"load": "00001900"}] where the consumer really
just wants "value": "00001900". Use ScalarContent
instead:
from asyoulikeit import (
Report, Reports, ScalarContent, report_output,
)
@click.command()
@report_output(reports={"title": "The disc image's title."})
@click.argument("image")
def disc_title(image):
return Reports(title=Report(data=ScalarContent(
value=_read_title(image),
title="Disc title",
)))
The three formatters render scalars differently, following what each format’s consumer actually wants:
displayemitsTitle: valueon a single line when a title is set (valuealone otherwise), and the description — if any — dim/italic on the line below.tsvemits just the raw value by default. The dominant pipe use case (disc title image | pbcopy) wants the answer, not a commented label.jsonemits the usualmetadata/valueshape, withmetadata.kind = "scalar". Consumers can read.reports.title.valueviajqand get exactly what they want.
Header resolution for scalars (and every other content kind) is
three-tier: the explicit CLI --header / --no-header wins if
given; otherwise Report.header is consulted; otherwise the
formatter picks a per-content default. TSV’s default for scalars is
header=False (bare value); display’s is True (labelled, if a
title is set). To force the labelled TSV form # Title\nvalue,
pass header=True on the Report, or
--header on the CLI.
Transposition
Sometimes a table reads better flipped: columns of values paired with
a label column on the left rather than a header row on top. Set
present_transposed=True on TableContent and the display and
tsv formatters will rotate the data at render time. JSON is
unaffected — it’s a structural format, not a visual one, and surfaces
the intent via a metadata.present_transposed flag instead.
Styling
The display formatter supports per-cell styling (foreground colour,
background colour, bold, italic). Attach a second TableContent to
your Report via the styles argument, with the same shape as the
data table but cell values as dictionaries keyed by
STYLE_FOREGROUND_COLOR,
STYLE_BOLD, and so on. TSV and JSON ignore styles;
display applies them via Rich.
Custom formatters
The built-in display, tsv, and json formatters are
stevedore-loaded entry points under the asyoulikeit.formatter
namespace. Third-party packages (or your own consumer project) can
register additional formatters the same way:
# in the consumer's pyproject.toml
[project.entry-points."asyoulikeit.formatter"]
xml = "mypackage.xml_formatter:Formatter"
Subclass Formatter and implement the single
format(reports) -> str method. After pip install / uv sync
of the registering package, the new format name appears automatically
in the --as choices of every command decorated with
@report_output.
Declaring reports
Every @report_output command must declare the report names it
produces via a reports= mapping of name → description.
Omitting the declaration raises
ReportDeclarationError at decoration time — there
is no back-compat fallback. This keeps the declaration honest and
lets the library validate --report values, generate help, and
run drift detection without guesswork.
from asyoulikeit import report_output, Report, Reports, TableContent
@click.command()
@report_output(reports={
"summary": "Site-wide totals",
"courses": "Per-course breakdown",
"structure": "Module and section hierarchy",
})
def video_audit():
"""Audit the video library."""
return Reports(
summary=Report(data=...),
courses=Report(data=...),
structure=Report(data=...),
)
The declaration drives four behaviours:
Decoration-time validation. Non-identifier keys, non-string descriptions, and
default_reportsentries that refer to an undeclared name all raiseReportDeclarationErrorat import time, not at first invocation.Auto-documented
--help. AProduces reports:block is appended to the command’s help text, listing each declared name with its description.Parse-time validation on
--report. When the declaration is fully static (noEllipsisslot),--reportbecomes aclick.Choice— typos fail at parse with Click’s list of valid values.Runtime drift detection. If the handler returns a
Reportswhose keys don’t match the declaration (and noEllipsisslot was declared, see below),ReportDeclarationErroris raised — catching the refactor-renamed-a-report-silently bug.
For action commands that always return None (no reports ever),
declare reports={}: the declaration is empty, the Produces
reports: help block is suppressed, and any accidental future return
of a named report will trip drift detection.
Dynamic report names
Some commands produce one report per input — a map-style command whose
report names are known only at runtime. Use Ellipsis (...) as a
key to declare “this command also produces dynamically-named reports”:
@report_output(reports={
...: "One report per input YAML file; name is the file stem.",
})
def validate(files):
return Reports({f.stem: Report(data=...) for f in files})
Mixed declarations work too — a fixed set of known names plus a dynamic tail:
@report_output(reports={
"overall": "Global summary across all files",
Ellipsis: "One report per validated file; name is the file stem.",
})
def validate_all(files):
...
Dynamic commands skip the click.Choice step on --report (the
valid set isn’t knowable at decoration time), but the rest of the
benefits — validation, help, drift detection on the static subset —
still apply.
Hyphens on the command line
Python identifiers can’t contain hyphens, but CLI convention strongly
prefers them. asyoulikeit normalises --report values
automatically: --report monthly-sales resolves to the declared
monthly_sales report.
Discovering reports: list-reports and describe-report
Mirroring formatter introspection, asyoulikeit ships two ready-made command factories that the host CLI drops into its group:
import click
from asyoulikeit import (
list_reports_command, describe_report_command,
)
@click.group()
def cli(): ...
cli.add_command(list_reports_command(), name="list-reports")
cli.add_command(describe_report_command(), name="describe-report")
Both commands walk click.get_current_context().find_root() at
invoke time, so they find every command the host has registered
without per-command wiring. They are themselves @report_output
commands, so they inherit --as / --report / --header / --detailed
and render in any format:
$ mytool list-reports # full listing
$ mytool list-reports video-audit # one command
$ mytool describe-report video-audit courses
$ mytool describe-report video-audit '<dynamic>' # describe the Ellipsis slot
A host group may also contain plain @click.command commands that
aren’t asyoulikeit-aware; those surface in list-reports with a
single <not a report-output command> marker child, so authors can
see at a glance which commands sit outside the library. Action
commands declared with reports={} show <no reports>.
Formatter introspection
Any CLI that lets users pass --as <format> eventually wants to
answer “which values are legal, and what does each one do?” asyoulikeit
exposes a primitive plus two command factories for exactly that.
The primitive describe_formatter() reads the
formatter class’s docstring (cleaned via inspect.cleandoc(),
without instantiating the class):
from asyoulikeit import describe_formatter, formatter_names
for name in formatter_names():
print(name, "—", describe_formatter(name, single_line=True))
Built on that primitive are two Click command factories,
list_formatters_command() and
describe_formatter_command(). Each returns a fully
assembled click.Command decorated with @report_output —
so the meta-commands render in every format asyoulikeit already
supports. The host CLI picks the displayed command name on
add_command:
import click
from asyoulikeit import (
list_formatters_command, describe_formatter_command,
)
@click.group()
def cli():
pass
cli.add_command(list_formatters_command(), name="list-formatters")
cli.add_command(describe_formatter_command(), name="describe-formatter")
The list command returns a
TableContent of Name + one-line
Description rows; the describe command takes a NAME argument
(restricted via click.Choice to the currently-registered
formatters) and returns a ScalarContent whose
value is the full cleaned docstring and whose title is the
formatter name. Both inherit --as / --report / --header /
--detailed, so:
cli list-formatters --as json | jqgives you a machine-readable catalogue;cli describe-formatter tsv(TTY) showstsv: <description>;cli describe-formatter tsv --as tsvpipes the bare description (headerless — the TSV default forScalarContent).
The factories are functions (not module-level command instances)
deliberately: formatter_names() and the click.Choice over it
are evaluated at factory-call time, so any formatter registered via
entry point before the host CLI builds its group is picked up without
a restart.
Testing commands that use @report_output
Click’s click.testing.CliRunner captures stdout into a string
buffer. That buffer’s isatty() returns False, so the smart
default for --as lands on tsv. If the test has assertions
about TSV output that’s fine — you don’t need to do anything. But if
your assertions depend on display-mode layout (Rich borders,
titles, colour, wrapped text) or json structure, the captured
buffer’s default doesn’t exercise that path.
Two ways to get the format you want in tests:
Pin the format on each invocation. Pass --as <format>
explicitly to the command under test:
def test_my_command_renders_the_title(runner):
result = runner.invoke(cli, ["my-command", "--as", "display", "input"])
assert "My Title" in result.output # only visible in display mode
This is the clearest option when a handful of tests care about format; the intent is visible at the call site and each test remains self- contained.
Set the environment variable. The decorator consults
ASYOULIKEIT_FORMAT before falling back to the TTY check, so a
session-wide, conftest-wide, or even test-wide override is one
line away:
# conftest.py — force display mode for every test in the suite
import os
os.environ["ASYOULIKEIT_FORMAT"] = "display"
# or per-test with pytest's monkeypatch fixture
def test_display_layout(runner, monkeypatch):
monkeypatch.setenv("ASYOULIKEIT_FORMAT", "display")
result = runner.invoke(cli, ["my-command", "input"])
assert "My Title" in result.output
Precedence: an explicit --as on the command line still wins over
ASYOULIKEIT_FORMAT, so individual tests can opt out of a
suite-wide default without interference. An invalid value in the env
var fails fast with a clear error rather than silently falling back,
so typos don’t quietly change which rendering path a test exercises.