Typer Extension
You can use AnyDI with Typer to add dependency injection to CLI applications. This extension supports both sync and async commands for building modern CLI tools with clean dependency management.
import anydi.ext.typer
import typer
from anydi import Container, Provide
class GreetingService:
def greet(self, name: str) -> str:
return f"Hello, {name}!"
container = Container()
container.register(GreetingService)
app = typer.Typer()
@app.command()
def hello(
name: str,
service: Provide[GreetingService],
) -> None:
"""Greet someone."""
greeting = service.greet(name)
typer.echo(greeting)
# Install `AnyDI` support in Typer
anydi.ext.typer.install(app, container)
Note
To detect a dependency type, provide a valid type annotation.
Provide[Service] is equivalent to Annotated[Service, Inject()].
You can also use the Inject() marker as a default value:
from anydi import Inject
@app.command()
def hello(
name: str,
service: GreetingService = Inject(),
) -> None:
"""Greet someone."""
greeting = service.greet(name)
typer.echo(greeting)
Async commands
The extension supports async commands fully. Define your command as async function and AnyDI will handle async execution with anyio automatically:
import anydi.ext.typer
import typer
from anydi import Container, Provide
class DatabaseService:
async def fetch_user(self, user_id: int) -> dict:
# Simulate async database call
return {"id": user_id, "name": f"User {user_id}"}
container = Container()
container.register(DatabaseService)
app = typer.Typer()
@app.command()
async def get_user(
user_id: int,
db: Provide[DatabaseService],
) -> None:
"""Fetch user from database."""
user = await db.fetch_user(user_id)
typer.echo(f"User: {user['name']}")
anydi.ext.typer.install(app, container)
Async commands work with dependency injection seamlessly. You can mix both sync and async commands in the same application.
Multiple dependencies
You can inject multiple dependencies into one command:
from typing import Annotated
import anydi.ext.typer
import typer
from anydi import Container, Provide
class ConfigService:
def get_api_url(self) -> str:
return "https://api.example.com"
class HttpClient:
def __init__(self, config: ConfigService) -> None:
self.base_url = config.get_api_url()
async def fetch(self, endpoint: str) -> dict:
return {"url": f"{self.base_url}/{endpoint}"}
container = Container()
container.register(ConfigService)
container.register(HttpClient)
app = typer.Typer()
@app.command()
async def api_call(
endpoint: str,
config: Provide[ConfigService],
client: Provide[HttpClient],
) -> None:
"""Make an API call."""
typer.echo(f"API URL: {config.get_api_url()}")
result = await client.fetch(endpoint)
typer.echo(f"Result: {result}")
anydi.ext.typer.install(app, container)
Callbacks
You can use dependency injection in Typer callbacks (common options/setup):
import anydi.ext.typer
import typer
from anydi import Container, Provide
class AppConfig:
def __init__(self, verbose: bool = False) -> None:
self.verbose = verbose
container = Container()
container.register(AppConfig)
app = typer.Typer()
@app.callback()
def main_callback(
verbose: bool = typer.Option(False, "--verbose", "-v"),
config: Provide[AppConfig],
) -> None:
"""Main application."""
config.verbose = verbose
if config.verbose:
typer.echo("Verbose mode enabled")
@app.command()
def process(
name: str,
config: Provide[AppConfig],
) -> None:
"""Process something."""
if config.verbose:
typer.echo(f"Processing: {name}")
typer.echo("Done!")
anydi.ext.typer.install(app, container)
Nested apps
The extension supports nested Typer apps (command groups) automatically:
import anydi.ext.typer
import typer
from anydi import Container, Provide
class UserService:
def create_user(self, name: str) -> str:
return f"Created user: {name}"
def delete_user(self, user_id: int) -> str:
return f"Deleted user: {user_id}"
container = Container()
container.register(UserService)
# Main app
app = typer.Typer()
# Sub-app for user commands
user_app = typer.Typer()
@user_app.command()
def create(
name: str,
service: Provide[UserService],
) -> None:
"""Create a new user."""
result = service.create_user(name)
typer.echo(result)
@user_app.command()
def delete(
user_id: int,
service: Provide[UserService],
) -> None:
"""Delete a user."""
result = service.delete_user(user_id)
typer.echo(result)
# Add sub-app to main app
app.add_typer(user_app, name="user")
anydi.ext.typer.install(app, container)
Run commands like:
python app.py user create Alice
python app.py user delete 123
Automatic scope context management
The Typer extension manages scope contexts for commands automatically. When you use dependencies with different scopes (singleton, request, or custom), the contexts are started and cleaned up automatically.
from typing import Annotated
from collections.abc import Iterator
import anydi.ext.typer
import typer
from anydi import Container, Provide
class DatabaseConnection:
def __init__(self) -> None:
print("Database connected")
def close(self) -> None:
print("Database disconnected")
container = Container()
container.register_scope("batch")
# Singleton with lifecycle management
@container.provider(scope="singleton")
def db_connection() -> Iterator[DatabaseConnection]:
conn = DatabaseConnection()
yield conn
conn.close()
# Request-scoped (fresh per invocation)
@container.provider(scope="request")
def request_id() -> int:
return 1
# Custom scope
@container.provider(scope="batch")
def batch_id() -> Annotated[str, "batch"]:
return "batch-1"
app = typer.Typer()
@app.command()
def process(
db: Provide[DatabaseConnection],
req: Provide[int],
batch: Provide[Annotated[str, "batch"]],
) -> None:
"""Command with mixed scopes."""
typer.echo(f"Request: {req}, Batch: {batch}")
anydi.ext.typer.install(app, container)
When you run this command: - The singleton container context is started (database connection is created) - The request and batch scope contexts are started - Your command executes with all dependencies - All contexts are properly cleaned up (database connection is closed)
Testing
Testing Typer commands with AnyDI is simple using CliRunner and container overrides:
Basic testing
from unittest import mock
import anydi.ext.typer
import typer
from typer.testing import CliRunner
from anydi import Container, Provide
class GreetingService:
def greet(self, name: str) -> str:
return f"Hello, {name}!"
container = Container()
container.register(GreetingService)
app = typer.Typer()
@app.command()
def hello(
name: str,
service: Provide[GreetingService],
) -> None:
"""Greet someone."""
typer.echo(service.greet(name))
anydi.ext.typer.install(app, container)
def test_hello_command() -> None:
"""Test the hello command."""
runner = CliRunner()
result = runner.invoke(app, ["hello", "World"])
assert result.exit_code == 0
assert "Hello, World!" in result.stdout
Testing with mocked dependencies
Use container.override() to replace dependencies with mocks:
from unittest import mock
import anydi.ext.typer
import typer
from typer.testing import CliRunner
from anydi import Container, Provide
class EmailService:
def send_email(self, to: str, subject: str) -> bool:
# In real implementation, sends actual email
return True
container = Container()
container.register(EmailService)
app = typer.Typer()
@app.command()
def send(
email: str,
subject: str,
service: Provide[EmailService],
) -> None:
"""Send an email."""
if service.send_email(email, subject):
typer.echo(f"Email sent to {email}")
else:
typer.echo("Failed to send email", err=True)
raise typer.Exit(code=1)
anydi.ext.typer.install(app, container)
def test_send_email_command() -> None:
"""Test email sending with mocked service."""
# Create a mock service
mock_service = mock.Mock(spec=EmailService)
mock_service.send_email.return_value = True
runner = CliRunner()
# Override the EmailService with mock
with container.override(EmailService, instance=mock_service):
result = runner.invoke(app, ["send", "user@example.com", "Test Subject"])
assert result.exit_code == 0
assert "Email sent to user@example.com" in result.stdout
# Verify the mock was called with correct arguments
mock_service.send_email.assert_called_once_with("user@example.com", "Test Subject")
def test_send_email_failure() -> None:
"""Test email sending failure."""
# Create a mock that simulates failure
mock_service = mock.Mock(spec=EmailService)
mock_service.send_email.return_value = False
runner = CliRunner()
with container.override(EmailService, instance=mock_service):
result = runner.invoke(app, ["send", "user@example.com", "Test Subject"])
assert result.exit_code == 1
assert "Failed to send email" in result.stderr
Testing Async Commands
Async commands are tested the same way as sync commands - CliRunner handles the async execution automatically:
import anydi.ext.typer
import typer
from typer.testing import CliRunner
from anydi import Container, Provide
class DatabaseService:
async def get_user(self, user_id: int) -> dict:
return {"id": user_id, "name": "Test User"}
container = Container()
container.register(DatabaseService)
app = typer.Typer()
@app.command()
async def get_user(
user_id: int,
db: Provide[DatabaseService],
) -> None:
"""Get user by ID."""
user = await db.get_user(user_id)
typer.echo(f"User: {user['name']}")
anydi.ext.typer.install(app, container)
def test_async_get_user() -> None:
"""Test async command."""
runner = CliRunner()
result = runner.invoke(app, ["get-user", "123"])
assert result.exit_code == 0
assert "User: Test User" in result.stdout
def test_async_get_user_with_mock() -> None:
"""Test async command with mocked database."""
mock_db = mock.Mock(spec=DatabaseService)
mock_db.get_user = mock.AsyncMock(return_value={"id": 999, "name": "Mock User"})
runner = CliRunner()
with container.override(DatabaseService, instance=mock_db):
result = runner.invoke(app, ["get-user", "999"])
assert result.exit_code == 0
assert "User: Mock User" in result.stdout
mock_db.get_user.assert_called_once_with(999)