Skip to content

Commit 25e907f

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

7 files changed

Lines changed: 159 additions & 28 deletions

File tree

src/fastapi_cli/cli.py

Lines changed: 10 additions & 2 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_logs,
27+
)
2428

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

278+
extra_uvicorn_kwargs: dict[str, Any] = {}
279+
if should_use_rich_logs():
280+
extra_uvicorn_kwargs["log_config"] = get_uvicorn_log_config()
281+
274282
uvicorn.run(
275283
app=import_string,
276284
host=host,
@@ -285,7 +293,7 @@ def _run(
285293
root_path=root_path,
286294
proxy_headers=proxy_headers,
287295
forwarded_allow_ips=forwarded_allow_ips,
288-
log_config=get_uvicorn_log_config(),
296+
**extra_uvicorn_kwargs,
289297
)
290298

291299

src/fastapi_cli/logging.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,38 @@
11
import logging
22

3-
from rich.console import Console
3+
from rich.errors import MarkupError
44
from rich.logging import RichHandler
5+
from rich.markup import render
56

7+
from .utils.cli import should_use_rich_logs
68

7-
def setup_logging(terminal_width: int | None = None, level: int = logging.INFO) -> None:
9+
10+
class PlainFormatter(logging.Formatter):
11+
def formatMessage(self, record: logging.LogRecord) -> str:
12+
try:
13+
record.message = render(record.message, emoji=False).plain
14+
except MarkupError:
15+
pass
16+
return super().formatMessage(record)
17+
18+
19+
def setup_logging(level: int = logging.INFO) -> None:
820
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)
21+
if should_use_rich_logs():
22+
rich_handler = RichHandler(
23+
show_time=False,
24+
rich_tracebacks=True,
25+
tracebacks_show_locals=True,
26+
markup=True,
27+
show_path=False,
28+
)
29+
rich_handler.setFormatter(logging.Formatter("%(message)s"))
30+
handler: logging.Handler = rich_handler
31+
else:
32+
handler = logging.StreamHandler()
33+
handler.setFormatter(PlainFormatter("%(levelname)s:%(name)s:%(message)s"))
34+
35+
logger.addHandler(handler)
2036

