Skip to content

Commit bf9bf54

Browse files
committed
✨ Add support for app ID in fastapi deploy
1 parent e79be2b commit bf9bf54

File tree

2 files changed

+234
-11
lines changed

2 files changed

+234
-11
lines changed

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020

2121
from fastapi_cloud_cli.commands.login import login
2222
from fastapi_cloud_cli.utils.api import APIClient, BuildLogError, TooManyRetriesError
23-
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
23+
from fastapi_cloud_cli.utils.apps import (
24+
AppConfig,
25+
get_app_config,
26+
write_app_config,
27+
)
2428
from fastapi_cloud_cli.utils.auth import is_logged_in
2529
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
2630

@@ -527,12 +531,20 @@ def deploy(
527531
skip_wait: Annotated[
528532
bool, typer.Option("--no-wait", help="Skip waiting for deployment status")
529533
] = False,
534+
app_id: Annotated[
535+
Union[str, None],
536+
typer.Option(
537+
"--app-id",
538+
help="Application ID to deploy to",
539+
envvar="FASTAPI_CLOUD_APP_ID",
540+
),
541+
] = None,
530542
) -> Any:
531543
"""
532544
Deploy a [bold]FastAPI[/bold] app to FastAPI Cloud. 🚀
533545
"""
534546
logger.debug("Deploy command started")
535-
logger.debug("Deploy path: %s, skip_wait: %s", path, skip_wait)
547+
logger.debug("Deploy path: %s, skip_wait: %s, app_id: %s", path, skip_wait, app_id)
536548

