From 2036a4f026f019f9a92be72ca3302c503cb32a22 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 10 Apr 2026 22:09:33 +0200 Subject: [PATCH 01/17] Add test --- tests/test_cli_login.py | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_cli_login.py b/tests/test_cli_login.py index 274c78f1..2c03aed1 100644 --- a/tests/test_cli_login.py +++ b/tests/test_cli_login.py @@ -77,6 +77,57 @@ def test_full_login( assert '"access_token":"test_token_1234"' in temp_auth_config.read_text() +@pytest.mark.respx +def test_full_login_with_deploy_token_set( + respx_mock: respx.MockRouter, temp_auth_config: Path, settings: Settings +) -> None: + with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open: + respx_mock.post( + "/login/device/authorization", data={"client_id": settings.client_id} + ).mock( + return_value=Response( + 200, + json={ + "verification_uri_complete": "http://test.com", + "verification_uri": "http://test.com", + "user_code": "1234", + "device_code": "5678", + }, + ) + ) + respx_mock.post( + "/login/device/token", + data={ + "device_code": "5678", + "client_id": settings.client_id, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + ).mock(return_value=Response(200, json={"access_token": "test_token_1234"})) + + # Verify no auth file exists before login + assert not temp_auth_config.exists() + + result = runner.invoke( + app, + ["login"], + env={"FASTAPI_CLOUD_TOKEN": "test_deploy_token"}, # Should be ignored + ) + + assert result.exit_code == 0 + assert mock_open.called + assert mock_open.call_args.args == ("http://test.com",) + + # Verify the warning message is shown + assert "You have FASTAPI_CLOUD_TOKEN environment variable set." in result.output + assert "This token will take precedence over the user token" in result.output + + assert "Now you are logged in!" in result.output + + # Verify auth file was created with correct content + assert temp_auth_config.exists() + assert '"access_token":"test_token_1234"' in temp_auth_config.read_text() + + @pytest.mark.respx def test_fetch_access_token_success_immediately( respx_mock: respx.MockRouter, settings: Settings From 4a7422cecbe850364d19a3364d5efbb292894d15 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Sun, 12 Apr 2026 21:38:19 +0200 Subject: [PATCH 02/17] Only use deploy token for `deploy` command --- src/fastapi_cloud_cli/commands/deploy.py | 62 ++++++++++++++++-------- src/fastapi_cloud_cli/commands/login.py | 9 ++++ src/fastapi_cloud_cli/commands/whoami.py | 31 ++++++------ src/fastapi_cloud_cli/utils/api.py | 15 ++++-- src/fastapi_cloud_cli/utils/auth.py | 40 +++++++-------- src/fastapi_cloud_cli/utils/cli.py | 19 +++++--- src/fastapi_cloud_cli/utils/sentry.py | 4 +- tests/test_auth.py | 4 +- tests/test_cli_deploy.py | 3 ++ tests/test_cli_whoami.py | 17 ++++++- tests/test_sentry.py | 16 ++++++ 11 files changed, 146 insertions(+), 74 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index f0bd1915..cc16eda2 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -28,7 +28,7 @@ TooManyRetriesError, ) from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config -from fastapi_cloud_cli.utils.auth import Identity +from fastapi_cloud_cli.utils.auth import AuthMode, Identity from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors from fastapi_cloud_cli.utils.progress_file import ProgressFile @@ -72,7 +72,7 @@ def _cancel_upload(deployment_id: str) -> None: logger.debug("Cancelling upload for deployment: %s", deployment_id) try: - with APIClient() as client: + with APIClient(use_deploy_token=True) as client: response = client.post(f"/deployments/{deployment_id}/upload-cancelled") response.raise_for_status() @@ -142,7 +142,7 @@ class Team(BaseModel): def _get_teams() -> list[Team]: - with APIClient() as client: + with APIClient(use_deploy_token=True) as client: response = client.get("/teams/") response.raise_for_status() @@ -158,7 +158,7 @@ class AppResponse(BaseModel): def _update_app(app_id: str, directory: str | None) -> AppResponse: - with APIClient() as client: + with APIClient(use_deploy_token=True) as client: response = client.patch( f"/apps/{app_id}", json={"directory": directory}, @@ -170,7 +170,7 @@ def _update_app(app_id: str, directory: str | None) -> AppResponse: def _create_app(team_id: str, app_name: str, directory: str | None) -> AppResponse: - with APIClient() as client: + with APIClient(use_deploy_token=True) as client: response = client.post( "/apps/", json={"name": app_name, "team_id": team_id, "directory": directory}, @@ -191,7 +191,7 @@ class CreateDeploymentResponse(BaseModel): def _create_deployment(app_id: str) -> CreateDeploymentResponse: - with APIClient() as client: + with APIClient(use_deploy_token=True) as client: response = client.post(f"/apps/{app_id}/deployments/") response.raise_for_status() @@ -230,7 +230,7 @@ def progress_callback(bytes_read: int) -> None: f"Uploading deployment ({_format_size(bytes_read)} of {archive_size_str})..." ) - with APIClient() as fastapi_client, Client() as client: + with APIClient(use_deploy_token=True) as fastapi_client, Client() as client: # Get the upload URL logger.debug("Requesting upload URL from API") response = fastapi_client.post(f"/deployments/{deployment_id}/upload") @@ -264,7 +264,7 @@ def progress_callback(bytes_read: int) -> None: def _get_app(app_slug: str) -> AppResponse | None: - with APIClient() as client: + with APIClient(use_deploy_token=True) as client: response = client.get(f"/apps/{app_slug}") if response.status_code == 404: @@ -278,7 +278,7 @@ def _get_app(app_slug: str) -> AppResponse | None: def _get_apps(team_id: str) -> list[AppResponse]: - with APIClient() as client: + with APIClient(use_deploy_token=True) as client: response = client.get("/apps/", params={"team_id": team_id}) response.raise_for_status() @@ -308,14 +308,20 @@ def _get_apps(team_id: str) -> list[AppResponse]: ] -def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig: +def _configure_app( + toolkit: RichToolkit, + path_to_deploy: Path, + auth_mode: AuthMode = "user", +) -> AppConfig: toolkit.print(f"Setting up and deploying [blue]{path_to_deploy}[/blue]", tag="path") toolkit.print_line() with toolkit.progress("Fetching teams...") as progress: with handle_http_errors( - progress, default_message="Error fetching teams. Please try again later." + progress, + default_message="Error fetching teams. Please try again later.", + auth_mode=auth_mode, ): teams = _get_teams() @@ -341,7 +347,9 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig: if not create_new_app: with toolkit.progress("Fetching apps...") as progress: with handle_http_errors( - progress, default_message="Error fetching apps. Please try again later." + progress, + default_message="Error fetching apps. Please try again later.", + auth_mode=auth_mode, ): apps = _get_apps(team.id) @@ -411,7 +419,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig: if directory != selected_app.directory: with ( toolkit.progress(title="Updating app directory...") as progress, - handle_http_errors(progress), + handle_http_errors(progress, auth_mode=auth_mode), ): app = _update_app(selected_app.id, directory=directory) @@ -420,7 +428,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig: app = selected_app else: with toolkit.progress(title="Creating app...") as progress: - with handle_http_errors(progress): + with handle_http_errors(progress, auth_mode=auth_mode): app = _create_app(team.id, app_name, directory=directory) progress.log(f"App created successfully! App slug: {app.slug}") @@ -485,7 +493,7 @@ def _wait_for_deployment( last_message_changed_at = time.monotonic() - with APIClient() as client: + with APIClient(use_deploy_token=True) as client: with ( toolkit.progress( next(messages), @@ -556,7 +564,7 @@ def _send_waitlist_form( toolkit: RichToolkit, ) -> None: with toolkit.progress("Sending your request...") as progress: - with APIClient() as client: + with APIClient(use_deploy_token=True) as client: with handle_http_errors(progress): response = client.post("/users/waiting-list", json=result.model_dump()) @@ -674,15 +682,18 @@ def deploy( ) identity = Identity() + use_deploy = identity.has_deploy_token() + has_auth = use_deploy or identity.is_logged_in() + auth_mode: AuthMode = "token" if use_deploy else "user" with get_rich_toolkit() as toolkit: - if not identity.is_logged_in(): + if not has_auth: logger.debug("User not logged in, prompting for login or waitlist") toolkit.print_title("Welcome to FastAPI Cloud!", tag="FastAPI") toolkit.print_line() - if identity.token and identity.is_expired(): + if identity.user_token and identity.is_user_token_expired(): toolkit.print( "Your session has expired. Please log in again.", tag="info", @@ -709,6 +720,13 @@ def deploy( _waitlist_form(toolkit) raise typer.Exit(1) + if use_deploy: + toolkit.print( + "Using token from [bold blue]FASTAPI_CLOUD_TOKEN[/] environment variable", + tag="info", + ) + toolkit.print_line() + toolkit.print_title("Starting deployment", tag="FastAPI") toolkit.print_line() @@ -738,7 +756,9 @@ def deploy( else: logger.debug("No app config found, configuring new app") - app_config = _configure_app(toolkit, path_to_deploy=path_to_deploy) + app_config = _configure_app( + toolkit, path_to_deploy=path_to_deploy, auth_mode=auth_mode + ) toolkit.print_line() target_app_id = app_config.app_id @@ -751,7 +771,7 @@ def deploy( toolkit.print_line() with toolkit.progress("Checking app...", transient=True) as progress: - with handle_http_errors(progress): + with handle_http_errors(progress, auth_mode=auth_mode): logger.debug("Checking app with ID: %s", target_app_id) app = _get_app(target_app_id) @@ -780,7 +800,7 @@ def deploy( toolkit.progress( title="Creating deployment", done_emoji="📦" ) as progress, - handle_http_errors(progress), + handle_http_errors(progress, auth_mode=auth_mode), ): logger.debug("Creating deployment for app: %s", app.id) deployment = _create_deployment(app.id) diff --git a/src/fastapi_cloud_cli/commands/login.py b/src/fastapi_cloud_cli/commands/login.py index 6c6ef07b..4d028efa 100644 --- a/src/fastapi_cloud_cli/commands/login.py +++ b/src/fastapi_cloud_cli/commands/login.py @@ -87,6 +87,15 @@ def login() -> Any: return + if identity.has_deploy_token(): + with get_rich_toolkit(minimal=True) as toolkit: + toolkit.print( + "You have [bold blue]FASTAPI_CLOUD_TOKEN[/] environment variable set.\n" + "This token will take precedence over the user token for " + "[blue]`fastapi deploy`[/] command.", + tag="Warning", + ) + with get_rich_toolkit() as toolkit, APIClient() as client: toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI") diff --git a/src/fastapi_cloud_cli/commands/whoami.py b/src/fastapi_cloud_cli/commands/whoami.py index ad94f6a9..67c12647 100644 --- a/src/fastapi_cloud_cli/commands/whoami.py +++ b/src/fastapi_cloud_cli/commands/whoami.py @@ -14,20 +14,21 @@ def whoami() -> Any: identity = Identity() - if identity.auth_mode == "token": - print("⚡ [bold]Using API token from environment variable[/bold]") - return - if not identity.is_logged_in(): print("No credentials found. Use [blue]`fastapi login`[/] to login.") - return - - with APIClient() as client: - with Progress(title="⚡ Fetching profile", transient=True) as progress: - with handle_http_errors(progress, default_message=""): - response = client.get("/users/me") - response.raise_for_status() - - data = response.json() - - print(f"⚡ [bold]{data['email']}[/bold]") + else: + with APIClient() as client: + with Progress(title="⚡ Fetching profile", transient=True) as progress: + with handle_http_errors(progress, default_message=""): + response = client.get("/users/me") + response.raise_for_status() + + data = response.json() + + print(f"⚡ [bold]{data['email']}[/bold]") + + if identity.has_deploy_token(): + print( + "⚡ [bold]Using API token from environment variable for " + "[blue]`fastapi deploy`[/blue] command.[/bold]" + ) diff --git a/src/fastapi_cloud_cli/utils/api.py b/src/fastapi_cloud_cli/utils/api.py index 9bc90046..1b85349a 100644 --- a/src/fastapi_cloud_cli/utils/api.py +++ b/src/fastapi_cloud_cli/utils/api.py @@ -19,7 +19,7 @@ from fastapi_cloud_cli import __version__ from fastapi_cloud_cli.config import Settings -from .auth import Identity +from .auth import AuthMode, Identity logger = logging.getLogger(__name__) @@ -195,15 +195,24 @@ def to_human_readable(cls, status: "DeploymentStatus") -> str: class APIClient(httpx.Client): - def __init__(self) -> None: + auth_mode: AuthMode + + def __init__(self, use_deploy_token: bool = False) -> None: settings = Settings.get() identity = Identity() + if use_deploy_token and identity.deploy_token: + token = identity.deploy_token + self.auth_mode = "token" + else: + token = identity.user_token + self.auth_mode = "user" + super().__init__( base_url=settings.base_api_url, timeout=httpx.Timeout(20), headers={ - "Authorization": f"Bearer {identity.token}", + "Authorization": f"Bearer {token}", "User-Agent": f"fastapi-cloud-cli/{__version__}", }, ) diff --git a/src/fastapi_cloud_cli/utils/auth.py b/src/fastapi_cloud_cli/utils/auth.py index b542fa28..c8cd2a45 100644 --- a/src/fastapi_cloud_cli/utils/auth.py +++ b/src/fastapi_cloud_cli/utils/auth.py @@ -12,6 +12,8 @@ logger = logging.getLogger("fastapi_cli") +AuthMode = Literal["token", "user"] + class AuthConfig(BaseModel): access_token: str @@ -109,34 +111,28 @@ def _is_jwt_expired(token: str) -> bool: class Identity: - auth_mode: Literal["token", "user"] - def __init__(self) -> None: - self.token = _get_auth_token() - self.auth_mode = "user" + self._user_token = _get_auth_token() + self._deploy_token: str | None = os.environ.get("FASTAPI_CLOUD_TOKEN") - # users using `FASTAPI_CLOUD_TOKEN` - if env_token := self._get_token_from_env(): - self.token = env_token - self.auth_mode = "token" + @property + def user_token(self) -> str | None: + return self._user_token - def _get_token_from_env(self) -> str | None: - return os.environ.get("FASTAPI_CLOUD_TOKEN") + @property + def deploy_token(self) -> str | None: + return self._deploy_token - def is_expired(self) -> bool: - if not self.token: + def is_user_token_expired(self) -> bool: + if not self._user_token: return True - return _is_jwt_expired(self.token) + return _is_jwt_expired(self._user_token) def is_logged_in(self) -> bool: - if self.token is None: - logger.debug("Login status: False (no token)") - return False + """Whether there is a valid user token""" + return self._user_token is not None and not self.is_user_token_expired() - if self.auth_mode == "user" and self.is_expired(): - logger.debug("Login status: False (token expired)") - return False - - logger.debug("Login status: True") - return True + def has_deploy_token(self) -> bool: + """Whether there is a deploy token""" + return self._deploy_token is not None diff --git a/src/fastapi_cloud_cli/utils/cli.py b/src/fastapi_cloud_cli/utils/cli.py index 72e25955..dac7c009 100644 --- a/src/fastapi_cloud_cli/utils/cli.py +++ b/src/fastapi_cloud_cli/utils/cli.py @@ -10,7 +10,7 @@ from rich_toolkit.progress import Progress from rich_toolkit.styles import MinimalStyle, TaggedStyle -from .auth import Identity, delete_auth_config +from .auth import AuthMode, delete_auth_config logger = logging.getLogger(__name__) @@ -75,12 +75,10 @@ def get_rich_toolkit(minimal: bool = False) -> RichToolkit: return RichToolkit(theme=theme) -def handle_unauthorized() -> str: +def handle_unauthorized(auth_mode: AuthMode = "user") -> str: message = "The specified token is not valid. " - identity = Identity() - - if identity.auth_mode == "user": + if auth_mode == "user": delete_auth_config() message += "Use `fastapi login` to generate a new token." @@ -90,7 +88,11 @@ def handle_unauthorized() -> str: return message -def handle_http_error(error: HTTPError, default_message: str | None = None) -> str: +def handle_http_error( + error: HTTPError, + default_message: str | None = None, + auth_mode: AuthMode = "user", +) -> str: message: str | None = None if isinstance(error, HTTPStatusError): @@ -101,7 +103,7 @@ def handle_http_error(error: HTTPError, default_message: str | None = None) -> s logger.debug(error.response.json()) # pragma: no cover elif status_code == 401: - message = handle_unauthorized() + message = handle_unauthorized(auth_mode=auth_mode) elif status_code == 403: message = "You don't have permissions for this resource" @@ -119,6 +121,7 @@ def handle_http_error(error: HTTPError, default_message: str | None = None) -> s def handle_http_errors( progress: Progress, default_message: str | None = None, + auth_mode: AuthMode = "user", ) -> Generator[None, None, None]: try: yield @@ -133,7 +136,7 @@ def handle_http_errors( except HTTPError as e: logger.debug(e) - message = handle_http_error(e, default_message) + message = handle_http_error(e, default_message, auth_mode=auth_mode) progress.set_error(message) diff --git a/src/fastapi_cloud_cli/utils/sentry.py b/src/fastapi_cloud_cli/utils/sentry.py index b4828e49..3db4223b 100644 --- a/src/fastapi_cloud_cli/utils/sentry.py +++ b/src/fastapi_cloud_cli/utils/sentry.py @@ -7,10 +7,10 @@ def init_sentry() -> None: - """Initialize Sentry error tracking only if user is logged in.""" + """Initialize Sentry error tracking only if user is logged in or has a deploy token.""" identity = Identity() - if not identity.is_logged_in(): + if not (identity.is_logged_in() or identity.has_deploy_token()): return sentry_sdk.init( diff --git a/tests/test_auth.py b/tests/test_auth.py index 2f78954a..de8fce73 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -78,9 +78,9 @@ def test_is_jwt_expired_edge_case_one_second_before() -> None: assert not _is_jwt_expired(token) -def test_is_expired_with_no_token(temp_auth_config: Path) -> None: +def test_is_user_token_expired_with_no_token(temp_auth_config: Path) -> None: assert not temp_auth_config.exists() - assert Identity().is_expired() + assert Identity().is_user_token_expired() def test_is_logged_in_with_no_token(temp_auth_config: Path) -> None: diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index 8a05e35d..07fdbc1a 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -1624,6 +1624,9 @@ def test_deploy_successfully_with_token( # check that logs are shown assert "All good!" in result.output + assert ( + "Using token from FASTAPI_CLOUD_TOKEN environment variable" in result.output + ) # check that the app URL is shown assert deployment_data["url"] in result.output diff --git a/tests/test_cli_whoami.py b/tests/test_cli_whoami.py index 98c1f14b..3f958bcf 100644 --- a/tests/test_cli_whoami.py +++ b/tests/test_cli_whoami.py @@ -82,8 +82,23 @@ def test_prints_not_logged_in(logged_out_cli: None) -> None: assert "No credentials found. Use `fastapi login` to login." in result.output -def test_shows_logged_in_via_token(logged_out_cli: None) -> None: +def test_shows_has_deploy_token(logged_out_cli: None) -> None: result = runner.invoke(app, ["whoami"], env={"FASTAPI_CLOUD_TOKEN": "ABC"}) assert result.exit_code == 0 assert "Using API token from environment variable" in result.output + + +@pytest.mark.respx +def test_shows_logged_in_and_has_deploy_token( + logged_in_cli: None, respx_mock: respx.MockRouter +) -> None: + respx_mock.get("/users/me").mock( + return_value=Response(200, json={"email": "email@fastapi.com"}) + ) + + result = runner.invoke(app, ["whoami"], env={"FASTAPI_CLOUD_TOKEN": "ABC"}) + + assert result.exit_code == 0 + assert "email@fastapi.com" in result.output + assert "Using API token from environment variable" in result.output diff --git a/tests/test_sentry.py b/tests/test_sentry.py index 07f4aae1..4e0e07b5 100644 --- a/tests/test_sentry.py +++ b/tests/test_sentry.py @@ -1,6 +1,8 @@ from pathlib import Path from unittest.mock import ANY, patch +import pytest + from fastapi_cloud_cli.utils.sentry import SENTRY_DSN, init_sentry @@ -15,6 +17,20 @@ def test_init_sentry_when_logged_in(logged_in_cli: Path) -> None: ) +def test_init_sentry_when_deployment_token( + logged_out_cli: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FASTAPI_CLOUD_TOKEN", "deployment-token") + with patch("fastapi_cloud_cli.utils.sentry.sentry_sdk.init") as mock_init: + init_sentry() + + mock_init.assert_called_once_with( + dsn=SENTRY_DSN, + integrations=[ANY], # TyperIntegration instance + send_default_pii=False, + ) + + def test_init_sentry_when_logged_out(logged_out_cli: Path) -> None: with patch("fastapi_cloud_cli.utils.sentry.sentry_sdk.init") as mock_init: init_sentry() From 7b7349399d8517f416624f5f6e9592c1c7c6a97b Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Sun, 12 Apr 2026 21:44:15 +0200 Subject: [PATCH 03/17] Add `unset_env_vars` fxture to ignore `FASTAPI_CLOUD_TOKEN` set on host in tests --- tests/conftest.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 852c90a3..b079a5b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,13 @@ from .utils import create_jwt_token +@pytest.fixture(autouse=True) +def unset_env_vars(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]: + """Fixture to unset environment variables that might interfere with tests.""" + monkeypatch.delenv("FASTAPI_CLOUD_TOKEN", raising=False) + yield + + @pytest.fixture(autouse=True) def isolated_config_path() -> Generator[Path, None, None]: with tempfile.TemporaryDirectory() as tmpdir: From 485540bd7ca9917bc35e7afd54b932ba6a10d542 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Sun, 12 Apr 2026 22:02:16 +0200 Subject: [PATCH 04/17] Fix output style for warning in `login` command --- src/fastapi_cloud_cli/commands/login.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastapi_cloud_cli/commands/login.py b/src/fastapi_cloud_cli/commands/login.py index 4d028efa..3304be3d 100644 --- a/src/fastapi_cloud_cli/commands/login.py +++ b/src/fastapi_cloud_cli/commands/login.py @@ -88,7 +88,7 @@ def login() -> Any: return if identity.has_deploy_token(): - with get_rich_toolkit(minimal=True) as toolkit: + with get_rich_toolkit() as toolkit: toolkit.print( "You have [bold blue]FASTAPI_CLOUD_TOKEN[/] environment variable set.\n" "This token will take precedence over the user token for " From cd393831291ac0fdf758d8dd34f6fb2c20b13033 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Sun, 12 Apr 2026 22:09:04 +0200 Subject: [PATCH 05/17] Fix typing issue --- src/fastapi_cloud_cli/utils/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/fastapi_cloud_cli/utils/api.py b/src/fastapi_cloud_cli/utils/api.py index 1b85349a..004b262b 100644 --- a/src/fastapi_cloud_cli/utils/api.py +++ b/src/fastapi_cloud_cli/utils/api.py @@ -201,6 +201,7 @@ def __init__(self, use_deploy_token: bool = False) -> None: settings = Settings.get() identity = Identity() + token: str | None if use_deploy_token and identity.deploy_token: token = identity.deploy_token self.auth_mode = "token" From 73a72cc45cbdec75f5675e146cfaf0c5a3c54592 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Sun, 12 Apr 2026 22:17:27 +0200 Subject: [PATCH 06/17] Fix unneded `use_deploy_token=True` in `_send_waitlist_form` --- src/fastapi_cloud_cli/commands/deploy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index cc16eda2..ea23ac79 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -564,7 +564,7 @@ def _send_waitlist_form( toolkit: RichToolkit, ) -> None: with toolkit.progress("Sending your request...") as progress: - with APIClient(use_deploy_token=True) as client: + with APIClient() as client: with handle_http_errors(progress): response = client.post("/users/waiting-list", json=result.model_dump()) From 582e48f5b29e2e59a9be1e14d058075f524e732d Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Mon, 13 Apr 2026 09:11:21 +0200 Subject: [PATCH 07/17] Fix sending `Bearer None` if not authorized --- src/fastapi_cloud_cli/utils/api.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/fastapi_cloud_cli/utils/api.py b/src/fastapi_cloud_cli/utils/api.py index 004b262b..3f670f31 100644 --- a/src/fastapi_cloud_cli/utils/api.py +++ b/src/fastapi_cloud_cli/utils/api.py @@ -209,13 +209,14 @@ def __init__(self, use_deploy_token: bool = False) -> None: token = identity.user_token self.auth_mode = "user" + headers = {"User-Agent": f"fastapi-cloud-cli/{__version__}"} + if token: + headers["Authorization"] = f"Bearer {token}" + super().__init__( base_url=settings.base_api_url, timeout=httpx.Timeout(20), - headers={ - "Authorization": f"Bearer {token}", - "User-Agent": f"fastapi-cloud-cli/{__version__}", - }, + headers=headers, ) @attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT) From 6a5dc07b82b36256c4cde139b0e28c78037d4d73 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Mon, 13 Apr 2026 21:16:42 +0200 Subject: [PATCH 08/17] Turn `handle_http_errors` into a method of `APIClient` --- src/fastapi_cloud_cli/commands/deploy.py | 120 +++++++++++---------- src/fastapi_cloud_cli/commands/env.py | 63 ++++++----- src/fastapi_cloud_cli/commands/link.py | 56 +++++----- src/fastapi_cloud_cli/commands/login.py | 6 +- src/fastapi_cloud_cli/commands/logs.py | 3 +- src/fastapi_cloud_cli/commands/setup_ci.py | 27 ++--- src/fastapi_cloud_cli/commands/whoami.py | 3 +- src/fastapi_cloud_cli/utils/api.py | 72 ++++++++++++- src/fastapi_cloud_cli/utils/cli.py | 75 ------------- 9 files changed, 222 insertions(+), 203 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index ea23ac79..02859426 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -28,8 +28,8 @@ TooManyRetriesError, ) from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config -from fastapi_cloud_cli.utils.auth import AuthMode, Identity -from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors +from fastapi_cloud_cli.utils.auth import Identity +from fastapi_cloud_cli.utils.cli import get_rich_toolkit from fastapi_cloud_cli.utils.progress_file import ProgressFile logger = logging.getLogger(__name__) @@ -141,12 +141,11 @@ class Team(BaseModel): name: str -def _get_teams() -> list[Team]: - with APIClient(use_deploy_token=True) as client: - response = client.get("/teams/") - response.raise_for_status() +def _get_teams(client: APIClient) -> list[Team]: + response = client.get("/teams/") + response.raise_for_status() - data = response.json()["data"] + data = response.json()["data"] return [Team.model_validate(team) for team in data] @@ -157,28 +156,28 @@ class AppResponse(BaseModel): directory: str | None -def _update_app(app_id: str, directory: str | None) -> AppResponse: - with APIClient(use_deploy_token=True) as client: - response = client.patch( - f"/apps/{app_id}", - json={"directory": directory}, - ) +def _update_app(client: APIClient, app_id: str, directory: str | None) -> AppResponse: + response = client.patch( + f"/apps/{app_id}", + json={"directory": directory}, + ) - response.raise_for_status() + response.raise_for_status() - return AppResponse.model_validate(response.json()) + return AppResponse.model_validate(response.json()) -def _create_app(team_id: str, app_name: str, directory: str | None) -> AppResponse: - with APIClient(use_deploy_token=True) as client: - response = client.post( - "/apps/", - json={"name": app_name, "team_id": team_id, "directory": directory}, - ) +def _create_app( + client: APIClient, team_id: str, app_name: str, directory: str | None +) -> AppResponse: + response = client.post( + "/apps/", + json={"name": app_name, "team_id": team_id, "directory": directory}, + ) - response.raise_for_status() + response.raise_for_status() - return AppResponse.model_validate(response.json()) + return AppResponse.model_validate(response.json()) class CreateDeploymentResponse(BaseModel): @@ -190,12 +189,11 @@ class CreateDeploymentResponse(BaseModel): url: str -def _create_deployment(app_id: str) -> CreateDeploymentResponse: - with APIClient(use_deploy_token=True) as client: - response = client.post(f"/apps/{app_id}/deployments/") - response.raise_for_status() +def _create_deployment(client: APIClient, app_id: str) -> CreateDeploymentResponse: + response = client.post(f"/apps/{app_id}/deployments/") + response.raise_for_status() - return CreateDeploymentResponse.model_validate(response.json()) + return CreateDeploymentResponse.model_validate(response.json()) class RequestUploadResponse(BaseModel): @@ -263,26 +261,24 @@ def progress_callback(bytes_read: int) -> None: logger.debug("Upload notification sent successfully") -def _get_app(app_slug: str) -> AppResponse | None: - with APIClient(use_deploy_token=True) as client: - response = client.get(f"/apps/{app_slug}") +def _get_app(client: APIClient, app_slug: str) -> AppResponse | None: + response = client.get(f"/apps/{app_slug}") - if response.status_code == 404: - return None + if response.status_code == 404: + return None - response.raise_for_status() + response.raise_for_status() - data = response.json() + data = response.json() return AppResponse.model_validate(data) -def _get_apps(team_id: str) -> list[AppResponse]: - with APIClient(use_deploy_token=True) as client: - response = client.get("/apps/", params={"team_id": team_id}) - response.raise_for_status() +def _get_apps(client: APIClient, team_id: str) -> list[AppResponse]: + response = client.get("/apps/", params={"team_id": team_id}) + response.raise_for_status() - data = response.json()["data"] + data = response.json()["data"] return [AppResponse.model_validate(app) for app in data] @@ -309,21 +305,20 @@ def _get_apps(team_id: str) -> list[AppResponse]: def _configure_app( + client: APIClient, toolkit: RichToolkit, path_to_deploy: Path, - auth_mode: AuthMode = "user", ) -> AppConfig: toolkit.print(f"Setting up and deploying [blue]{path_to_deploy}[/blue]", tag="path") toolkit.print_line() with toolkit.progress("Fetching teams...") as progress: - with handle_http_errors( + with client.handle_http_errors( progress, default_message="Error fetching teams. Please try again later.", - auth_mode=auth_mode, ): - teams = _get_teams() + teams = _get_teams(client) toolkit.print_line() @@ -346,12 +341,11 @@ def _configure_app( if not create_new_app: with toolkit.progress("Fetching apps...") as progress: - with handle_http_errors( + with client.handle_http_errors( progress, default_message="Error fetching apps. Please try again later.", - auth_mode=auth_mode, ): - apps = _get_apps(team.id) + apps = _get_apps(client=client, team_id=team.id) toolkit.print_line() @@ -419,17 +413,26 @@ def _configure_app( if directory != selected_app.directory: with ( toolkit.progress(title="Updating app directory...") as progress, - handle_http_errors(progress, auth_mode=auth_mode), + client.handle_http_errors(progress), ): - app = _update_app(selected_app.id, directory=directory) + app = _update_app( + client=client, + app_id=selected_app.id, + directory=directory + ) progress.log(f"App directory updated to '{directory or '.'}'") else: app = selected_app else: with toolkit.progress(title="Creating app...") as progress: - with handle_http_errors(progress, auth_mode=auth_mode): - app = _create_app(team.id, app_name, directory=directory) + with client.handle_http_errors(progress): + app = _create_app( + client=client, + team_id=team.id, + app_name=app_name, + directory=directory + ) progress.log(f"App created successfully! App slug: {app.slug}") @@ -565,7 +568,7 @@ def _send_waitlist_form( ) -> None: with toolkit.progress("Sending your request...") as progress: with APIClient() as client: - with handle_http_errors(progress): + with client.handle_http_errors(progress): response = client.post("/users/waiting-list", json=result.model_dump()) response.raise_for_status() @@ -684,7 +687,6 @@ def deploy( identity = Identity() use_deploy = identity.has_deploy_token() has_auth = use_deploy or identity.is_logged_in() - auth_mode: AuthMode = "token" if use_deploy else "user" with get_rich_toolkit() as toolkit: if not has_auth: @@ -727,6 +729,8 @@ def deploy( ) toolkit.print_line() + client = APIClient(use_deploy_token=use_deploy) + toolkit.print_title("Starting deployment", tag="FastAPI") toolkit.print_line() @@ -757,7 +761,7 @@ def deploy( logger.debug("No app config found, configuring new app") app_config = _configure_app( - toolkit, path_to_deploy=path_to_deploy, auth_mode=auth_mode + client=client, toolkit=toolkit, path_to_deploy=path_to_deploy, ) toolkit.print_line() @@ -771,9 +775,9 @@ def deploy( toolkit.print_line() with toolkit.progress("Checking app...", transient=True) as progress: - with handle_http_errors(progress, auth_mode=auth_mode): + with client.handle_http_errors(progress): logger.debug("Checking app with ID: %s", target_app_id) - app = _get_app(target_app_id) + app = _get_app(client=client, app_slug=target_app_id) if not app: logger.debug("App not found in API") @@ -800,10 +804,10 @@ def deploy( toolkit.progress( title="Creating deployment", done_emoji="📦" ) as progress, - handle_http_errors(progress, auth_mode=auth_mode), + client.handle_http_errors(progress), ): logger.debug("Creating deployment for app: %s", app.id) - deployment = _create_deployment(app.id) + deployment = _create_deployment(client=client, app_id=app.id) try: progress.log( diff --git a/src/fastapi_cloud_cli/commands/env.py b/src/fastapi_cloud_cli/commands/env.py index b9694dcd..3fe16dee 100644 --- a/src/fastapi_cloud_cli/commands/env.py +++ b/src/fastapi_cloud_cli/commands/env.py @@ -8,7 +8,7 @@ from fastapi_cloud_cli.utils.api import APIClient from fastapi_cloud_cli.utils.apps import get_app_config from fastapi_cloud_cli.utils.auth import Identity -from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors +from fastapi_cloud_cli.utils.cli import get_rich_toolkit from fastapi_cloud_cli.utils.env import validate_environment_variable_name logger = logging.getLogger(__name__) @@ -23,17 +23,17 @@ class EnvironmentVariableResponse(BaseModel): data: list[EnvironmentVariable] -def _get_environment_variables(app_id: str) -> EnvironmentVariableResponse: - with APIClient() as client: - response = client.get(f"/apps/{app_id}/environment-variables/") - response.raise_for_status() +def _get_environment_variables( + client: APIClient, app_id: str +) -> EnvironmentVariableResponse: + response = client.get(f"/apps/{app_id}/environment-variables/") + response.raise_for_status() - return EnvironmentVariableResponse.model_validate(response.json()) + return EnvironmentVariableResponse.model_validate(response.json()) -def _delete_environment_variable(app_id: str, name: str) -> bool: - with APIClient() as client: - response = client.delete(f"/apps/{app_id}/environment-variables/{name}") +def _delete_environment_variable(client: APIClient, app_id: str, name: str) -> bool: + response = client.delete(f"/apps/{app_id}/environment-variables/{name}") if response.status_code == 404: return False @@ -44,14 +44,13 @@ def _delete_environment_variable(app_id: str, name: str) -> bool: def _set_environment_variable( - app_id: str, name: str, value: str, is_secret: bool = False + client: APIClient, app_id: str, name: str, value: str, is_secret: bool = False ) -> None: - with APIClient() as client: - response = client.post( - f"/apps/{app_id}/environment-variables/", - json={"name": name, "value": value, "is_secret": is_secret}, - ) - response.raise_for_status() + response = client.post( + f"/apps/{app_id}/environment-variables/", + json={"name": name, "value": value, "is_secret": is_secret}, + ) + response.raise_for_status() env_app = typer.Typer() @@ -91,11 +90,15 @@ def list( ) raise typer.Exit(1) + client = APIClient() + with toolkit.progress( "Fetching environment variables...", transient=True ) as progress: - with handle_http_errors(progress): - environment_variables = _get_environment_variables(app_config.app_id) + with client.handle_http_errors(progress): + environment_variables = _get_environment_variables( + client=client, app_id=app_config.app_id + ) if not environment_variables.data: toolkit.print("No environment variables found.") @@ -146,13 +149,15 @@ def delete( ) raise typer.Exit(1) + client = APIClient() + if not name: with toolkit.progress( "Fetching environment variables...", transient=True ) as progress: - with handle_http_errors(progress): + with client.handle_http_errors(progress): environment_variables = _get_environment_variables( - app_config.app_id + client=client, app_id=app_config.app_id ) if not environment_variables.data: @@ -180,8 +185,10 @@ def delete( with toolkit.progress( "Deleting environment variable", transient=True ) as progress: - with handle_http_errors(progress): - deleted = _delete_environment_variable(app_config.app_id, name) + with client.handle_http_errors(progress): + deleted = _delete_environment_variable( + client=client, app_id=app_config.app_id, name=name + ) if not deleted: toolkit.print("Environment variable not found.") @@ -253,14 +260,22 @@ def set( else: value = toolkit.input("Enter the value of the environment variable:") + client = APIClient() + with toolkit.progress( "Setting environment variable", transient=True ) as progress: assert name is not None assert value is not None - with handle_http_errors(progress): - _set_environment_variable(app_config.app_id, name, value, secret) + with client.handle_http_errors(progress): + _set_environment_variable( + client=client, + app_id=app_config.app_id, + name=name, + value=value, + is_secret=secret, + ) if secret: toolkit.print(f"Secret environment variable [bold]{name}[/] set.") diff --git a/src/fastapi_cloud_cli/commands/link.py b/src/fastapi_cloud_cli/commands/link.py index ffdba6e1..fa17ac69 100644 --- a/src/fastapi_cloud_cli/commands/link.py +++ b/src/fastapi_cloud_cli/commands/link.py @@ -8,7 +8,7 @@ from fastapi_cloud_cli.utils.api import APIClient from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config from fastapi_cloud_cli.utils.auth import Identity -from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors +from fastapi_cloud_cli.utils.cli import get_rich_toolkit logger = logging.getLogger(__name__) @@ -47,40 +47,42 @@ def link() -> Any: toolkit.print_title("Link to FastAPI Cloud", tag="FastAPI") toolkit.print_line() - with toolkit.progress("Fetching teams...") as progress: - with handle_http_errors( - progress, - default_message="Error fetching teams. Please try again later.", - ): - with APIClient() as client: + with APIClient() as client: + with toolkit.progress("Fetching teams...") as progress: + with client.handle_http_errors( + progress, + default_message="Error fetching teams. Please try again later.", + ): response = client.get("/teams/") response.raise_for_status() teams_data = response.json()["data"] - if not teams_data: - toolkit.print( - "[error]No teams found. Please create a team first.[/]", - ) - raise typer.Exit(1) + if not teams_data: + toolkit.print( + "[error]No teams found. Please create a team first.[/]", + ) + raise typer.Exit(1) - toolkit.print_line() + toolkit.print_line() - team = toolkit.ask( - "Select the team:", - tag="team", - options=[ - Option({"name": t["name"], "value": {"id": t["id"], "name": t["name"]}}) - for t in teams_data - ], - ) + team = toolkit.ask( + "Select the team:", + tag="team", + options=[ + Option( + {"name": t["name"], "value": {"id": t["id"], "name": t["name"]}} + ) + for t in teams_data + ], + ) - toolkit.print_line() + toolkit.print_line() - with toolkit.progress("Fetching apps...") as progress: - with handle_http_errors( - progress, default_message="Error fetching apps. Please try again later." - ): - with APIClient() as client: + with toolkit.progress("Fetching apps...") as progress: + with client.handle_http_errors( + progress, + default_message="Error fetching apps. Please try again later.", + ): response = client.get("/apps/", params={"team_id": team["id"]}) response.raise_for_status() apps_data = response.json()["data"] diff --git a/src/fastapi_cloud_cli/commands/login.py b/src/fastapi_cloud_cli/commands/login.py index 3304be3d..d6328881 100644 --- a/src/fastapi_cloud_cli/commands/login.py +++ b/src/fastapi_cloud_cli/commands/login.py @@ -9,7 +9,7 @@ from fastapi_cloud_cli.config import Settings from fastapi_cloud_cli.utils.api import APIClient from fastapi_cloud_cli.utils.auth import AuthConfig, Identity, write_auth_config -from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors +from fastapi_cloud_cli.utils.cli import get_rich_toolkit logger = logging.getLogger(__name__) @@ -102,7 +102,7 @@ def login() -> Any: toolkit.print_line() with toolkit.progress("Starting authorization") as progress: - with handle_http_errors(progress): + with client.handle_http_errors(progress): authorization_data = _start_device_authorization(client) url = authorization_data.verification_uri_complete @@ -114,7 +114,7 @@ def login() -> Any: with toolkit.progress("Waiting for user to authorize...") as progress: typer.launch(url) - with handle_http_errors(progress): + with client.handle_http_errors(progress): access_token = _fetch_access_token( client, authorization_data.device_code, authorization_data.interval ) diff --git a/src/fastapi_cloud_cli/commands/logs.py b/src/fastapi_cloud_cli/commands/logs.py index 16ea4ae9..cf4dc475 100644 --- a/src/fastapi_cloud_cli/commands/logs.py +++ b/src/fastapi_cloud_cli/commands/logs.py @@ -14,10 +14,11 @@ AppLogEntry, StreamLogError, TooManyRetriesError, + handle_http_error, ) from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config from fastapi_cloud_cli.utils.auth import Identity -from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_error +from fastapi_cloud_cli.utils.cli import get_rich_toolkit logger = logging.getLogger(__name__) diff --git a/src/fastapi_cloud_cli/commands/setup_ci.py b/src/fastapi_cloud_cli/commands/setup_ci.py index 562adc25..baff286a 100644 --- a/src/fastapi_cloud_cli/commands/setup_ci.py +++ b/src/fastapi_cloud_cli/commands/setup_ci.py @@ -10,7 +10,7 @@ from fastapi_cloud_cli.utils.api import APIClient from fastapi_cloud_cli.utils.apps import get_app_config from fastapi_cloud_cli.utils.auth import Identity -from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors +from fastapi_cloud_cli.utils.cli import get_rich_toolkit logger = logging.getLogger(__name__) @@ -96,19 +96,18 @@ def _set_github_secret(name: str, value: str) -> None: raise GitHubSecretError(f"Failed to set GitHub secret '{name}'") from e -def _create_token(app_id: str, token_name: str) -> dict[str, str]: +def _create_token(client: APIClient, app_id: str, token_name: str) -> dict[str, str]: """Create a new deploy token. Returns token_data dict with 'value' and 'expired_at' keys. """ - with APIClient() as client: - response = client.post( - f"/apps/{app_id}/tokens", - json={"name": token_name, "expires_in_days": TOKEN_EXPIRES_DAYS}, - ) - response.raise_for_status() - data = response.json() - return {"value": data["value"], "expired_at": data["expired_at"]} + response = client.post( + f"/apps/{app_id}/tokens", + json={"name": token_name, "expires_in_days": TOKEN_EXPIRES_DAYS}, + ) + response.raise_for_status() + data = response.json() + return {"value": data["value"], "expired_at": data["expired_at"]} def _get_default_branch() -> str: @@ -284,13 +283,17 @@ def setup_ci( timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") token_name = f"GitHub Actions — {repo_slug} ({timestamp})" + client = APIClient() + with ( toolkit.progress(title="Generating deploy token...") as progress, - handle_http_errors( + client.handle_http_errors( progress, default_message="Error creating deploy token." ), ): - token_data = _create_token(app_config.app_id, token_name) + token_data = _create_token( + client=client, app_id=app_config.app_id, token_name=token_name + ) progress.log(msg_token) toolkit.print_line() diff --git a/src/fastapi_cloud_cli/commands/whoami.py b/src/fastapi_cloud_cli/commands/whoami.py index 67c12647..2d68377e 100644 --- a/src/fastapi_cloud_cli/commands/whoami.py +++ b/src/fastapi_cloud_cli/commands/whoami.py @@ -6,7 +6,6 @@ from fastapi_cloud_cli.utils.api import APIClient from fastapi_cloud_cli.utils.auth import Identity -from fastapi_cloud_cli.utils.cli import handle_http_errors logger = logging.getLogger(__name__) @@ -19,7 +18,7 @@ def whoami() -> Any: else: with APIClient() as client: with Progress(title="⚡ Fetching profile", transient=True) as progress: - with handle_http_errors(progress, default_message=""): + with client.handle_http_errors(progress, default_message=""): response = client.get("/users/me") response.raise_for_status() diff --git a/src/fastapi_cloud_cli/utils/api.py b/src/fastapi_cloud_cli/utils/api.py index 3f670f31..12ef45e9 100644 --- a/src/fastapi_cloud_cli/utils/api.py +++ b/src/fastapi_cloud_cli/utils/api.py @@ -13,13 +13,15 @@ ) import httpx +import typer from pydantic import BaseModel, Field, TypeAdapter, ValidationError +from rich_toolkit.progress import Progress from typing_extensions import ParamSpec from fastapi_cloud_cli import __version__ from fastapi_cloud_cli.config import Settings -from .auth import AuthMode, Identity +from .auth import AuthMode, Identity, delete_auth_config logger = logging.getLogger(__name__) @@ -194,6 +196,48 @@ def to_human_readable(cls, status: "DeploymentStatus") -> str: POLL_MAX_RETRIES = 5 +def _handle_unauthorized(auth_mode: AuthMode) -> str: + message = "The specified token is not valid. " + + if auth_mode == "user": + delete_auth_config() + + message += "Use `fastapi login` to generate a new token." + else: + message += "Make sure to use a valid token." + + return message + + +def handle_http_error( + error: httpx.HTTPError, + default_message: str | None = None, + auth_mode: AuthMode = "user", +) -> str: + message: str | None = None + + if isinstance(error, httpx.HTTPStatusError): + status_code = error.response.status_code + + # Handle validation errors from Pydantic models, this should make it easier to debug :) + if status_code == 422: + logger.debug(error.response.json()) # pragma: no cover + + elif status_code == 401: + message = _handle_unauthorized(auth_mode=auth_mode) + + elif status_code == 403: + message = "You don't have permissions for this resource" + + if not message: + message = ( + default_message + or f"Something went wrong while contacting the FastAPI Cloud server. Please try again later. \n\n{error}" + ) + + return message + + class APIClient(httpx.Client): auth_mode: AuthMode @@ -219,6 +263,32 @@ def __init__(self, use_deploy_token: bool = False) -> None: headers=headers, ) + @contextmanager + def handle_http_errors( + self, + progress: Progress, + default_message: str | None = None, + ) -> Generator[None, None, None]: + try: + yield + except httpx.ReadTimeout as e: + logger.debug(e) + + progress.set_error( + "The request to the FastAPI Cloud server timed out." + " Please try again later." + ) + + raise typer.Exit(1) from None + except httpx.HTTPError as e: + logger.debug(e) + + message = handle_http_error(e, default_message, auth_mode=self.auth_mode) + + progress.set_error(message) + + raise typer.Exit(1) from None + @attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT) def stream_build_logs( self, deployment_id: str diff --git a/src/fastapi_cloud_cli/utils/cli.py b/src/fastapi_cloud_cli/utils/cli.py index dac7c009..99f5a829 100644 --- a/src/fastapi_cloud_cli/utils/cli.py +++ b/src/fastapi_cloud_cli/utils/cli.py @@ -1,17 +1,10 @@ -import contextlib import logging -from collections.abc import Generator from typing import Any, Literal -import typer -from httpx import HTTPError, HTTPStatusError, ReadTimeout from rich.segment import Segment from rich_toolkit import RichToolkit, RichToolkitTheme -from rich_toolkit.progress import Progress from rich_toolkit.styles import MinimalStyle, TaggedStyle -from .auth import AuthMode, delete_auth_config - logger = logging.getLogger(__name__) @@ -73,71 +66,3 @@ def get_rich_toolkit(minimal: bool = False) -> RichToolkit: ) return RichToolkit(theme=theme) - - -def handle_unauthorized(auth_mode: AuthMode = "user") -> str: - message = "The specified token is not valid. " - - if auth_mode == "user": - delete_auth_config() - - message += "Use `fastapi login` to generate a new token." - else: - message += "Make sure to use a valid token." - - return message - - -def handle_http_error( - error: HTTPError, - default_message: str | None = None, - auth_mode: AuthMode = "user", -) -> str: - message: str | None = None - - if isinstance(error, HTTPStatusError): - status_code = error.response.status_code - - # Handle validation errors from Pydantic models, this should make it easier to debug :) - if status_code == 422: - logger.debug(error.response.json()) # pragma: no cover - - elif status_code == 401: - message = handle_unauthorized(auth_mode=auth_mode) - - elif status_code == 403: - message = "You don't have permissions for this resource" - - if not message: - message = ( - default_message - or f"Something went wrong while contacting the FastAPI Cloud server. Please try again later. \n\n{error}" - ) - - return message - - -@contextlib.contextmanager -def handle_http_errors( - progress: Progress, - default_message: str | None = None, - auth_mode: AuthMode = "user", -) -> Generator[None, None, None]: - try: - yield - except ReadTimeout as e: - logger.debug(e) - - progress.set_error( - "The request to the FastAPI Cloud server timed out. Please try again later." - ) - - raise typer.Exit(1) from None - except HTTPError as e: - logger.debug(e) - - message = handle_http_error(e, default_message, auth_mode=auth_mode) - - progress.set_error(message) - - raise typer.Exit(1) from None From 23ef7f7f254e0ba9f34c2581361dc5b37f1dd755 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Mon, 13 Apr 2026 23:09:42 +0200 Subject: [PATCH 09/17] Reuse existing `APIClient` instance instead of instantiating new in sub-calls --- src/fastapi_cloud_cli/commands/deploy.py | 145 ++++++++++++----------- 1 file changed, 79 insertions(+), 66 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 02859426..f04195ac 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -68,15 +68,14 @@ def validate_app_directory(v: str | None) -> str | None: AppDirectory = Annotated[str | None, AfterValidator(validate_app_directory)] -def _cancel_upload(deployment_id: str) -> None: +def _cancel_upload(client: APIClient, deployment_id: str) -> None: logger.debug("Cancelling upload for deployment: %s", deployment_id) try: - with APIClient(use_deploy_token=True) as client: - response = client.post(f"/deployments/{deployment_id}/upload-cancelled") - response.raise_for_status() + response = client.post(f"/deployments/{deployment_id}/upload-cancelled") + response.raise_for_status() - logger.debug("Upload cancellation notification sent successfully") + logger.debug("Upload cancellation notification sent successfully") except Exception as e: logger.debug("Failed to notify server about upload cancellation: %s", e) @@ -211,7 +210,10 @@ def _format_size(size_in_bytes: int) -> str: def _upload_deployment( - deployment_id: str, archive_path: Path, progress: Progress + fastapi_client: APIClient, + deployment_id: str, + archive_path: Path, + progress: Progress, ) -> None: archive_size = archive_path.stat().st_size archive_size_str = _format_size(archive_size) @@ -228,37 +230,37 @@ def progress_callback(bytes_read: int) -> None: f"Uploading deployment ({_format_size(bytes_read)} of {archive_size_str})..." ) - with APIClient(use_deploy_token=True) as fastapi_client, Client() as client: - # Get the upload URL - logger.debug("Requesting upload URL from API") - response = fastapi_client.post(f"/deployments/{deployment_id}/upload") - response.raise_for_status() + # Get the upload URL + logger.debug("Requesting upload URL from API") + response = fastapi_client.post(f"/deployments/{deployment_id}/upload") + response.raise_for_status() - upload_data = RequestUploadResponse.model_validate(response.json()) - logger.debug("Received upload URL: %s", upload_data.url) + upload_data = RequestUploadResponse.model_validate(response.json()) + logger.debug("Received upload URL: %s", upload_data.url) - logger.debug("Starting file upload to S3") + logger.debug("Starting file upload to S3") + with Client() as s3_client: with open(archive_path, "rb") as archive_file: archive_file_with_progress = ProgressFile( archive_file, progress_callback=progress_callback ) - upload_response = client.post( + upload_response = s3_client.post( upload_data.url, data=upload_data.fields, files={"file": cast(BinaryIO, archive_file_with_progress)}, ) - upload_response.raise_for_status() - logger.debug("File upload completed successfully") + upload_response.raise_for_status() + logger.debug("File upload completed successfully") - # Notify the server that the upload is complete - logger.debug("Notifying API that upload is complete") - notify_response = fastapi_client.post( - f"/deployments/{deployment_id}/upload-complete" - ) + # Notify the server that the upload is complete + logger.debug("Notifying API that upload is complete") + notify_response = fastapi_client.post( + f"/deployments/{deployment_id}/upload-complete" + ) - notify_response.raise_for_status() - logger.debug("Upload notification sent successfully") + notify_response.raise_for_status() + logger.debug("Upload notification sent successfully") def _get_app(client: APIClient, app_slug: str) -> AppResponse | None: @@ -480,7 +482,10 @@ def _verify_deployment( def _wait_for_deployment( - toolkit: RichToolkit, app_id: str, deployment: CreateDeploymentResponse + toolkit: RichToolkit, + client: APIClient, + app_id: str, + deployment: CreateDeploymentResponse, ) -> None: messages = cycle(WAITING_MESSAGES) @@ -496,59 +501,60 @@ def _wait_for_deployment( last_message_changed_at = time.monotonic() - with APIClient(use_deploy_token=True) as client: - with ( - toolkit.progress( - next(messages), - inline_logs=True, - lines_to_show=20, - done_emoji="🚀", - ) as progress, - ): - build_complete = False + with ( + toolkit.progress( + next(messages), + inline_logs=True, + lines_to_show=20, + done_emoji="🚀", + ) as progress, + ): + build_complete = False - try: - for log in client.stream_build_logs(deployment.id): - time_elapsed = time.monotonic() - started_at + try: + for log in client.stream_build_logs(deployment.id): + time_elapsed = time.monotonic() - started_at - if log.type == "message": - progress.log(Text.from_ansi(log.message.rstrip())) # ty: ignore[unresolved-attribute] + if log.type == "message": + progress.log(Text.from_ansi(log.message.rstrip())) # ty: ignore[unresolved-attribute] - if log.type == "complete": - build_complete = True - progress.title = "Build complete!" - break + if log.type == "complete": + build_complete = True + progress.title = "Build complete!" + break - if log.type == "failed": - progress.log("") - progress.log( - f"😔 Oh no! Something went wrong. Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]" - ) - raise typer.Exit(1) + if log.type == "failed": + progress.log("") + progress.log( + f"😔 Oh no! Something went wrong. Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]" + ) + raise typer.Exit(1) - if time_elapsed > 30: - messages = cycle(LONG_WAIT_MESSAGES) + if time_elapsed > 30: + messages = cycle(LONG_WAIT_MESSAGES) - if (time.monotonic() - last_message_changed_at) > 2: - progress.title = next(messages) + if (time.monotonic() - last_message_changed_at) > 2: + progress.title = next(messages) - last_message_changed_at = time.monotonic() + last_message_changed_at = time.monotonic() - except (StreamLogError, TooManyRetriesError, TimeoutError) as e: - progress.set_error( - dedent(f""" + except (StreamLogError, TooManyRetriesError, TimeoutError) as e: + progress.set_error( + dedent(f""" [error]Build log streaming failed: {e}[/] Unable to stream build logs. Check the dashboard for status: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link] """).strip() - ) + ) - raise typer.Exit(1) from None + raise typer.Exit(1) from None - if build_complete: - toolkit.print_line() + if build_complete: + toolkit.print_line() - _verify_deployment(toolkit, client, app_id, deployment) + _verify_deployment( + toolkit=toolkit, client=client, app_id=app_id, deployment=deployment + ) class SignupToWaitingList(BaseModel): @@ -814,18 +820,25 @@ def deploy( f"Deployment created successfully! Deployment slug: {deployment.slug}" ) - _upload_deployment(deployment.id, archive_path, progress=progress) + _upload_deployment( + fastapi_client=client, + deployment_id=deployment.id, + archive_path=archive_path, + progress=progress, + ) progress.log("Deployment uploaded successfully!") except KeyboardInterrupt: - _cancel_upload(deployment.id) + _cancel_upload(client=client, deployment_id=deployment.id) raise toolkit.print_line() if not skip_wait: logger.debug("Waiting for deployment to complete") - _wait_for_deployment(toolkit, app.id, deployment=deployment) + _wait_for_deployment( + toolkit=toolkit, client=client, app_id=app.id, deployment=deployment + ) else: logger.debug("Skipping deployment wait as requested") toolkit.print( From cba124a578ba5fe91d1b710780c3b30371e1a3ec Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Mon, 13 Apr 2026 23:10:45 +0200 Subject: [PATCH 10/17] Fix parameter order inconsistency --- src/fastapi_cloud_cli/commands/deploy.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index f04195ac..4d742c03 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -307,8 +307,8 @@ def _get_apps(client: APIClient, team_id: str) -> list[AppResponse]: def _configure_app( - client: APIClient, toolkit: RichToolkit, + client: APIClient, path_to_deploy: Path, ) -> AppConfig: toolkit.print(f"Setting up and deploying [blue]{path_to_deploy}[/blue]", tag="path") @@ -418,9 +418,7 @@ def _configure_app( client.handle_http_errors(progress), ): app = _update_app( - client=client, - app_id=selected_app.id, - directory=directory + client=client, app_id=selected_app.id, directory=directory ) progress.log(f"App directory updated to '{directory or '.'}'") @@ -433,7 +431,7 @@ def _configure_app( client=client, team_id=team.id, app_name=app_name, - directory=directory + directory=directory, ) progress.log(f"App created successfully! App slug: {app.slug}") @@ -767,7 +765,9 @@ def deploy( logger.debug("No app config found, configuring new app") app_config = _configure_app( - client=client, toolkit=toolkit, path_to_deploy=path_to_deploy, + toolkit=toolkit, + client=client, + path_to_deploy=path_to_deploy, ) toolkit.print_line() From a7662cbe42c3267541c42dcc59dd09e0d247ae60 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Mon, 13 Apr 2026 23:30:06 +0200 Subject: [PATCH 11/17] Always use context manager with `APIClient` --- src/fastapi_cloud_cli/commands/deploy.py | 186 +++++++++++---------- src/fastapi_cloud_cli/commands/env.py | 117 +++++++------ src/fastapi_cloud_cli/commands/setup_ci.py | 3 +- 3 files changed, 152 insertions(+), 154 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 4d742c03..088e02c6 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -733,114 +733,116 @@ def deploy( ) toolkit.print_line() - client = APIClient(use_deploy_token=use_deploy) - - toolkit.print_title("Starting deployment", tag="FastAPI") - toolkit.print_line() - - path_to_deploy = path or Path.cwd() - logger.debug("Deploying from path: %s", path_to_deploy) - - app_config = get_app_config(path_to_deploy) - - if app_config and provided_app_id and app_config.app_id != provided_app_id: - toolkit.print( - f"[error]Error: Provided app ID ({provided_app_id}) does not match the local " - f"config ({app_config.app_id}).[/]" - ) + with APIClient(use_deploy_token=use_deploy) as client: + toolkit.print_title("Starting deployment", tag="FastAPI") toolkit.print_line() - toolkit.print( - "Run [bold]fastapi cloud unlink[/] to remove the local config, " - "or remove --app-id / unset FASTAPI_CLOUD_APP_ID to use the configured app.", - tag="tip", - ) - - raise typer.Exit(1) from None - - if provided_app_id: - target_app_id = provided_app_id - elif app_config: - target_app_id = app_config.app_id - else: - logger.debug("No app config found, configuring new app") - app_config = _configure_app( - toolkit=toolkit, - client=client, - path_to_deploy=path_to_deploy, - ) - toolkit.print_line() + path_to_deploy = path or Path.cwd() + logger.debug("Deploying from path: %s", path_to_deploy) - target_app_id = app_config.app_id + app_config = get_app_config(path_to_deploy) - if provided_app_id: - toolkit.print(f"Deploying to app [blue]{target_app_id}[/blue]...") - else: - toolkit.print("Deploying app...") + if app_config and provided_app_id and app_config.app_id != provided_app_id: + toolkit.print( + f"[error]Error: Provided app ID ({provided_app_id}) does not match the local " + f"config ({app_config.app_id}).[/]" + ) + toolkit.print_line() + toolkit.print( + "Run [bold]fastapi cloud unlink[/] to remove the local config, " + "or remove --app-id / unset FASTAPI_CLOUD_APP_ID to use the configured app.", + tag="tip", + ) - toolkit.print_line() + raise typer.Exit(1) from None - with toolkit.progress("Checking app...", transient=True) as progress: - with client.handle_http_errors(progress): - logger.debug("Checking app with ID: %s", target_app_id) - app = _get_app(client=client, app_slug=target_app_id) + if provided_app_id: + target_app_id = provided_app_id + elif app_config: + target_app_id = app_config.app_id + else: + logger.debug("No app config found, configuring new app") - if not app: - logger.debug("App not found in API") - progress.set_error( - "App not found. Make sure you're logged in the correct account." + app_config = _configure_app( + toolkit=toolkit, + client=client, + path_to_deploy=path_to_deploy, ) + toolkit.print_line() - if not app: - toolkit.print_line() + target_app_id = app_config.app_id - if not provided_app_id: - toolkit.print( - "If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration.", - tag="tip", - ) - raise typer.Exit(1) + if provided_app_id: + toolkit.print(f"Deploying to app [blue]{target_app_id}[/blue]...") + else: + toolkit.print("Deploying app...") - with tempfile.TemporaryDirectory() as temp_dir: - logger.debug("Creating archive for deployment") - archive_path = Path(temp_dir) / "archive.tar" - archive(path or Path.cwd(), archive_path) + toolkit.print_line() - with ( - toolkit.progress( - title="Creating deployment", done_emoji="📦" - ) as progress, - client.handle_http_errors(progress), - ): - logger.debug("Creating deployment for app: %s", app.id) - deployment = _create_deployment(client=client, app_id=app.id) + with toolkit.progress("Checking app...", transient=True) as progress: + with client.handle_http_errors(progress): + logger.debug("Checking app with ID: %s", target_app_id) + app = _get_app(client=client, app_slug=target_app_id) - try: - progress.log( - f"Deployment created successfully! Deployment slug: {deployment.slug}" + if not app: + logger.debug("App not found in API") + progress.set_error( + "App not found. Make sure you're logged in the correct account." ) - _upload_deployment( - fastapi_client=client, - deployment_id=deployment.id, - archive_path=archive_path, - progress=progress, + if not app: + toolkit.print_line() + + if not provided_app_id: + toolkit.print( + "If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration.", + tag="tip", ) + raise typer.Exit(1) - progress.log("Deployment uploaded successfully!") - except KeyboardInterrupt: - _cancel_upload(client=client, deployment_id=deployment.id) - raise + with tempfile.TemporaryDirectory() as temp_dir: + logger.debug("Creating archive for deployment") + archive_path = Path(temp_dir) / "archive.tar" + archive(path or Path.cwd(), archive_path) + + with ( + toolkit.progress( + title="Creating deployment", done_emoji="📦" + ) as progress, + client.handle_http_errors(progress), + ): + logger.debug("Creating deployment for app: %s", app.id) + deployment = _create_deployment(client=client, app_id=app.id) + + try: + progress.log( + f"Deployment created successfully! Deployment slug: {deployment.slug}" + ) + + _upload_deployment( + fastapi_client=client, + deployment_id=deployment.id, + archive_path=archive_path, + progress=progress, + ) + + progress.log("Deployment uploaded successfully!") + except KeyboardInterrupt: + _cancel_upload(client=client, deployment_id=deployment.id) + raise - toolkit.print_line() + toolkit.print_line() - if not skip_wait: - logger.debug("Waiting for deployment to complete") - _wait_for_deployment( - toolkit=toolkit, client=client, app_id=app.id, deployment=deployment - ) - else: - logger.debug("Skipping deployment wait as requested") - toolkit.print( - f"Check the status of your deployment at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]" - ) + if not skip_wait: + logger.debug("Waiting for deployment to complete") + _wait_for_deployment( + toolkit=toolkit, + client=client, + app_id=app.id, + deployment=deployment, + ) + else: + logger.debug("Skipping deployment wait as requested") + toolkit.print( + f"Check the status of your deployment at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]" + ) diff --git a/src/fastapi_cloud_cli/commands/env.py b/src/fastapi_cloud_cli/commands/env.py index 3fe16dee..905024f2 100644 --- a/src/fastapi_cloud_cli/commands/env.py +++ b/src/fastapi_cloud_cli/commands/env.py @@ -90,15 +90,14 @@ def list( ) raise typer.Exit(1) - client = APIClient() - - with toolkit.progress( - "Fetching environment variables...", transient=True - ) as progress: - with client.handle_http_errors(progress): - environment_variables = _get_environment_variables( - client=client, app_id=app_config.app_id - ) + with APIClient() as client: + with toolkit.progress( + "Fetching environment variables...", transient=True + ) as progress: + with client.handle_http_errors(progress): + environment_variables = _get_environment_variables( + client=client, app_id=app_config.app_id + ) if not environment_variables.data: toolkit.print("No environment variables found.") @@ -149,47 +148,46 @@ def delete( ) raise typer.Exit(1) - client = APIClient() + with APIClient() as client: + if not name: + with toolkit.progress( + "Fetching environment variables...", transient=True + ) as progress: + with client.handle_http_errors(progress): + environment_variables = _get_environment_variables( + client=client, app_id=app_config.app_id + ) + + if not environment_variables.data: + toolkit.print("No environment variables found.") + return + + name = toolkit.ask( + "Select the environment variable to delete:", + options=[ + {"name": env_var.name, "value": env_var.name} + for env_var in environment_variables.data + ], + ) + + assert name + else: + if not validate_environment_variable_name(name): + toolkit.print( + f"The environment variable name [bold]{name}[/] is invalid." + ) + raise typer.Exit(1) + + toolkit.print_line() - if not name: with toolkit.progress( - "Fetching environment variables...", transient=True + "Deleting environment variable", transient=True ) as progress: with client.handle_http_errors(progress): - environment_variables = _get_environment_variables( - client=client, app_id=app_config.app_id + deleted = _delete_environment_variable( + client=client, app_id=app_config.app_id, name=name ) - if not environment_variables.data: - toolkit.print("No environment variables found.") - return - - name = toolkit.ask( - "Select the environment variable to delete:", - options=[ - {"name": env_var.name, "value": env_var.name} - for env_var in environment_variables.data - ], - ) - - assert name - else: - if not validate_environment_variable_name(name): - toolkit.print( - f"The environment variable name [bold]{name}[/] is invalid." - ) - raise typer.Exit(1) - - toolkit.print_line() - - with toolkit.progress( - "Deleting environment variable", transient=True - ) as progress: - with client.handle_http_errors(progress): - deleted = _delete_environment_variable( - client=client, app_id=app_config.app_id, name=name - ) - if not deleted: toolkit.print("Environment variable not found.") raise typer.Exit(1) @@ -260,22 +258,21 @@ def set( else: value = toolkit.input("Enter the value of the environment variable:") - client = APIClient() - - with toolkit.progress( - "Setting environment variable", transient=True - ) as progress: - assert name is not None - assert value is not None - - with client.handle_http_errors(progress): - _set_environment_variable( - client=client, - app_id=app_config.app_id, - name=name, - value=value, - is_secret=secret, - ) + with APIClient() as client: + with toolkit.progress( + "Setting environment variable", transient=True + ) as progress: + assert name is not None + assert value is not None + + with client.handle_http_errors(progress): + _set_environment_variable( + client=client, + app_id=app_config.app_id, + name=name, + value=value, + is_secret=secret, + ) if secret: toolkit.print(f"Secret environment variable [bold]{name}[/] set.") diff --git a/src/fastapi_cloud_cli/commands/setup_ci.py b/src/fastapi_cloud_cli/commands/setup_ci.py index baff286a..f90ea39c 100644 --- a/src/fastapi_cloud_cli/commands/setup_ci.py +++ b/src/fastapi_cloud_cli/commands/setup_ci.py @@ -283,9 +283,8 @@ def setup_ci( timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") token_name = f"GitHub Actions — {repo_slug} ({timestamp})" - client = APIClient() - with ( + APIClient() as client, toolkit.progress(title="Generating deploy token...") as progress, client.handle_http_errors( progress, default_message="Error creating deploy token." From 00f8316bf6459fe25851d45f6029fd7efdf028b4 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 14 Apr 2026 01:07:02 +0200 Subject: [PATCH 12/17] Restore removed logging in `auth.py` --- src/fastapi_cloud_cli/utils/auth.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/fastapi_cloud_cli/utils/auth.py b/src/fastapi_cloud_cli/utils/auth.py index c8cd2a45..3f3ca2c3 100644 --- a/src/fastapi_cloud_cli/utils/auth.py +++ b/src/fastapi_cloud_cli/utils/auth.py @@ -131,8 +131,24 @@ def is_user_token_expired(self) -> bool: def is_logged_in(self) -> bool: """Whether there is a valid user token""" - return self._user_token is not None and not self.is_user_token_expired() + + if self._user_token is None: + logger.debug("Login status: False (no token)") + return False + + if self.is_user_token_expired(): + logger.debug("Login status: False (token expired)") + return False + + logger.debug("Login status: True") + return True def has_deploy_token(self) -> bool: """Whether there is a deploy token""" - return self._deploy_token is not None + + if self._deploy_token is None: + logger.debug("Deploy token is not provided") + return False + + logger.debug("Deploy token found") + return True From 1b2891c621f26ee53c35bbd7aacb5986a9bb4176 Mon Sep 17 00:00:00 2001 From: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:12:14 +0200 Subject: [PATCH 13/17] Apply suggestions from code review Co-authored-by: Patrick Arminio --- src/fastapi_cloud_cli/utils/auth.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/fastapi_cloud_cli/utils/auth.py b/src/fastapi_cloud_cli/utils/auth.py index 3f3ca2c3..dc8b63c0 100644 --- a/src/fastapi_cloud_cli/utils/auth.py +++ b/src/fastapi_cloud_cli/utils/auth.py @@ -130,7 +130,6 @@ def is_user_token_expired(self) -> bool: return _is_jwt_expired(self._user_token) def is_logged_in(self) -> bool: - """Whether there is a valid user token""" if self._user_token is None: logger.debug("Login status: False (no token)") @@ -144,7 +143,6 @@ def is_logged_in(self) -> bool: return True def has_deploy_token(self) -> bool: - """Whether there is a deploy token""" if self._deploy_token is None: logger.debug("Deploy token is not provided") From 9673a152766c6a20b82c17dbdb4d13b9a915cbe1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 12:12:44 +0000 Subject: [PATCH 14/17] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/fastapi_cloud_cli/utils/auth.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/fastapi_cloud_cli/utils/auth.py b/src/fastapi_cloud_cli/utils/auth.py index dc8b63c0..86527363 100644 --- a/src/fastapi_cloud_cli/utils/auth.py +++ b/src/fastapi_cloud_cli/utils/auth.py @@ -130,7 +130,6 @@ def is_user_token_expired(self) -> bool: return _is_jwt_expired(self._user_token) def is_logged_in(self) -> bool: - if self._user_token is None: logger.debug("Login status: False (no token)") return False @@ -143,7 +142,6 @@ def is_logged_in(self) -> bool: return True def has_deploy_token(self) -> bool: - if self._deploy_token is None: logger.debug("Deploy token is not provided") return False From cc1948ef771e51b2fbfd53db63a52954f4a7cf11 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 15 Apr 2026 14:23:56 +0200 Subject: [PATCH 15/17] Rename `use_deploy` -> `use_deploy_token` --- src/fastapi_cloud_cli/commands/deploy.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 088e02c6..7bd1bab0 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -689,8 +689,8 @@ def deploy( ) identity = Identity() - use_deploy = identity.has_deploy_token() - has_auth = use_deploy or identity.is_logged_in() + use_deploy_token = identity.has_deploy_token() + has_auth = use_deploy_token or identity.is_logged_in() with get_rich_toolkit() as toolkit: if not has_auth: @@ -726,14 +726,14 @@ def deploy( _waitlist_form(toolkit) raise typer.Exit(1) - if use_deploy: + if use_deploy_token: toolkit.print( "Using token from [bold blue]FASTAPI_CLOUD_TOKEN[/] environment variable", tag="info", ) toolkit.print_line() - with APIClient(use_deploy_token=use_deploy) as client: + with APIClient(use_deploy_token=use_deploy_token) as client: toolkit.print_title("Starting deployment", tag="FastAPI") toolkit.print_line() From 7cfde6d568bc9ddc02fd4cef8d7692e60f64146f Mon Sep 17 00:00:00 2001 From: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:55:04 +0200 Subject: [PATCH 16/17] Add debug log message about auth mode --- src/fastapi_cloud_cli/commands/deploy.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 7bd1bab0..3d1f1aed 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -691,6 +691,10 @@ def deploy( identity = Identity() use_deploy_token = identity.has_deploy_token() has_auth = use_deploy_token or identity.is_logged_in() + + logger.debug( + "Authentication mode: %s", "deploy token" if use_deploy_token else "user token" + ) with get_rich_toolkit() as toolkit: if not has_auth: From f6ea3c7a00f3a1138dced62d518be0cc7343b8e9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 12:56:07 +0000 Subject: [PATCH 17/17] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/fastapi_cloud_cli/commands/deploy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 3d1f1aed..d41cbd0c 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -691,7 +691,7 @@ def deploy( identity = Identity() use_deploy_token = identity.has_deploy_token() has_auth = use_deploy_token or identity.is_logged_in() - + logger.debug( "Authentication mode: %s", "deploy token" if use_deploy_token else "user token" )