diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index f0bd191..d41cbd0 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -29,7 +29,7 @@ ) 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 from fastapi_cloud_cli.utils.progress_file import ProgressFile logger = logging.getLogger(__name__) @@ -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() 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) @@ -141,12 +140,11 @@ class Team(BaseModel): name: str -def _get_teams() -> list[Team]: - with APIClient() 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 +155,28 @@ class AppResponse(BaseModel): directory: str | None -def _update_app(app_id: str, directory: str | None) -> AppResponse: - with APIClient() 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() 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 +188,11 @@ class CreateDeploymentResponse(BaseModel): url: str -def _create_deployment(app_id: str) -> CreateDeploymentResponse: - with APIClient() 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): @@ -213,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) @@ -230,59 +230,57 @@ 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: - # 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(app_slug: str) -> AppResponse | None: - with APIClient() 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() 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] @@ -308,16 +306,21 @@ def _get_apps(team_id: str) -> list[AppResponse]: ] -def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig: +def _configure_app( + toolkit: RichToolkit, + client: APIClient, + path_to_deploy: Path, +) -> 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." + with client.handle_http_errors( + progress, + default_message="Error fetching teams. Please try again later.", ): - teams = _get_teams() + teams = _get_teams(client) toolkit.print_line() @@ -340,10 +343,11 @@ 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." + with client.handle_http_errors( + progress, + default_message="Error fetching apps. Please try again later.", ): - apps = _get_apps(team.id) + apps = _get_apps(client=client, team_id=team.id) toolkit.print_line() @@ -411,17 +415,24 @@ 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), + 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): - 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}") @@ -469,7 +480,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) @@ -485,59 +499,60 @@ def _wait_for_deployment( last_message_changed_at = time.monotonic() - with APIClient() 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): @@ -557,7 +572,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() @@ -674,15 +689,21 @@ 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 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,101 +730,123 @@ def deploy( _waitlist_form(toolkit) raise typer.Exit(1) - 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: + if use_deploy_token: toolkit.print( - f"[error]Error: Provided app ID ({provided_app_id}) does not match the local " - f"config ({app_config.app_id}).[/]" + "Using token from [bold blue]FASTAPI_CLOUD_TOKEN[/] environment variable", + tag="info", ) 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, path_to_deploy=path_to_deploy) + with APIClient(use_deploy_token=use_deploy_token) as client: + toolkit.print_title("Starting deployment", tag="FastAPI") toolkit.print_line() - target_app_id = app_config.app_id + path_to_deploy = path or Path.cwd() + logger.debug("Deploying from path: %s", path_to_deploy) - if provided_app_id: - toolkit.print(f"Deploying to app [blue]{target_app_id}[/blue]...") - else: - toolkit.print("Deploying app...") + app_config = get_app_config(path_to_deploy) - toolkit.print_line() + 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", + ) - with toolkit.progress("Checking app...", transient=True) as progress: - with handle_http_errors(progress): - logger.debug("Checking app with ID: %s", target_app_id) - app = _get_app(target_app_id) + raise typer.Exit(1) from None - 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." + 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() + + target_app_id = app_config.app_id + + if provided_app_id: + toolkit.print(f"Deploying to app [blue]{target_app_id}[/blue]...") + else: + toolkit.print("Deploying app...") - 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) + 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) - 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) + 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." + ) - with ( - toolkit.progress( - title="Creating deployment", done_emoji="📦" - ) as progress, - handle_http_errors(progress), - ): - logger.debug("Creating deployment for app: %s", app.id) - deployment = _create_deployment(app.id) + if not app: + toolkit.print_line() - try: - progress.log( - f"Deployment created successfully! Deployment slug: {deployment.slug}" + 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) + + 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(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) - raise + 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, 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 b9694dc..905024f 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,14 @@ def list( ) raise typer.Exit(1) - 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 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.") @@ -146,42 +148,45 @@ def delete( ) raise typer.Exit(1) - if not name: - with toolkit.progress( - "Fetching environment variables...", transient=True - ) as progress: - with handle_http_errors(progress): - environment_variables = _get_environment_variables( - 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." + 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 + ], ) - raise typer.Exit(1) - toolkit.print_line() + 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 handle_http_errors(progress): - deleted = _delete_environment_variable(app_config.app_id, name) + 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.") @@ -253,14 +258,21 @@ def set( else: value = toolkit.input("Enter the value of the environment variable:") - 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 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/link.py b/src/fastapi_cloud_cli/commands/link.py index ffdba6e..fa17ac6 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 6c6ef07..d632888 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__) @@ -87,13 +87,22 @@ def login() -> Any: return + if identity.has_deploy_token(): + 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") 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 @@ -105,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 16ea4ae..cf4dc47 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 562adc2..f90ea39 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: @@ -285,12 +284,15 @@ def setup_ci( token_name = f"GitHub Actions — {repo_slug} ({timestamp})" with ( + APIClient() as client, 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 ad94f6a..2d68377 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__) @@ -14,20 +13,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 client.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 9bc9004..12ef45e 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 Identity +from .auth import AuthMode, Identity, delete_auth_config logger = logging.getLogger(__name__) @@ -194,20 +196,99 @@ 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): - def __init__(self) -> None: + auth_mode: AuthMode + + 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" + else: + 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 {identity.token}", - "User-Agent": f"fastapi-cloud-cli/{__version__}", - }, + 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/auth.py b/src/fastapi_cloud_cli/utils/auth.py index b542fa2..8652736 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,40 @@ 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: + if self._user_token is None: logger.debug("Login status: False (no token)") return False - if self.auth_mode == "user" and self.is_expired(): + 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: + if self._deploy_token is None: + logger.debug("Deploy token is not provided") + return False + + logger.debug("Deploy token found") + return True diff --git a/src/fastapi_cloud_cli/utils/cli.py b/src/fastapi_cloud_cli/utils/cli.py index 72e2595..99f5a82 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 Identity, delete_auth_config - logger = logging.getLogger(__name__) @@ -73,68 +66,3 @@ def get_rich_toolkit(minimal: bool = False) -> RichToolkit: ) return RichToolkit(theme=theme) - - -def handle_unauthorized() -> str: - message = "The specified token is not valid. " - - identity = Identity() - - if identity.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) -> 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() - - 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, -) -> 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) - - progress.set_error(message) - - raise typer.Exit(1) from None diff --git a/src/fastapi_cloud_cli/utils/sentry.py b/src/fastapi_cloud_cli/utils/sentry.py index b4828e4..3db4223 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/conftest.py b/tests/conftest.py index 852c90a..b079a5b 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: diff --git a/tests/test_auth.py b/tests/test_auth.py index 2f78954..de8fce7 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 8a05e35..07fdbc1 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_login.py b/tests/test_cli_login.py index 274c78f..2c03aed 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 diff --git a/tests/test_cli_whoami.py b/tests/test_cli_whoami.py index 98c1f14..3f958bc 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 07f4aae..4e0e07b 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()