Skip to content

Commit c9eb92a

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

7 files changed

Lines changed: 291 additions & 86 deletions

File tree

src/fastapi_cli/cli.py

Lines changed: 72 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,77 @@ 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}[/]")
269+
toolkit.print("")
257270

258-
if command == "dev":
271+
if command == "dev" and use_rich:
259272
toolkit.print_line()
260273
toolkit.print(
261274
"Running in development mode, for production use: [bold]fastapi run[/]",
@@ -267,9 +280,10 @@ def _run(
267280
"Could not import Uvicorn, try running 'pip install uvicorn'"
268281
) from None
269282

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

274288
uvicorn.run(
275289
app=import_string,

src/fastapi_cli/logging.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,35 @@
11
import logging
2+
import sys
23

34
from rich.console import Console
45
from rich.logging import RichHandler
56

67

7-
def setup_logging(terminal_width: int | None = None, level: int = logging.INFO) -> None:
8+
def setup_logging(
9+
terminal_width: int | None = None,
10+
level: int = logging.INFO,
11+
use_rich: bool | None = None,
12+
) -> None:
813
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)
14+
if use_rich is None:
15+
use_rich = sys.stderr.isatty()
16+
17+
if use_rich:
18+
console = Console(width=terminal_width) if terminal_width else None
19+
handler: logging.Handler = RichHandler(
20+
show_time=False,
21+
rich_tracebacks=True,
22+
tracebacks_show_locals=True,
23+
markup=True,
24+
show_path=False,
25+
console=console,
26+
)
27+
handler.setFormatter(logging.Formatter("%(message)s"))
28+
else:
29+
handler = logging.StreamHandler()
30+
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
31+
32+
logger.addHandler(handler)
2033

2134
logger.setLevel(level)
2235
logger.propagate = False

src/fastapi_cli/utils/cli.py

Lines changed: 40 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,36 @@ def formatMessage(self, record: logging.LogRecord) -> str:
2021
return result
2122

2223

23-
def get_uvicorn_log_config() -> dict[str, Any]:
24+
def get_uvicorn_log_config(use_rich: bool | None = None) -> dict[str, Any]:
25+
if use_rich is None:
26+
use_rich = sys.stderr.isatty() and sys.stdout.isatty()
27+
28+
default_formatter: dict[str, Any] = {
29+
"()": DefaultFormatter,
30+
"fmt": "%(levelprefix)s %(message)s",
31+
"use_colors": None,
32+
}
33+
access_formatter: dict[str, Any] = {
34+
"()": AccessFormatter,
35+
"fmt": '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
36+
}
37+
if use_rich:
38+
default_formatter = {
39+
"()": CustomFormatter,
40+
"fmt": "%(levelprefix)s %(message)s",
41+
"use_colors": None,
42+
}
43+
access_formatter = {
44+
"()": CustomFormatter,
45+
"fmt": "%(levelprefix)s %(client_addr)s - '%(request_line)s' %(status_code)s",
46+
}
47+
2448
return {
2549
"version": 1,
2650
"disable_existing_loggers": False,
2751
"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-
},
52+
"default": default_formatter,
53+
"access": access_formatter,
3754
},
3855
"handlers": {
3956
"default": {
@@ -66,7 +83,17 @@ def get_uvicorn_log_config() -> dict[str, Any]:
6683
logger = logging.getLogger(__name__)
6784

6885

69-
def get_rich_toolkit() -> RichToolkit:
86+
def should_use_rich_toolkit() -> bool:
87+
return sys.stdout.isatty()
88+
89+
90+
def get_rich_toolkit(use_rich: bool | None = None) -> RichToolkit:
91+
if use_rich is None:
92+
use_rich = should_use_rich_toolkit()
93+
94+
if not use_rich:
95+
return RichToolkit()
96+
7097
theme = RichToolkitTheme(
7198
style=TaggedStyle(tag_width=11),
7299
theme={

tests/test_cli.py

Lines changed: 32 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,33 @@ 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 "📚 Docs: http://0.0.0.0:8000/docs\n\n" in result.output
73+
assert "Logs:" not in result.output
74+
assert "Source:" not in result.output
75+
assert "Server started at" not in result.output
76+
assert "Documentation at" not in result.output
77+
assert "Searching for package file structure" not in result.output
78+
assert "Importing from" not in result.output
79+
assert "🐍 single_file_app.py" not in result.output
80+
assert "Importing the FastAPI app object" not in result.output
81+
assert "Using import string:" not in result.output
82+
assert "Configuration sources:" not in result.output
83+
assert "You can configure an entrypoint" not in result.output
84+
85+
5486
def test_dev_no_args_auto_discovery() -> None:
5587
"""Test that auto-discovery works when no args and no pyproject.toml entrypoint"""
5688
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)