Skip to content

Commit a2447b9

Browse files
committed
🐛 Avoid fancy logs for non-TTY output
Shortcake-Parent: main
1 parent daeba73 commit a2447b9

7 files changed

Lines changed: 298 additions & 86 deletions

File tree

src/fastapi_cli/cli.py

Lines changed: 71 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from . import __version__
2222
from .logging import setup_logging
23-
from .utils.cli import get_rich_toolkit, get_uvicorn_log_config
23+
from .utils.cli import get_rich_toolkit, get_uvicorn_log_config, should_use_rich_toolkit
2424

2525
app = typer.Typer(
2626
rich_markup_mode="rich", context_settings={"help_option_names": ["-h", "--help"]}
@@ -139,15 +139,20 @@ def _run(
139139
forwarded_allow_ips: str | None = None,
140140
public_url: str | None = None,
141141
) -> None:
142-
with get_rich_toolkit() as toolkit:
142+
use_rich = should_use_rich_toolkit()
143+
with get_rich_toolkit(use_rich=use_rich) as toolkit:
143144
server_type = "development" if command == "dev" else "production"
144145

145-
toolkit.print_title(f"Starting {server_type} server 🚀", tag="FastAPI")
146-
toolkit.print_line()
146+
if use_rich:
147+
toolkit.print_title(f"Starting {server_type} server 🚀", tag="FastAPI")
148+
else:
149+
toolkit.print_title("⚡️ Starting FastAPI")
147150

148-
toolkit.print(
149-
"Searching for package file structure from directories with [blue]__init__.py[/blue] files"
150-
)
151+
if use_rich:
152+
toolkit.print_line()
153+
toolkit.print(
154+
"Searching for package file structure from directories with [blue]__init__.py[/blue] files"
155+
)
151156

152157
if entrypoint and (path or app):
153158
toolkit.print_line()
@@ -193,69 +198,76 @@ def _run(
193198
module_data = import_data.module_data
194199
import_string = import_data.import_string
195200

196-
toolkit.print(f"Importing from {module_data.extra_sys_path}")
197-
toolkit.print_line()
198-
199-
if module_data.module_paths:
200-
root_tree = _get_module_tree(module_data.module_paths)
201-
202-
toolkit.print(root_tree, tag="module")
201+
if use_rich:
202+
toolkit.print(f"Importing from {module_data.extra_sys_path}")
203203
toolkit.print_line()
204204

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

220-
mod_source_desc = SOURCE_DESCRIPTIONS[import_data.module_config_source]
221-
app_source_desc = SOURCE_DESCRIPTIONS[import_data.app_name_config_source]
222-
toolkit.print_line()
223-
toolkit.print("Configuration sources:", tag="info")
224-
if mod_source_desc == app_source_desc:
225-
toolkit.print(f" • Import string: {mod_source_desc}")
226-
else:
227-
toolkit.print(f" • Module: {mod_source_desc}")
228-
toolkit.print(f" • App name: {app_source_desc}")
208+
toolkit.print(root_tree, tag="module")
209+
toolkit.print_line()
229210

230-
if import_data.module_config_source == "auto-discovery":
211+
toolkit.print(
212+
"Importing the FastAPI app object from the module with the following code:",
213+
tag="code",
214+
)
231215
toolkit.print_line()
232216
toolkit.print(
233-
"You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:",
234-
tag="tip",
217+
f"[underline]from [bold]{module_data.module_import_str}[/bold] import [bold]{import_data.app_name}[/bold]"
235218
)
236219
toolkit.print_line()
220+
237221
toolkit.print(
238-
Syntax(
239-
(
240-
"[tool.fastapi]\n"
241-
f'entrypoint = "{import_data.module_data.module_import_str}:{import_data.app_name}"'
242-
),
243-
"toml",
244-
theme="ansi_light",
245-
)
222+
f"Using import string: [blue]{import_string}[/]",
223+
tag="app",
246224
)
225+
else:
226+
toolkit.print(f"🐍 App: [blue]{import_string}[/]")
227+
228+
if use_rich:
229+
mod_source_desc = SOURCE_DESCRIPTIONS[import_data.module_config_source]
230+
app_source_desc = SOURCE_DESCRIPTIONS[import_data.app_name_config_source]
231+
toolkit.print_line()
232+
toolkit.print("Configuration sources:", tag="info")
233+
if mod_source_desc == app_source_desc:
234+
toolkit.print(f" • Import string: {mod_source_desc}")
235+
else:
236+
toolkit.print(f" • Module: {mod_source_desc}")
237+
toolkit.print(f" • App name: {app_source_desc}")
238+
239+
if import_data.module_config_source == "auto-discovery":
240+
toolkit.print_line()
241+
toolkit.print(
242+
"You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:",
243+
tag="tip",
244+
)
245+
toolkit.print_line()
246+
toolkit.print(
247+
Syntax(
248+
(
249+
"[tool.fastapi]\n"
250+
f'entrypoint = "{import_data.module_data.module_import_str}:{import_data.app_name}"'
251+
),
252+
"toml",
253+
theme="ansi_light",
254+
)
255+
)
247256

248257
url = public_url.rstrip("/") if public_url else f"http://{host}:{port}"
249258
url_docs = f"{url}/docs"
250259

251-
toolkit.print_line()
252-
toolkit.print(
253-
f"Server started at [link={url}]{url}[/]",
254-
f"Documentation at [link={url_docs}]{url_docs}[/]",
255-
tag="server",
256-
)
260+
if use_rich:
261+
toolkit.print_line()
262+
toolkit.print(f"Server started at [link={url}]{url}[/]", tag="server")
263+
toolkit.print(
264+
f"Documentation at [link={url_docs}]{url_docs}[/]", tag="server"
265+
)
266+
else:
267+
toolkit.print(f"🌐 Server: [link={url}]{url}[/]")
268+
toolkit.print(f"📚 Docs: [link={url_docs}]{url_docs}[/]")
257269

258-
if command == "dev":
270+
if command == "dev" and use_rich:
259271
toolkit.print_line()
260272
toolkit.print(
261273
"Running in development mode, for production use: [bold]fastapi run[/]",
@@ -267,9 +279,10 @@ def _run(
267279
"Could not import Uvicorn, try running 'pip install uvicorn'"
268280
) from None
269281

270-
toolkit.print_line()
271-
toolkit.print("Logs:")
272-
toolkit.print_line()
282+
if use_rich:
283+
toolkit.print_line()
284+
toolkit.print("Logs:")
285+
toolkit.print_line()
273286

274287
uvicorn.run(
275288
app=import_string,

src/fastapi_cli/logging.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,40 @@
11
import logging
2+
import sys
3+
from typing import Any
24

35
from rich.console import Console
46
from rich.logging import RichHandler
57

68

7-
def setup_logging(terminal_width: int | None = None, level: int = logging.INFO) -> None:
9+
def _stream_is_tty(stream: Any) -> bool:
10+
return bool(getattr(stream, "isatty", lambda: False)())
11+
12+
13+
def setup_logging(
14+
terminal_width: int | None = None,
15+
level: int = logging.INFO,
16+
use_rich: bool | None = None,
17+
) -> None:
818
logger = logging.getLogger("fastapi_cli")
9-
console = Console(width=terminal_width) if terminal_width else None
10-
rich_handler = RichHandler(
11-
show_time=False,
12-
rich_tracebacks=True,
13-
tracebacks_show_locals=True,
14-
markup=True,
15-
show_path=False,
16-
console=console,
17-
)
18-
rich_handler.setFormatter(logging.Formatter("%(message)s"))
19-
logger.addHandler(rich_handler)
19+
if use_rich is None:
20+
use_rich = _stream_is_tty(sys.stderr)
21+
22+
if use_rich:
23+
console = Console(width=terminal_width) if terminal_width else None
24+
handler: logging.Handler = RichHandler(
25+
show_time=False,
26+
rich_tracebacks=True,
27+
tracebacks_show_locals=True,
28+
markup=True,
29+
show_path=False,
30+
console=console,
31+
)
32+
handler.setFormatter(logging.Formatter("%(message)s"))
33+
else:
34+
handler = logging.StreamHandler()
35+
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
36+
37+
logger.addHandler(handler)
2038

2139
logger.setLevel(level)
2240
logger.propagate = False

src/fastapi_cli/utils/cli.py

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import logging
2+
import sys
23
from typing import Any
34

45
from rich_toolkit import RichToolkit, RichToolkitTheme
56
from rich_toolkit.styles import TaggedStyle
6-
from uvicorn.logging import DefaultFormatter
7+
from uvicorn.logging import AccessFormatter, DefaultFormatter
78

89

910
class CustomFormatter(DefaultFormatter):
1011
def __init__(self, *args: Any, **kwargs: Any) -> None:
1112
super().__init__(*args, **kwargs)
12-
self.toolkit = get_rich_toolkit()
13+
self.toolkit = get_rich_toolkit(use_rich=True)
1314

1415
def formatMessage(self, record: logging.LogRecord) -> str:
1516
message = record.getMessage()
@@ -20,20 +21,40 @@ def formatMessage(self, record: logging.LogRecord) -> str:
2021
return result
2122

2223

23-
def get_uvicorn_log_config() -> dict[str, Any]:
24+
def _stream_is_tty(stream: Any) -> bool:
25+
return bool(getattr(stream, "isatty", lambda: False)())
26+
27+
28+
def get_uvicorn_log_config(use_rich: bool | None = None) -> dict[str, Any]:
29+
if use_rich is None:
30+
use_rich = _stream_is_tty(sys.stderr) and _stream_is_tty(sys.stdout)
31+
32+
default_formatter: dict[str, Any] = {
33+
"()": DefaultFormatter,
34+
"fmt": "%(levelprefix)s %(message)s",
35+
"use_colors": None,
36+
}
37+
access_formatter: dict[str, Any] = {
38+
"()": AccessFormatter,
39+
"fmt": '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
40+
}
41+
if use_rich:
42+
default_formatter = {
43+
"()": CustomFormatter,
44+
"fmt": "%(levelprefix)s %(message)s",
45+
"use_colors": None,
46+
}
47+
access_formatter = {
48+
"()": CustomFormatter,
49+
"fmt": "%(levelprefix)s %(client_addr)s - '%(request_line)s' %(status_code)s",
50+
}
51+
2452
return {
2553
"version": 1,
2654
"disable_existing_loggers": False,
2755
"formatters": {
28-
"default": {
29-
"()": CustomFormatter,
30-
"fmt": "%(levelprefix)s %(message)s",
31-
"use_colors": None,
32-
},
33-
"access": {
34-
"()": CustomFormatter,
35-
"fmt": "%(levelprefix)s %(client_addr)s - '%(request_line)s' %(status_code)s",
36-
},
56+
"default": default_formatter,
57+
"access": access_formatter,
3758
},
3859
"handlers": {
3960
"default": {
@@ -66,7 +87,17 @@ def get_uvicorn_log_config() -> dict[str, Any]:
6687
logger = logging.getLogger(__name__)
6788

6889

69-
def get_rich_toolkit() -> RichToolkit:
90+
def should_use_rich_toolkit() -> bool:
91+
return _stream_is_tty(sys.stdout)
92+
93+
94+
def get_rich_toolkit(use_rich: bool | None = None) -> RichToolkit:
95+
if use_rich is None:
96+
use_rich = should_use_rich_toolkit()
97+
98+
if not use_rich:
99+
return RichToolkit()
100+
70101
theme = RichToolkitTheme(
71102
style=TaggedStyle(tag_width=11),
72103
theme={

tests/test_cli.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
assets_path = Path(__file__).parent / "assets"
1717

1818

19+
@pytest.fixture(autouse=True)
20+
def force_rich_toolkit(monkeypatch: pytest.MonkeyPatch) -> None:
21+
monkeypatch.setattr("fastapi_cli.cli.should_use_rich_toolkit", lambda: True)
22+
23+
1924
def test_dev() -> None:
2025
with changing_dir(assets_path):
2126
with patch.object(uvicorn, "run") as mock_run:
@@ -51,6 +56,32 @@ def test_dev() -> None:
5156
assert "🐍 single_file_app.py" in result.output
5257

5358

59+
def test_run_uses_minimal_output_without_tty(monkeypatch: pytest.MonkeyPatch) -> None:
60+
monkeypatch.setattr("fastapi_cli.cli.should_use_rich_toolkit", lambda: False)
61+
62+
with changing_dir(assets_path):
63+
with patch.object(uvicorn, "run") as mock_run:
64+
result = runner.invoke(app, ["run", "single_file_app.py"])
65+
assert result.exit_code == 0, result.output
66+
assert mock_run.called
67+
68+
assert "⚡️ Starting FastAPI" in result.output
69+
assert "🐍 App: single_file_app:app" in result.output
70+
assert "🌐 Server: http://0.0.0.0:8000" in result.output
71+
assert "📚 Docs: http://0.0.0.0:8000/docs" in result.output
72+
assert "Logs:" not in result.output
73+
assert "Source:" not in result.output
74+
assert "Server started at" not in result.output
75+
assert "Documentation at" not in result.output
76+
assert "Searching for package file structure" not in result.output
77+
assert "Importing from" not in result.output
78+
assert "🐍 single_file_app.py" not in result.output
79+
assert "Importing the FastAPI app object" not in result.output
80+
assert "Using import string:" not in result.output
81+
assert "Configuration sources:" not in result.output
82+
assert "You can configure an entrypoint" not in result.output
83+
84+
5485
def test_dev_no_args_auto_discovery() -> None:
5586
"""Test that auto-discovery works when no args and no pyproject.toml entrypoint"""
5687
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"),

0 commit comments

Comments
 (0)