Skip to content

Commit b52cbee

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

5 files changed

Lines changed: 155 additions & 60 deletions

File tree

src/fastapi_cli/cli.py

Lines changed: 72 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from .utils.cli import (
2525
get_rich_toolkit,
2626
get_uvicorn_log_config,
27+
should_use_rich_toolkit,
2728
should_use_rich_uvicorn_logs,
2829
)
2930

@@ -144,15 +145,20 @@ def _run(
144145
forwarded_allow_ips: str | None = None,
145146
public_url: str | None = None,
146147
) -> None:
147-
with get_rich_toolkit() as toolkit:
148+
use_rich = should_use_rich_toolkit()
149+
with get_rich_toolkit(use_rich=use_rich) as toolkit:
148150
server_type = "development" if command == "dev" else "production"
149151

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

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

157163
if entrypoint and (path or app):
158164
toolkit.print_line()
@@ -198,69 +204,77 @@ def _run(
198204
module_data = import_data.module_data
199205
import_string = import_data.import_string
200206

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")
207+
if use_rich:
208+
toolkit.print(f"Importing from {module_data.extra_sys_path}")
208209
toolkit.print_line()
209210

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-
)
211+
if module_data.module_paths:
212+
root_tree = _get_module_tree(module_data.module_paths)
224213

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}")
214+
toolkit.print(root_tree, tag="module")
215+
toolkit.print_line()
234216

235-
if import_data.module_config_source == "auto-discovery":
217+
toolkit.print(
218+
"Importing the FastAPI app object from the module with the following code:",
219+
tag="code",
220+
)
236221
toolkit.print_line()
237222
toolkit.print(
238-
"You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:",
239-
tag="tip",
223+
f"[underline]from [bold]{module_data.module_import_str}[/bold] import [bold]{import_data.app_name}[/bold]"
240224
)
241225
toolkit.print_line()
226+
242227
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-
)
228+
f"Using import string: [blue]{import_string}[/]",
229+
tag="app",
251230
)
231+
else:
232+
toolkit.print(f"🐍 App: [blue]{import_string}[/]")
233+
234+
if use_rich:
235+
mod_source_desc = SOURCE_DESCRIPTIONS[import_data.module_config_source]
236+
app_source_desc = SOURCE_DESCRIPTIONS[import_data.app_name_config_source]
237+
toolkit.print_line()
238+
toolkit.print("Configuration sources:", tag="info")
239+
if mod_source_desc == app_source_desc:
240+
toolkit.print(f" • Import string: {mod_source_desc}")
241+
else:
242+
toolkit.print(f" • Module: {mod_source_desc}")
243+
toolkit.print(f" • App name: {app_source_desc}")
244+
245+
if import_data.module_config_source == "auto-discovery":
246+
toolkit.print_line()
247+
toolkit.print(
248+
"You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:",
249+
tag="tip",
250+
)
251+
toolkit.print_line()
252+
toolkit.print(
253+
Syntax(
254+
(
255+
"[tool.fastapi]\n"
256+
f'entrypoint = "{import_data.module_data.module_import_str}:{import_data.app_name}"'
257+
),
258+
"toml",
259+
theme="ansi_light",
260+
)
261+
)
252262

253263
url = public_url.rstrip("/") if public_url else f"http://{host}:{port}"
254264
url_docs = f"{url}/docs"
255265

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-
)
266+
if use_rich:
267+
toolkit.print_line()
268+
toolkit.print(f"Server started at [link={url}]{url}[/]", tag="server")
269+
toolkit.print(
270+
f"Documentation at [link={url_docs}]{url_docs}[/]", tag="server"
271+
)
272+
else:
273+
toolkit.print(f"🌐 Server: [link={url}]{url}[/]")
274+
toolkit.print(f"📚 Docs: [link={url_docs}]{url_docs}[/]")
275+
toolkit.print("")
262276

263-
if command == "dev":
277+
if command == "dev" and use_rich:
264278
toolkit.print_line()
265279
toolkit.print(
266280
"Running in development mode, for production use: [bold]fastapi run[/]",
@@ -272,9 +286,10 @@ def _run(
272286
"Could not import Uvicorn, try running 'pip install uvicorn'"
273287
) from None
274288

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

279294
extra_uvicorn_kwargs: dict[str, Any] = {}
280295
if should_use_rich_uvicorn_logs():

src/fastapi_cli/utils/cli.py

Lines changed: 12 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,17 @@ def get_uvicorn_log_config() -> dict[str, Any]:
7171
logger = logging.getLogger(__name__)
7272

7373

74-
def get_rich_toolkit() -> RichToolkit:
74+
def should_use_rich_toolkit() -> bool:
75+
return sys.stdout.isatty()
76+
77+
78+
def get_rich_toolkit(use_rich: bool | None = None) -> RichToolkit:
79+
if use_rich is None:
80+
use_rich = should_use_rich_toolkit()
81+
82+
if not use_rich:
83+
return RichToolkit()
84+
7585
theme = RichToolkitTheme(
7686
style=TaggedStyle(tag_width=11),
7787
theme={

tests/test_cli.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717

1818

1919
@pytest.fixture(autouse=True)
20-
def force_rich_uvicorn_logs(monkeypatch: pytest.MonkeyPatch) -> None:
20+
def force_rich_output(monkeypatch: pytest.MonkeyPatch) -> None:
21+
monkeypatch.setattr("fastapi_cli.cli.should_use_rich_toolkit", lambda: True)
2122
monkeypatch.setattr("fastapi_cli.cli.should_use_rich_uvicorn_logs", lambda: True)
2223

2324

@@ -71,6 +72,36 @@ def test_run_uses_uvicorn_default_log_config_without_rich_logs(
7172
assert "log_config" not in mock_run.call_args.kwargs
7273

7374

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

@@ -29,6 +32,36 @@ def test_should_use_rich_uvicorn_logs_is_false_without_tty(
2932
assert should_use_rich_uvicorn_logs() is False
3033

3134

35+
def test_get_rich_toolkit_uses_tagged_style_when_requested() -> None:
36+
toolkit = get_rich_toolkit(use_rich=True)
37+
38+
assert isinstance(toolkit.style, TaggedStyle)
39+
40+
41+
def test_get_rich_toolkit_uses_minimal_style_without_rich() -> None:
42+
toolkit = get_rich_toolkit(use_rich=False)
43+
44+
assert isinstance(toolkit.style, MinimalStyle)
45+
46+
47+
def test_get_rich_toolkit_uses_minimal_style_without_tty(
48+
monkeypatch: MonkeyPatch,
49+
) -> None:
50+
monkeypatch.setattr(sys, "stdout", io.StringIO())
51+
52+
toolkit = get_rich_toolkit()
53+
54+
assert isinstance(toolkit.style, MinimalStyle)
55+
56+
57+
def test_should_use_rich_toolkit_is_false_without_tty(
58+
monkeypatch: MonkeyPatch,
59+
) -> None:
60+
monkeypatch.setattr(sys, "stdout", io.StringIO())
61+
62+
assert should_use_rich_toolkit() is False
63+
64+
3265
def test_custom_formatter() -> None:
3366
formatter = CustomFormatter()
3467

0 commit comments

Comments
 (0)