Groups¶
Organize related commands under a shared namespace with first‑class Sayer integration and polished help output.
Sayer builds on top of Click's grouping model and adds:
- A convenience
group()
factory that returns aclick.Group
(defaulting to Sayer's rich UISayerGroup
); - Automatic binding of
@...command
to Sayer's enhanced@command
(type conversion, state injection, JSON molding, middleware hooks, async support); - A global registry for groups created via
sayer.group(...)
, useful for inspection and testing.
This page shows how to define groups, add commands, nest groups, plug them into your Sayer
application, and test them.
Quick Start¶
from sayer import Sayer, group
import click
app = Sayer(name="myapp", help="Demo app")
# 1) Create (or retrieve) a group
tools = group(name="tools", help="Utilities & helpers")
# 2) Attach commands to the group
@tools.command(name="echo", help="Echo a message")
def echo(msg: str):
click.echo(msg)
# 3) Register the group in your app
app.add_command(tools)
# 4) CLI
# $ myapp tools echo --msg "Hello"
When should I use sayer.group(...)
vs click.Group
?¶
You can use either:
sayer.group(...)
returns aclick.Group
(by default aSayerGroup
) and auto‑registers it in Sayer's group registry.- Any
click.Group
(including ones you create from Click directly) will still bind commands via Sayer's command decorator, because Sayer monkey‑patchesclick.Group.command
globally. You still need to add your group to yourSayer
app withapp.add_command(group)
.
Tip
Recommendation: Use sayer.group(...)
for consistent help formatting and easier discovery in tests/tooling.
Creating a Group¶
from sayer import group
admin = group(
name="admin",
help="Administrative commands"
# group_cls=<custom click.Group subclass> # optional
)
Parameters
name: str
— CLI name (admin
→ invoked asmyapp admin ...
).help: str | None
— Text shown in--help
.group_cls: type[click.Group] | None
— Custom group class. Defaults toSayerGroup
for rich help formatting.kwargs: type[Any]
- Any kwargs to be passed to theclick.Group
.
Behavior
- If a group with the same
name
already exists,group(name, ...)
returns the existing instance (idempotent by name). - All groups created via
sayer.group(...)
are recorded in Sayer'sGROUPS
registry (accessible viaget_groups()
).
Note
If you pass a custom group_cls
, Sayer still binds the .command
method so your commands get the full Sayer behavior.
Adding Commands to a Group¶
Attach commands to the group exactly like with Click:
import click
from sayer import command, group
tools = group("tools", help="Utilities")
@tools.command(name="greet", help="Greet someone")
def greet(name: str, upper: bool = False):
msg = f"hello {name}"
click.echo(msg.upper() if upper else msg)
What Sayer adds on top of Click:
- Automatic type conversion based on annotations (e.g.,
bool
,int
,Enum
,UUID
,date/datetime
,Path
). - JSON → object molding when using
JsonParam
or supported encoders (dataclasses
, Pydantic, msgspec, etc.). - State injection: annotate a parameter with a
State
subclass to receive the app's state instance. - Middleware hooks (
before
/after
) at the command level. - Async support:
async def
commands are supported (executed via AnyIO when invoked from CLI; programmatic calls are AnyIO‑aware).
Nesting Groups¶
You can nest groups in two ways:
Using Click's @parent.group(...)
decorator¶
Because Sayer patches click.Group.command
globally, commands inside nested groups still bind to Sayer:
from sayer import group
import click
root = group("root", help="Root commands")
@root.group(name="db", help="Database operations")
def db(): # this returns a click.Group
"""Subgroup created via Click."""
pass
@db.command(name="init")
def db_init(url: str):
click.echo(f"init db at {url}")
Creating subgroups via sayer.group()
and adding them¶
from sayer import group
root = group("root")
db = group("db", help="Database operations")
root.add_command(db) # db lives under root
@db.command
def init(url: str): ...
Which to choose?
Use whichever fits your style. If you want Sayer's registry to know about the subgroup, prefer the sayer.group()
approach for the subgroup as well.
Registering Groups in an App¶
Attach your groups to a Sayer
application:
from sayer import Sayer, group
app = Sayer(name="myapp", help="Demo application")
admin = group("admin", help="Administrative commands")
@app.command # optional: top-level commands directly on app
def version():
return "1.0.0"
app.add_command(admin) # now accessible under `myapp admin ...`
CLI
$ myapp --help
$ myapp admin --help
$ myapp admin users list
Rich Help Output¶
Groups created with sayer.group(...)
default to SayerGroup
, which formats help content with Sayer's rich console renderer.
If you need a custom presentation, pass your own group_cls
.
The command objects themselves also use Sayer's SayerCommand
(by default), which integrates a rich help renderer for command-level help.
Testing Groups¶
Sayer supports both Click's CliRunner
and a convenience SayerTestClient
.
Using CliRunner
¶
import click
from click.testing import CliRunner
from sayer import Sayer, group
def test_group_with_click_runner():
app = Sayer(name="test", help="A test application")
nested = group(name="nested", help="An example group")
@nested.command(name="display", help="A command within the group")
def display():
click.echo("hello from group")
app.add_command(nested)
runner = CliRunner()
result = runner.invoke(app.cli, ["nested", "display"])
assert result.exit_code == 0
assert result.output.strip() == "hello from group"
Using SayerTestClient
¶
from sayer.testing import SayerTestClient
def test_group_with_sayer_client():
app = Sayer(name="test", help="A test application")
nested = group(name="nested", help="An example group")
@nested.command(name="display", help="A command within the group")
def display():
click.echo("hello from group")
app.add_command(nested)
client = SayerTestClient(app)
result = client.invoke(["nested", "display"])
assert result.exit_code == 0
assert result.output.strip() == "hello from group"
Tip
SayerTestClient
mirrors Click's CliRunner
API but is pre-wired for Sayer's app entrypoint (app.cli
), so you don't have to pass it explicitly.
Group Patterns & How‑tos¶
Group‑level organization¶
Keep each group in its own module to keep large CLIs maintainable:
myapp/
__init__.py
app.py # creates Sayer app, attaches groups
groups/
__init__.py
admin.py # defines `admin` group + its commands
tools.py # defines `tools` group + its commands
groups/admin.py
:
# groups/admin.py
from sayer import group
import click
admin = group("admin", help="Administrative commands")
@admin.command
def users_list():
click.echo("alice\nbob")
app.py
:
from sayer import Sayer
from .groups.admin import admin
from .groups.tools import tools
app = Sayer(name="myapp", help="Demo app")
app.add_command(admin)
app.add_command(tools)
Sharing common options across group commands¶
Create a thin decorator that wraps Sayer's @command
to inject common options:
from functools import wraps
from sayer import command
from sayer.params import Option
def group_command_with_token(fn=None, /, *, required=True):
def decorator(f):
@command # Sayer's command (still binds to group via .command)
@wraps(f)
def inner(token: str = Option(required=required), **kwargs):
return f(token=token, **kwargs)
return inner
return decorator(fn) if fn else decorator
# usage:
@admin.command
@group_command_with_token
def rotate_keys(token: str):
...
Group discovery at runtime¶
If you created groups with sayer.group(...)
, you can inspect them:
from sayer.temp import get_groups # adjust import path to your engine
for name, grp in get_groups().items():
print(name, grp.commands.keys())
Common Pitfalls & Troubleshooting¶
"My command doesn't show under the group."
- Ensure you decorated with the group's
.command
, not@command
alone, or add the decorated command object to the group manually. - Ensure you added the group to the app via
app.add_command(group)
.
"I created a group with the same name and got the old one."
sayer.group(name, ...)
is idempotent by name. If you want a different instance, use a different name.
"Help output looks like vanilla Click."
- You may have supplied a custom
group_cls
that doesn't render with Sayer'sSayerGroup
. Use the default or implement similar help formatting in your custom class.
"Nested subgroup isn't in Sayer's registry."
- Subgroups defined via
@parent.group
are standard Click objects. They still bind commands through Sayer, but they won't appear inget_groups()
unless you created them withsayer.group(...)
.
Examples¶
Minimal grouped command¶
from sayer import Sayer, group
import click
app = Sayer(name="demo", help="Demo")
files = group("files", help="File operations")
@files.command
def ls(path: str = "."):
import os
click.echo("\n".join(sorted(os.listdir(path))))
app.add_command(files)
$ demo files ls --path src
Nested groups¶
root = group("root")
@root.group("db")
def db(): pass
@db.command
def migrate(step: int = 1):
click.echo(f"Migrating by {step}")
Custom Groups¶
Sayer supports marking groups as custom so that they appear under their own titled section in the help output. This is useful for visually separating framework-defined commands from project-specific or feature-specific groups.
Defining a Custom Group¶
Use the group()
factory with is_custom=True
and give it a custom_command_name
:
from sayer import Sayer, group
import click
app = Sayer(name="myapp", help="Demo app")
# A custom group with its own section title
reports = group(
name="reports",
help="Reporting commands",
is_custom=True,
custom_command_name="Reporting Suite"
)
@reports.command(name="daily", help="Generate daily report")
def daily():
click.echo("generated daily report")
app.add_command(reports)
This will internally tell Sayer to create a different section in your command-line instead of aggregating everything in one go.
CLI Help Output¶
$ myapp --help
The following (similar) should show like this:
Usage: myapp [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
version Show version
Reporting Suite:
reports Reporting commands
Multiple Custom Groups¶
If you register more than one custom group, each one appears under its own section,
grouped by the custom_command_name
you provide:
analytics = group(
name="analytics",
help="Analytics commands",
is_custom=True,
custom_command_name="Analytics Suite"
)
app.add_command(reports)
app.add_command(analytics)
Help output:
Reporting Suite:
reports Reporting commands
Analytics Suite:
analytics Analytics commands
Testing Custom Groups¶
You can test them with SayerTestClient
the same way as regular groups:
from sayer.testing import SayerTestClient
def test_custom_group_runs():
app = Sayer(name="test")
reports = group("reports", help="Reporting", is_custom=True, custom_command_name="Reports")
@reports.command(name="daily")
def daily():
click.echo("ok")
app.add_command(reports)
client = SayerTestClient(app)
result = client.invoke(["reports", "daily"])
assert result.exit_code == 0
assert result.output.strip() == "ok"
Best Practices¶
- One group per module for clarity; attach in your app's single assembly point.
- Prefer kebab‑case command names for readability (Sayer converts underscores in function names).
- Keep group names stable; changing them is a breaking CLI change.
- Use
sayer.group(...)
for subgroups you want to discover/test via the registry. - Write CLI tests with
CliRunner
and app‑level tests withSayerTestClient
.