From e93b815ae9c5392e443043b948005bd058d9c8f8 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 24 Apr 2026 10:19:34 +0200 Subject: [PATCH 1/3] Add `public-url` option and env var --- src/fastapi_cli/cli.py | 19 ++++++++- tests/test_cli.py | 88 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 4348e599..11544d05 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -112,6 +112,7 @@ def _run( entrypoint: str | None = None, proxy_headers: bool = False, forwarded_allow_ips: str | None = None, + public_url: str | None = None, ) -> None: with get_rich_toolkit() as toolkit: server_type = "development" if command == "dev" else "production" @@ -189,7 +190,7 @@ def _run( tag="app", ) - url = f"http://{host}:{port}" + url = public_url.rstrip("/") if public_url else f"http://{host}:{port}" url_docs = f"{url}/docs" toolkit.print_line() @@ -299,6 +300,13 @@ def dev( help="Comma separated list of IP Addresses to trust with proxy headers. The literal '*' means trust everything." ), ] = None, + public_url: Annotated[ + str | None, + typer.Option( + help="The public URL where the server is accessible. Used for the URLs printed at startup. Defaults to [blue]http://{host}:{port}[/blue].", + envvar="FASTAPI_PUBLIC_URL", + ), + ] = None, ) -> Any: """ Run a [bold]FastAPI[/bold] app in [yellow]development[/yellow] mode. ๐Ÿงช @@ -337,6 +345,7 @@ def dev( command="dev", proxy_headers=proxy_headers, forwarded_allow_ips=forwarded_allow_ips, + public_url=public_url, ) @@ -406,6 +415,13 @@ def run( help="Comma separated list of IP Addresses to trust with proxy headers. The literal '*' means trust everything." ), ] = None, + public_url: Annotated[ + str | None, + typer.Option( + help="The public URL where the server is accessible. Used for the URLs printed at startup. Defaults to [blue]http://{host}:{port}[/blue].", + envvar="FASTAPI_PUBLIC_URL", + ), + ] = None, ) -> Any: """ Run a [bold]FastAPI[/bold] app in [green]production[/green] mode. ๐Ÿš€ @@ -444,6 +460,7 @@ def run( command="run", proxy_headers=proxy_headers, forwarded_allow_ips=forwarded_allow_ips, + public_url=public_url, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index ddfb808b..8bd5f796 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ from pathlib import Path from unittest.mock import patch +import pytest import uvicorn from typer.testing import CliRunner @@ -391,6 +392,91 @@ def test_run_env_vars_and_args() -> None: assert "Documentation at http://0.0.0.0:8080/docs" in result.output +@pytest.mark.parametrize("command", ["dev", "run"]) +@pytest.mark.parametrize( + "public_url", ["https://myapp.example.com", "https://myapp.example.com/"] +) +def test_public_url(command: str, public_url: str) -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, + [ + command, + "single_file_app.py", + "--host", + "0.0.0.0", + "--public-url", + public_url, + ], + ) + assert result.exit_code == 0, result.output + assert mock_run.called + assert mock_run.call_args + assert mock_run.call_args.kwargs == { + "app": "single_file_app:app", + "host": "0.0.0.0", + "port": 8000, + "reload": True if command == "dev" else False, + "reload_dirs": None, + "workers": None, + "root_path": "", + "proxy_headers": True, + "forwarded_allow_ips": None, + "log_config": get_uvicorn_log_config(), + } + + assert "Using import string: single_file_app:app" in result.output + assert ( + f"Starting {'development' if command == 'dev' else 'production'} server ๐Ÿš€" + in result.output + ) + assert "Server started at https://myapp.example.com" in result.output + assert "Documentation at https://myapp.example.com/docs" in result.output + + +@pytest.mark.parametrize("command", ["dev", "run"]) +@pytest.mark.parametrize( + "public_url", ["https://myapp.example.com", "https://myapp.example.com/"] +) +def test_public_url_env_var(command: str, public_url: str) -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, + [ + command, + "single_file_app.py", + "--host", + "0.0.0.0", + ], + env={"FASTAPI_PUBLIC_URL": public_url}, + ) + assert result.exit_code == 0, result.output + assert mock_run.called + assert mock_run.call_args + assert mock_run.call_args.kwargs == { + "app": "single_file_app:app", + "host": "0.0.0.0", + "port": 8000, + "reload": True if command == "dev" else False, + "reload_dirs": None, + "workers": None, + "root_path": "", + "proxy_headers": True, + "forwarded_allow_ips": None, + "log_config": get_uvicorn_log_config(), + } + + assert "Using import string: single_file_app:app" in result.output + assert ( + f"Starting {'development' if command == 'dev' else 'production'} server ๐Ÿš€" + in result.output + ) + assert "Server started at https://myapp.example.com" in result.output + assert "Documentation at https://myapp.example.com/docs" in result.output + + def test_run_error() -> None: with changing_dir(assets_path): result = runner.invoke(app, ["run", "non_existing_file.py"]) @@ -417,6 +503,7 @@ def test_dev_help() -> None: assert "Set reload directories explicitly" in result.output assert "The root path is used to tell your app" in result.output assert "The name of the variable that contains the FastAPI app" in result.output + assert "The public URL where the server is accessible" in result.output assert "Use multiple worker processes." not in result.output @@ -438,6 +525,7 @@ def test_run_help() -> None: assert "Enable auto-reload of the server when (code) files change." in result.output assert "The root path is used to tell your app" in result.output assert "The name of the variable that contains the FastAPI app" in result.output + assert "The public URL where the server is accessible" in result.output assert "Use multiple worker processes." in result.output From a8d74219d71c04a9d47002586d68aee93210b616 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 24 Apr 2026 12:46:34 +0200 Subject: [PATCH 2/3] Parameterize test for url with prefix --- tests/test_cli.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8bd5f796..7db61d66 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -394,7 +394,12 @@ def test_run_env_vars_and_args() -> None: @pytest.mark.parametrize("command", ["dev", "run"]) @pytest.mark.parametrize( - "public_url", ["https://myapp.example.com", "https://myapp.example.com/"] + "public_url", + [ + "https://myapp.example.com", + "https://myapp.example.com/", + "https://myapp.example.com/subpath/", + ], ) def test_public_url(command: str, public_url: str) -> None: with changing_dir(assets_path): @@ -431,13 +436,19 @@ def test_public_url(command: str, public_url: str) -> None: f"Starting {'development' if command == 'dev' else 'production'} server ๐Ÿš€" in result.output ) - assert "Server started at https://myapp.example.com" in result.output - assert "Documentation at https://myapp.example.com/docs" in result.output + expected_url_base = public_url.rstrip("/") + assert f"Server started at {expected_url_base}" in result.output + assert f"Documentation at {expected_url_base}/docs" in result.output @pytest.mark.parametrize("command", ["dev", "run"]) @pytest.mark.parametrize( - "public_url", ["https://myapp.example.com", "https://myapp.example.com/"] + "public_url", + [ + "https://myapp.example.com", + "https://myapp.example.com/", + "https://myapp.example.com/subpath/", + ], ) def test_public_url_env_var(command: str, public_url: str) -> None: with changing_dir(assets_path): @@ -473,8 +484,9 @@ def test_public_url_env_var(command: str, public_url: str) -> None: f"Starting {'development' if command == 'dev' else 'production'} server ๐Ÿš€" in result.output ) - assert "Server started at https://myapp.example.com" in result.output - assert "Documentation at https://myapp.example.com/docs" in result.output + expected_url_base = public_url.rstrip("/") + assert f"Server started at {expected_url_base}" in result.output + assert f"Documentation at {expected_url_base}/docs" in result.output def test_run_error() -> None: From 8351fcbb7da3c3d380c4c9ecd8e7cdaa277c96d8 Mon Sep 17 00:00:00 2001 From: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:47:36 +0200 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/fastapi_cli/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 11544d05..f42d3664 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -303,7 +303,7 @@ def dev( public_url: Annotated[ str | None, typer.Option( - help="The public URL where the server is accessible. Used for the URLs printed at startup. Defaults to [blue]http://{host}:{port}[/blue].", + help="The public URL where the server is accessible. Used for the URLs printed at startup. Defaults to [blue]http://HOST:PORT[/blue] from [bold]--host[/bold] and [bold]--port[/bold].", envvar="FASTAPI_PUBLIC_URL", ), ] = None, @@ -418,7 +418,7 @@ def run( public_url: Annotated[ str | None, typer.Option( - help="The public URL where the server is accessible. Used for the URLs printed at startup. Defaults to [blue]http://{host}:{port}[/blue].", + help="The public URL where the server is accessible. Used for the URLs printed at startup. By default, the printed URLs use the configured host and port.", envvar="FASTAPI_PUBLIC_URL", ), ] = None,