CLI Context: How Global Options Flow to Commands#
What is this guide?#
This guide explains how Typer’s Context object lets you:
Define options globally (once in
main.py)Access them in any command without redefining them
Allow commands to override global options when needed
Perfect for beginners learning how Haniwers v1 CLI is organized!
The Big Picture#
Think of it like this: Global options are shared with all commands through a messenger (Context).
┌─────────────────────────────────────┐
│ Global Callback in main.py │
│ (runs first, before any command) │
│ │
│ - Load .env.haniwers │
│ - Parse global --config option │
│ - Parse global --verbose flag │
│ - Save everything in ctx.obj │
└────────────┬────────────────────────┘
│ Context object carries the data
│ (like a messenger bag)
▼
┌─────────────────────────────────────┐
│ Command (config show, daq, etc) │
│ (runs second) │
│ │
│ - Receive ctx from main │
│ - Access ctx.obj["config_path"] │
│ - Can override with own --config │
└─────────────────────────────────────┘
How It Works: Step by Step#
Step 1: Define Global Options in main.py#
@app.callback()
def main(
ctx: Context, # ← This is the messenger
env_path: Path = typer.Option(..., "--env", ...),
config_path: Path = typer.Option(..., "--config", "-c", ...),
verbose: bool = typer.Option(False, "--verbose", "-v", ...),
) -> None:
"""Global callback runs FIRST, before any command."""
# Do setup
load_dotenv(dotenv_path=env_path)
configure_logging(verbose=verbose)
# Store in ctx.obj (the messenger bag)
ctx.obj = {
"env_path": env_path,
"config_path": config_path,
"verbose": verbose
}
Key insight: ctx.obj is just a dictionary that holds any data you want to share.
Step 2: Receive Global Options in Commands#
@app.command(name="show")
def show_config(
ctx: typer.Context, # ← Receive the messenger
config: Optional[Path] = typer.Option(None, "--config", "-c", ...),
) -> None:
"""Show configuration file contents."""
# Access global options from ctx.obj
global_config_path = ctx.obj.get("config_path")
# Use fallback pattern: command-level > global > default
config_path = config or global_config_path or "hnw.toml"
print(f"Using config: {config_path}")
Key insight: ctx.obj is available to ANY command registered with the app.
The Fallback Pattern#
This is the most important concept. Commands can be called three ways:
Pattern: Command-level option only#
$ haniwers-v1 config show --config my.toml
# ↓
# config = Path("my.toml")
# global_config_path = None (or "hnw.toml")
# Result: uses "my.toml" (command-level wins)
Pattern: Global option only#
$ haniwers-v1 --config my.toml config show
# ↓
# config = None (command didn't specify it)
# global_config_path = Path("my.toml")
# Result: uses "my.toml" (global option applies)
Pattern: No options (use defaults)#
$ haniwers-v1 config show
# ↓
# config = None (command didn't specify it)
# global_config_path = None (global didn't specify it)
# Result: uses "hnw.toml" (hardcoded default)
Pattern: Both specified (command-level wins)#
$ haniwers-v1 --config global.toml config show --config local.toml
# ↓
# config = Path("local.toml") (command-level specified)
# global_config_path = Path("global.toml") (global specified)
# Result: uses "local.toml" (command-level takes precedence)
Implementation Example#
Here’s the actual code pattern used in Haniwers:
Bad Way (Doesn’t work properly)#
# ✗ This DOESN'T inherit global options
@app.command(name="show")
def show_config(
config: str = typer.Option("hnw.toml", "--config", "-c", ...),
) -> None:
# Problem: Default "hnw.toml" is always truthy
# So command never sees global option!
pass
Good Way (Inherits properly)#
# ✓ This DOES inherit global options
@app.command(name="show")
def show_config(
ctx: typer.Context, # ← Add Context parameter
config: Optional[Path] = typer.Option(None, "--config", "-c", ...),
) -> None:
"""Display and validate your configuration file."""
# Fallback chain: command > global > default
config_path = config or ctx.obj.get("config_path") or "hnw.toml"
# Now use config_path
print(f"Using: {config_path}")
Key differences:
Add
ctx: typer.Contextas first parameterChange default from
"hnw.toml"toNone(so we can tell if user specified it)Use fallback chain with
oroperator
Why None is Important#
Understanding this is crucial for beginners:
# When user doesn't specify an option, Typer sets it to:
# - The default value (if you provided one)
# - None (if no default)
# Example 1: Default is "hnw.toml"
config: str = typer.Option("hnw.toml", ...)
# If user doesn't specify --config:
# config = "hnw.toml" (always truthy!)
# Never checks ctx.obj.get("config_path")
# Example 2: Default is None
config: Optional[str] = typer.Option(None, ...)
# If user doesn't specify --config:
# config = None (falsy - condition skips it)
# Falls through to: ctx.obj.get("config_path") or default
Remember: Use None as default to enable proper fallback!
Checking if Value Came from User#
Sometimes you need to know: “Did the user specify this, or is it a default?”
def my_command(
ctx: typer.Context,
count: Optional[int] = typer.Option(None, "--count", ...),
) -> None:
"""Example command."""
# Check: did user specify --count?
if count is not None:
print(f"User specified: {count}")
else:
# Use global or default
global_count = ctx.obj.get("count")
if global_count is not None:
count = global_count
print(f"Using global: {count}")
else:
count = 10 # hardcoded default
print(f"Using default: {count}")
Key insight: Only None means “not specified”. Check with is not None, not truthiness.
Common Mistakes#
Mistake 1: Forgetting ctx Parameter#
# ✗ WRONG - no ctx parameter
def show_config(
config: str = typer.Option(None, ...),
) -> None:
# Crash! AttributeError: 'NoneType' object has no attribute 'obj'
path = ctx.obj.get("config_path")
Fix: Add ctx: typer.Context as first parameter
Mistake 2: Using Hardcoded Default#
# ✗ WRONG - default prevents fallback
def show_config(
ctx: typer.Context,
config: str = typer.Option("hnw.toml", ...), # Bad!
) -> None:
# "hnw.toml" is always truthy
# ctx.obj.get("config_path") never used
path = config or ctx.obj.get("config_path") or "hnw.toml"
Fix: Use None as default
Mistake 3: Using Truthiness for Numbers#
# ✗ WRONG - fails when value is 0
def my_command(
ctx: typer.Context,
count: Optional[int] = typer.Option(None, ...),
) -> None:
if count: # WRONG! Fails for count=0
use_value = count
Fix: Use is not None
# ✓ CORRECT - works for 0, -1, etc
if count is not None: # RIGHT! Works for all numbers
use_value = count
The Three-Level Pattern#
This is the standard pattern used throughout Haniwers v1 CLI:
@app.command()
def my_command(
ctx: typer.Context,
# Option 1: Command-level (highest priority)
my_option: Optional[str] = typer.Option(None, "--my-option", ...),
) -> None:
"""Example command with three-level fallback."""
# Fallback chain (in order of priority):
# 1. Command-level (if user specified --my-option)
# 2. Global (from main callback in ctx.obj)
# 3. Default (hardcoded in this command)
final_value = (
my_option
or ctx.obj.get("my_option")
or "default_value"
)
print(f"Using: {final_value}")
Accessing Different Types of Global Data#
@app.command()
def my_command(ctx: typer.Context) -> None:
"""Access all types of global data."""
# String values
config_path = ctx.obj.get("config_path")
# Boolean flags
is_verbose = ctx.obj.get("verbose")
# Paths
env_path = ctx.obj.get("env_path")
# Nested objects (if you store them)
device_config = ctx.obj.get("device")
# Check if key exists
if "custom_key" in ctx.obj:
value = ctx.obj["custom_key"]
# Get with default if missing
timeout = ctx.obj.get("timeout", 5.0) # defaults to 5.0
Storing Complex Objects in Context#
You can store more than just strings:
# In main.py callback
@app.callback()
def main(ctx: Context, ...):
"""Store complex objects in context."""
# Load configuration
loader = ConfigLoader(config_path)
# Store everything in context
ctx.obj = {
"config_path": config_path,
"config": loader.config, # ← Store config object
"device_config": loader.config.device, # ← Store nested object
"logger": configure_logger(verbose), # ← Store logger
}
# In command
@app.command()
def my_command(ctx: typer.Context) -> None:
"""Use complex objects from context."""
# Access config object directly (no loading needed!)
cfg = ctx.obj.get("config")
print(f"Device port: {cfg.device.port}")
# Access logger
logger = ctx.obj.get("logger")
logger.info("Command running")
Testing Commands with Context#
When writing tests, you need to provide a mock context:
from typer.testing import CliRunner
from unittest.mock import MagicMock
runner = CliRunner()
def test_command_with_global_config():
"""Test that command uses global config."""
# Create mock context
mock_ctx = MagicMock()
mock_ctx.obj = {
"config_path": "/path/to/config.toml",
"verbose": True,
}
# Run command - Typer provides mock_ctx automatically
result = runner.invoke(app, ["my-command"])
assert result.exit_code == 0
Real Example: config show Command#
Here’s the actual implementation in Haniwers:
# src/haniwers/v1/cli/config.py
@app.command(name="show")
def show_config(
ctx: typer.Context,
config: Optional[Path] = typer.Option(None, "--config", "-c", help="Path to config file"),
) -> None:
"""Display and validate your configuration file.
Priority order:
1. Command-level --config (if you provide it): highest priority
2. Global --config (if you used global option): middle priority
3. Default "hnw.toml" (if neither provided): fallback
"""
# Fallback chain
config_path = config or ctx.obj.get("config_path") or "hnw.toml"
try:
loader = ConfigLoader(config_path)
json = loader.config.model_dump_json(indent=2)
typer.echo(json)
except Exception as e:
msg = f"[ERROR] Failed to load config: {e}"
typer.echo(msg, err=True)
raise typer.Exit(code=1)
Usage examples:
# Use default
$ haniwers-v1 config show
# Uses: hnw.toml
# Use global option
$ haniwers-v1 --config my.toml config show
# Uses: my.toml (inherited from global)
# Override global with command-level
$ haniwers-v1 --config global.toml config show --config local.toml
# Uses: local.toml (command-level wins)
Summary: Context Inheritance Pattern#
Item |
Where |
How to Access |
|---|---|---|
Global options |
Defined in |
Stored in |
Pass to commands |
Via |
Commands receive |
Command options |
Defined in command function |
Use function parameters (default |
Precedence |
Command-level > Global > Hardcoded |
Use fallback chain with |
Testing |
Mock |
Use |
Next Steps#
Look at
src/haniwers/v1/cli/main.py- See global options definedLook at
src/haniwers/v1/cli/config.py- See commands using contextLook at
src/haniwers/v1/cli/daq.py- See complex precedence logicWrite your own command following this pattern
Test with all three scenarios (command, global, default)