Skip to content

Commit c899a93

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

6 files changed

Lines changed: 141 additions & 27 deletions

File tree

src/fastapi_cli/cli.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
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 (
24+
get_rich_toolkit,
25+
get_uvicorn_log_config,
26+
should_use_rich_uvicorn_logs,
27+
)
2428

2529
app = typer.Typer(
2630
rich_markup_mode="rich", context_settings={"help_option_names": ["-h", "--help"]}
@@ -271,22 +275,25 @@ def _run(
271275
toolkit.print("Logs:")
272276
toolkit.print_line()
273277

274-
uvicorn.run(
275-
app=import_string,
276-
host=host,
277-
port=port,
278-
reload=reload,
279-
reload_dirs=(
278+
uvicorn_kwargs: dict[str, Any] = {
279+
"app": import_string,
280+
"host": host,
281+
"port": port,
282+
"reload": reload,
283+
"reload_dirs": (
280284
[str(directory.resolve()) for directory in reload_dirs]
281285
if reload_dirs
282286
else None
283287
),
284-
workers=workers,
285-
root_path=root_path,
286-
proxy_headers=proxy_headers,
287-
forwarded_allow_ips=forwarded_allow_ips,
288-
log_config=get_uvicorn_log_config(),
289-
)
288+
"workers": workers,
289+
"root_path": root_path,
290+
"proxy_headers": proxy_headers,
291+
"forwarded_allow_ips": forwarded_allow_ips,
292+
}
293+
if should_use_rich_uvicorn_logs():
294+
uvicorn_kwargs["log_config"] = get_uvicorn_log_config()
295+
296+
uvicorn.run(**uvicorn_kwargs)
290297

291298

292299
@app.command()

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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import sys
23
from typing import Any
34

45
from rich_toolkit import RichToolkit, RichToolkitTheme
@@ -20,6 +21,10 @@ def formatMessage(self, record: logging.LogRecord) -> str:
2021
return result
2122

2223

24+
def should_use_rich_uvicorn_logs() -> bool:
25+
return sys.stderr.isatty() and sys.stdout.isatty()
26+
27+
2328
def get_uvicorn_log_config() -> dict[str, Any]:
2429
return {
2530
"version": 1,

tests/test_cli.py

Lines changed: 20 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_uvicorn_logs(monkeypatch: pytest.MonkeyPatch) -> None:
21+
monkeypatch.setattr("fastapi_cli.cli.should_use_rich_uvicorn_logs", 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,21 @@ def test_dev() -> None:
5156
assert "🐍 single_file_app.py" in result.output
5257

5358

59+
def test_run_uses_uvicorn_default_log_config_without_rich_logs(
60+
monkeypatch: pytest.MonkeyPatch,
61+
) -> None:
62+
monkeypatch.setattr("fastapi_cli.cli.should_use_rich_uvicorn_logs", lambda: False)
63+
64+
with changing_dir(assets_path):
65+
with patch.object(uvicorn, "run") as mock_run:
66+
result = runner.invoke(app, ["run", "single_file_app.py"])
67+
assert result.exit_code == 0, result.output
68+
assert mock_run.called
69+
assert mock_run.call_args
70+
71+
assert "log_config" not in mock_run.call_args.kwargs
72+
73+
5474
def test_dev_no_args_auto_discovery() -> None:
5575
"""Test that auto-discovery works when no args and no pyproject.toml entrypoint"""
5676
with changing_dir(assets_path / "default_files" / "default_main"):

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: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
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
57

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

814

915
def test_get_uvicorn_config_uses_custom_formatter() -> None:
@@ -14,6 +20,15 @@ def test_get_uvicorn_config_uses_custom_formatter() -> None:
1420
assert config["loggers"]["uvicorn"]["propagate"] is False
1521

1622

23+
def test_should_use_rich_uvicorn_logs_is_false_without_tty(
24+
monkeypatch: MonkeyPatch,
25+
) -> None:
26+
monkeypatch.setattr(sys, "stderr", io.StringIO())
27+
monkeypatch.setattr(sys, "stdout", io.StringIO())
28+
29+
assert should_use_rich_uvicorn_logs() is False
30+
31+
1732
def test_custom_formatter() -> None:
1833
formatter = CustomFormatter()
1934

0 commit comments

Comments
 (0)