Skip to content

Commit 9e040d3

Browse files
committed
🐛 Use minimal startup output outside TTY
Shortcake-Parent: main
1 parent c1402c0 commit 9e040d3

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
@@ -144,7 +144,9 @@ def _run(
144144
public_url: str | None = None,
145145
verbose: bool = False,
146146
) -> None:
147-
with get_rich_toolkit() as toolkit:
147+
use_rich = should_use_rich_logs()
148+
149+
with get_rich_toolkit(use_rich=use_rich) as toolkit:
148150
server_type = "development" if command == "dev" else "production"
149151

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

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

267-
toolkit.print_line()
269+
if use_rich:
270+
toolkit.print_line()
268271
toolkit.print(f"Server started at [link={url}]{url}[/]", emoji="🌐")
269272
toolkit.print(f"Documentation at [link={url_docs}]{url_docs}[/]")
270273

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

276-
toolkit.print_line()
277-
toolkit.print("Logs:", bullet=False)
278-
toolkit.print_line()
279+
if use_rich:
280+
toolkit.print_line()
281+
toolkit.print("Logs:", bullet=False)
282+
toolkit.print_line()
283+
else:
284+
toolkit.print("")
279285

280286
extra_uvicorn_kwargs: dict[str, Any] = (
281-
{"log_config": get_uvicorn_log_config()} if should_use_rich_logs() else {}
287+
{"log_config": get_uvicorn_log_config()} if use_rich else {}
282288
)
283289

284290
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)