Skip to content

Commit 60f6b21

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

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
@@ -23,6 +23,7 @@
2323
from .utils.cli import (
2424
get_rich_toolkit,
2525
get_uvicorn_log_config,
26+
should_use_rich_toolkit,
2627
should_use_rich_uvicorn_logs,
2728
)
2829

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

149-
toolkit.print_title(f"Starting {server_type} server 🚀", tag="FastAPI")
150-
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")
151155

152-
toolkit.print(
153-
"Searching for package file structure from directories with [blue]__init__.py[/blue] files"
154-
)
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+
)
155161

156162
if entrypoint and (path or app):
157163
toolkit.print_line()
@@ -197,69 +203,77 @@ def _run(
197203
module_data = import_data.module_data
198204
import_string = import_data.import_string
199205

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

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

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

234-
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+
)
235220
toolkit.print_line()
236221
toolkit.print(
237-
"You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:",
238-
tag="tip",
222+
f"[underline]from [bold]{module_data.module_import_str}[/bold] import [bold]{import_data.app_name}[/bold]"
239223
)
240224
toolkit.print_line()
225+
241226
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-
)
227+
f"Using import string: [blue]{import_string}[/]",
228+
tag="app",
250229
)
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+
)
251261

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

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-
)
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("")
261275

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

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

278293
uvicorn_kwargs: dict[str, Any] = {
279294
"app": import_string,

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)