diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index f3983fcd..2fbdb346 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -91,7 +91,6 @@ def callback( "--version", help="Show the version and exit.", callback=version_callback ), ] = None, - verbose: bool = typer.Option(False, help="Enable verbose output"), ) -> None: """ FastAPI CLI - The [bold]fastapi[/bold] command line app. ๐Ÿ˜Ž @@ -101,9 +100,7 @@ def callback( Read more in the docs: [link=https://fastapi.tiangolo.com/fastapi-cli/]https://fastapi.tiangolo.com/fastapi-cli/[/link]. """ - log_level = logging.DEBUG if verbose else logging.INFO - - setup_logging(level=log_level) + setup_logging() def _get_module_tree(module_paths: list[Path]) -> Tree: @@ -142,23 +139,29 @@ def _run( proxy_headers: bool = False, forwarded_allow_ips: str | None = None, public_url: str | None = None, + verbose: bool = False, ) -> None: - with get_rich_toolkit() as toolkit: + use_rich = should_use_rich_logs() + # The step-by-step "how the app was found and imported" narration is a + # teaching aid; show it only with --verbose so the default output stays lean + show_details = use_rich and verbose + 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() + toolkit.print(f"Starting FastAPI in {server_type} mode", emoji="โšก๏ธ") - toolkit.print( - "Searching for package file structure from directories with [blue]__init__.py[/blue] files" - ) + if show_details: + toolkit.print_line() + toolkit.print( + "Searching for package file structure from directories with [blue]__init__.py[/blue] files", + emoji="๐Ÿ”Ž", + ) if entrypoint and (path or app): toolkit.print_line() toolkit.print( "[error]Cannot use --entrypoint together with path or --app arguments" ) - toolkit.print_line() raise typer.Exit(code=1) try: @@ -172,8 +175,6 @@ def _run( field = ".".join(str(loc) for loc in error["loc"]) toolkit.print(f" [red]โ€ข[/red] {field}: {error['msg']}") - toolkit.print_line() - raise typer.Exit(code=1) from None try: @@ -196,46 +197,58 @@ def _run( module_data = import_data.module_data import_string = import_data.import_string + is_auto_discovery = import_data.module_config_source == "auto-discovery" - toolkit.print(f"Importing from {module_data.extra_sys_path}") - toolkit.print_line() + if show_details: + toolkit.print_line() + toolkit.print(f"Importing from {module_data.extra_sys_path}", emoji="๐Ÿ“‚") + toolkit.print_line() - if module_data.module_paths: - root_tree = _get_module_tree(module_data.module_paths) + if module_data.module_paths: + root_tree = _get_module_tree(module_data.module_paths) + + toolkit.print(root_tree) - toolkit.print(root_tree, tag="module") 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( + "Importing the FastAPI app object from the module with the following code:", + ) + toolkit.print_line() + toolkit.print( + f"[blue]from [bold]{module_data.module_import_str}[/bold] import [bold]{import_data.app_name}[/bold]", + ) + + # Outside --verbose, fold the resolution source into the import string + # line and point newcomers at --verbose for the full explanation + if is_auto_discovery and not verbose: + app_note = " [dim](auto-discovered, use --verbose to learn more)[/]" + else: + app_note = "" + toolkit.print_line() toolkit.print( - f"Using import string: [blue]{import_string}[/]", - tag="app", + f"Using import string: [blue]{import_string}[/]{app_note}", emoji="๐Ÿ" ) - 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 show_details: + toolkit.print_line() + mod_source_desc = SOURCE_DESCRIPTIONS[import_data.module_config_source] + app_source_desc = SOURCE_DESCRIPTIONS[import_data.app_name_config_source] + toolkit.print("Configuration sources:", emoji="๐Ÿ“‹") + 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": + # 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 use_rich and is_auto_discovery: toolkit.print_line() toolkit.print( "You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:", - tag="tip", + emoji="๐Ÿ’ก", ) toolkit.print_line() toolkit.print( @@ -246,37 +259,31 @@ def _run( ), "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 command == "dev": + if use_rich: toolkit.print_line() - toolkit.print( - "Running in development mode, for production use: [bold]fastapi run[/]", - tag="tip", - ) + toolkit.print(f"Server started at [link={url}]{url}[/]", emoji="๐ŸŒ") + toolkit.print(f"Documentation at [link={url_docs}]{url_docs}[/]") if not uvicorn: raise FastAPICLIException( "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:", 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( @@ -363,6 +370,14 @@ def dev( help="Comma separated list of IP Addresses to trust with proxy headers. The literal '*' means trust everything." ), ] = None, + verbose: Annotated[ + bool, + typer.Option( + "--verbose", + "-v", + help="Show detailed startup output: how the [bold]FastAPI[/bold] app was discovered, imported, and configured.", + ), + ] = False, ) -> Any: """ Run a [bold]FastAPI[/bold] app in [yellow]development[/yellow] mode. ๐Ÿงช @@ -402,6 +417,7 @@ def dev( proxy_headers=proxy_headers, forwarded_allow_ips=forwarded_allow_ips, public_url=os.getenv("FASTAPI_PUBLIC_URL"), + verbose=verbose, ) @@ -471,6 +487,14 @@ def run( help="Comma separated list of IP Addresses to trust with proxy headers. The literal '*' means trust everything." ), ] = None, + verbose: Annotated[ + bool, + typer.Option( + "--verbose", + "-v", + help="Show detailed startup output: how the [bold]FastAPI[/bold] app was discovered, imported, and configured.", + ), + ] = False, ) -> Any: """ Run a [bold]FastAPI[/bold] app in [green]production[/green] mode. ๐Ÿš€ @@ -510,6 +534,7 @@ def run( proxy_headers=proxy_headers, forwarded_allow_ips=forwarded_allow_ips, public_url=os.getenv("FASTAPI_PUBLIC_URL"), + verbose=verbose, ) diff --git a/src/fastapi_cli/utils/cli.py b/src/fastapi_cli/utils/cli.py index abc02a46..df73fef7 100644 --- a/src/fastapi_cli/utils/cli.py +++ b/src/fastapi_cli/utils/cli.py @@ -2,30 +2,171 @@ import sys from typing import Any -from rich_toolkit import RichToolkit, RichToolkitTheme -from rich_toolkit.styles import TaggedStyle +from rich._loop import loop_first +from rich.console import Console, ConsoleOptions, RenderableType, RenderResult +from rich.segment import Segment +from rich.text import Text +from rich_toolkit import RichToolkit +from rich_toolkit.element import Element +from rich_toolkit.styles import BaseStyle, MinimalStyle from uvicorn.logging import DefaultFormatter +logger = logging.getLogger(__name__) + + +def should_use_rich_logs() -> bool: + """Return True when stdout is a TTY and rich logs should be used, False otherwise.""" + return sys.stdout.isatty() + + +class IndentedBlock: + """Indent a renderable, hanging a prefix (e.g. an emoji bullet) on the + first line and aligning wrapped/extra lines under the text.""" + + def __init__( + self, + renderable: RenderableType, + *, + first_prefix: Text, + prefix: Text, + ) -> None: + self.renderable = renderable + self.first_prefix = first_prefix + self.prefix = prefix + + # Text renders its `end` ("\n" by default), which would break lines + self.first_prefix.end = "" + self.prefix.end = "" + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + prefix_width = max(self.first_prefix.cell_len, self.prefix.cell_len) + lines = console.render_lines( + self.renderable, + options.update_width(options.max_width - prefix_width), + pad=False, + ) + + new_line = Segment.line() + + for first, line in loop_first(lines): + if any(segment.text.strip() for segment in line): + yield from console.render( + self.first_prefix if first else self.prefix, options + ) + yield from line + + yield new_line + + +class FastAPIStyle(BaseStyle): + """Uniform left indent with emoji bullets hanging to the left of the text. + + ``emoji=`` renders as a bullet in a fixed column; ``bullet=False`` drops + the bullet column and just indents. This is a small, purpose-built subset + of the richer style in fastapi-cloud-cli. + """ + + content_padding = 1 + emoji_column_width = 3 + + 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 not emoji and not kwargs.get("bullet", True): + indent = Text(" " * (self.content_padding + 1)) + return IndentedBlock(rendered, first_prefix=indent, prefix=indent) + + return IndentedBlock( + rendered, + first_prefix=self._get_bullet_prefix(emoji), + prefix=Text(" " * (self.content_padding + self.emoji_column_width)), + ) + + def _get_bullet_prefix(self, emoji: str) -> Text: + prefix = Text(" " * self.content_padding) + + if emoji: + prefix.append_text(Text.from_markup(emoji)) + + prefix.pad_right( + self.content_padding + self.emoji_column_width - prefix.cell_len + ) + + 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", + "warning": "yellow", + "warn": "yellow", + "error": "red", + "critical": "magenta", + "fatal": "magenta", +} + + +def _get_log_bullet(level: str) -> str: + """Colored bar rendered in the emoji bullet column, matching the log + level, like the ``fastapi cloud logs`` output.""" + color = LOG_LEVEL_COLORS.get(level.lower(), "dim") + + return f"[{color}]โ–•[/{color}]" + 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() - result = self.toolkit.print_as_string(message, tag=record.levelname) + result = self.toolkit.print_as_string( + message, emoji=_get_log_bullet(record.levelname) + ) # Prepend newline to fix alignment after ^C is printed by the terminal if message == "Shutting down": result = "\n" + result return result -def should_use_rich_logs() -> bool: - """Return True when stdout is a TTY and rich logs should be used, False otherwise.""" - return sys.stdout.isatty() - - def get_uvicorn_log_config() -> dict[str, Any]: return { "version": 1, @@ -69,23 +210,7 @@ def get_uvicorn_log_config() -> dict[str, Any]: } -logger = logging.getLogger(__name__) - - -def get_rich_toolkit() -> RichToolkit: - theme = RichToolkitTheme( - style=TaggedStyle(tag_width=11), - theme={ - "tag.title": "white on #009485", - "tag": "white on #007166", - "placeholder": "grey85", - "text": "white", - "selected": "#007166", - "result": "grey85", - "progress": "on #007166", - "error": "red", - "log.info": "black on blue", - }, - ) +def get_rich_toolkit(*, use_rich: bool) -> RichToolkit: + style: BaseStyle = FastAPIStyle() if use_rich else MinimalEmojiStyle() - return RichToolkit(theme=theme) + return RichToolkit(style=style) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4acdfc5c..62b1814c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -40,20 +40,16 @@ def test_dev() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } + assert "Starting FastAPI in development mode" in result.output assert "Using import string: single_file_app:app" in result.output - assert "Configuration sources:" in result.output - assert "Module: path CLI argument" in result.output - assert "App name: auto-discovery" in result.output - assert "You can configure an entrypoint in pyproject.toml" not in result.output - assert "Starting development server ๐Ÿš€" in result.output assert "Server started at http://127.0.0.1:8000" in result.output assert "Documentation at http://127.0.0.1:8000/docs" in result.output - assert ( - "Running in development mode, for production use: fastapi run" - in result.output - ) - - assert "๐Ÿ single_file_app.py" in result.output + # a passed path is explicit, so no source hint and no pyproject nudge + assert "(auto-discovered" not in result.output + assert "You can configure an entrypoint in pyproject.toml" not in result.output + # the step-by-step narration is --verbose only + assert "Searching for package file structure" not in result.output + assert "Configuration sources:" not in result.output def test_run_uses_uvicorn_default_log_config_without_rich_logs( @@ -71,6 +67,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"): @@ -83,12 +100,16 @@ def test_dev_no_args_auto_discovery() -> None: assert mock_run.call_args.kwargs["host"] == "127.0.0.1" assert mock_run.call_args.kwargs["port"] == 8000 assert mock_run.call_args.kwargs["reload"] is True + assert "Starting FastAPI in development mode" in result.output assert "Using import string: main:app" in result.output - assert "Configuration sources:" in result.output - assert "Import string: auto-discovery" in result.output + # the resolution source is folded into the import string line + assert "(auto-discovered, use --verbose to learn more)" in result.output + # auto-discovery nudges you to pin the entrypoint assert "You can configure an entrypoint in pyproject.toml" in result.output assert "[tool.fastapi]" in result.output assert 'entrypoint = "main:app"' in result.output + # the full breakdown stays behind --verbose + assert "Configuration sources:" not in result.output def test_dev_package() -> None: @@ -110,22 +131,14 @@ def test_dev_package() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } + assert "Starting FastAPI in development mode" in result.output assert "Using import string: nested_package.package:app" in result.output - assert "Module: path CLI argument" in result.output - assert "App name: auto-discovery" in result.output - assert "You can configure an entrypoint in pyproject.toml" not in result.output - assert "Starting development server ๐Ÿš€" in result.output assert "Server started at http://127.0.0.1:8000" in result.output assert "Documentation at http://127.0.0.1:8000/docs" in result.output - assert ( - "Running in development mode, for production use: fastapi run" - in result.output - ) - - assert "๐Ÿ“ package" in result.output - assert "โ””โ”€โ”€ ๐Ÿ __init__.py" in result.output - assert "โ””โ”€โ”€ ๐Ÿ“ package" in result.output - assert " โ””โ”€โ”€ ๐Ÿ __init__.py" in result.output + assert "(auto-discovered" not in result.output + assert "You can configure an entrypoint in pyproject.toml" not in result.output + # the file tree is --verbose only + assert "๐Ÿ“ package" not in result.output def test_dev_args() -> None: @@ -163,17 +176,12 @@ def test_dev_args() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } + assert "Starting FastAPI in development mode" in result.output assert "Using import string: single_file_app:api" in result.output - assert "Module: path CLI argument" in result.output - assert "App name: --app CLI option" in result.output - assert "You can configure an entrypoint in pyproject.toml" not in result.output - assert "Starting development server ๐Ÿš€" in result.output assert "Server started at http://192.168.0.2:8080" in result.output assert "Documentation at http://192.168.0.2:8080/docs" in result.output - assert ( - "Running in development mode, for production use: fastapi run" - in result.output - ) + assert "(auto-discovered" not in result.output + assert "You can configure an entrypoint in pyproject.toml" not in result.output def test_dev_env_vars() -> None: @@ -197,17 +205,10 @@ def test_dev_env_vars() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } + assert "Starting FastAPI in development mode" in result.output assert "Using import string: single_file_app:app" in result.output - assert "Module: path CLI argument" in result.output - assert "App name: auto-discovery" in result.output - assert "You can configure an entrypoint in pyproject.toml" not in result.output - assert "Starting development server ๐Ÿš€" in result.output assert "Server started at http://127.0.0.1:8111" in result.output assert "Documentation at http://127.0.0.1:8111/docs" in result.output - assert ( - "Running in development mode, for production use: fastapi run" - in result.output - ) def test_dev_env_vars_and_args() -> None: @@ -238,17 +239,51 @@ def test_dev_env_vars_and_args() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } + assert "Starting FastAPI in development mode" in result.output assert "Using import string: single_file_app:app" in result.output - assert "Module: path CLI argument" in result.output - assert "App name: auto-discovery" in result.output - assert "You can configure an entrypoint in pyproject.toml" not in result.output - assert "Starting development server ๐Ÿš€" in result.output assert "Server started at http://127.0.0.1:8080" in result.output assert "Documentation at http://127.0.0.1:8080/docs" in result.output - assert ( - "Running in development mode, for production use: fastapi run" - in result.output - ) + + +def test_dev_verbose() -> None: + """--verbose restores the full narration and the module file tree.""" + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke(app, ["dev", "nested_package/package", "--verbose"]) + assert result.exit_code == 0, result.output + assert mock_run.called + assert "Starting FastAPI in development mode" in result.output + assert "Searching for package file structure" in result.output + assert "Importing from" in result.output + assert "Importing the FastAPI app object" in result.output + assert "from nested_package.package import app" in result.output + assert "Using import string: nested_package.package:app" in result.output + # module and app come from different sources here + assert "Configuration sources:" in result.output + assert "Module: path CLI argument" in result.output + assert "App name: auto-discovery" in result.output + # the file tree + assert "๐Ÿ“ package" in result.output + assert "๐Ÿ __init__.py" in result.output + # the source hint is folded away in verbose mode + assert "(auto-discovered, use --verbose to learn more)" not in result.output + + +def test_dev_verbose_auto_discovery() -> None: + """--verbose with auto-discovery shows the single 'import string' source.""" + with changing_dir(assets_path / "default_files" / "default_main"): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke(app, ["dev", "--verbose"]) + assert result.exit_code == 0, result.output + assert mock_run.called + assert "Searching for package file structure" in result.output + assert "Importing the FastAPI app object" in result.output + assert "Using import string: main:app" in result.output + assert "Configuration sources:" in result.output + # module and app share the same source (auto-discovery) + assert "Import string: auto-discovery" in result.output + # auto-discovery still nudges to pin the entrypoint + assert "You can configure an entrypoint in pyproject.toml" in result.output def test_entrypoint_mutually_exclusive_with_path() -> None: @@ -287,11 +322,8 @@ def test_run() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } + assert "Starting FastAPI in production mode" in result.output assert "Using import string: single_file_app:app" in result.output - assert "Module: path CLI argument" in result.output - assert "App name: auto-discovery" in result.output - assert "You can configure an entrypoint in pyproject.toml" not in result.output - assert "Starting production server ๐Ÿš€" 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 @@ -317,14 +349,10 @@ def test_run_trust_proxy() -> None: "forwarded_allow_ips": "*", "log_config": get_uvicorn_log_config(), } + assert "Starting FastAPI in production mode" in result.output assert "Using import string: single_file_app:app" in result.output - assert "Starting production server ๐Ÿš€" 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 ( - "Running in development mode, for production use: fastapi run" - not in result.output - ) def test_run_args() -> None: @@ -365,17 +393,10 @@ def test_run_args() -> None: "log_config": get_uvicorn_log_config(), } + assert "Starting FastAPI in production mode" in result.output assert "Using import string: single_file_app:api" in result.output - assert "Module: path CLI argument" in result.output - assert "App name: --app CLI option" in result.output - assert "You can configure an entrypoint in pyproject.toml" not in result.output - assert "Starting production server ๐Ÿš€" in result.output assert "Server started at http://192.168.0.2:8080" in result.output assert "Documentation at http://192.168.0.2:8080/docs" in result.output - assert ( - "Running in development mode, for production use: fastapi run" - not in result.output - ) def test_run_env_vars() -> None: @@ -399,11 +420,8 @@ def test_run_env_vars() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } + assert "Starting FastAPI in production mode" in result.output assert "Using import string: single_file_app:app" in result.output - assert "Module: path CLI argument" in result.output - assert "App name: auto-discovery" in result.output - assert "You can configure an entrypoint in pyproject.toml" not in result.output - assert "Starting production server ๐Ÿš€" in result.output assert "Server started at http://0.0.0.0:8111" in result.output assert "Documentation at http://0.0.0.0:8111/docs" in result.output @@ -436,11 +454,8 @@ def test_run_env_vars_and_args() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } + assert "Starting FastAPI in production mode" in result.output assert "Using import string: single_file_app:app" in result.output - assert "Module: path CLI argument" in result.output - assert "App name: auto-discovery" in result.output - assert "You can configure an entrypoint in pyproject.toml" not in result.output - assert "Starting production server ๐Ÿš€" in result.output assert "Server started at http://0.0.0.0:8080" in result.output assert "Documentation at http://0.0.0.0:8080/docs" in result.output @@ -485,7 +500,7 @@ def test_public_url_env_var(command: str, public_url: str) -> None: assert "Using import string: single_file_app:app" in result.output assert ( - f"Starting {'development' if command == 'dev' else 'production'} server ๐Ÿš€" + f"Starting FastAPI in {'development' if command == 'dev' else 'production'} mode" in result.output ) expected_url_base = public_url.rstrip("/") @@ -601,7 +616,8 @@ def test_dev_with_import_string() -> None: "log_config": get_uvicorn_log_config(), } assert "Using import string: single_file_app:api" in result.output - assert "Import string: --entrypoint CLI option" in result.output + # an explicit entrypoint is not auto-discovery + assert "(auto-discovered" not in result.output assert "You can configure an entrypoint in pyproject.toml" not in result.output @@ -625,7 +641,7 @@ def test_run_with_import_string() -> None: "log_config": get_uvicorn_log_config(), } assert "Using import string: single_file_app:app" in result.output - assert "Import string: --entrypoint CLI option" in result.output + assert "(auto-discovered" not in result.output assert "You can configure an entrypoint in pyproject.toml" not in result.output diff --git a/tests/test_cli_pyproject.py b/tests/test_cli_pyproject.py index b2acff5b..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"), @@ -26,8 +32,9 @@ def test_dev_with_pyproject_app_config_uses() -> None: assert mock_run.call_args.kwargs["reload"] is True assert "Using import string: my_module:app" in result.output - assert "Configuration sources:" in result.output - assert "Import string: entrypoint in pyproject.toml" in result.output + # a configured entrypoint is not auto-discovery + assert "(auto-discovered" not in result.output + assert "Configuration sources:" not in result.output def test_run_with_pyproject_app_config() -> None: @@ -44,8 +51,8 @@ def test_run_with_pyproject_app_config() -> None: assert mock_run.call_args.kwargs["reload"] is False assert "Using import string: my_module:app" in result.output - assert "Configuration sources:" in result.output - assert "Import string: entrypoint in pyproject.toml" in result.output + assert "(auto-discovered" not in result.output + assert "Configuration sources:" not in result.output def test_cli_arg_overrides_pyproject_config() -> None: @@ -58,9 +65,8 @@ def test_cli_arg_overrides_pyproject_config() -> None: assert result.exit_code == 0, result.output assert mock_run.call_args.kwargs["app"] == "another_module:app" - assert "Configuration sources:" in result.output - assert "Module: path CLI argument" in result.output - assert "App name: auto-discovery" in result.output + assert "Using import string: another_module:app" in result.output + assert "Configuration sources:" not in result.output def test_pyproject_app_config_invalid_format() -> None: diff --git a/tests/test_utils_cli.py b/tests/test_utils_cli.py index f10c7891..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() @@ -47,7 +72,8 @@ def test_custom_formatter() -> None: formatted = formatter.formatMessage(record) - assert "INFO" in formatted + # the log level is shown as a colored bar bullet, like `fastapi cloud logs` + assert "โ–•" in formatted assert "127.0.0.1" in formatted assert "GET / HTTP/1.1" in formatted assert "200" in formatted