From b4404e37fc603a827c0b38e8fbd4a8bbee52fc2b Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Tue, 30 Jun 2026 17:29:17 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Add=20cloud=20apps=20update=20c?= =?UTF-8?q?ommand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shortcake-Parent: main --- .../commands/apps/__init__.py | 2 + src/fastapi_cloud_cli/commands/apps/update.py | 122 ++++++++++++++++++ tests/test_cli_apps.py | 109 ++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 src/fastapi_cloud_cli/commands/apps/update.py diff --git a/src/fastapi_cloud_cli/commands/apps/__init__.py b/src/fastapi_cloud_cli/commands/apps/__init__.py index bb7f43d3..628e49a5 100644 --- a/src/fastapi_cloud_cli/commands/apps/__init__.py +++ b/src/fastapi_cloud_cli/commands/apps/__init__.py @@ -5,6 +5,7 @@ from fastapi_cloud_cli.commands.apps.link import link_app from fastapi_cloud_cli.commands.apps.list import list_apps from fastapi_cloud_cli.commands.apps.unlink import unlink_app +from fastapi_cloud_cli.commands.apps.update import update_app from fastapi_cloud_cli.commands.logs import logs apps_app = typer.Typer( @@ -17,5 +18,6 @@ apps_app.command("list")(list_apps) apps_app.command("logs")(logs) apps_app.command("unlink")(unlink_app) +apps_app.command("update")(update_app) __all__ = ["apps_app"] diff --git a/src/fastapi_cloud_cli/commands/apps/update.py b/src/fastapi_cloud_cli/commands/apps/update.py new file mode 100644 index 00000000..f2fd6205 --- /dev/null +++ b/src/fastapi_cloud_cli/commands/apps/update.py @@ -0,0 +1,122 @@ +import logging +from typing import Annotated, Any + +import typer +from pydantic import BaseModel +from rich_toolkit import RichToolkit + +from fastapi_cloud_cli.commands.deploy.archive import validate_app_directory +from fastapi_cloud_cli.utils.api import APIClient +from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail +from fastapi_cloud_cli.utils.auth import Identity +from fastapi_cloud_cli.utils.cli import get_rich_toolkit +from fastapi_cloud_cli.utils.execution import JsonOutputOption + +logger = logging.getLogger(__name__) + + +class UpdatedApp(BaseModel): + id: str + team_id: str + slug: str + name: str + directory: str | None + + +class AppsUpdateOutput(BaseModel): + app: UpdatedApp + + +def _update_app(client: APIClient, *, app_id: str, directory: str | None) -> UpdatedApp: + response = client.patch( + f"/apps/{app_id}", + json={"directory": directory}, + ) + response.raise_for_status() + + return UpdatedApp.model_validate(response.json()) + + +def _render_apps_update_output(data: AppsUpdateOutput, toolkit: RichToolkit) -> None: + toolkit.print(f"Updated app [bold]{data.app.name}[/bold]", bullet=False) + toolkit.print( + f"Directory: [bold]{data.app.directory if data.app.directory is not None else '.'}[/bold]", + bullet=False, + ) + + +def update_app( + app_id: Annotated[ + str | None, + typer.Argument( + help="ID of the app to update (defaults to the app linked to the current directory).", + ), + ] = None, + directory: Annotated[ + str | None, + typer.Option( + "--directory", + help=( + "Relative app directory containing the pyproject.toml " + "(for example: src or backend)." + ), + ), + ] = None, + json_output: JsonOutputOption = False, +) -> Any: + """ + Update FastAPI Cloud app metadata. + """ + identity = Identity() + + with get_rich_toolkit(json_output=json_output) as toolkit: + if not identity.is_logged_in(): + toolkit.fail( + "not_logged_in", + "No credentials found.", + hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.", + ) + + if directory is None: + toolkit.fail( + "missing_required_input", + "No updates provided.", + hint="Pass --directory to update the app directory.", + ) + + target_app_id = resolve_app_id_or_fail( + toolkit, + app_id=app_id, + hint="Pass an app ID or run `fastapi cloud apps create --link` first.", + ) + + try: + directory = validate_app_directory(directory) + except ValueError as e: + toolkit.fail( + "invalid_input", + f"Invalid app directory: {e}", + hint="Pass a relative app directory such as `src` or `backend`.", + ) + + with APIClient() as client: + with toolkit.progress( + title="Updating app", + transient=True, + ) as progress: + with client.handle_http_errors( + progress, + default_message="Error updating app. Please try again later.", + not_found_message="App not found.", + toolkit=toolkit, + ): + app = _update_app( + client, + app_id=target_app_id, + directory=directory, + ) + + toolkit.success( + AppsUpdateOutput(app=app), + render_output=_render_apps_update_output, + ) diff --git a/tests/test_cli_apps.py b/tests/test_cli_apps.py index 770c3a96..c33b7251 100644 --- a/tests/test_cli_apps.py +++ b/tests/test_cli_apps.py @@ -352,6 +352,115 @@ def test_creates_app_json_rejects_invalid_directory(logged_in_cli: None) -> None assert result.stderr == "" +@pytest.mark.respx +def test_updates_app_directory_as_json( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + app_id = "00000000-0000-4000-8000-000000000002" + app_data = { + "id": app_id, + "team_id": "00000000-0000-4000-8000-000000000001", + "slug": "api", + "name": "API", + "directory": "backend", + } + respx_mock.patch( + f"/apps/{app_id}", + json={"directory": "backend"}, + ).mock(return_value=Response(200, json=app_data)) + + result = runner.invoke( + app, + [ + "apps", + "update", + app_id, + "--directory", + "backend", + "--json", + ], + ) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == {"data": {"app": app_data}} + assert result.stderr == "" + + +@pytest.mark.respx +def test_updates_linked_app_directory_in_human_output( + logged_in_cli: None, + respx_mock: respx.MockRouter, + configured_app: ConfiguredApp, +) -> None: + app_data = { + "id": configured_app.app_id, + "team_id": configured_app.team_id, + "slug": "api", + "name": "API", + "directory": "src", + } + respx_mock.patch( + f"/apps/{configured_app.app_id}", + json={"directory": "src"}, + ).mock(return_value=Response(200, json=app_data)) + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["apps", "update", "--directory", "src"]) + + assert result.exit_code == 0 + assert "Updated app API" in result.output + assert "Directory: src" in result.output + + +def test_updates_app_json_returns_missing_required_input_without_update_flags( + logged_in_cli: None, +) -> None: + result = runner.invoke( + app, + [ + "apps", + "update", + "00000000-0000-4000-8000-000000000002", + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "missing_required_input", + "message": "No updates provided.", + "hint": "Pass --directory to update the app directory.", + } + } + assert result.stderr == "" + + +def test_updates_app_json_rejects_invalid_directory(logged_in_cli: None) -> None: + result = runner.invoke( + app, + [ + "apps", + "update", + "00000000-0000-4000-8000-000000000002", + "--directory", + "/tmp/api", + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "invalid_input", + "message": ("Invalid app directory: must be a relative path, not absolute"), + "hint": "Pass a relative app directory such as `src` or `backend`.", + } + } + assert result.stderr == "" + + @pytest.mark.respx def test_links_existing_app_to_path_as_json( logged_in_cli: None, From f0d601db5be5bb9e34a90143e8aa87ba9edbfdf8 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Tue, 30 Jun 2026 17:37:30 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=85=20Cover=20apps=20update=20logged?= =?UTF-8?q?=20out=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_cli_apps.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_cli_apps.py b/tests/test_cli_apps.py index c33b7251..963c37aa 100644 --- a/tests/test_cli_apps.py +++ b/tests/test_cli_apps.py @@ -352,6 +352,32 @@ def test_creates_app_json_rejects_invalid_directory(logged_in_cli: None) -> None assert result.stderr == "" +def test_updates_app_json_returns_not_logged_in_when_logged_out( + logged_out_cli: None, +) -> None: + result = runner.invoke( + app, + [ + "apps", + "update", + "00000000-0000-4000-8000-000000000002", + "--directory", + "backend", + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "not_logged_in", + "message": "No credentials found.", + "hint": "Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.", + } + } + assert result.stderr == "" + + @pytest.mark.respx def test_updates_app_directory_as_json( logged_in_cli: None,