diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 92fa444f..1f1b6064 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -142,17 +142,19 @@ 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: server_type = "development" if command == "dev" else "production" toolkit.print(f"Starting FastAPI in {server_type} mode", emoji="โšก๏ธ") - toolkit.print_line() - toolkit.print( - "Searching for package file structure from directories with [blue]__init__.py[/blue] files", - emoji="๐Ÿ”Ž", - ) + if verbose: + 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() @@ -194,40 +196,54 @@ 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_line() - toolkit.print(f"Importing from {module_data.extra_sys_path}", emoji="๐Ÿ“‚") - toolkit.print_line() + if verbose: + 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) - toolkit.print_line() + 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( - "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]", + f"Using import string: [blue]{import_string}[/]{app_note}", emoji="๐Ÿ" ) - toolkit.print_line() - toolkit.print(f"Using import string: [blue]{import_string}[/]", emoji="๐Ÿ") - - 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 verbose: + 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 is_auto_discovery: toolkit.print_line() toolkit.print( "You can configure an entrypoint in [blue]pyproject.toml[/] for this app with:", @@ -252,13 +268,6 @@ def _run( toolkit.print(f"Server started at [link={url}]{url}[/]", emoji="๐ŸŒ") toolkit.print(f"Documentation at [link={url_docs}]{url_docs}[/]") - if command == "dev": - toolkit.print_line() - toolkit.print( - "Running in development mode, for production use: [bold]fastapi run[/]", - emoji="๐Ÿ’ก", - ) - if not uvicorn: raise FastAPICLIException( "Could not import Uvicorn, try running 'pip install uvicorn'" @@ -356,6 +365,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 and debug logs: how the [bold]FastAPI[/bold] app was discovered, imported, and configured.", + ), + ] = False, ) -> Any: """ Run a [bold]FastAPI[/bold] app in [yellow]development[/yellow] mode. ๐Ÿงช @@ -395,6 +412,7 @@ def dev( proxy_headers=proxy_headers, forwarded_allow_ips=forwarded_allow_ips, public_url=os.getenv("FASTAPI_PUBLIC_URL"), + verbose=verbose, ) @@ -464,6 +482,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 and debug logs: how the [bold]FastAPI[/bold] app was discovered, imported, and configured.", + ), + ] = False, ) -> Any: """ Run a [bold]FastAPI[/bold] app in [green]production[/green] mode. ๐Ÿš€ @@ -503,6 +529,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/tests/test_cli.py b/tests/test_cli.py index ce333976..05523ae9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import logging import subprocess import sys from pathlib import Path @@ -40,20 +41,16 @@ def test_dev() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } - 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 FastAPI in development mode" in result.output + assert "Using import string: single_file_app:app" 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( @@ -83,12 +80,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 +111,14 @@ def test_dev_package() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } - 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 FastAPI in development mode" in result.output + assert "Using import string: nested_package.package:app" 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 +156,12 @@ def test_dev_args() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } - 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 FastAPI in development mode" in result.output + assert "Using import string: single_file_app:api" 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 +185,10 @@ def test_dev_env_vars() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } - 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 FastAPI in development mode" in result.output + assert "Using import string: single_file_app:app" 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 +219,68 @@ def test_dev_env_vars_and_args() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } - 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 FastAPI in development mode" in result.output + assert "Using import string: single_file_app:app" 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_global_verbose_enables_debug_logging() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run"): + result = runner.invoke(app, ["--verbose", "dev", "single_file_app.py"]) + assert result.exit_code == 0, result.output + + assert logging.getLogger("fastapi_cli").level == logging.DEBUG + + # a run without --verbose resets the level + with changing_dir(assets_path): + with patch.object(uvicorn, "run"): + result = runner.invoke(app, ["dev", "single_file_app.py"]) + assert result.exit_code == 0, result.output + + assert logging.getLogger("fastapi_cli").level == logging.INFO def test_entrypoint_mutually_exclusive_with_path() -> None: @@ -287,11 +319,8 @@ def test_run() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } - 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 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 @@ -317,14 +346,10 @@ def test_run_trust_proxy() -> None: "forwarded_allow_ips": "*", "log_config": get_uvicorn_log_config(), } - assert "Using import string: single_file_app:app" in result.output 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 ( - "Running in development mode, for production use: fastapi run" - not in result.output - ) def test_run_args() -> None: @@ -365,17 +390,10 @@ def test_run_args() -> None: "log_config": get_uvicorn_log_config(), } - 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 FastAPI in production mode" in result.output + assert "Using import string: single_file_app:api" 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 +417,8 @@ def test_run_env_vars() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } - 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 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:8111" in result.output assert "Documentation at http://0.0.0.0:8111/docs" in result.output @@ -436,11 +451,8 @@ def test_run_env_vars_and_args() -> None: "forwarded_allow_ips": None, "log_config": get_uvicorn_log_config(), } - 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 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:8080" in result.output assert "Documentation at http://0.0.0.0:8080/docs" in result.output @@ -601,7 +613,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 +638,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..f6b36890 100644 --- a/tests/test_cli_pyproject.py +++ b/tests/test_cli_pyproject.py @@ -26,8 +26,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 +45,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 +59,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: