diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index f3983fcd..da4e5745 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -143,15 +143,20 @@ def _run( forwarded_allow_ips: str | None = None, public_url: str | None = None, ) -> 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_title(f"Starting {server_type} server 🚀", tag="FastAPI") - toolkit.print_line() + if use_rich: + toolkit.print_title(f"Starting {server_type} server 🚀", tag="FastAPI") + else: + toolkit.print_title("⚡️ Starting FastAPI") - toolkit.print( - "Searching for package file structure from directories with [blue]__init__.py[/blue] files" - ) + if use_rich: + toolkit.print_line() + toolkit.print( + "Searching for package file structure from directories with [blue]__init__.py[/blue] files" + ) if entrypoint and (path or app): toolkit.print_line() @@ -197,69 +202,77 @@ def _run( module_data = import_data.module_data import_string = import_data.import_string - toolkit.print(f"Importing from {module_data.extra_sys_path}") - toolkit.print_line() - - if module_data.module_paths: - root_tree = _get_module_tree(module_data.module_paths) - - toolkit.print(root_tree, tag="module") + if use_rich: + toolkit.print(f"Importing from {module_data.extra_sys_path}") toolkit.print_line() - toolkit.print( - "Importing the FastAPI app object from the module with the following code:", - tag="code", - ) - toolkit.print_line() - toolkit.print( - f"[underline]from [bold]{module_data.module_import_str}[/bold] import [bold]{import_data.app_name}[/bold]" - ) - toolkit.print_line() - - toolkit.print( - f"Using import string: [blue]{import_string}[/]", - tag="app", - ) + if module_data.module_paths: + root_tree = _get_module_tree(module_data.module_paths) - mod_source_desc = SOURCE_DESCRIPTIONS[import_data.module_config_source] - app_source_desc = SOURCE_DESCRIPTIONS[import_data.app_name_config_source] - toolkit.print_line() - toolkit.print("Configuration sources:", tag="info") - if mod_source_desc == app_source_desc: - toolkit.print(f" • Import string: {mod_source_desc}") - else: - toolkit.print(f" • Module: {mod_source_desc}") - toolkit.print(f" • App name: {app_source_desc}") + toolkit.print(root_tree, tag="module") + toolkit.print_line() - if import_data.module_config_source == "auto-discovery": + toolkit.print( + "Importing the FastAPI app object from the module with the following code:", + tag="code", + ) toolkit.print_line() toolkit.print( - "You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:", - tag="tip", + f"[underline]from [bold]{module_data.module_import_str}[/bold] import [bold]{import_data.app_name}[/bold]" ) toolkit.print_line() + toolkit.print( - Syntax( - ( - "[tool.fastapi]\n" - f'entrypoint = "{import_data.module_data.module_import_str}:{import_data.app_name}"' - ), - "toml", - theme="ansi_light", - ) + f"Using import string: [blue]{import_string}[/]", + tag="app", ) + else: + toolkit.print(f"🐍 App: [blue]{import_string}[/]") + + if use_rich: + mod_source_desc = SOURCE_DESCRIPTIONS[import_data.module_config_source] + app_source_desc = SOURCE_DESCRIPTIONS[import_data.app_name_config_source] + toolkit.print_line() + toolkit.print("Configuration sources:", tag="info") + if mod_source_desc == app_source_desc: + toolkit.print(f" • Import string: {mod_source_desc}") + else: + toolkit.print(f" • Module: {mod_source_desc}") + toolkit.print(f" • App name: {app_source_desc}") + + if import_data.module_config_source == "auto-discovery": + toolkit.print_line() + toolkit.print( + "You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:", + tag="tip", + ) + toolkit.print_line() + toolkit.print( + Syntax( + ( + "[tool.fastapi]\n" + f'entrypoint = "{import_data.module_data.module_import_str}:{import_data.app_name}"' + ), + "toml", + theme="ansi_light", + ) + ) url = public_url.rstrip("/") if public_url else f"http://{host}:{port}" url_docs = f"{url}/docs" - toolkit.print_line() - toolkit.print( - f"Server started at [link={url}]{url}[/]", - f"Documentation at [link={url_docs}]{url_docs}[/]", - tag="server", - ) + if use_rich: + toolkit.print_line() + toolkit.print(f"Server started at [link={url}]{url}[/]", tag="server") + toolkit.print( + f"Documentation at [link={url_docs}]{url_docs}[/]", tag="server" + ) + else: + toolkit.print(f"🌐 Server: [link={url}]{url}[/]") + toolkit.print(f"📚 Docs: [link={url_docs}]{url_docs}[/]") + toolkit.print("") - if command == "dev": + if command == "dev" and use_rich: toolkit.print_line() toolkit.print( "Running in development mode, for production use: [bold]fastapi run[/]", @@ -271,9 +284,10 @@ def _run( "Could not import Uvicorn, try running 'pip install uvicorn'" ) from None - toolkit.print_line() - toolkit.print("Logs:") - toolkit.print_line() + if use_rich: + toolkit.print_line() + toolkit.print("Logs:") + toolkit.print_line() extra_uvicorn_kwargs: dict[str, Any] = ( {"log_config": get_uvicorn_log_config()} if should_use_rich_logs() else {} diff --git a/src/fastapi_cli/utils/cli.py b/src/fastapi_cli/utils/cli.py index abc02a46..e4667811 100644 --- a/src/fastapi_cli/utils/cli.py +++ b/src/fastapi_cli/utils/cli.py @@ -3,14 +3,14 @@ from typing import Any from rich_toolkit import RichToolkit, RichToolkitTheme -from rich_toolkit.styles import TaggedStyle +from rich_toolkit.styles import MinimalStyle, TaggedStyle from uvicorn.logging import DefaultFormatter 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() @@ -72,7 +72,10 @@ def get_uvicorn_log_config() -> dict[str, Any]: logger = logging.getLogger(__name__) -def get_rich_toolkit() -> RichToolkit: +def get_rich_toolkit(*, use_rich: bool) -> RichToolkit: + if not use_rich: + return RichToolkit(style=MinimalStyle()) + theme = RichToolkitTheme( style=TaggedStyle(tag_width=11), theme={ diff --git a/tests/test_cli.py b/tests/test_cli.py index 4acdfc5c..a62c562d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -71,6 +71,35 @@ 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 result.output + assert "🐍 App: single_file_app:app" in result.output + assert "🌐 Server: http://0.0.0.0:8000" in result.output + assert "📚 Docs: http://0.0.0.0:8000/docs" in result.output + assert "📚 Docs: http://0.0.0.0:8000/docs\n\n" in result.output + assert "Logs:" not in result.output + assert "Source:" not in result.output + assert "Server started at" not in result.output + assert "Documentation at" not in result.output + assert "Searching for package file structure" not in result.output + assert "Importing from" not in result.output + assert "🐍 single_file_app.py" not in result.output + assert "Importing the FastAPI app object" not in result.output + assert "Using import string:" 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 b2acff5b..583cb754 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 f10c7891..6c480a1f 100644 --- a/tests/test_utils_cli.py +++ b/tests/test_utils_cli.py @@ -4,9 +4,11 @@ from logging.config import dictConfig from pytest import LogCaptureFixture, MonkeyPatch +from rich_toolkit.styles import MinimalStyle, TaggedStyle from fastapi_cli.utils.cli import ( CustomFormatter, + get_rich_toolkit, get_uvicorn_log_config, should_use_rich_logs, ) @@ -28,6 +30,28 @@ def test_should_use_rich_logs_is_false_without_tty( assert should_use_rich_logs() is False +def test_get_rich_toolkit_uses_tagged_style_when_requested() -> None: + toolkit = get_rich_toolkit(use_rich=True) + + assert isinstance(toolkit.style, TaggedStyle) + + +def test_get_rich_toolkit_uses_minimal_style_without_rich() -> None: + toolkit = get_rich_toolkit(use_rich=False) + + assert isinstance(toolkit.style, MinimalStyle) + + +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, MinimalStyle) + + def test_custom_formatter() -> None: formatter = CustomFormatter()