2137
logger.setLevel(level)
2238
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_logs() -> bool:
25+
return sys.stdout.isatty()
26+
27+
2328
def get_uvicorn_log_config() -> dict[str, Any]:
2429
return {
2530
"version": 1,

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@ def reset_syspath() -> Generator[None, None, None]:
2020
def setup_terminal() -> None:
2121
rich_utils.MAX_WIDTH = 3000
2222
rich_utils.FORCE_TERMINAL = False
23-
setup_logging(terminal_width=3000)
23+
setup_logging()
2424
return

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_logs(monkeypatch: pytest.MonkeyPatch) -> None:
21+
monkeypatch.setattr("fastapi_cli.cli.should_use_rich_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_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_utils_cli.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
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.logging import RichHandler
58

6-
from fastapi_cli.utils.cli import CustomFormatter, get_uvicorn_log_config
9+
from fastapi_cli import logging as cli_logging
10+
from fastapi_cli.logging import PlainFormatter
11+
from fastapi_cli.utils.cli import (
12+
CustomFormatter,
13+
get_uvicorn_log_config,
14+
should_use_rich_logs,
15+
)
716

817

918
def test_get_uvicorn_config_uses_custom_formatter() -> None:
@@ -14,6 +23,68 @@ def test_get_uvicorn_config_uses_custom_formatter() -> None:
1423
assert config["loggers"]["uvicorn"]["propagate"] is False
1524

1625

26+
def test_should_use_rich_logs_is_false_without_tty(
27+
monkeypatch: MonkeyPatch,
28+
) -> None:
29+
monkeypatch.setattr(sys, "stdout", io.StringIO())
30+
31+
assert should_use_rich_logs() is False
32+
33+
34+
def test_plain_formatter_strips_rich_markup() -> None:
35+
formatter = PlainFormatter("%(levelname)s:%(name)s:%(message)s")
36+
record = logging.LogRecord(
37+
name="fastapi_cli.discover",
38+
level=logging.WARNING,
39+
pathname="",
40+
lineno=0,
41+
msg="Ensure all the package directories have an [blue]__init__.py[/blue] file",
42+
args=(),
43+
exc_info=None,
44+
)
45+
46+
assert (
47+
formatter.format(record)
48+
== "WARNING:fastapi_cli.discover:Ensure all the package directories have an __init__.py file"
49+
)
50+
51+
52+
def test_plain_formatter_preserves_invalid_markup() -> None:
53+
formatter = PlainFormatter("%(message)s")
54+
record = logging.LogRecord(
55+
name="fastapi_cli.discover",
56+
level=logging.WARNING,
57+
pathname="",
58+
lineno=0,
59+
msg="[/blue]",
60+
args=(),
61+
exc_info=None,
62+
)
63+
64+
assert formatter.format(record) == "[/blue]"
65+
66+
67+
def test_setup_logging_uses_rich_handler_when_enabled(
68+
monkeypatch: MonkeyPatch,
69+
) -> None:
70+
logger = logging.getLogger("fastapi_cli")
71+
original_handlers = logger.handlers.copy()
72+
original_level = logger.level
73+
original_propagate = logger.propagate
74+
logger.handlers.clear()
75+
monkeypatch.setattr(cli_logging, "should_use_rich_logs", lambda: True)
76+
try:
77+
cli_logging.setup_logging(level=logging.DEBUG)
78+
79+
assert isinstance(logger.handlers[0], RichHandler)
80+
assert logger.level == logging.DEBUG
81+
assert logger.propagate is False
82+
finally:
83+
logger.handlers = original_handlers
84+
logger.setLevel(original_level)
85+
logger.propagate = original_propagate
86+
87+
1788
def test_custom_formatter() -> None:
1889
formatter = CustomFormatter()
1990

tests/test_utils_package.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import io
2+
import logging
13
from pathlib import Path
24

35
import pytest
4-
from pytest import CaptureFixture
56

67
from fastapi_cli.discover import get_import_data
78
from fastapi_cli.exceptions import FastAPICLIException
9+
from fastapi_cli.logging import PlainFormatter
810
from tests.utils import changing_dir
911

1012
assets_path = Path(__file__).parent / "assets"
@@ -158,15 +160,24 @@ def test_package_dir_explicit_app() -> None:
158160
assert import_data.module_data.module_import_str == "package"
159161

160162

161-
def test_broken_package_dir(capsys: CaptureFixture[str]) -> None:
162-
with changing_dir(assets_path):
163-
# TODO (when deprecating Python 3.8): remove ValueError
164-
with pytest.raises((ImportError, ValueError)):
165-
get_import_data(path=Path("broken_package/mod/app.py"))
166-
167-
captured = capsys.readouterr()
168-
assert "Import error:" in captured.out
169-
assert "Ensure all the package directories have an __init__.py file" in captured.out
163+
def test_broken_package_dir() -> None:
164+
stream = io.StringIO()
165+
handler = logging.StreamHandler(stream)
166+
handler.setFormatter(PlainFormatter("%(levelname)s:%(name)s:%(message)s"))
167+
logger = logging.getLogger("fastapi_cli")
168+
logger.addHandler(handler)
169+
try:
170+
with changing_dir(assets_path):
171+
# TODO (when deprecating Python 3.8): remove ValueError
172+
with pytest.raises((ImportError, ValueError)):
173+
get_import_data(path=Path("broken_package/mod/app.py"))
174+
finally:
175+
logger.removeHandler(handler)
176+
handler.close()
177+
178+
output = stream.getvalue()
179+
assert "Import error:" in output
180+
assert "Ensure all the package directories have an __init__.py file" in output
170181

171182

172183
def test_package_dir_no_app() -> None:

0 commit comments

Comments
 (0)