Skip to content

Commit 9ce1508

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 9ce1508

4 files changed

Lines changed: 141 additions & 25 deletions

File tree

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: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
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):
@@ -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": {

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: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,42 @@
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 uvicorn.logging import AccessFormatter, DefaultFormatter
58

69
from fastapi_cli.utils.cli import CustomFormatter, get_uvicorn_log_config
710

811

912
def test_get_uvicorn_config_uses_custom_formatter() -> None:
10-
config = get_uvicorn_log_config()
13+
config = get_uvicorn_log_config(use_rich=True)
1114

1215
assert config["formatters"]["default"]["()"] is CustomFormatter
1316
assert config["formatters"]["access"]["()"] is CustomFormatter
1417
assert config["loggers"]["uvicorn"]["propagate"] is False
1518

1619

20+
def test_get_uvicorn_config_uses_plain_formatters_without_rich() -> None:
21+
config = get_uvicorn_log_config(use_rich=False)
22+
23+
assert config["formatters"]["default"]["()"] is DefaultFormatter
24+
assert config["formatters"]["access"]["()"] is AccessFormatter
25+
assert config["loggers"]["uvicorn"]["propagate"] is False
26+
27+
28+
def test_get_uvicorn_config_uses_plain_formatters_without_tty(
29+
monkeypatch: MonkeyPatch,
30+
) -> None:
31+
monkeypatch.setattr(sys, "stderr", io.StringIO())
32+
monkeypatch.setattr(sys, "stdout", io.StringIO())
33+
34+
config = get_uvicorn_log_config()
35+
36+
assert config["formatters"]["default"]["()"] is DefaultFormatter
37+
assert config["formatters"]["access"]["()"] is AccessFormatter
38+
39+
1740
def test_custom_formatter() -> None:
1841
formatter = CustomFormatter()
1942

0 commit comments

Comments
 (0)