From 721b10f493780d0debf007b4bfc3b99fb077537a Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 2 Apr 2026 21:01:02 +0200 Subject: [PATCH 01/18] Add `prefer_auth_mode` param to `Identity` --- src/fastapi_cloud_cli/utils/auth.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/fastapi_cloud_cli/utils/auth.py b/src/fastapi_cloud_cli/utils/auth.py index b542fa28..25f4bbe1 100644 --- a/src/fastapi_cloud_cli/utils/auth.py +++ b/src/fastapi_cloud_cli/utils/auth.py @@ -111,19 +111,23 @@ def _is_jwt_expired(token: str) -> bool: class Identity: auth_mode: Literal["token", "user"] - def __init__(self) -> None: + def __init__(self, prefer_auth_mode: Literal["token", "user"] = "user") -> None: self.token = _get_auth_token() self.auth_mode = "user" - # users using `FASTAPI_CLOUD_TOKEN` - if env_token := self._get_token_from_env(): - self.token = env_token - self.auth_mode = "token" + if prefer_auth_mode == "token": + # users using `FASTAPI_CLOUD_TOKEN` + if env_token := self._get_token_from_env(): + self.token = env_token + self.auth_mode = "token" def _get_token_from_env(self) -> str | None: return os.environ.get("FASTAPI_CLOUD_TOKEN") def is_expired(self) -> bool: + if self.auth_mode != "user": # pragma: no cover # Should never happen + raise RuntimeError("Expiration check is only applicable for user tokens") + if not self.token: return True From 83738d13db8febcc34e61ee740891980d04ca60d Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 2 Apr 2026 21:10:12 +0200 Subject: [PATCH 02/18] Add `Context` to tell wich token to use in each command --- src/fastapi_cloud_cli/commands/deploy.py | 5 +++-- src/fastapi_cloud_cli/commands/env.py | 8 +++---- src/fastapi_cloud_cli/commands/link.py | 4 ++-- src/fastapi_cloud_cli/commands/login.py | 5 +++-- src/fastapi_cloud_cli/commands/logs.py | 4 ++-- src/fastapi_cloud_cli/commands/setup_ci.py | 4 ++-- src/fastapi_cloud_cli/commands/whoami.py | 5 +++-- src/fastapi_cloud_cli/context.py | 25 ++++++++++++++++++++++ src/fastapi_cloud_cli/utils/api.py | 5 ++--- src/fastapi_cloud_cli/utils/cli.py | 6 ++++-- src/fastapi_cloud_cli/utils/sentry.py | 4 +++- 11 files changed, 53 insertions(+), 22 deletions(-) create mode 100644 src/fastapi_cloud_cli/context.py diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 44cdc13c..6e521b0f 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -19,6 +19,7 @@ from rich_toolkit.menu import Option from fastapi_cloud_cli.commands.login import login +from fastapi_cloud_cli.context import ctx from fastapi_cloud_cli.utils.api import ( SUCCESSFUL_STATUSES, APIClient, @@ -27,7 +28,6 @@ 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.cli import get_rich_toolkit, handle_http_errors logger = logging.getLogger(__name__) @@ -652,7 +652,8 @@ def deploy( "Deploy path: %s, skip_wait: %s, app_id: %s", path, skip_wait, provided_app_id ) - identity = Identity() + ctx.initialize(prefer_auth_mode="token") + identity = ctx.get_identity() with get_rich_toolkit() as toolkit: if not identity.is_logged_in(): diff --git a/src/fastapi_cloud_cli/commands/env.py b/src/fastapi_cloud_cli/commands/env.py index b9694dcd..9ccb038c 100644 --- a/src/fastapi_cloud_cli/commands/env.py +++ b/src/fastapi_cloud_cli/commands/env.py @@ -5,9 +5,9 @@ import typer from pydantic import BaseModel +from fastapi_cloud_cli.context import ctx 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.env import validate_environment_variable_name @@ -70,7 +70,7 @@ def list( List the environment variables for the app. """ - identity = Identity() + identity = ctx.get_identity() with get_rich_toolkit(minimal=True) as toolkit: if not identity.is_logged_in(): @@ -125,7 +125,7 @@ def delete( Delete an environment variable from the app. """ - identity = Identity() + identity = ctx.get_identity() with get_rich_toolkit(minimal=True) as toolkit: if not identity.is_logged_in(): @@ -218,7 +218,7 @@ def set( Set an environment variable for the app. """ - identity = Identity() + identity = ctx.get_identity() with get_rich_toolkit(minimal=True) as toolkit: if not identity.is_logged_in(): diff --git a/src/fastapi_cloud_cli/commands/link.py b/src/fastapi_cloud_cli/commands/link.py index ffdba6e1..f7b533ab 100644 --- a/src/fastapi_cloud_cli/commands/link.py +++ b/src/fastapi_cloud_cli/commands/link.py @@ -5,9 +5,9 @@ import typer from rich_toolkit.menu import Option +from fastapi_cloud_cli.context import ctx 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 logger = logging.getLogger(__name__) @@ -17,7 +17,7 @@ def link() -> Any: """ Link a local directory to an existing FastAPI Cloud app. """ - identity = Identity() + identity = ctx.get_identity() with get_rich_toolkit() as toolkit: if not identity.is_logged_in(): diff --git a/src/fastapi_cloud_cli/commands/login.py b/src/fastapi_cloud_cli/commands/login.py index 6c6ef07b..3bcbf8e6 100644 --- a/src/fastapi_cloud_cli/commands/login.py +++ b/src/fastapi_cloud_cli/commands/login.py @@ -7,8 +7,9 @@ from pydantic import BaseModel from fastapi_cloud_cli.config import Settings +from fastapi_cloud_cli.context import ctx 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.auth import AuthConfig, write_auth_config from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors logger = logging.getLogger(__name__) @@ -76,7 +77,7 @@ def login() -> Any: """ Login to FastAPI Cloud. 🚀 """ - identity = Identity() + identity = ctx.get_identity() if identity.is_logged_in(): with get_rich_toolkit(minimal=True) as toolkit: diff --git a/src/fastapi_cloud_cli/commands/logs.py b/src/fastapi_cloud_cli/commands/logs.py index 16ea4ae9..e30774eb 100644 --- a/src/fastapi_cloud_cli/commands/logs.py +++ b/src/fastapi_cloud_cli/commands/logs.py @@ -9,6 +9,7 @@ from rich.markup import escape from rich_toolkit import RichToolkit +from fastapi_cloud_cli.context import ctx from fastapi_cloud_cli.utils.api import ( APIClient, AppLogEntry, @@ -16,7 +17,6 @@ TooManyRetriesError, ) 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 logger = logging.getLogger(__name__) @@ -144,7 +144,7 @@ def logs( fastapi cloud logs --no-follow # Fetch recent logs and exit fastapi cloud logs --tail 50 --since 1h # Last 50 logs from the past hour """ - identity = Identity() + identity = ctx.get_identity() with get_rich_toolkit(minimal=True) as toolkit: if not identity.is_logged_in(): toolkit.print( diff --git a/src/fastapi_cloud_cli/commands/setup_ci.py b/src/fastapi_cloud_cli/commands/setup_ci.py index 562adc25..b7401c51 100644 --- a/src/fastapi_cloud_cli/commands/setup_ci.py +++ b/src/fastapi_cloud_cli/commands/setup_ci.py @@ -7,9 +7,9 @@ import typer +from fastapi_cloud_cli.context import ctx 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 logger = logging.getLogger(__name__) @@ -198,7 +198,7 @@ def setup_ci( fastapi cloud setup-ci --file ci.yml # Writes workflow to .github/workflows/ci.yml """ - identity = Identity() + identity = ctx.get_identity() with get_rich_toolkit() as toolkit: if not identity.is_logged_in(): diff --git a/src/fastapi_cloud_cli/commands/whoami.py b/src/fastapi_cloud_cli/commands/whoami.py index ad94f6a9..be30c839 100644 --- a/src/fastapi_cloud_cli/commands/whoami.py +++ b/src/fastapi_cloud_cli/commands/whoami.py @@ -4,15 +4,16 @@ from rich import print from rich_toolkit.progress import Progress +from fastapi_cloud_cli.context import ctx 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__) def whoami() -> Any: - identity = Identity() + ctx.initialize(prefer_auth_mode="token") + identity = ctx.get_identity() if identity.auth_mode == "token": print("⚡ [bold]Using API token from environment variable[/bold]") diff --git a/src/fastapi_cloud_cli/context.py b/src/fastapi_cloud_cli/context.py new file mode 100644 index 00000000..4f8203ee --- /dev/null +++ b/src/fastapi_cloud_cli/context.py @@ -0,0 +1,25 @@ +import logging +from typing import Literal + +from fastapi_cloud_cli.utils.auth import Identity + +logger = logging.getLogger(__name__) + + +class Context: + def __init__(self): + self._is_initialized = False + + def initialize(self, prefer_auth_mode: Literal["token", "user"] = "user") -> None: + logger.debug("Initializing context with prefer_auth_mode: %s", prefer_auth_mode) + self.prefer_auth_mode = prefer_auth_mode + self._is_initialized = True + + def get_identity(self) -> Identity: + if not self._is_initialized: + logger.debug("Context not initialized, initializing with default settings") + self.initialize() + return Identity(prefer_auth_mode=self.prefer_auth_mode) + + +ctx = Context() diff --git a/src/fastapi_cloud_cli/utils/api.py b/src/fastapi_cloud_cli/utils/api.py index 5815a753..0b08cda7 100644 --- a/src/fastapi_cloud_cli/utils/api.py +++ b/src/fastapi_cloud_cli/utils/api.py @@ -18,8 +18,7 @@ from fastapi_cloud_cli import __version__ from fastapi_cloud_cli.config import Settings - -from .auth import Identity +from fastapi_cloud_cli.context import ctx logger = logging.getLogger(__name__) @@ -193,7 +192,7 @@ def to_human_readable(cls, status: "DeploymentStatus") -> str: class APIClient(httpx.Client): def __init__(self) -> None: settings = Settings.get() - identity = Identity() + identity = ctx.get_identity() super().__init__( base_url=settings.base_api_url, diff --git a/src/fastapi_cloud_cli/utils/cli.py b/src/fastapi_cloud_cli/utils/cli.py index 72e25955..e91914dc 100644 --- a/src/fastapi_cloud_cli/utils/cli.py +++ b/src/fastapi_cloud_cli/utils/cli.py @@ -10,7 +10,9 @@ from rich_toolkit.progress import Progress from rich_toolkit.styles import MinimalStyle, TaggedStyle -from .auth import Identity, delete_auth_config +from fastapi_cloud_cli.context import ctx + +from .auth import delete_auth_config logger = logging.getLogger(__name__) @@ -78,7 +80,7 @@ def get_rich_toolkit(minimal: bool = False) -> RichToolkit: def handle_unauthorized() -> str: message = "The specified token is not valid. " - identity = Identity() + identity = ctx.get_identity() if identity.auth_mode == "user": delete_auth_config() diff --git a/src/fastapi_cloud_cli/utils/sentry.py b/src/fastapi_cloud_cli/utils/sentry.py index b4828e49..b9a74ef7 100644 --- a/src/fastapi_cloud_cli/utils/sentry.py +++ b/src/fastapi_cloud_cli/utils/sentry.py @@ -8,7 +8,9 @@ def init_sentry() -> None: """Initialize Sentry error tracking only if user is logged in.""" - identity = Identity() + identity = Identity( + prefer_auth_mode="token" # Use auth_mode="token" as it has a fallback to user token + ) if not identity.is_logged_in(): return From f109a3b74cae838bcdb22750ad96eaea7ddfabac Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 2 Apr 2026 21:14:08 +0200 Subject: [PATCH 03/18] Add tests for sentry init with deployment token only --- tests/test_sentry.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_sentry.py b/tests/test_sentry.py index 07f4aae1..539d3d4b 100644 --- a/tests/test_sentry.py +++ b/tests/test_sentry.py @@ -20,3 +20,16 @@ def test_init_sentry_when_logged_out(logged_out_cli: Path) -> None: init_sentry() mock_init.assert_not_called() + + +def test_init_sentry_when_deployment_token(logged_out_cli: Path, 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, + ) From aec339c95c37b2c55e1c475bbad5da968d302778 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 2 Apr 2026 21:49:07 +0200 Subject: [PATCH 04/18] Initialize token in app callback --- src/fastapi_cloud_cli/cli.py | 22 ++++++++++++++++++++++ src/fastapi_cloud_cli/commands/deploy.py | 1 - src/fastapi_cloud_cli/commands/whoami.py | 1 - src/fastapi_cloud_cli/context.py | 3 +-- tests/test_api_client.py | 6 ++++++ 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/fastapi_cloud_cli/cli.py b/src/fastapi_cloud_cli/cli.py index 3fb70800..7a3f8bdf 100644 --- a/src/fastapi_cloud_cli/cli.py +++ b/src/fastapi_cloud_cli/cli.py @@ -9,11 +9,14 @@ from .commands.setup_ci import setup_ci from .commands.unlink import unlink from .commands.whoami import whoami +from .context import ctx from .logging import setup_logging from .utils.sentry import init_sentry setup_logging() +COMMANDS_USE_TOKEN = {"deploy", "whoami"} + app = typer.Typer(rich_markup_mode="rich") cloud_app = typer.Typer( @@ -21,6 +24,25 @@ help="Manage [bold]FastAPI[/bold] Cloud deployments. 🚀", ) + +@cloud_app.callback(invoke_without_command=True) +def cloud_callback(typer_ctx: typer.Context) -> None: + if typer_ctx.invoked_subcommand in COMMANDS_USE_TOKEN: + ctx.initialize(prefer_auth_mode="token") + else: + ctx.initialize() + + +@app.callback(invoke_without_command=True) +def app_callback(typer_ctx: typer.Context) -> None: + if typer_ctx.invoked_subcommand == "cloud": + return + if typer_ctx.invoked_subcommand in COMMANDS_USE_TOKEN: + ctx.initialize(prefer_auth_mode="token") + else: + ctx.initialize() + + # TODO: use the app structure # Additional commands diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 6e521b0f..67ce998c 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -652,7 +652,6 @@ def deploy( "Deploy path: %s, skip_wait: %s, app_id: %s", path, skip_wait, provided_app_id ) - ctx.initialize(prefer_auth_mode="token") identity = ctx.get_identity() with get_rich_toolkit() as toolkit: diff --git a/src/fastapi_cloud_cli/commands/whoami.py b/src/fastapi_cloud_cli/commands/whoami.py index be30c839..6cea3b6e 100644 --- a/src/fastapi_cloud_cli/commands/whoami.py +++ b/src/fastapi_cloud_cli/commands/whoami.py @@ -12,7 +12,6 @@ def whoami() -> Any: - ctx.initialize(prefer_auth_mode="token") identity = ctx.get_identity() if identity.auth_mode == "token": diff --git a/src/fastapi_cloud_cli/context.py b/src/fastapi_cloud_cli/context.py index 4f8203ee..1a1cebf0 100644 --- a/src/fastapi_cloud_cli/context.py +++ b/src/fastapi_cloud_cli/context.py @@ -17,8 +17,7 @@ def initialize(self, prefer_auth_mode: Literal["token", "user"] = "user") -> Non def get_identity(self) -> Identity: if not self._is_initialized: - logger.debug("Context not initialized, initializing with default settings") - self.initialize() + raise RuntimeError("Context must be initialized before use") return Identity(prefer_auth_mode=self.prefer_auth_mode) diff --git a/tests/test_api_client.py b/tests/test_api_client.py index aac9cf25..a2b980ef 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -7,6 +7,7 @@ from httpx import Response from time_machine import TimeMachineFixture +from fastapi_cloud_cli.context import ctx from fastapi_cloud_cli.utils.api import ( STREAM_LOGS_MAX_RETRIES, APIClient, @@ -34,6 +35,11 @@ def logs_route(respx_mock: respx.MockRouter, deployment_id: str) -> respx.Route: return respx_mock.get(f"/deployments/{deployment_id}/build-logs") +@pytest.fixture(autouse=True) +def init_context() -> None: + ctx.initialize() # Initialize context with defaults + + def test_stream_build_logs_successful( logs_route: respx.Route, client: APIClient, From 90e662bdbfe04bcb6dc39d1a4506998d17ab2cfa Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 2 Apr 2026 21:49:31 +0200 Subject: [PATCH 05/18] Add test for login when deploy token is set --- tests/test_cli_login.py | 46 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_cli_login.py b/tests/test_cli_login.py index 274c78f1..754c092d 100644 --- a/tests/test_cli_login.py +++ b/tests/test_cli_login.py @@ -77,6 +77,52 @@ 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",) + 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 f59a0c650d768fd8788eb708c1e64ec2a751ba9a Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 2 Apr 2026 22:03:26 +0200 Subject: [PATCH 06/18] Notify when deploy token is used --- src/fastapi_cloud_cli/commands/deploy.py | 7 +++++++ tests/test_cli_deploy.py | 3 +++ 2 files changed, 10 insertions(+) diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 67ce998c..6febf9ea 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -688,6 +688,13 @@ def deploy( _waitlist_form(toolkit) raise typer.Exit(1) + if identity.auth_mode == "token": + 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() diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index dae8ca99..7b7f7e3b 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -1622,6 +1622,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 From 737215ad0ae6ecc57c9b2d6a63dabf05a6b79fc1 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 3 Apr 2026 10:25:46 +0200 Subject: [PATCH 07/18] Refactor `Identity` to store deploy token even if unused --- src/fastapi_cloud_cli/utils/auth.py | 47 ++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/src/fastapi_cloud_cli/utils/auth.py b/src/fastapi_cloud_cli/utils/auth.py index 25f4bbe1..b4b29343 100644 --- a/src/fastapi_cloud_cli/utils/auth.py +++ b/src/fastapi_cloud_cli/utils/auth.py @@ -109,38 +109,57 @@ def _is_jwt_expired(token: str) -> bool: class Identity: - auth_mode: Literal["token", "user"] - def __init__(self, prefer_auth_mode: Literal["token", "user"] = "user") -> None: - self.token = _get_auth_token() - self.auth_mode = "user" - - if prefer_auth_mode == "token": - # users using `FASTAPI_CLOUD_TOKEN` - if env_token := self._get_token_from_env(): - self.token = env_token - self.auth_mode = "token" + self._user_token = _get_auth_token() + self._auth_mode = "user" + self._deploy_token: str | None = None + + # users using `FASTAPI_CLOUD_TOKEN` + if env_token := self._get_token_from_env(): + logger.debug("Reading token from FASTAPI_CLOUD_TOKEN environment variable") + self._deploy_token = env_token + if prefer_auth_mode == "token": + self._auth_mode = "token" + logger.debug("Using `token` auth mode") def _get_token_from_env(self) -> str | None: return os.environ.get("FASTAPI_CLOUD_TOKEN") def is_expired(self) -> bool: - if self.auth_mode != "user": # pragma: no cover # Should never happen + if self._auth_mode != "user": # pragma: no cover # Should never happen raise RuntimeError("Expiration check is only applicable for user tokens") - if not self.token: + 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 - if self.auth_mode == "user" and self.is_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 + + @property + def auth_mode(self) -> Literal["token", "user"]: + return self._auth_mode + + @property + def token(self) -> str | None: + if self._auth_mode == "token": + return self._deploy_token or self._user_token + return self._user_token + + @property + def user_token(self) -> str | None: + return self._user_token + + @property + def deploy_token(self) -> str | None: + return self._deploy_token From b3c14525ec9098eb5b81cb81a0e431104adc09e2 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 3 Apr 2026 10:27:14 +0200 Subject: [PATCH 08/18] Remove callback for top level app (doesn't work), add init to top level commands --- src/fastapi_cloud_cli/cli.py | 10 ---------- src/fastapi_cloud_cli/commands/deploy.py | 4 ++++ src/fastapi_cloud_cli/commands/login.py | 5 +++++ 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/fastapi_cloud_cli/cli.py b/src/fastapi_cloud_cli/cli.py index 7a3f8bdf..88d15b9e 100644 --- a/src/fastapi_cloud_cli/cli.py +++ b/src/fastapi_cloud_cli/cli.py @@ -33,16 +33,6 @@ def cloud_callback(typer_ctx: typer.Context) -> None: ctx.initialize() -@app.callback(invoke_without_command=True) -def app_callback(typer_ctx: typer.Context) -> None: - if typer_ctx.invoked_subcommand == "cloud": - return - if typer_ctx.invoked_subcommand in COMMANDS_USE_TOKEN: - ctx.initialize(prefer_auth_mode="token") - else: - ctx.initialize() - - # TODO: use the app structure # Additional commands diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index 6febf9ea..3ffc4bda 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -652,6 +652,10 @@ def deploy( "Deploy path: %s, skip_wait: %s, app_id: %s", path, skip_wait, provided_app_id ) + # Duplicate context initialization here to make `fastapi deploy` command work + # (callback doesn't take effect in this case) + ctx.initialize(prefer_auth_mode="token") + identity = ctx.get_identity() with get_rich_toolkit() as toolkit: diff --git a/src/fastapi_cloud_cli/commands/login.py b/src/fastapi_cloud_cli/commands/login.py index 3bcbf8e6..26010768 100644 --- a/src/fastapi_cloud_cli/commands/login.py +++ b/src/fastapi_cloud_cli/commands/login.py @@ -77,6 +77,11 @@ def login() -> Any: """ Login to FastAPI Cloud. 🚀 """ + + # Duplicate context initialization here to make `fastapi login` command work + # (callback doesn't take effect in this case) + ctx.initialize() + identity = ctx.get_identity() if identity.is_logged_in(): From 092f407352e5ba0bf4d5b41914533954d97f1473 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 3 Apr 2026 10:28:00 +0200 Subject: [PATCH 09/18] Warn on login command when deploy token is set --- src/fastapi_cloud_cli/commands/login.py | 11 +++++++++++ tests/test_cli_login.py | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/src/fastapi_cloud_cli/commands/login.py b/src/fastapi_cloud_cli/commands/login.py index 26010768..6030b175 100644 --- a/src/fastapi_cloud_cli/commands/login.py +++ b/src/fastapi_cloud_cli/commands/login.py @@ -93,6 +93,17 @@ def login() -> Any: return + if identity.deploy_token is not None: + 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 " + + "[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/tests/test_cli_login.py b/tests/test_cli_login.py index 754c092d..2c03aed1 100644 --- a/tests/test_cli_login.py +++ b/tests/test_cli_login.py @@ -116,6 +116,11 @@ def test_full_login_with_deploy_token_set( 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 From 1d3293a28f3b85178370f105fd5fc39da8705439 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 3 Apr 2026 08:42:50 +0000 Subject: [PATCH 10/18] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_sentry.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_sentry.py b/tests/test_sentry.py index 539d3d4b..1cd95b75 100644 --- a/tests/test_sentry.py +++ b/tests/test_sentry.py @@ -23,7 +23,6 @@ def test_init_sentry_when_logged_out(logged_out_cli: Path) -> None: def test_init_sentry_when_deployment_token(logged_out_cli: Path, monkeypatch) -> None: - monkeypatch.setenv("FASTAPI_CLOUD_TOKEN", "deployment-token") with patch("fastapi_cloud_cli.utils.sentry.sentry_sdk.init") as mock_init: init_sentry() From 5a15e8d30a343009df7c6578df028732072e7b99 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 3 Apr 2026 10:47:21 +0200 Subject: [PATCH 11/18] Fix typing issues --- src/fastapi_cloud_cli/context.py | 2 +- src/fastapi_cloud_cli/utils/auth.py | 2 +- tests/test_sentry.py | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/fastapi_cloud_cli/context.py b/src/fastapi_cloud_cli/context.py index 1a1cebf0..e26881be 100644 --- a/src/fastapi_cloud_cli/context.py +++ b/src/fastapi_cloud_cli/context.py @@ -7,7 +7,7 @@ class Context: - def __init__(self): + def __init__(self) -> None: self._is_initialized = False def initialize(self, prefer_auth_mode: Literal["token", "user"] = "user") -> None: diff --git a/src/fastapi_cloud_cli/utils/auth.py b/src/fastapi_cloud_cli/utils/auth.py index b4b29343..8298098a 100644 --- a/src/fastapi_cloud_cli/utils/auth.py +++ b/src/fastapi_cloud_cli/utils/auth.py @@ -111,7 +111,7 @@ def _is_jwt_expired(token: str) -> bool: class Identity: def __init__(self, prefer_auth_mode: Literal["token", "user"] = "user") -> None: self._user_token = _get_auth_token() - self._auth_mode = "user" + self._auth_mode: Literal["token", "user"] = "user" self._deploy_token: str | None = None # users using `FASTAPI_CLOUD_TOKEN` diff --git a/tests/test_sentry.py b/tests/test_sentry.py index 1cd95b75..fffa272d 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 @@ -22,7 +24,9 @@ def test_init_sentry_when_logged_out(logged_out_cli: Path) -> None: mock_init.assert_not_called() -def test_init_sentry_when_deployment_token(logged_out_cli: Path, monkeypatch) -> 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() From 0dcf57390822e69f54fd990aeb51a35e2e543314 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 3 Apr 2026 11:33:06 +0200 Subject: [PATCH 12/18] Improve the output of `whoami` command --- src/fastapi_cloud_cli/cli.py | 2 +- src/fastapi_cloud_cli/commands/whoami.py | 32 +++++++++++++----------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/fastapi_cloud_cli/cli.py b/src/fastapi_cloud_cli/cli.py index 88d15b9e..044e758f 100644 --- a/src/fastapi_cloud_cli/cli.py +++ b/src/fastapi_cloud_cli/cli.py @@ -15,7 +15,7 @@ setup_logging() -COMMANDS_USE_TOKEN = {"deploy", "whoami"} +COMMANDS_USE_TOKEN = {"deploy"} app = typer.Typer(rich_markup_mode="rich") diff --git a/src/fastapi_cloud_cli/commands/whoami.py b/src/fastapi_cloud_cli/commands/whoami.py index 6cea3b6e..506f29ce 100644 --- a/src/fastapi_cloud_cli/commands/whoami.py +++ b/src/fastapi_cloud_cli/commands/whoami.py @@ -14,20 +14,22 @@ def whoami() -> Any: identity = ctx.get_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]") + + # Deplotment token status + if identity.deploy_token is not None: + print( + "⚡ [bold]Using API token from environment variable for " + "[blue]`fastapi deploy`[/blue] command.[/bold]" + ) From 0a0cde464bf4b498bd3382bab77687e09eb4c471 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 3 Apr 2026 11:33:54 +0200 Subject: [PATCH 13/18] Add `unset_env_vars` fxture to ignore `FASTAPI_CLOUD_TOKEN` breaking tests --- tests/conftest.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 852c90a3..f6b13d7f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -90,3 +90,10 @@ def configured_app(tmp_path: Path) -> ConfiguredApp: config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') return ConfiguredApp(app_id=app_id, team_id=team_id, path=tmp_path) + + +@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 From 730a42f4c54cc794ec18ec987c468f4369640714 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 3 Apr 2026 11:34:43 +0200 Subject: [PATCH 14/18] Configure test to run with `deploy` and `cloud deploy` (for coverage) --- tests/test_cli_deploy.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index 7b7f7e3b..98c3e815 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -1554,9 +1554,16 @@ def test_cancel_upload_swallows_exceptions( assert "HTTPStatusError" not in result.output +@pytest.mark.parametrize( + "command", + [ + ["deploy"], + ["cloud", "deploy"], + ], +) @pytest.mark.respx def test_deploy_successfully_with_token( - logged_out_cli: None, tmp_path: Path, respx_mock: respx.MockRouter + logged_out_cli: None, command: str, tmp_path: Path, respx_mock: respx.MockRouter ) -> None: app_data = _get_random_app() team_data = _get_random_team() @@ -1616,7 +1623,7 @@ def test_deploy_successfully_with_token( ).mock(return_value=Response(200, json={**deployment_data, "status": "success"})) with changing_dir(tmp_path): - result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_TOKEN": "hello"}) + result = runner.invoke(app, command, env={"FASTAPI_CLOUD_TOKEN": "hello"}) assert result.exit_code == 0 From a2752e665b939344de8461b90fcadff92379144d Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 3 Apr 2026 11:37:04 +0200 Subject: [PATCH 15/18] Fix coverage --- src/fastapi_cloud_cli/context.py | 2 +- src/fastapi_cloud_cli/utils/auth.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/fastapi_cloud_cli/context.py b/src/fastapi_cloud_cli/context.py index e26881be..b45ac138 100644 --- a/src/fastapi_cloud_cli/context.py +++ b/src/fastapi_cloud_cli/context.py @@ -16,7 +16,7 @@ def initialize(self, prefer_auth_mode: Literal["token", "user"] = "user") -> Non self._is_initialized = True def get_identity(self) -> Identity: - if not self._is_initialized: + if not self._is_initialized: #pragma: no cover raise RuntimeError("Context must be initialized before use") return Identity(prefer_auth_mode=self.prefer_auth_mode) diff --git a/src/fastapi_cloud_cli/utils/auth.py b/src/fastapi_cloud_cli/utils/auth.py index 8298098a..d0c1923f 100644 --- a/src/fastapi_cloud_cli/utils/auth.py +++ b/src/fastapi_cloud_cli/utils/auth.py @@ -129,10 +129,10 @@ def is_expired(self) -> bool: if self._auth_mode != "user": # pragma: no cover # Should never happen raise RuntimeError("Expiration check is only applicable for user tokens") - if not self._user_token: + if not self.user_token: return True - return _is_jwt_expired(self._user_token) + return _is_jwt_expired(self.user_token) def is_logged_in(self) -> bool: if self.token is None: @@ -153,8 +153,8 @@ def auth_mode(self) -> Literal["token", "user"]: @property def token(self) -> str | None: if self._auth_mode == "token": - return self._deploy_token or self._user_token - return self._user_token + return self.deploy_token or self.user_token + return self.user_token @property def user_token(self) -> str | None: From c737d50ae23652b016162cefd928aeaf892bbc1e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 3 Apr 2026 09:38:04 +0000 Subject: [PATCH 16/18] =?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/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastapi_cloud_cli/context.py b/src/fastapi_cloud_cli/context.py index b45ac138..85acb8fc 100644 --- a/src/fastapi_cloud_cli/context.py +++ b/src/fastapi_cloud_cli/context.py @@ -16,7 +16,7 @@ def initialize(self, prefer_auth_mode: Literal["token", "user"] = "user") -> Non self._is_initialized = True def get_identity(self) -> Identity: - if not self._is_initialized: #pragma: no cover + if not self._is_initialized: # pragma: no cover raise RuntimeError("Context must be initialized before use") return Identity(prefer_auth_mode=self.prefer_auth_mode) From 12a7d06577a0f9cb71ac36d61348f53b977e9f30 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 3 Apr 2026 14:21:31 +0200 Subject: [PATCH 17/18] Address review comments from Copilot --- src/fastapi_cloud_cli/commands/whoami.py | 2 +- src/fastapi_cloud_cli/utils/sentry.py | 4 +++- tests/test_cli_deploy.py | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/whoami.py b/src/fastapi_cloud_cli/commands/whoami.py index 506f29ce..a33f5aec 100644 --- a/src/fastapi_cloud_cli/commands/whoami.py +++ b/src/fastapi_cloud_cli/commands/whoami.py @@ -27,7 +27,7 @@ def whoami() -> Any: print(f"⚡ [bold]{data['email']}[/bold]") - # Deplotment token status + # Deployment token status if identity.deploy_token is not None: print( "⚡ [bold]Using API token from environment variable for " diff --git a/src/fastapi_cloud_cli/utils/sentry.py b/src/fastapi_cloud_cli/utils/sentry.py index b9a74ef7..515589c2 100644 --- a/src/fastapi_cloud_cli/utils/sentry.py +++ b/src/fastapi_cloud_cli/utils/sentry.py @@ -7,7 +7,9 @@ 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 deployment token. + """ identity = Identity( prefer_auth_mode="token" # Use auth_mode="token" as it has a fallback to user token ) diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index 98c3e815..6b99fcfb 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -1563,7 +1563,10 @@ def test_cancel_upload_swallows_exceptions( ) @pytest.mark.respx def test_deploy_successfully_with_token( - logged_out_cli: None, command: str, tmp_path: Path, respx_mock: respx.MockRouter + logged_out_cli: None, + command: list[str], + tmp_path: Path, + respx_mock: respx.MockRouter, ) -> None: app_data = _get_random_app() team_data = _get_random_team() From 653d7047ea68ed73c49a3ca51835ca4e269058a3 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 7 Apr 2026 11:01:52 +0200 Subject: [PATCH 18/18] Fix changed behavior for `fastapi cloud` command without subcommand --- src/fastapi_cloud_cli/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastapi_cloud_cli/cli.py b/src/fastapi_cloud_cli/cli.py index 044e758f..78746809 100644 --- a/src/fastapi_cloud_cli/cli.py +++ b/src/fastapi_cloud_cli/cli.py @@ -25,7 +25,7 @@ ) -@cloud_app.callback(invoke_without_command=True) +@cloud_app.callback() def cloud_callback(typer_ctx: typer.Context) -> None: if typer_ctx.invoked_subcommand in COMMANDS_USE_TOKEN: ctx.initialize(prefer_auth_mode="token")