Skip to content

Commit b4404e3

Browse files
committed
✨ Add cloud apps update command
Shortcake-Parent: main
1 parent a1a0d2c commit b4404e3

3 files changed

Lines changed: 233 additions & 0 deletions

File tree

src/fastapi_cloud_cli/commands/apps/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from fastapi_cloud_cli.commands.apps.link import link_app
66
from fastapi_cloud_cli.commands.apps.list import list_apps
77
from fastapi_cloud_cli.commands.apps.unlink import unlink_app
8+
from fastapi_cloud_cli.commands.apps.update import update_app
89
from fastapi_cloud_cli.commands.logs import logs
910

1011
apps_app = typer.Typer(
@@ -17,5 +18,6 @@
1718
apps_app.command("list")(list_apps)
1819
apps_app.command("logs")(logs)
1920
apps_app.command("unlink")(unlink_app)
21+
apps_app.command("update")(update_app)
2022

2123
__all__ = ["apps_app"]
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import logging
2+
from typing import Annotated, Any
3+
4+
import typer
5+
from pydantic import BaseModel
6+
from rich_toolkit import RichToolkit
7+
8+
from fastapi_cloud_cli.commands.deploy.archive import validate_app_directory
9+
from fastapi_cloud_cli.utils.api import APIClient
10+
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
11+
from fastapi_cloud_cli.utils.auth import Identity
12+
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
13+
from fastapi_cloud_cli.utils.execution import JsonOutputOption
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class UpdatedApp(BaseModel):
19+
id: str
20+
team_id: str
21+
slug: str
22+
name: str
23+
directory: str | None
24+
25+
26+
class AppsUpdateOutput(BaseModel):
27+
app: UpdatedApp
28+
29+
30+
def _update_app(client: APIClient, *, app_id: str, directory: str | None) -> UpdatedApp:
31+
response = client.patch(
32+
f"/apps/{app_id}",
33+
json={"directory": directory},
34+
)
35+
response.raise_for_status()
36+
37+
return UpdatedApp.model_validate(response.json())
38+
39+
40+
def _render_apps_update_output(data: AppsUpdateOutput, toolkit: RichToolkit) -> None:
41+
toolkit.print(f"Updated app [bold]{data.app.name}[/bold]", bullet=False)
42+
toolkit.print(
43+
f"Directory: [bold]{data.app.directory if data.app.directory is not None else '.'}[/bold]",
44+
bullet=False,
45+
)
46+
47+
48+
def update_app(
49+
app_id: Annotated[
50+
str | None,
51+
typer.Argument(
52+
help="ID of the app to update (defaults to the app linked to the current directory).",
53+
),
54+
] = None,
55+
directory: Annotated[
56+
str | None,
57+
typer.Option(
58+
"--directory",
59+
help=(
60+
"Relative app directory containing the pyproject.toml "
61+
"(for example: src or backend)."
62+
),
63+
),
64+
] = None,
65+
json_output: JsonOutputOption = False,
66+
) -> Any:
67+
"""
68+
Update FastAPI Cloud app metadata.
69+
"""
70+
identity = Identity()
71+
72+
with get_rich_toolkit(json_output=json_output) as toolkit:
73+
if not identity.is_logged_in():
74+
toolkit.fail(
75+
"not_logged_in",
76+
"No credentials found.",
77+
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
78+
)
79+
80+
if directory is None:
81+
toolkit.fail(
82+
"missing_required_input",
83+
"No updates provided.",
84+
hint="Pass --directory to update the app directory.",
85+
)
86+
87+
target_app_id = resolve_app_id_or_fail(
88+
toolkit,
89+
app_id=app_id,
90+
hint="Pass an app ID or run `fastapi cloud apps create --link` first.",
91+
)
92+
93+
try:
94+
directory = validate_app_directory(directory)
95+
except ValueError as e:
96+
toolkit.fail(
97+
"invalid_input",
98+
f"Invalid app directory: {e}",
99+
hint="Pass a relative app directory such as `src` or `backend`.",
100+
)
101+
102+
with APIClient() as client:
103+
with toolkit.progress(
104+
title="Updating app",
105+
transient=True,
106+
) as progress:
107+
with client.handle_http_errors(
108+
progress,
109+
default_message="Error updating app. Please try again later.",
110+
not_found_message="App not found.",
111+
toolkit=toolkit,
112+
):
113+
app = _update_app(
114+
client,
115+
app_id=target_app_id,
116+
directory=directory,
117+
)
118+
119+
toolkit.success(
120+
AppsUpdateOutput(app=app),
121+
render_output=_render_apps_update_output,
122+
)

tests/test_cli_apps.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,115 @@ def test_creates_app_json_rejects_invalid_directory(logged_in_cli: None) -> None
352352
assert result.stderr == ""
353353

354354

355+
@pytest.mark.respx
356+
def test_updates_app_directory_as_json(
357+
logged_in_cli: None,
358+
respx_mock: respx.MockRouter,
359+
) -> None:
360+
app_id = "00000000-0000-4000-8000-000000000002"
361+
app_data = {
362+
"id": app_id,
363+
"team_id": "00000000-0000-4000-8000-000000000001",
364+
"slug": "api",
365+
"name": "API",
366+
"directory": "backend",
367+
}
368+
respx_mock.patch(
369+
f"/apps/{app_id}",
370+
json={"directory": "backend"},
371+
).mock(return_value=Response(200, json=app_data))
372+
373+
result = runner.invoke(
374+
app,
375+
[
376+
"apps",
377+
"update",
378+
app_id,
379+
"--directory",
380+
"backend",
381+
"--json",
382+
],
383+
)
384+
385+
assert result.exit_code == 0
386+
assert json.loads(result.stdout) == {"data": {"app": app_data}}
387+
assert result.stderr == ""
388+
389+
390+
@pytest.mark.respx
391+
def test_updates_linked_app_directory_in_human_output(
392+
logged_in_cli: None,
393+
respx_mock: respx.MockRouter,
394+
configured_app: ConfiguredApp,
395+
) -> None:
396+
app_data = {
397+
"id": configured_app.app_id,
398+
"team_id": configured_app.team_id,
399+
"slug": "api",
400+
"name": "API",
401+
"directory": "src",
402+
}
403+
respx_mock.patch(
404+
f"/apps/{configured_app.app_id}",
405+
json={"directory": "src"},
406+
).mock(return_value=Response(200, json=app_data))
407+
408+
with changing_dir(configured_app.path):
409+
result = runner.invoke(app, ["apps", "update", "--directory", "src"])
410+
411+
assert result.exit_code == 0
412+
assert "Updated app API" in result.output
413+
assert "Directory: src" in result.output
414+
415+
416+
def test_updates_app_json_returns_missing_required_input_without_update_flags(
417+
logged_in_cli: None,
418+
) -> None:
419+
result = runner.invoke(
420+
app,
421+
[
422+
"apps",
423+
"update",
424+
"00000000-0000-4000-8000-000000000002",
425+
"--json",
426+
],
427+
)
428+
429+
assert result.exit_code == 1
430+
assert json.loads(result.stdout) == {
431+
"error": {
432+
"code": "missing_required_input",
433+
"message": "No updates provided.",
434+
"hint": "Pass --directory to update the app directory.",
435+
}
436+
}
437+
assert result.stderr == ""
438+
439+
440+
def test_updates_app_json_rejects_invalid_directory(logged_in_cli: None) -> None:
441+
result = runner.invoke(
442+
app,
443+
[
444+
"apps",
445+
"update",
446+
"00000000-0000-4000-8000-000000000002",
447+
"--directory",
448+
"/tmp/api",
449+
"--json",
450+
],
451+
)
452+
453+
assert result.exit_code == 1
454+
assert json.loads(result.stdout) == {
455+
"error": {
456+
"code": "invalid_input",
457+
"message": ("Invalid app directory: must be a relative path, not absolute"),
458+
"hint": "Pass a relative app directory such as `src` or `backend`.",
459+
}
460+
}
461+
assert result.stderr == ""
462+
463+
355464
@pytest.mark.respx
356465
def test_links_existing_app_to_path_as_json(
357466
logged_in_cli: None,

0 commit comments

Comments
 (0)