Skip to content

Commit 39a8fc8

Browse files
committed
🐛 Avoid fancy logs for non-TTY output
Shortcake-Parent: 2026-07-03-fix-duplicate-uvicorn-logs-under-fastapi-cli
1 parent a81445e commit 39a8fc8

5 files changed

Lines changed: 179 additions & 33 deletions

File tree

src/fastapi_cli/cli.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,8 @@ def _run(
249249
url_docs = f"{url}/docs"
250250

251251
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-
)
252+
toolkit.print(f"Server started at [link={url}]{url}[/]", tag="server")
253+
toolkit.print(f"Documentation at [link={url_docs}]{url_docs}[/]", tag="server")
257254

258255
if command == "dev":
259256
toolkit.print_line()

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: 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,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,13 @@ def get_uvicorn_log_config() -> dict[str, Any]:
6687
logger = logging.getLogger(__name__)
6788

6889

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

tests/test_logging.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import io
2+
import logging
3+
import sys
4+
5+
from pytest import MonkeyPatch
6+
from rich.logging import RichHandler
7+
8+
from fastapi_cli.logging import setup_logging
9+
10+
11+
def test_setup_logging_uses_rich_handler_when_requested() -> None:
12+
logger = logging.getLogger("fastapi_cli")
13+
original_handlers = logger.handlers[:]
14+
original_level = logger.level
15+
original_propagate = logger.propagate
16+
try:
17+
logger.handlers = []
18+
19+
setup_logging(use_rich=True)
20+
21+
assert len(logger.handlers) == 1
22+
assert isinstance(logger.handlers[0], RichHandler)
23+
finally:
24+
for handler in logger.handlers:
25+
if handler not in original_handlers:
26+
handler.close()
27+
logger.handlers = original_handlers
28+
logger.setLevel(original_level)
29+
logger.propagate = original_propagate
30+
31+
32+
def test_setup_logging_uses_stream_handler_without_tty(
33+
monkeypatch: MonkeyPatch,
34+
) -> None:
35+
logger = logging.getLogger("fastapi_cli")
36+
original_handlers = logger.handlers[:]
37+
original_level = logger.level
38+
original_propagate = logger.propagate
39+
try:
40+
logger.handlers = []
41+
monkeypatch.setattr(sys, "stderr", io.StringIO())
42+
43+
setup_logging()
44+
45+
assert len(logger.handlers) == 1
46+
assert isinstance(logger.handlers[0], logging.StreamHandler)
47+
assert not isinstance(logger.handlers[0], RichHandler)
48+
finally:
49+
for handler in logger.handlers:
50+
if handler not in original_handlers:
51+
handler.close()
52+
logger.handlers = original_handlers
53+
logger.setLevel(original_level)
54+
logger.propagate = original_propagate

tests/test_utils_cli.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,69 @@
1+
import io
12
import logging
3+
import sys
24
from logging.config import dictConfig
35

4-
from pytest import LogCaptureFixture
6+
from pytest import LogCaptureFixture, MonkeyPatch
7+
from rich_toolkit.styles import MinimalStyle, TaggedStyle
8+
from uvicorn.logging import AccessFormatter, DefaultFormatter
59

6-
from fastapi_cli.utils.cli import CustomFormatter, get_uvicorn_log_config
10+
from fastapi_cli.utils.cli import (
11+
CustomFormatter,
12+
get_rich_toolkit,
13+
get_uvicorn_log_config,
14+
)
715

816

917
def test_get_uvicorn_config_uses_custom_formatter() -> None:
10-
config = get_uvicorn_log_config()
18+
config = get_uvicorn_log_config(use_rich=True)
1119

1220
assert config["formatters"]["default"]["()"] is CustomFormatter
1321
assert config["formatters"]["access"]["()"] is CustomFormatter
1422
assert config["loggers"]["uvicorn"]["propagate"] is False
1523

1624

25+
def test_get_uvicorn_config_uses_plain_formatters_without_rich() -> None:
26+
config = get_uvicorn_log_config(use_rich=False)
27+
28+
assert config["formatters"]["default"]["()"] is DefaultFormatter
29+
assert config["formatters"]["access"]["()"] is AccessFormatter
30+
assert config["loggers"]["uvicorn"]["propagate"] is False
31+
32+
33+
def test_get_uvicorn_config_uses_plain_formatters_without_tty(
34+
monkeypatch: MonkeyPatch,
35+
) -> None:
36+
monkeypatch.setattr(sys, "stderr", io.StringIO())
37+
monkeypatch.setattr(sys, "stdout", io.StringIO())
38+
39+
config = get_uvicorn_log_config()
40+
41+
assert config["formatters"]["default"]["()"] is DefaultFormatter
42+
assert config["formatters"]["access"]["()"] is AccessFormatter
43+
44+
45+
def test_get_rich_toolkit_uses_tagged_style_when_requested() -> None:
46+
toolkit = get_rich_toolkit(use_rich=True)
47+
48+
assert isinstance(toolkit.style, TaggedStyle)
49+
50+
51+
def test_get_rich_toolkit_uses_minimal_style_without_rich() -> None:
52+
toolkit = get_rich_toolkit(use_rich=False)
53+
54+
assert isinstance(toolkit.style, MinimalStyle)
55+
56+
57+
def test_get_rich_toolkit_uses_minimal_style_without_tty(
58+
monkeypatch: MonkeyPatch,
59+
) -> None:
60+
monkeypatch.setattr(sys, "stdout", io.StringIO())
61+
62+
toolkit = get_rich_toolkit()
63+
64+
assert isinstance(toolkit.style, MinimalStyle)
65+
66+
1767
def test_custom_formatter() -> None:
1868
formatter = CustomFormatter()
1969

0 commit comments

Comments
 (0)