Skip to content

Commit 98c5e68

Browse files
authored
Merge pull request fastapi#31 from fastapilabs/09-13-_handle_deploying_to_non-existing_apps
✨ Handle deploying to non-existing apps
2 parents 54d812c + 5685083 commit 98c5e68

File tree

6 files changed

+142
-19
lines changed

6 files changed

+142
-19
lines changed

src/fastapi_cli/cli.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from logging import getLogger
1+
import logging
22
from pathlib import Path
33
from typing import Any, Union
44

@@ -20,8 +20,7 @@
2020

2121
app = typer.Typer(rich_markup_mode="rich")
2222

23-
setup_logging()
24-
logger = getLogger(__name__)
23+
logger = logging.getLogger(__name__)
2524

2625

2726
try:
@@ -45,6 +44,7 @@ def callback(
4544
),
4645
] = None,
4746
base_api_url: Union[str, None] = typer.Option(None, help="Base URL of the API"),
47+
verbose: bool = typer.Option(False, help="Enable verbose output"),
4848
) -> None:
4949
"""
5050
FastAPI CLI - The [bold]fastapi[/bold] command line app. 😎
@@ -56,6 +56,10 @@ def callback(
5656
if base_api_url:
5757
settings.base_api_url = base_api_url
5858

59+
log_level = logging.DEBUG if verbose else logging.INFO
60+
61+
setup_logging(level=log_level)
62+
5963

6064
def _run(
6165
path: Union[Path, None] = None,

src/fastapi_cli/commands/deploy.py

+39-11
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from enum import Enum
77
from itertools import cycle
88
from pathlib import Path
9-
from typing import Any, Dict, List, Union
9+
from typing import Any, Dict, List, Optional, Tuple, Union
1010

1111
import rignore
1212
import typer
@@ -76,14 +76,12 @@ def _get_teams() -> List[Team]:
7676
return [Team.model_validate(team) for team in data]
7777

7878

79-
class CreateAppResponse(BaseModel):
80-
name: str
79+
class AppResponse(BaseModel):
8180
id: str
82-
team_id: str
8381
slug: str
8482

8583

86-
def _create_app(team_slug: str, app_name: str) -> CreateAppResponse:
84+
def _create_app(team_slug: str, app_name: str) -> AppResponse:
8785
with APIClient() as client:
8886
response = client.post(
8987
"/apps/",
@@ -92,7 +90,7 @@ def _create_app(team_slug: str, app_name: str) -> CreateAppResponse:
9290

9391
response.raise_for_status()
9492

95-
return CreateAppResponse.model_validate(response.json())
93+
return AppResponse.model_validate(response.json())
9694

9795

9896
class DeploymentStatus(str, Enum):
@@ -140,6 +138,20 @@ def _upload_deployment(deployment_id: str, archive_path: Path) -> None:
140138
response.raise_for_status()
141139

142140

141+
def _get_app(app_slug: str) -> Optional[AppResponse]:
142+
with APIClient() as client:
143+
response = client.get(f"/apps/{app_slug}")
144+
145+
if response.status_code == 404:
146+
return None
147+
148+
response.raise_for_status()
149+
150+
data = response.json()
151+
152+
return AppResponse.model_validate(data)
153+
154+
143155
class DeploymentResponse(BaseModel):
144156
id: str
145157
app_id: str
@@ -179,7 +191,9 @@ def _get_deployment(app_id: str, deployment_id: str) -> DeploymentResponse:
179191
]
180192

181193

182-
def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
194+
def _configure_app(
195+
toolkit: RichToolkit, path_to_deploy: Path
196+
) -> Tuple[AppConfig, AppResponse]:
183197
if not toolkit.confirm(f"Setup and deploy [blue]{path_to_deploy}[/]?", tag="dir"):
184198
raise typer.Exit(0)
185199

@@ -215,7 +229,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
215229

216230
progress.log(f"App created successfully! App slug: {app_data.slug}")
217231

218-
return write_app_config(path_to_deploy, id=app_data.id, slug=app_data.slug)
232+
return write_app_config(path_to_deploy, slug=app_data.slug), app_data
219233

220234

221235
def _wait_for_deployment(
@@ -297,19 +311,33 @@ def deploy(
297311
path_to_deploy = path or Path.cwd()
298312

299313
app_config = get_app_config(path_to_deploy)
314+
app_data: Optional[AppResponse] = None
300315

301316
if not app_config:
302-
app_config = _configure_app(toolkit, path_to_deploy=path_to_deploy)
317+
app_config, app_data = _configure_app(
318+
toolkit, path_to_deploy=path_to_deploy
319+
)
303320
else:
304321
toolkit.print(f"Deploying app [blue]{app_config.slug}[blue]...")
322+
toolkit.print_line()
323+
324+
with toolkit.progress("Checking app...", transient=True) as progress:
325+
app_data = _get_app(app_config.slug)
326+
327+
if not app_data:
328+
progress.set_error(
329+
"App not found. Make sure you're logged in the correct account."
330+
)
331+
332+
raise typer.Exit(1)
305333

306334
toolkit.print_line()
307335

308336
archive_path = archive(path or Path.cwd()) # noqa: F841
309337

310338
with toolkit.progress(title="Creating deployment") as progress:
311339
with handle_http_errors(progress):
312-
deployment = _create_deployment(app_config.id)
340+
deployment = _create_deployment(app_data.id)
313341

314342
progress.log(
315343
f"Deployment created successfully! Deployment slug: {deployment.slug}"
@@ -330,7 +358,7 @@ def deploy(
330358

331359
if not skip_wait:
332360
_wait_for_deployment(
333-
toolkit, app_config.id, deployment.id, check_deployment_url
361+
toolkit, app_data.id, deployment.id, check_deployment_url
334362
)
335363
else:
336364
toolkit.print(

src/fastapi_cli/logging.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
from rich.logging import RichHandler
66

77

8-
def setup_logging(terminal_width: Union[int, None] = None) -> None:
8+
def setup_logging(
9+
terminal_width: Union[int, None] = None, level: int = logging.INFO
10+
) -> None:
911
logger = logging.getLogger("fastapi_cli")
1012
console = Console(width=terminal_width) if terminal_width else None
1113
rich_handler = RichHandler(
@@ -19,5 +21,5 @@ def setup_logging(terminal_width: Union[int, None] = None) -> None:
1921
rich_handler.setFormatter(logging.Formatter("%(message)s"))
2022
logger.addHandler(rich_handler)
2123

22-
logger.setLevel(logging.INFO)
24+
logger.setLevel(level)
2325
logger.propagate = False

src/fastapi_cli/utils/apps.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
class AppConfig(BaseModel):
88
slug: str
9-
id: str
109

1110

1211
def get_app_config(path_to_deploy: Path) -> Optional[AppConfig]:
@@ -18,11 +17,11 @@ def get_app_config(path_to_deploy: Path) -> Optional[AppConfig]:
1817
return AppConfig.model_validate_json(config_path.read_text(encoding="utf-8"))
1918

2019

21-
def write_app_config(path_to_deploy: Path, slug: str, id: str) -> AppConfig:
20+
def write_app_config(path_to_deploy: Path, slug: str) -> AppConfig:
2221
config_path = path_to_deploy / ".fastapi/cloud.json"
2322
config_path.parent.mkdir(parents=True, exist_ok=True)
2423

25-
app_config = AppConfig(slug=slug, id=id)
24+
app_config = AppConfig(slug=slug)
2625

2726
config_path.write_text(
2827
app_config.model_dump_json(),

src/fastapi_cli/utils/cli.py

+4
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ def handle_http_errors(
4747
except HTTPError as e:
4848
logger.debug(e)
4949

50+
# Handle validation errors from Pydantic models, this should make it easier to debug :)
51+
if isinstance(e, HTTPStatusError) and e.response.status_code == 422:
52+
logger.debug(e.response.json())
53+
5054
if isinstance(e, HTTPStatusError) and e.response.status_code in (401, 403):
5155
message = "The specified token is not valid. Use `fastapi login` to generate a new token."
5256

tests/test_cli_deploy.py

+86
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,92 @@ def test_exists_successfully_when_deployment_is_done(
339339
assert "Ready the chicken! Your app is ready at" in result.output
340340

341341

342+
@pytest.mark.respx(base_url=settings.base_api_url)
343+
def test_exists_successfully_when_deployment_is_done_when_app_is_configured(
344+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
345+
) -> None:
346+
config_path = tmp_path / ".fastapi" / "cloud.json"
347+
348+
config_path.parent.mkdir(parents=True, exist_ok=True)
349+
config_path.write_text('{"slug": "demo"}')
350+
351+
respx_mock.get("/apps/demo").mock(
352+
return_value=Response(200, json={"slug": "demo", "id": "1234"})
353+
)
354+
355+
respx_mock.post(
356+
"/apps/1234/deployments/",
357+
).mock(
358+
return_value=Response(
359+
201,
360+
json={
361+
"id": "deployment-1",
362+
"app_id": "1234",
363+
"slug": "demo",
364+
"status": "waiting_upload",
365+
"url": "http://test.com",
366+
},
367+
)
368+
)
369+
respx_mock.post(
370+
"/deployments/deployment-1/upload",
371+
).mock(
372+
return_value=Response(
373+
200,
374+
json={
375+
"url": "http://test.com",
376+
"fields": {"key": "value"},
377+
},
378+
)
379+
)
380+
381+
respx_mock.post(
382+
"http://test.com",
383+
data={"key": "value"},
384+
).mock(return_value=Response(200))
385+
386+
respx_mock.get(
387+
"/apps/1234/deployments/deployment-1",
388+
).mock(
389+
return_value=Response(
390+
200,
391+
json={
392+
"id": "deployment-1",
393+
"app_id": "1234",
394+
"slug": "demo",
395+
"status": "success",
396+
"url": "http://test.com",
397+
},
398+
)
399+
)
400+
401+
with changing_dir(tmp_path):
402+
result = runner.invoke(app, ["deploy"])
403+
404+
assert result.exit_code == 0
405+
406+
assert "Ready the chicken! Your app is ready at" in result.output
407+
408+
409+
@pytest.mark.respx(base_url=settings.base_api_url)
410+
def test_shows_error_when_app_does_not_exist(
411+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
412+
) -> None:
413+
config_path = tmp_path / ".fastapi" / "cloud.json"
414+
415+
config_path.parent.mkdir(parents=True, exist_ok=True)
416+
config_path.write_text('{"slug": "demo"}')
417+
418+
respx_mock.get("/apps/demo").mock(return_value=Response(404))
419+
420+
with changing_dir(tmp_path):
421+
result = runner.invoke(app, ["deploy"])
422+
423+
assert result.exit_code == 1
424+
425+
assert "App not found" in result.output
426+
427+
342428
@pytest.mark.respx(base_url=settings.base_api_url)
343429
def test_can_skip_waiting(
344430
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter

0 commit comments

Comments
 (0)