From d1f65d57f904d77ef5f6aa297a89db1163ebe399 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Fri, 3 Jul 2026 16:19:59 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Use=20minimal=20startup=20output?= =?UTF-8?q?=20outside=20TTY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shortcake-Parent: 2026-07-09-trim-startup-output-add-verbose --- src/fastapi_cli/cli.py | 20 +++++++++++++------- src/fastapi_cli/utils/cli.py | 34 ++++++++++++++++++++++++++++++---- tests/test_cli.py | 21 +++++++++++++++++++++ tests/test_cli_pyproject.py | 6 ++++++ tests/test_utils_cli.py | 25 +++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 11 deletions(-) diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 1f1b6064..ce926c8f 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -144,7 +144,9 @@ def _run( public_url: str | None = None, verbose: bool = False, ) -> None: - with get_rich_toolkit() as toolkit: + use_rich = should_use_rich_logs() + + with get_rich_toolkit(use_rich=use_rich) as toolkit: server_type = "development" if command == "dev" else "production" toolkit.print(f"Starting FastAPI in {server_type} mode", emoji="⚡️") @@ -243,7 +245,7 @@ def _run( # Nudge to pin the entrypoint whenever it was auto-discovered, so it's # explicit next time — shown in the default output, not just --verbose - if is_auto_discovery: + if use_rich and is_auto_discovery: toolkit.print_line() toolkit.print( "You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:", @@ -264,7 +266,8 @@ def _run( url = public_url.rstrip("/") if public_url else f"http://{host}:{port}" url_docs = f"{url}/docs" - toolkit.print_line() + if use_rich: + toolkit.print_line() toolkit.print(f"Server started at [link={url}]{url}[/]", emoji="🌐") toolkit.print(f"Documentation at [link={url_docs}]{url_docs}[/]") @@ -273,12 +276,15 @@ def _run( "Could not import Uvicorn, try running 'pip install uvicorn'" ) from None - toolkit.print_line() - toolkit.print("Logs:", bullet=False) - toolkit.print_line() + if use_rich: + toolkit.print_line() + toolkit.print("Logs:", bullet=False) + toolkit.print_line() + else: + toolkit.print("") extra_uvicorn_kwargs: dict[str, Any] = ( - {"log_config": get_uvicorn_log_config()} if should_use_rich_logs() else {} + {"log_config": get_uvicorn_log_config()} if use_rich else {} ) uvicorn.run( diff --git a/src/fastapi_cli/utils/cli.py b/src/fastapi_cli/utils/cli.py index 3403d5ca..df73fef7 100644 --- a/src/fastapi_cli/utils/cli.py +++ b/src/fastapi_cli/utils/cli.py @@ -8,7 +8,7 @@ from rich.text import Text from rich_toolkit import RichToolkit from rich_toolkit.element import Element -from rich_toolkit.styles import BaseStyle +from rich_toolkit.styles import BaseStyle, MinimalStyle from uvicorn.logging import DefaultFormatter logger = logging.getLogger(__name__) @@ -108,6 +108,30 @@ def _get_bullet_prefix(self, emoji: str) -> Text: return prefix +class MinimalEmojiStyle(MinimalStyle): + """Minimal style that keeps the ``emoji=`` bullet as an inline prefix, so + the same ``toolkit.print(..., emoji=...)`` calls read as ``🐍 App: …`` + outside a TTY, where ``MinimalStyle`` would otherwise drop the emoji.""" + + def render_element( + self, + element: Any, + is_active: bool = False, + done: bool = False, + parent: Element | None = None, + **kwargs: Any, + ) -> RenderableType: + rendered = super().render_element( + element=element, is_active=is_active, done=done, parent=parent, **kwargs + ) + + emoji = kwargs.get("emoji", "") + if emoji and isinstance(rendered, str): + return f"{emoji} {rendered}" + + return rendered + + LOG_LEVEL_COLORS = { "debug": "blue", "info": "cyan", @@ -130,7 +154,7 @@ def _get_log_bullet(level: str) -> str: class CustomFormatter(DefaultFormatter): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.toolkit = get_rich_toolkit() + self.toolkit = get_rich_toolkit(use_rich=True) def formatMessage(self, record: logging.LogRecord) -> str: message = record.getMessage() @@ -186,5 +210,7 @@ def get_uvicorn_log_config() -> dict[str, Any]: } -def get_rich_toolkit() -> RichToolkit: - return RichToolkit(style=FastAPIStyle()) +def get_rich_toolkit(*, use_rich: bool) -> RichToolkit: + style: BaseStyle = FastAPIStyle() if use_rich else MinimalEmojiStyle() + + return RichToolkit(style=style) diff --git a/tests/test_cli.py b/tests/test_cli.py index 05523ae9..cb060e7e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -68,6 +68,27 @@ def test_run_uses_uvicorn_default_log_config_without_rich_logs( assert "log_config" not in mock_run.call_args.kwargs +def test_run_uses_minimal_output_without_tty(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("fastapi_cli.cli.should_use_rich_logs", lambda: False) + + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke(app, ["run", "single_file_app.py"]) + assert result.exit_code == 0, result.output + assert mock_run.called + assert mock_run.call_args + + assert "⚡️ Starting FastAPI in production mode" in result.output + assert "🐍 Using import string: single_file_app:app" in result.output + assert "🌐 Server started at http://0.0.0.0:8000" in result.output + assert "Documentation at http://0.0.0.0:8000/docs" in result.output + assert "Logs:" not in result.output + assert "Searching for package file structure" not in result.output + assert "Configuration sources:" not in result.output + assert "You can configure an entrypoint" not in result.output + assert "log_config" not in mock_run.call_args.kwargs + + def test_dev_no_args_auto_discovery() -> None: """Test that auto-discovery works when no args and no pyproject.toml entrypoint""" with changing_dir(assets_path / "default_files" / "default_main"): diff --git a/tests/test_cli_pyproject.py b/tests/test_cli_pyproject.py index f6b36890..ffd27aa5 100644 --- a/tests/test_cli_pyproject.py +++ b/tests/test_cli_pyproject.py @@ -1,6 +1,7 @@ from pathlib import Path from unittest.mock import patch +import pytest import uvicorn from typer.testing import CliRunner @@ -12,6 +13,11 @@ assets_path = Path(__file__).parent / "assets" +@pytest.fixture(autouse=True) +def force_rich_logs(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("fastapi_cli.cli.should_use_rich_logs", lambda: True) + + def test_dev_with_pyproject_app_config_uses() -> None: with ( changing_dir(assets_path / "pyproject_config"), diff --git a/tests/test_utils_cli.py b/tests/test_utils_cli.py index 597f25c6..d46cb447 100644 --- a/tests/test_utils_cli.py +++ b/tests/test_utils_cli.py @@ -7,6 +7,9 @@ from fastapi_cli.utils.cli import ( CustomFormatter, + FastAPIStyle, + MinimalEmojiStyle, + get_rich_toolkit, get_uvicorn_log_config, should_use_rich_logs, ) @@ -28,6 +31,28 @@ def test_should_use_rich_logs_is_false_without_tty( assert should_use_rich_logs() is False +def test_get_rich_toolkit_uses_fastapi_style_when_requested() -> None: + toolkit = get_rich_toolkit(use_rich=True) + + assert isinstance(toolkit.style, FastAPIStyle) + + +def test_get_rich_toolkit_uses_minimal_style_without_rich() -> None: + toolkit = get_rich_toolkit(use_rich=False) + + assert isinstance(toolkit.style, MinimalEmojiStyle) + + +def test_get_rich_toolkit_uses_minimal_style_without_tty( + monkeypatch: MonkeyPatch, +) -> None: + monkeypatch.setattr(sys, "stdout", io.StringIO()) + + toolkit = get_rich_toolkit(use_rich=should_use_rich_logs()) + + assert isinstance(toolkit.style, MinimalEmojiStyle) + + def test_custom_formatter() -> None: formatter = CustomFormatter()