Callbacks¶
This guide offers an exhaustive, thoroughly explained exploration of Sayer’s @app.callback system. It includes detailed explanations of the purpose, behavior, and usage of callbacks, along with richly annotated examples and best practices.
Overview¶
Sayer's @app.callback
decorator is used to register a root-level function that is executed when the CLI is invoked. It can:
- Run before commands to perform initialization tasks.
- Capture input parameters (like options and arguments) directly from the CLI.
- Control behavior using
invoke_without_command
, deciding if the callback runs before commands or only when no commands are specified.
This feature is essential for:
- Setting up global context.
- Validating or preprocessing input.
- Managing configuration options shared across commands.
Key Concepts¶
- @app.callback: Registers the callback function.
- invoke_without_command: Boolean flag controlling execution behavior. If
True
, the callback always runs; ifFalse
, it only runs when no subcommand is provided. -
Parameter Injection: Parameters are automatically injected based on CLI input, including:
-
Option
: Optional or required flags. Argument
: Positional arguments.JsonParam
: JSON-formatted input.- Environment variable defaults.
Fully Explained Examples¶
Basic Callback: Why and How¶
This is the simplest callback, used to perform setup when the CLI runs.
from sayer import Sayer
app = Sayer()
@app.callback()
def root():
print("Root callback executed!")
Why: This callback executes every time the CLI runs (unless overridden by invoke_without_command
). It's useful for initialization like setting up logging or loading configuration files.
Callback with Options and Arguments¶
from typing import Annotated
from sayer import Sayer, Option, Argument
app = Sayer()
@app.callback()
def root(name: Annotated[str, Option("--name", required=True)], path: Annotated[str, Argument()]):
print(f"Name: {name}, Path: {path}")
Why: This example demonstrates how CLI options and arguments map directly to parameters in the callback. --name
is a required flag, and path
is a positional argument.
How: Sayer parses the CLI input and injects them as arguments to root
.
Optional Parameters with Defaults¶
@app.callback()
def root(flag: Annotated[bool, Option("--flag", default=False)]):
print(f"Flag: {flag}")
Why: Optional parameters allow customization without requiring explicit CLI input. Defaults are provided if the user omits them.
How: The flag is False
by default, but --flag
sets it to True
.
JSON Input with JsonParam¶
from typing import Annotated
from sayer import Sayer, JsonParam
app = Sayer()
@app.callback()
def root(config: Annotated[dict, JsonParam()]):
print(f"Config: {config}")
Why: JsonParam enables complex data input in JSON format, making it easier to handle structured data.
How: Pass --config '{"key": "value"}'
to inject the parsed dictionary.
Using Environment Variables as Fallbacks¶
@app.callback()
def root(secret: Annotated[str, Option("--secret", envvar="MY_SECRET")]):
print(f"Secret: {secret}")
Why: This approach supports secure and configurable defaults without exposing sensitive data in commands.
How: Sayer checks for the environment variable MY_SECRET
if --secret
isn't provided.
Advanced Example: Multiple Parameters¶
@app.callback()
def root(user: Annotated[str, Option("--user")], count: Annotated[int, Option("--count")], verbose: Annotated[bool, Option("--verbose")]):
print(f"User: {user}, Count: {count}, Verbose: {verbose}")
Why: Real-world callbacks often need multiple parameters to control CLI behavior globally. How: Each parameter maps to a corresponding option, with type conversion and defaults handled automatically.
Controlling Callback Execution¶
app = Sayer(invoke_without_command=False) # Runs callback only when no subcommand
app = Sayer(invoke_without_command=True) # Runs callback before any subcommand
Why: This allows fine-grained control over when the callback executes.
How: Set invoke_without_command
during app initialization.
Asynchronous Callback¶
import anyio
@app.callback()
async def root():
await anyio.sleep(1)
print("Async callback executed")
Why: Async callbacks can handle I/O operations like network requests. How: Sayer detects the async function and awaits it during execution.
Complete and Commented Example¶
from sayer import Sayer, command
from typing import Annotated
from sayer.params import Option, JsonParam
app = Sayer(invoke_without_command=True)
# This callback sets up config and verbosity before commands
@app.callback()
def root(config: Annotated[dict, JsonParam()], verbose: Annotated[bool, Option("--verbose")], user: Annotated[str, Option("--user")]):
print(f"Config: {config}, Verbose: {verbose}, User: {user}")
@command()
def greet():
return "Hello!"
Execution Flow Diagram¶
graph TD
Start --> CheckInvoke
CheckInvoke -- True --> Callback
CheckInvoke -- False --> SkipCallback
Callback --> Command
SkipCallback --> Command
Best Practices with Context and Explanation¶
- ✅ Clearly define parameter types to enable correct parsing.
- ✅ Use
invoke_without_command
thoughtfully to control execution order. - ✅ Validate inputs in the callback to catch errors early.
- ✅ Use async callbacks for long-running or I/O-bound tasks.
- ✅ Leverage environment variables for secure configuration.
- ❌ Avoid stateful side-effects in callbacks to keep them testable.
- ❌ Don't assume callbacks always run; control with
invoke_without_command
.
Conclusion¶
Sayer’s callback system provides powerful tools for pre-command logic, configuration management, and input handling. With this complete guide, developers can confidently build sophisticated CLI applications with Sayer.