537549
with get_rich_toolkit() as toolkit:
538550
if not is_logged_in():
@@ -572,19 +584,44 @@ def deploy(
572584

573585
app_config = get_app_config(path_to_deploy)
574586

575-
if not app_config:
587+
if app_id and app_config and app_id != app_config.app_id:
588+
logger.debug(
589+
"Provided app_id (%s) differs from local config (%s)",
590+
app_id,
591+
app_config.app_id,
592+
)
593+
594+
toolkit.print(
595+
f"[error]Error: Provided app ID ({app_id}) does not match the local "
596+
f"config ({app_config.app_id}).[/]"
597+
)
598+
toolkit.print_line()
599+
toolkit.print(
600+
"Run [bold]fastapi cloud unlink[/] to remove the local config, "
601+
"or remove --app-id / unset FASTAPI_CLOUD_APP_ID to use the configured app.",
602+
tag="tip",
603+
)
604+
raise typer.Exit(1) from None
605+
606+
target_app_id = app_id or (app_config.app_id if app_config else None)
607+
608+
if not target_app_id:
576609
logger.debug("No app config found, configuring new app")
577610
app_config = _configure_app(toolkit, path_to_deploy=path_to_deploy)
578611
toolkit.print_line()
612+
target_app_id = app_config.app_id
613+
614+
if app_id:
615+
toolkit.print(f"Deploying to app [blue]{target_app_id}[/blue]...")
579616
else:
580-
logger.debug("Existing app config found, proceeding with deployment")
581617
toolkit.print("Deploying app...")
582-
toolkit.print_line()
618+
619+
toolkit.print_line()
583620

584621
with toolkit.progress("Checking app...", transient=True) as progress:
585622
with handle_http_errors(progress):
586-
logger.debug("Checking app with ID: %s", app_config.app_id)
587-
app = _get_app(app_config.app_id)
623+
logger.debug("Checking app with ID: %s", target_app_id)
624+
app = _get_app(target_app_id)
588625

589626
if not app:
590627
logger.debug("App not found in API")
@@ -594,10 +631,11 @@ def deploy(
594631

595632
if not app:
596633
toolkit.print_line()
597-
toolkit.print(
598-
"If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration.",
599-
tag="tip",
600-
)
634+
if not app_id:
635+
toolkit.print(
636+
"If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration.",
637+
tag="tip",
638+
)
601639
raise typer.Exit(1)
602640

603641
with tempfile.TemporaryDirectory() as temp_dir:

tests/test_cli_deploy.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,3 +1079,188 @@ def test_cancel_upload_swallows_exceptions(
10791079

10801080
assert upload_cancelled_route.called
10811081
assert "HTTPStatusError" not in result.output
1082+
1083+
1084+
@pytest.mark.respx(base_url=settings.base_api_url)
1085+
def test_deploy_with_app_id_arg(
1086+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1087+
) -> None:
1088+
app_data = _get_random_app()
1089+
app_id = app_data["id"]
1090+
deployment_data = _get_random_deployment(app_id=app_id)
1091+
1092+
respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data))
1093+
1094+
respx_mock.post(f"/apps/{app_id}/deployments/").mock(
1095+
return_value=Response(201, json=deployment_data)
1096+
)
1097+
1098+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock(
1099+
return_value=Response(
1100+
200,
1101+
json={"url": "http://test.com", "fields": {"key": "value"}},
1102+
)
1103+
)
1104+
1105+
respx_mock.post("http://test.com", data={"key": "value"}).mock(
1106+
return_value=Response(200)
1107+
)
1108+
1109+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock(
1110+
return_value=Response(200)
1111+
)
1112+
1113+
respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock(
1114+
return_value=Response(
1115+
200,
1116+
content=build_logs_response(
1117+
{"type": "message", "message": "Building...", "id": "1"},
1118+
{"type": "complete"},
1119+
),
1120+
)
1121+
)
1122+
1123+
with changing_dir(tmp_path):
1124+
result = runner.invoke(app, ["deploy", "--app-id", app_id])
1125+
1126+
assert result.exit_code == 0
1127+
assert f"Deploying to app {app_id}" in result.output
1128+
1129+
1130+
@pytest.mark.respx(base_url=settings.base_api_url)
1131+
def test_deploy_with_app_id_from_env_var(
1132+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1133+
) -> None:
1134+
app_data = _get_random_app()
1135+
app_id = app_data["id"]
1136+
deployment_data = _get_random_deployment(app_id=app_id)
1137+
1138+
respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data))
1139+
1140+
respx_mock.post(f"/apps/{app_id}/deployments/").mock(
1141+
return_value=Response(201, json=deployment_data)
1142+
)
1143+
1144+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock(
1145+
return_value=Response(
1146+
200,
1147+
json={"url": "http://test.com", "fields": {"key": "value"}},
1148+
)
1149+
)
1150+
1151+
respx_mock.post("http://test.com", data={"key": "value"}).mock(
1152+
return_value=Response(200)
1153+
)
1154+
1155+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock(
1156+
return_value=Response(200)
1157+
)
1158+
1159+
respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock(
1160+
return_value=Response(
1161+
200,
1162+
content=build_logs_response(
1163+
{"type": "message", "message": "Building...", "id": "1"},
1164+
{"type": "complete"},
1165+
),
1166+
)
1167+
)
1168+
1169+
with changing_dir(tmp_path):
1170+
result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_APP_ID": app_id})
1171+
1172+
assert result.exit_code == 0
1173+
assert f"Deploying to app {app_id}" in result.output
1174+
1175+
1176+
@pytest.mark.respx(base_url=settings.base_api_url)
1177+
def test_deploy_with_app_id_matching_local_config(
1178+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1179+
) -> None:
1180+
app_data = _get_random_app()
1181+
app_id = app_data["id"]
1182+
team_id = "some-team-id"
1183+
deployment_data = _get_random_deployment(app_id=app_id)
1184+
1185+
config_path = tmp_path / ".fastapicloud" / "cloud.json"
1186+
config_path.parent.mkdir(parents=True, exist_ok=True)
1187+
config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}')
1188+
1189+
respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data))
1190+
1191+
respx_mock.post(f"/apps/{app_id}/deployments/").mock(
1192+
return_value=Response(201, json=deployment_data)
1193+
)
1194+
1195+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock(
1196+
return_value=Response(
1197+
200,
1198+
json={"url": "http://test.com", "fields": {"key": "value"}},
1199+
)
1200+
)
1201+
1202+
respx_mock.post("http://test.com", data={"key": "value"}).mock(
1203+
return_value=Response(200)
1204+
)
1205+
1206+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock(
1207+
return_value=Response(200)
1208+
)
1209+
1210+
respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock(
1211+
return_value=Response(
1212+
200,
1213+
content=build_logs_response(
1214+
{"type": "message", "message": "Building...", "id": "1"},
1215+
{"type": "complete"},
1216+
),
1217+
)
1218+
)
1219+
1220+
with changing_dir(tmp_path):
1221+
result = runner.invoke(app, ["deploy", "--app-id", app_id])
1222+
1223+
assert result.exit_code == 0
1224+
# Should NOT show mismatch warning
1225+
assert "does not match" not in result.output
1226+
assert f"Deploying to app {app_id}" in result.output
1227+
1228+
1229+
@pytest.mark.respx(base_url=settings.base_api_url)
1230+
def test_deploy_with_app_id_mismatch_fails(
1231+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1232+
) -> None:
1233+
local_app_data = _get_random_app()
1234+
local_app_id = local_app_data["id"]
1235+
team_id = "some-team-id"
1236+
1237+
config_path = tmp_path / ".fastapicloud" / "cloud.json"
1238+
config_path.parent.mkdir(parents=True, exist_ok=True)
1239+
config_path.write_text(f'{{"app_id": "{local_app_id}", "team_id": "{team_id}"}}')
1240+
1241+
cli_app_id = "different-app-id"
1242+
1243+
with changing_dir(tmp_path):
1244+
result = runner.invoke(app, ["deploy", "--app-id", cli_app_id])
1245+
1246+
assert result.exit_code == 1
1247+
assert "does not match" in result.output
1248+
assert "fastapi cloud unlink" in result.output
1249+
assert "FASTAPI_CLOUD_APP_ID" in result.output
1250+
1251+
1252+
@pytest.mark.respx(base_url=settings.base_api_url)
1253+
def test_deploy_with_app_id_arg_app_not_found(
1254+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1255+
) -> None:
1256+
app_id = "nonexistent-app-id"
1257+
1258+
respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(404))
1259+
1260+
with changing_dir(tmp_path):
1261+
result = runner.invoke(app, ["deploy", "--app-id", app_id])
1262+
1263+
assert result.exit_code == 1
1264+
assert "App not found" in result.output
1265+
# Should NOT show unlink tip when using --app-id
1266+
assert "unlink" not in result.output

0 commit comments

Comments
 (0)