Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/fastapi_cloud_cli/commands/apps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"]
122 changes: 122 additions & 0 deletions src/fastapi_cloud_cli/commands/apps/update.py
Original file line number Diff line number Diff line change
@@ -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,
)
135 changes: 135 additions & 0 deletions tests/test_cli_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,141 @@ 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,
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,
Expand Down
Loading