Skip to content

Commit 494cdf4

Browse files
committed
🐛 Use minimal startup output outside TTY
Shortcake-Parent: 2026-07-09-trim-startup-output-add-verbose
1 parent d2326f6 commit 494cdf4

5 files changed

Lines changed: 95 additions & 11 deletions

File tree

src/fastapi_cli/cli.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,9 @@ def _run(
147147
logging.DEBUG if verbose else logging.INFO
148148
)
149149

150-
with get_rich_toolkit() as toolkit:
150+
use_rich = should_use_rich_logs()
151+
152+
with get_rich_toolkit(use_rich=use_rich) as toolkit:
151153
server_type = "development" if command == "dev" else "production"
152154

153155
toolkit.print(f"Starting FastAPI in {server_type} mode", emoji="⚡️")
@@ -246,7 +248,7 @@ def _run(
246248

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

270-
toolkit.print_line()
272+
if use_rich:
273+
toolkit.print_line()
271274
toolkit.print(f"Server started at [link={url}]{url}[/]", emoji="🌐")
272275
toolkit.print(f"Documentation at [link={url_docs}]{url_docs}[/]")
273276

@@ -276,12 +279,15 @@ def _run(
276279
"Could not import Uvicorn, try running 'pip install uvicorn'"
277280
) from None
278281

279-
toolkit.print_line()
280-
toolkit.print("Logs:", bullet=False)
281-
toolkit.print_line()
282+
if use_rich:
283+
toolkit.print_line()
284+
toolkit.print("Logs:", bullet=False)
285+
toolkit.print_line()
286+
else:
287+
toolkit.print("")
282288

283289
extra_uvicorn_kwargs: dict[str, Any] = (
284-
{"log_config": get_uvicorn_log_config()} if should_use_rich_logs() else {}
290+
{"log_config": get_uvicorn_log_config()} if use_rich else {}
285291
)
286292

287293
uvicorn.run(

src/fastapi_cli/utils/cli.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from rich.text import Text
99
from rich_toolkit import RichToolkit
1010
from rich_toolkit.element import Element
11-
from rich_toolkit.styles import BaseStyle
11+
from rich_toolkit.styles import BaseStyle, MinimalStyle
1212
from uvicorn.logging import DefaultFormatter
1313

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

110110

111+
class MinimalEmojiStyle(MinimalStyle):
112+
"""Minimal style that keeps the ``emoji=`` bullet as an inline prefix, so
113+
the same ``toolkit.print(..., emoji=...)`` calls read as ``🐍 App: …``
114+
outside a TTY, where ``MinimalStyle`` would otherwise drop the emoji."""
115+
116+
def render_element(
117+
self,
118+
element: Any,
119+
is_active: bool = False,
120+
done: bool = False,
121+
parent: Element | None = None,
122+
**kwargs: Any,
123+
) -> RenderableType:
124+
rendered = super().render_element(
125+
element=element, is_active=is_active, done=done, parent=parent, **kwargs
126+
)
127+
128+
emoji = kwargs.get("emoji", "")
129+
if emoji and isinstance(rendered, str):
130+
return f"{emoji} {rendered}"
131+
132+
return rendered
133+
134+
111135
LOG_LEVEL_COLORS = {
112136
"debug": "blue",
113137
"info": "cyan",
@@ -130,7 +154,7 @@ def _get_log_bullet(level: str) -> str:
130154
class CustomFormatter(DefaultFormatter):
131155
def __init__(self, *args: Any, **kwargs: Any) -> None:
132156
super().__init__(*args, **kwargs)
133-
self.toolkit = get_rich_toolkit()
157+
self.toolkit = get_rich_toolkit(use_rich=True)
134158

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

188212

189-
def get_rich_toolkit() -> RichToolkit:
190-
return RichToolkit(style=FastAPIStyle())
213+
def get_rich_toolkit(*, use_rich: bool) -> RichToolkit:
214+
style: BaseStyle = FastAPIStyle() if use_rich else MinimalEmojiStyle()
215+
216+
return RichToolkit(style=style)

tests/test_cli.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,27 @@ def test_run_uses_uvicorn_default_log_config_without_rich_logs(
6868
assert "log_config" not in mock_run.call_args.kwargs
6969

7070

71+
def test_run_uses_minimal_output_without_tty(monkeypatch: pytest.MonkeyPatch) -> None:
72+
monkeypatch.setattr("fastapi_cli.cli.should_use_rich_logs", lambda: False)
73+
74+
with changing_dir(assets_path):
75+
with patch.object(uvicorn, "run") as mock_run:
76+
result = runner.invoke(app, ["run", "single_file_app.py"])
77+
assert result.exit_code == 0, result.output
78+
assert mock_run.called
79+
assert mock_run.call_args
80+
81+
assert "⚡️ Starting FastAPI in production mode" in result.output
82+
assert "🐍 Using import string: single_file_app:app" in result.output
83+
assert "🌐 Server started at http://0.0.0.0:8000" in result.output
84+
assert "Documentation at http://0.0.0.0:8000/docs" in result.output
85+
assert "Logs:" not in result.output
86+
assert "Searching for package file structure" not in result.output
87+
assert "Configuration sources:" not in result.output
88+
assert "You can configure an entrypoint" not in result.output
89+
assert "log_config" not in mock_run.call_args.kwargs
90+
91+
7192
def test_dev_no_args_auto_discovery() -> None:
7293
"""Test that auto-discovery works when no args and no pyproject.toml entrypoint"""
7394
with changing_dir(assets_path / "default_files" / "default_main"):

tests/test_cli_pyproject.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from pathlib import Path
22
from unittest.mock import patch
33

4+
import pytest
45
import uvicorn
56
from typer.testing import CliRunner
67

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

1415

16+
@pytest.fixture(autouse=True)
17+
def force_rich_logs(monkeypatch: pytest.MonkeyPatch) -> None:
18+
monkeypatch.setattr("fastapi_cli.cli.should_use_rich_logs", lambda: True)
19+
20+
1521
def test_dev_with_pyproject_app_config_uses() -> None:
1622
with (
1723
changing_dir(assets_path / "pyproject_config"),

tests/test_utils_cli.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
from fastapi_cli.utils.cli import (
99
CustomFormatter,
10+
FastAPIStyle,
11+
MinimalEmojiStyle,
12+
get_rich_toolkit,
1013
get_uvicorn_log_config,
1114
should_use_rich_logs,
1215
)
@@ -28,6 +31,28 @@ def test_should_use_rich_logs_is_false_without_tty(
2831
assert should_use_rich_logs() is False
2932

3033

34+
def test_get_rich_toolkit_uses_fastapi_style_when_requested() -> None:
35+
toolkit = get_rich_toolkit(use_rich=True)
36+
37+
assert isinstance(toolkit.style, FastAPIStyle)
38+
39+
40+
def test_get_rich_toolkit_uses_minimal_style_without_rich() -> None:
41+
toolkit = get_rich_toolkit(use_rich=False)
42+
43+
assert isinstance(toolkit.style, MinimalEmojiStyle)
44+
45+
46+
def test_get_rich_toolkit_uses_minimal_style_without_tty(
47+
monkeypatch: MonkeyPatch,
48+
) -> None:
49+
monkeypatch.setattr(sys, "stdout", io.StringIO())
50+
51+
toolkit = get_rich_toolkit(use_rich=should_use_rich_logs())
52+
53+
assert isinstance(toolkit.style, MinimalEmojiStyle)
54+
55+
3156
def test_custom_formatter() -> None:
3257
formatter = CustomFormatter()
3358

0 commit comments

Comments
 (0)