Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions src/fastapi_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@ def _run(
public_url: str | None = None,
verbose: bool = False,
) -> None:
with get_rich_toolkit() as toolkit:
use_rich = should_use_rich_logs()

with get_rich_toolkit(use_rich=use_rich) as toolkit:
server_type = "development" if command == "dev" else "production"

toolkit.print(f"Starting FastAPI in {server_type} mode", emoji="⚡️")
Expand Down Expand Up @@ -243,7 +245,7 @@ def _run(

# Nudge to pin the entrypoint whenever it was auto-discovered, so it's
# explicit next time — shown in the default output, not just --verbose
if is_auto_discovery:
if use_rich and is_auto_discovery:
toolkit.print_line()
toolkit.print(
"You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:",
Expand All @@ -264,7 +266,8 @@ def _run(
url = public_url.rstrip("/") if public_url else f"http://{host}:{port}"
url_docs = f"{url}/docs"

toolkit.print_line()
if use_rich:
toolkit.print_line()
toolkit.print(f"Server started at [link={url}]{url}[/]", emoji="🌐")
toolkit.print(f"Documentation at [link={url_docs}]{url_docs}[/]")

Expand All @@ -273,12 +276,15 @@ def _run(
"Could not import Uvicorn, try running 'pip install uvicorn'"
) from None

toolkit.print_line()
toolkit.print("Logs:", bullet=False)
toolkit.print_line()
if use_rich:
toolkit.print_line()
toolkit.print("Logs:", bullet=False)
toolkit.print_line()
else:
toolkit.print("")

extra_uvicorn_kwargs: dict[str, Any] = (
{"log_config": get_uvicorn_log_config()} if should_use_rich_logs() else {}
{"log_config": get_uvicorn_log_config()} if use_rich else {}
)

uvicorn.run(
Expand Down
34 changes: 30 additions & 4 deletions src/fastapi_cli/utils/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from rich.text import Text
from rich_toolkit import RichToolkit
from rich_toolkit.element import Element
from rich_toolkit.styles import BaseStyle
from rich_toolkit.styles import BaseStyle, MinimalStyle
from uvicorn.logging import DefaultFormatter

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -108,6 +108,30 @@ def _get_bullet_prefix(self, emoji: str) -> Text:
return prefix


class MinimalEmojiStyle(MinimalStyle):
"""Minimal style that keeps the ``emoji=`` bullet as an inline prefix, so
the same ``toolkit.print(..., emoji=...)`` calls read as ``🐍 App: …``
outside a TTY, where ``MinimalStyle`` would otherwise drop the emoji."""

def render_element(
self,
element: Any,
is_active: bool = False,
done: bool = False,
parent: Element | None = None,
**kwargs: Any,
) -> RenderableType:
rendered = super().render_element(
element=element, is_active=is_active, done=done, parent=parent, **kwargs
)

emoji = kwargs.get("emoji", "")
if emoji and isinstance(rendered, str):
return f"{emoji} {rendered}"

return rendered


LOG_LEVEL_COLORS = {
"debug": "blue",
"info": "cyan",
Expand All @@ -130,7 +154,7 @@ def _get_log_bullet(level: str) -> str:
class CustomFormatter(DefaultFormatter):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.toolkit = get_rich_toolkit()
self.toolkit = get_rich_toolkit(use_rich=True)

def formatMessage(self, record: logging.LogRecord) -> str:
message = record.getMessage()
Expand Down Expand Up @@ -186,5 +210,7 @@ def get_uvicorn_log_config() -> dict[str, Any]:
}


def get_rich_toolkit() -> RichToolkit:
return RichToolkit(style=FastAPIStyle())
def get_rich_toolkit(*, use_rich: bool) -> RichToolkit:
style: BaseStyle = FastAPIStyle() if use_rich else MinimalEmojiStyle()

return RichToolkit(style=style)
21 changes: 21 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,27 @@ def test_run_uses_uvicorn_default_log_config_without_rich_logs(
assert "log_config" not in mock_run.call_args.kwargs


def test_run_uses_minimal_output_without_tty(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("fastapi_cli.cli.should_use_rich_logs", lambda: False)

with changing_dir(assets_path):
with patch.object(uvicorn, "run") as mock_run:
result = runner.invoke(app, ["run", "single_file_app.py"])
assert result.exit_code == 0, result.output
assert mock_run.called
assert mock_run.call_args

assert "⚡️ Starting FastAPI in production mode" in result.output
assert "🐍 Using import string: single_file_app:app" in result.output
assert "🌐 Server started at http://0.0.0.0:8000" in result.output
assert "Documentation at http://0.0.0.0:8000/docs" in result.output
assert "Logs:" not in result.output
assert "Searching for package file structure" not in result.output
assert "Configuration sources:" not in result.output
assert "You can configure an entrypoint" not in result.output
assert "log_config" not in mock_run.call_args.kwargs


def test_dev_no_args_auto_discovery() -> None:
"""Test that auto-discovery works when no args and no pyproject.toml entrypoint"""
with changing_dir(assets_path / "default_files" / "default_main"):
Expand Down
6 changes: 6 additions & 0 deletions tests/test_cli_pyproject.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from pathlib import Path
from unittest.mock import patch

import pytest
import uvicorn
from typer.testing import CliRunner

Expand All @@ -12,6 +13,11 @@
assets_path = Path(__file__).parent / "assets"


@pytest.fixture(autouse=True)
def force_rich_logs(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("fastapi_cli.cli.should_use_rich_logs", lambda: True)


def test_dev_with_pyproject_app_config_uses() -> None:
with (
changing_dir(assets_path / "pyproject_config"),
Expand Down
25 changes: 25 additions & 0 deletions tests/test_utils_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

from fastapi_cli.utils.cli import (
CustomFormatter,
FastAPIStyle,
MinimalEmojiStyle,
get_rich_toolkit,
get_uvicorn_log_config,
should_use_rich_logs,
)
Expand All @@ -28,6 +31,28 @@ def test_should_use_rich_logs_is_false_without_tty(
assert should_use_rich_logs() is False


def test_get_rich_toolkit_uses_fastapi_style_when_requested() -> None:
toolkit = get_rich_toolkit(use_rich=True)

assert isinstance(toolkit.style, FastAPIStyle)


def test_get_rich_toolkit_uses_minimal_style_without_rich() -> None:
toolkit = get_rich_toolkit(use_rich=False)

assert isinstance(toolkit.style, MinimalEmojiStyle)


def test_get_rich_toolkit_uses_minimal_style_without_tty(
monkeypatch: MonkeyPatch,
) -> None:
monkeypatch.setattr(sys, "stdout", io.StringIO())

toolkit = get_rich_toolkit(use_rich=should_use_rich_logs())

assert isinstance(toolkit.style, MinimalEmojiStyle)


def test_custom_formatter() -> None:
formatter = CustomFormatter()

Expand Down
Loading