Skip to content

Commit a11b9d3

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

4 files changed

Lines changed: 136 additions & 26 deletions

File tree

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: 28 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,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": {

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: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,45 @@
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

6-
from fastapi_cli.utils.cli import CustomFormatter, get_uvicorn_log_config
9+
from fastapi_cli.utils.cli import (
10+
CustomFormatter,
11+
get_uvicorn_log_config,
12+
)
713

814

915
def test_get_uvicorn_config_uses_custom_formatter() -> None:
10-
config = get_uvicorn_log_config()
16+
config = get_uvicorn_log_config(use_rich=True)
1117

1218
assert config["formatters"]["default"]["()"] is CustomFormatter
1319
assert config["formatters"]["access"]["()"] is CustomFormatter
1420
assert config["loggers"]["uvicorn"]["propagate"] is False
1521

1622

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

0 commit comments

Comments
 (0)