Skip to content

Commit e1b07fd

Browse files
committed
🐛 Use minimal startup output outside TTY
Shortcake-Parent: 2026-07-03-avoid-fancy-logs-for-non-tty-output
1 parent 0ce6ca6 commit e1b07fd

5 files changed

Lines changed: 138 additions & 59 deletions

File tree

src/fastapi_cli/cli.py

Lines changed: 71 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -144,15 +144,20 @@ def _run(
144144
forwarded_allow_ips: str | None = None,
145145
public_url: str | None = None,
146146
) -> None:
147-
with get_rich_toolkit() as toolkit:
147+
use_rich = should_use_rich_logs()
148+
with get_rich_toolkit(use_rich=use_rich) as toolkit:
148149
server_type = "development" if command == "dev" else "production"
149150

150-
toolkit.print_title(f"Starting {server_type} server 🚀", tag="FastAPI")
151-
toolkit.print_line()
151+
if use_rich:
152+
toolkit.print_title(f"Starting {server_type} server 🚀", tag="FastAPI")
153+
else:
154+
toolkit.print_title("⚡️ Starting FastAPI")
152155

153-
toolkit.print(
154-
"Searching for package file structure from directories with [blue]__init__.py[/blue] files"
155-
)
156+
if use_rich:
157+
toolkit.print_line()
158+
toolkit.print(
159+
"Searching for package file structure from directories with [blue]__init__.py[/blue] files"
160+
)
156161

