Skip to content

Commit cf26bbd

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

5 files changed

Lines changed: 136 additions & 60 deletions

File tree

src/fastapi_cli/cli.py

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

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

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

156161
if entrypoint and (path or app):
157162
toolkit.print_line()
@@ -197,69 +202,77 @@ def _run(
197202
module_data = import_data.module_data
198203
import_string = import_data.import_string
199204

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

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

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

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

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

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

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

274-
toolkit.print_line()
275-
toolkit.print("Logs:")
276-
toolkit.print_line()
287+
if use_rich:
288+
toolkit.print_line()
289+
toolkit.print("Logs:")
290+
toolkit.print_line()
277291

278292
extra_uvicorn_kwargs = (
279293
get_uvicorn_log_config() if should_use_rich_logs() else {}

src/fastapi_cli/utils/cli.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
from typing import Any
44

55
from rich_toolkit import RichToolkit, RichToolkitTheme
6-
from rich_toolkit.styles import TaggedStyle
6+
from rich_toolkit.styles import MinimalStyle, TaggedStyle
77
from uvicorn.logging import DefaultFormatter
88

99

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()
@@ -72,7 +72,10 @@ def get_uvicorn_log_config() -> dict[str, Any]:
7272
logger = logging.getLogger(__name__)
7373

7474

75-
def get_rich_toolkit() -> RichToolkit:
75+
def get_rich_toolkit(*, use_rich: bool) -> RichToolkit:
76+
if not use_rich:
77+
return RichToolkit(style=MinimalStyle())
78+
7679
theme = RichToolkitTheme(
7780
style=TaggedStyle(tag_width=11),
7881
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
@@ -5,10 +5,12 @@
55

66
from pytest import LogCaptureFixture, MonkeyPatch
77
from rich.logging import RichHandler
8+
from rich_toolkit.styles import MinimalStyle, TaggedStyle
89

910
from fastapi_cli import logging as cli_logging
1011
from fastapi_cli.utils.cli import (
1112
CustomFormatter,
13+
get_rich_toolkit,
1214
get_uvicorn_log_config,
1315
should_use_rich_logs,
1416
)
@@ -48,6 +50,28 @@ def test_setup_logging_uses_rich_handler() -> None:
4850
logger.propagate = original_propagate
4951

5052

53+
def test_get_rich_toolkit_uses_tagged_style_when_requested() -> None:
54+
toolkit = get_rich_toolkit(use_rich=True)
55+
56+
assert isinstance(toolkit.style, TaggedStyle)
57+
58+
59+
def test_get_rich_toolkit_uses_minimal_style_without_rich() -> None:
60+
toolkit = get_rich_toolkit(use_rich=False)
61+
62+
assert isinstance(toolkit.style, MinimalStyle)
63+
64+
65+
def test_get_rich_toolkit_uses_minimal_style_without_tty(
66+
monkeypatch: MonkeyPatch,
67+
) -> None:
68+
monkeypatch.setattr(sys, "stdout", io.StringIO())
69+
70+
toolkit = get_rich_toolkit(use_rich=should_use_rich_logs())
71+
72+
assert isinstance(toolkit.style, MinimalStyle)
73+
74+
5175
def test_custom_formatter() -> None:
5276
formatter = CustomFormatter()
5377

0 commit comments

Comments
 (0)