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:

  1. Add ctx: typer.Context as first parameter

  2. Change default from "hnw.toml" to None (so we can tell if user specified it)

  3. Use fallback chain with or operator

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 main.py callback

Stored in ctx.obj dictionary

Pass to commands

Via @app.callback() mechanism

Commands receive ctx parameter

Command options

Defined in command function

Use function parameters (default None)

Precedence

Command-level > Global > Hardcoded

Use fallback chain with or operator

Testing

Mock ctx.obj dictionary

Use runner.invoke() with test data

Next Steps#

  1. Look at src/haniwers/v1/cli/main.py - See global options defined

  2. Look at src/haniwers/v1/cli/config.py - See commands using context

  3. Look at src/haniwers/v1/cli/daq.py - See complex precedence logic

  4. Write your own command following this pattern

  5. Test with all three scenarios (command, global, default)

For More Information#