157162
if entrypoint and (path or app):
158163
toolkit.print_line()
@@ -198,69 +203,77 @@ def _run(
198203
module_data = import_data.module_data
199204
import_string = import_data.import_string
200205

201-
toolkit.print(f"Importing from {module_data.extra_sys_path}")
202-
toolkit.print_line()
203-
204-
if module_data.module_paths:
205-
root_tree = _get_module_tree(module_data.module_paths)
206-
207-
toolkit.print(root_tree, tag="module")
206+
if use_rich:
207+
toolkit.print(f"Importing from {module_data.extra_sys_path}")
208208
toolkit.print_line()
209209

210-
toolkit.print(
211-
"Importing the FastAPI app object from the module with the following code:",
212-
tag="code",
213-
)
214-
toolkit.print_line()
215-
toolkit.print(
216-
f"[underline]from [bold]{module_data.module_import_str}[/bold] import [bold]{import_data.app_name}[/bold]"
217-
)
218-
toolkit.print_line()
219-
220-
toolkit.print(
221-
f"Using import string: [blue]{import_string}[/]",
222-
tag="app",
223-
)
210+
if module_data.module_paths:
211+
root_tree = _get_module_tree(module_data.module_paths)
224212

225-
mod_source_desc = SOURCE_DESCRIPTIONS[import_data.module_config_source]
226-
app_source_desc = SOURCE_DESCRIPTIONS[import_data.app_name_config_source]
227-
toolkit.print_line()
228-
toolkit.print("Configuration sources:", tag="info")
229-
if mod_source_desc == app_source_desc:
230-
toolkit.print(f" • Import string: {mod_source_desc}")
231-
else:
232-
toolkit.print(f" • Module: {mod_source_desc}")
233-
toolkit.print(f" • App name: {app_source_desc}")
213+
toolkit.print(root_tree, tag="module")
214+
toolkit.print_line()
234215

235-
if import_data.module_config_source == "auto-discovery":
216+
toolkit.print(
217+
"Importing the FastAPI app object from the module with the following code:",
218+
tag="code",
219+
)
236220
toolkit.print_line()
237221
toolkit.print(
238-
"You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:",
239-
tag="tip",
222+
f"[underline]from [bold]{module_data.module_import_str}[/bold] import [bold]{import_data.app_name}[/bold]"
240223
)
241224
toolkit.print_line()
225+
242226
toolkit.print(
243-
Syntax(
244-
(
245-
"[tool.fastapi]\n"
246-
f'entrypoint = "{import_data.module_data.module_import_str}:{import_data.app_name}"'
247-
),
248-
"toml",
249-
theme="ansi_light",
250-
)
227+
f"Using import string: [blue]{import_string}[/]",
228+
tag="app",
251229
)
230+
else:
231+
toolkit.print(f"🐍 App: [blue]{import_string}[/]")
232+
233+
if use_rich:
234+
mod_source_desc = SOURCE_DESCRIPTIONS[import_data.module_config_source]
235+
app_source_desc = SOURCE_DESCRIPTIONS[import_data.app_name_config_source]
236+
toolkit.print_line()
237+
toolkit.print("Configuration sources:", tag="info")
238+
if mod_source_desc == app_source_desc:
239+
toolkit.print(f" • Import string: {mod_source_desc}")
240+
else:
241+
toolkit.print(f" • Module: {mod_source_desc}")
242+
toolkit.print(f" • App name: {app_source_desc}")
243+
244+
if import_data.module_config_source == "auto-discovery":
245+
toolkit.print_line()
246+
toolkit.print(
247+
"You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:",
248+
tag="tip",
249+
)
250+
toolkit.print_line()
251+
toolkit.print(
252+
Syntax(
253+
(
254+
"[tool.fastapi]\n"
255+
f'entrypoint = "{import_data.module_data.module_import_str}:{import_data.app_name}"'
256+
),
257+
"toml",
258+
theme="ansi_light",
259+
)
260+
)
252261

253262
url = public_url.rstrip("/") if public_url else f"http://{host}:{port}"
254263
url_docs = f"{url}/docs"
255264

256-
toolkit.print_line()
257-
toolkit.print(
258-
f"Server started at [link={url}]{url}[/]",
259-
f"Documentation at [link={url_docs}]{url_docs}[/]",
260-
tag="server",
261-
)
265+
if use_rich:
266+
toolkit.print_line()
267+
toolkit.print(f"Server started at [link={url}]{url}[/]", tag="server")
268+
toolkit.print(
269+
f"Documentation at [link={url_docs}]{url_docs}[/]", tag="server"
270+
)
271+
else:
272+
toolkit.print(f"🌐 Server: [link={url}]{url}[/]")
273+
toolkit.print(f"📚 Docs: [link={url_docs}]{url_docs}[/]")
274+
toolkit.print("")
262275

263-
if command == "dev":
276+
if command == "dev" and use_rich:
264277
toolkit.print_line()
265278
toolkit.print(
266279
"Running in development mode, for production use: [bold]fastapi run[/]",
@@ -272,9 +285,10 @@ def _run(
272285
"Could not import Uvicorn, try running 'pip install uvicorn'"
273286
) from None
274287

275-
toolkit.print_line()
276-
toolkit.print("Logs:")
277-
toolkit.print_line()
288+
if use_rich:
289+
toolkit.print_line()
290+
toolkit.print("Logs:")
291+
toolkit.print_line()
278292

279293
extra_uvicorn_kwargs: dict[str, Any] = {}
280294
if should_use_rich_logs():

src/fastapi_cli/utils/cli.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
class CustomFormatter(DefaultFormatter):
1111
def __init__(self, *args: Any, **kwargs: Any) -> None:
1212
super().__init__(*args, **kwargs)
13-
self.toolkit = get_rich_toolkit()
13+
self.toolkit = get_rich_toolkit(use_rich=True)
1414

1515
def formatMessage(self, record: logging.LogRecord) -> str:
1616
message = record.getMessage()
@@ -71,7 +71,13 @@ def get_uvicorn_log_config() -> dict[str, Any]:
7171
logger = logging.getLogger(__name__)
7272

7373

74-
def get_rich_toolkit() -> RichToolkit:
74+
def get_rich_toolkit(use_rich: bool | None = None) -> RichToolkit:
75+
if use_rich is None:
76+
use_rich = should_use_rich_logs()
77+
78+
if not use_rich:
79+
return RichToolkit()
80+
7581
theme = RichToolkitTheme(
7682
style=TaggedStyle(tag_width=11),
7783
theme={

tests/test_cli.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,35 @@ def test_run_uses_uvicorn_default_log_config_without_rich_logs(
7171
assert "log_config" not in mock_run.call_args.kwargs
7272

7373

74+
def test_run_uses_minimal_output_without_tty(monkeypatch: pytest.MonkeyPatch) -> None:
75+
monkeypatch.setattr("fastapi_cli.cli.should_use_rich_logs", lambda: False)
76+
77+
with changing_dir(assets_path):
78+
with patch.object(uvicorn, "run") as mock_run:
79+
result = runner.invoke(app, ["run", "single_file_app.py"])
80+
assert result.exit_code == 0, result.output
81+
assert mock_run.called
82+
assert mock_run.call_args
83+
84+
assert "⚡️ Starting FastAPI" in result.output
85+
assert "🐍 App: single_file_app:app" in result.output
86+
assert "🌐 Server: http://0.0.0.0:8000" in result.output
87+
assert "📚 Docs: http://0.0.0.0:8000/docs" in result.output
88+
assert "📚 Docs: http://0.0.0.0:8000/docs\n\n" in result.output
89+
assert "Logs:" not in result.output
90+
assert "Source:" not in result.output
91+
assert "Server started at" not in result.output
92+
assert "Documentation at" not in result.output
93+
assert "Searching for package file structure" not in result.output
94+
assert "Importing from" not in result.output
95+
assert "🐍 single_file_app.py" not in result.output
96+
assert "Importing the FastAPI app object" not in result.output
97+
assert "Using import string:" not in result.output
98+
assert "Configuration sources:" not in result.output
99+
assert "You can configure an entrypoint" not in result.output
100+
assert "log_config" not in mock_run.call_args.kwargs
101+
102+
74103
def test_dev_no_args_auto_discovery() -> None:
75104
"""Test that auto-discovery works when no args and no pyproject.toml entrypoint"""
76105
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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
from logging.config import dictConfig
55

66
from pytest import LogCaptureFixture, MonkeyPatch
7+
from rich_toolkit.styles import MinimalStyle, TaggedStyle
78

89
from fastapi_cli.utils.cli import (
910
CustomFormatter,
11+
get_rich_toolkit,
1012
get_uvicorn_log_config,
1113
should_use_rich_logs,
1214
)
@@ -28,6 +30,28 @@ def test_should_use_rich_logs_is_false_without_tty(
2830
assert should_use_rich_logs() is False
2931

3032

33+
def test_get_rich_toolkit_uses_tagged_style_when_requested() -> None:
34+
toolkit = get_rich_toolkit(use_rich=True)
35+
36+
assert isinstance(toolkit.style, TaggedStyle)
37+
38+
39+
def test_get_rich_toolkit_uses_minimal_style_without_rich() -> None:
40+
toolkit = get_rich_toolkit(use_rich=False)
41+
42+
assert isinstance(toolkit.style, MinimalStyle)
43+
44+
45+
def test_get_rich_toolkit_uses_minimal_style_without_tty(
46+
monkeypatch: MonkeyPatch,
47+
) -> None:
48+
monkeypatch.setattr(sys, "stdout", io.StringIO())
49+
50+
toolkit = get_rich_toolkit()
51+
52+
assert isinstance(toolkit.style, MinimalStyle)
53+
54+
3155
def test_custom_formatter() -> None:
3256
formatter = CustomFormatter()
3357

0 commit comments

Comments
 (0)