Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 71 additions & 57 deletions src/fastapi_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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[/]",
Expand All @@ -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 {}
Expand Down
9 changes: 6 additions & 3 deletions src/fastapi_cli/utils/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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={
Expand Down
29 changes: 29 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
6 changes: 6 additions & 0 deletions tests/test_cli_pyproject.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from pathlib import Path
from unittest.mock import patch

import pytest
import uvicorn
from typer.testing import CliRunner

Expand All @@ -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"),
Expand Down
24 changes: 24 additions & 0 deletions tests/test_utils_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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()

Expand Down
Loading