fastapi-cloud-cli 0.13.0__tar.gz → 0.14.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/PKG-INFO +2 -2
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/pyproject.toml +2 -2
- fastapi_cloud_cli-0.14.0/src/fastapi_cloud_cli/__init__.py +1 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/deploy.py +59 -56
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/api.py +82 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/cli.py +9 -3
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_api_client.py +53 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli_deploy.py +265 -18
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_deploy_utils.py +2 -5
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/utils.py +22 -9
- fastapi_cloud_cli-0.13.0/src/fastapi_cloud_cli/__init__.py +0 -1
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/LICENSE +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/README.md +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/scripts/format.sh +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/scripts/lint.sh +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/scripts/test-cov-html.sh +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/scripts/test.sh +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/__main__.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/cli.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/env.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/link.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/login.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/logs.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/config.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/logging.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/py.typed +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/auth.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/config.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/__init__.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/broken_package/mod/__init__.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/broken_package/mod/app.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/broken_package/utils.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_api/api.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app/api.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app/app.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_api/app/api.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_app/app/api.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_app/app/app.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_main/app/api.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_main/app/app.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_main/app/main.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_non_default/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_non_default/app/nondefault.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_main/api.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_main/app.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_main/main.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/non_default/nonstandard.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/__init__.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/core/__init__.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/core/utils.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/mod/__init__.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/mod/api.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/mod/app.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/mod/other.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/single_file_api.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/single_file_app.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/single_file_other.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/conftest.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_archive.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_auth.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli_link.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli_login.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli_logout.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli_unlink.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli_whoami.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_config.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_env_delete.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_env_list.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_env_set.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_logs.py +0 -0
- {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_sentry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: fastapi-cloud-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.0
|
|
4
4
|
Summary: Deploy and manage FastAPI Cloud apps from the command line 🚀
|
|
5
5
|
Author-Email: Patrick Arminio <patrick@fastapilabs.com>
|
|
6
6
|
License: MIT
|
|
@@ -33,7 +33,7 @@ Requires-Dist: typer>=0.16.0
|
|
|
33
33
|
Requires-Dist: uvicorn[standard]>=0.17.6
|
|
34
34
|
Requires-Dist: rignore>=0.5.1
|
|
35
35
|
Requires-Dist: httpx>=0.27.0
|
|
36
|
-
Requires-Dist: rich-toolkit>=0.19.
|
|
36
|
+
Requires-Dist: rich-toolkit>=0.19.7
|
|
37
37
|
Requires-Dist: pydantic[email]>=2.7.4; python_version < "3.13"
|
|
38
38
|
Requires-Dist: pydantic[email]>=2.8.0; python_version == "3.13"
|
|
39
39
|
Requires-Dist: pydantic[email]>=2.12.0; python_version >= "3.14"
|
|
@@ -33,14 +33,14 @@ dependencies = [
|
|
|
33
33
|
"uvicorn[standard] >= 0.17.6",
|
|
34
34
|
"rignore >= 0.5.1",
|
|
35
35
|
"httpx >= 0.27.0",
|
|
36
|
-
"rich-toolkit >= 0.19.
|
|
36
|
+
"rich-toolkit >= 0.19.7",
|
|
37
37
|
"pydantic[email] >= 2.7.4; python_version < '3.13'",
|
|
38
38
|
"pydantic[email] >= 2.8.0; python_version == '3.13'",
|
|
39
39
|
"pydantic[email] >= 2.12.0; python_version >= '3.14'",
|
|
40
40
|
"sentry-sdk >= 2.20.0",
|
|
41
41
|
"fastar >= 0.8.0",
|
|
42
42
|
]
|
|
43
|
-
version = "0.
|
|
43
|
+
version = "0.14.0"
|
|
44
44
|
|
|
45
45
|
[project.license]
|
|
46
46
|
text = "MIT"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.14.0"
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/deploy.py
RENAMED
|
@@ -4,7 +4,6 @@ import re
|
|
|
4
4
|
import subprocess
|
|
5
5
|
import tempfile
|
|
6
6
|
import time
|
|
7
|
-
from enum import Enum
|
|
8
7
|
from itertools import cycle
|
|
9
8
|
from pathlib import Path, PurePosixPath
|
|
10
9
|
from textwrap import dedent
|
|
@@ -20,7 +19,13 @@ from rich_toolkit import RichToolkit
|
|
|
20
19
|
from rich_toolkit.menu import Option
|
|
21
20
|
|
|
22
21
|
from fastapi_cloud_cli.commands.login import login
|
|
23
|
-
from fastapi_cloud_cli.utils.api import
|
|
22
|
+
from fastapi_cloud_cli.utils.api import (
|
|
23
|
+
SUCCESSFUL_STATUSES,
|
|
24
|
+
APIClient,
|
|
25
|
+
DeploymentStatus,
|
|
26
|
+
StreamLogError,
|
|
27
|
+
TooManyRetriesError,
|
|
28
|
+
)
|
|
24
29
|
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
|
|
25
30
|
from fastapi_cloud_cli.utils.auth import Identity
|
|
26
31
|
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
|
|
@@ -174,42 +179,6 @@ def _create_app(team_id: str, app_name: str, directory: str | None) -> AppRespon
|
|
|
174
179
|
return AppResponse.model_validate(response.json())
|
|
175
180
|
|
|
176
181
|
|
|
177
|
-
class DeploymentStatus(str, Enum):
|
|
178
|
-
waiting_upload = "waiting_upload"
|
|
179
|
-
ready_for_build = "ready_for_build"
|
|
180
|
-
building = "building"
|
|
181
|
-
extracting = "extracting"
|
|
182
|
-
extracting_failed = "extracting_failed"
|
|
183
|
-
building_image = "building_image"
|
|
184
|
-
building_image_failed = "building_image_failed"
|
|
185
|
-
deploying = "deploying"
|
|
186
|
-
deploying_failed = "deploying_failed"
|
|
187
|
-
verifying = "verifying"
|
|
188
|
-
verifying_failed = "verifying_failed"
|
|
189
|
-
verifying_skipped = "verifying_skipped"
|
|
190
|
-
success = "success"
|
|
191
|
-
failed = "failed"
|
|
192
|
-
|
|
193
|
-
@classmethod
|
|
194
|
-
def to_human_readable(cls, status: "DeploymentStatus") -> str:
|
|
195
|
-
return {
|
|
196
|
-
cls.waiting_upload: "Waiting for upload",
|
|
197
|
-
cls.ready_for_build: "Ready for build",
|
|
198
|
-
cls.building: "Building",
|
|
199
|
-
cls.extracting: "Extracting",
|
|
200
|
-
cls.extracting_failed: "Extracting failed",
|
|
201
|
-
cls.building_image: "Building image",
|
|
202
|
-
cls.building_image_failed: "Build failed",
|
|
203
|
-
cls.deploying: "Deploying",
|
|
204
|
-
cls.deploying_failed: "Deploying failed",
|
|
205
|
-
cls.verifying: "Verifying",
|
|
206
|
-
cls.verifying_failed: "Verifying failed",
|
|
207
|
-
cls.verifying_skipped: "Verification skipped",
|
|
208
|
-
cls.success: "Success",
|
|
209
|
-
cls.failed: "Failed",
|
|
210
|
-
}[status]
|
|
211
|
-
|
|
212
|
-
|
|
213
182
|
class CreateDeploymentResponse(BaseModel):
|
|
214
183
|
id: str
|
|
215
184
|
app_id: str
|
|
@@ -440,6 +409,42 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
|
|
|
440
409
|
return app_config
|
|
441
410
|
|
|
442
411
|
|
|
412
|
+
def _verify_deployment(
|
|
413
|
+
toolkit: RichToolkit,
|
|
414
|
+
client: APIClient,
|
|
415
|
+
app_id: str,
|
|
416
|
+
deployment: CreateDeploymentResponse,
|
|
417
|
+
) -> None:
|
|
418
|
+
with toolkit.progress(
|
|
419
|
+
title="Verifying deployment...",
|
|
420
|
+
inline_logs=True,
|
|
421
|
+
done_emoji="✅",
|
|
422
|
+
) as progress:
|
|
423
|
+
try:
|
|
424
|
+
final_status = client.poll_deployment_status(app_id, deployment.id)
|
|
425
|
+
except (TimeoutError, TooManyRetriesError, StreamLogError):
|
|
426
|
+
progress.metadata["done_emoji"] = "⚠️"
|
|
427
|
+
progress.current_message = (
|
|
428
|
+
f"Could not confirm deployment status. "
|
|
429
|
+
f"Check the dashboard: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
|
|
430
|
+
)
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
if final_status in SUCCESSFUL_STATUSES:
|
|
434
|
+
progress.current_message = f"Ready the chicken! 🐔 Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
|
|
435
|
+
else:
|
|
436
|
+
progress.metadata["done_emoji"] = "❌"
|
|
437
|
+
progress.current_message = "Deployment failed"
|
|
438
|
+
|
|
439
|
+
human_status = DeploymentStatus.to_human_readable(final_status)
|
|
440
|
+
|
|
441
|
+
progress.log(
|
|
442
|
+
f"😔 Oh no! Deployment failed: {human_status}. "
|
|
443
|
+
f"Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
|
|
444
|
+
)
|
|
445
|
+
raise typer.Exit(1)
|
|
446
|
+
|
|
447
|
+
|
|
443
448
|
def _wait_for_deployment(
|
|
444
449
|
toolkit: RichToolkit, app_id: str, deployment: CreateDeploymentResponse
|
|
445
450
|
) -> None:
|
|
@@ -451,11 +456,6 @@ def _wait_for_deployment(
|
|
|
451
456
|
)
|
|
452
457
|
toolkit.print_line()
|
|
453
458
|
|
|
454
|
-
toolkit.print(
|
|
455
|
-
f"You can also check the status at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]",
|
|
456
|
-
)
|
|
457
|
-
toolkit.print_line()
|
|
458
|
-
|
|
459
459
|
time_elapsed = 0.0
|
|
460
460
|
|
|
461
461
|
started_at = time.monotonic()
|
|
@@ -464,10 +464,15 @@ def _wait_for_deployment(
|
|
|
464
464
|
|
|
465
465
|
with (
|
|
466
466
|
toolkit.progress(
|
|
467
|
-
next(messages),
|
|
467
|
+
next(messages),
|
|
468
|
+
inline_logs=True,
|
|
469
|
+
lines_to_show=20,
|
|
470
|
+
done_emoji="🚀",
|
|
468
471
|
) as progress,
|
|
469
472
|
APIClient() as client,
|
|
470
473
|
):
|
|
474
|
+
build_complete = False
|
|
475
|
+
|
|
471
476
|
try:
|
|
472
477
|
for log in client.stream_build_logs(deployment.id):
|
|
473
478
|
time_elapsed = time.monotonic() - started_at
|
|
@@ -476,17 +481,8 @@ def _wait_for_deployment(
|
|
|
476
481
|
progress.log(Text.from_ansi(log.message.rstrip()))
|
|
477
482
|
|
|
478
483
|
if log.type == "complete":
|
|
479
|
-
|
|
480
|
-
progress.
|
|
481
|
-
f"You can also check the app logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
|
|
482
|
-
)
|
|
483
|
-
|
|
484
|
-
progress.log("")
|
|
485
|
-
|
|
486
|
-
progress.log(
|
|
487
|
-
f"🐔 Ready the chicken! Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
|
|
488
|
-
)
|
|
489
|
-
|
|
484
|
+
build_complete = True
|
|
485
|
+
progress.title = "Build complete!"
|
|
490
486
|
break
|
|
491
487
|
|
|
492
488
|
if log.type == "failed":
|
|
@@ -515,6 +511,11 @@ def _wait_for_deployment(
|
|
|
515
511
|
|
|
516
512
|
raise typer.Exit(1) from None
|
|
517
513
|
|
|
514
|
+
if build_complete:
|
|
515
|
+
toolkit.print_line()
|
|
516
|
+
|
|
517
|
+
_verify_deployment(toolkit, client, app_id, deployment)
|
|
518
|
+
|
|
518
519
|
|
|
519
520
|
class SignupToWaitingList(BaseModel):
|
|
520
521
|
email: EmailStr
|
|
@@ -753,7 +754,9 @@ def deploy(
|
|
|
753
754
|
archive(path or Path.cwd(), archive_path)
|
|
754
755
|
|
|
755
756
|
with (
|
|
756
|
-
toolkit.progress(
|
|
757
|
+
toolkit.progress(
|
|
758
|
+
title="Creating deployment", done_emoji="📦"
|
|
759
|
+
) as progress,
|
|
757
760
|
handle_http_errors(progress),
|
|
758
761
|
):
|
|
759
762
|
logger.debug("Creating deployment for app: %s", app.id)
|
|
@@ -4,6 +4,7 @@ import time
|
|
|
4
4
|
from collections.abc import Callable, Generator
|
|
5
5
|
from contextlib import contextmanager
|
|
6
6
|
from datetime import timedelta
|
|
7
|
+
from enum import Enum
|
|
7
8
|
from functools import wraps
|
|
8
9
|
from typing import (
|
|
9
10
|
Annotated,
|
|
@@ -138,6 +139,57 @@ def attempts(
|
|
|
138
139
|
return decorator
|
|
139
140
|
|
|
140
141
|
|
|
142
|
+
class DeploymentStatus(str, Enum):
|
|
143
|
+
waiting_upload = "waiting_upload"
|
|
144
|
+
ready_for_build = "ready_for_build"
|
|
145
|
+
building = "building"
|
|
146
|
+
extracting = "extracting"
|
|
147
|
+
extracting_failed = "extracting_failed"
|
|
148
|
+
building_image = "building_image"
|
|
149
|
+
building_image_failed = "building_image_failed"
|
|
150
|
+
deploying = "deploying"
|
|
151
|
+
deploying_failed = "deploying_failed"
|
|
152
|
+
verifying = "verifying"
|
|
153
|
+
verifying_failed = "verifying_failed"
|
|
154
|
+
verifying_skipped = "verifying_skipped"
|
|
155
|
+
success = "success"
|
|
156
|
+
failed = "failed"
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def to_human_readable(cls, status: "DeploymentStatus") -> str:
|
|
160
|
+
return {
|
|
161
|
+
cls.waiting_upload: "Waiting for upload",
|
|
162
|
+
cls.ready_for_build: "Ready for build",
|
|
163
|
+
cls.building: "Building",
|
|
164
|
+
cls.extracting: "Extracting",
|
|
165
|
+
cls.extracting_failed: "Extracting failed",
|
|
166
|
+
cls.building_image: "Building image",
|
|
167
|
+
cls.building_image_failed: "Build failed",
|
|
168
|
+
cls.deploying: "Deploying",
|
|
169
|
+
cls.deploying_failed: "Deploying failed",
|
|
170
|
+
cls.verifying: "Verifying",
|
|
171
|
+
cls.verifying_failed: "Verifying failed",
|
|
172
|
+
cls.verifying_skipped: "Verification skipped",
|
|
173
|
+
cls.success: "Success",
|
|
174
|
+
cls.failed: "Failed",
|
|
175
|
+
}[status]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
SUCCESSFUL_STATUSES = {DeploymentStatus.success, DeploymentStatus.verifying_skipped}
|
|
179
|
+
FAILED_STATUSES = {
|
|
180
|
+
DeploymentStatus.failed,
|
|
181
|
+
DeploymentStatus.verifying_failed,
|
|
182
|
+
DeploymentStatus.deploying_failed,
|
|
183
|
+
DeploymentStatus.building_image_failed,
|
|
184
|
+
DeploymentStatus.extracting_failed,
|
|
185
|
+
}
|
|
186
|
+
TERMINAL_STATUSES = SUCCESSFUL_STATUSES | FAILED_STATUSES
|
|
187
|
+
|
|
188
|
+
POLL_INTERVAL = 2.0
|
|
189
|
+
POLL_TIMEOUT = timedelta(seconds=120)
|
|
190
|
+
POLL_MAX_RETRIES = 5
|
|
191
|
+
|
|
192
|
+
|
|
141
193
|
class APIClient(httpx.Client):
|
|
142
194
|
def __init__(self) -> None:
|
|
143
195
|
settings = Settings.get()
|
|
@@ -241,3 +293,33 @@ class APIClient(httpx.Client):
|
|
|
241
293
|
except ValidationError as e: # pragma: no cover
|
|
242
294
|
logger.debug("Failed to parse log entry: %s - %s", data, e)
|
|
243
295
|
continue
|
|
296
|
+
|
|
297
|
+
def poll_deployment_status(
|
|
298
|
+
self,
|
|
299
|
+
app_id: str,
|
|
300
|
+
deployment_id: str,
|
|
301
|
+
) -> DeploymentStatus:
|
|
302
|
+
start = time.monotonic()
|
|
303
|
+
error_count = 0
|
|
304
|
+
|
|
305
|
+
while True:
|
|
306
|
+
if time.monotonic() - start > POLL_TIMEOUT.total_seconds():
|
|
307
|
+
raise TimeoutError("Deployment verification timed out")
|
|
308
|
+
|
|
309
|
+
with attempt(error_count):
|
|
310
|
+
response = self.get(f"/apps/{app_id}/deployments/{deployment_id}")
|
|
311
|
+
response.raise_for_status()
|
|
312
|
+
status = DeploymentStatus(response.json()["status"])
|
|
313
|
+
error_count = 0
|
|
314
|
+
|
|
315
|
+
if status in TERMINAL_STATUSES:
|
|
316
|
+
return status
|
|
317
|
+
|
|
318
|
+
time.sleep(POLL_INTERVAL)
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
error_count += 1
|
|
322
|
+
if error_count >= POLL_MAX_RETRIES:
|
|
323
|
+
raise TooManyRetriesError(
|
|
324
|
+
f"Failed after {POLL_MAX_RETRIES} attempts polling deployment status"
|
|
325
|
+
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import contextlib
|
|
2
2
|
import logging
|
|
3
3
|
from collections.abc import Generator
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any, Literal
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
7
|
from httpx import HTTPError, HTTPStatusError, ReadTimeout
|
|
@@ -24,9 +24,12 @@ class FastAPIStyle(TaggedStyle):
|
|
|
24
24
|
metadata: dict[str, Any],
|
|
25
25
|
is_animated: bool = False,
|
|
26
26
|
done: bool = False,
|
|
27
|
+
animation_status: Literal["started", "stopped", "error"] | None = None,
|
|
27
28
|
) -> tuple[list[Segment], int]:
|
|
28
29
|
if not is_animated:
|
|
29
|
-
return super()._get_tag_segments(
|
|
30
|
+
return super()._get_tag_segments(
|
|
31
|
+
metadata, is_animated, done, animation_status=animation_status
|
|
32
|
+
)
|
|
30
33
|
|
|
31
34
|
emojis = [
|
|
32
35
|
"🥚",
|
|
@@ -40,7 +43,10 @@ class FastAPIStyle(TaggedStyle):
|
|
|
40
43
|
tag = emojis[self.animation_counter % len(emojis)]
|
|
41
44
|
|
|
42
45
|
if done:
|
|
43
|
-
tag = emojis[-1]
|
|
46
|
+
tag = metadata.get("done_emoji", emojis[-1])
|
|
47
|
+
|
|
48
|
+
if animation_status == "error":
|
|
49
|
+
tag = "🟡"
|
|
44
50
|
|
|
45
51
|
left_padding = self.tag_width - 1
|
|
46
52
|
left_padding = max(0, left_padding)
|
|
@@ -11,6 +11,7 @@ from fastapi_cloud_cli.utils.api import (
|
|
|
11
11
|
STREAM_LOGS_MAX_RETRIES,
|
|
12
12
|
APIClient,
|
|
13
13
|
BuildLogLineMessage,
|
|
14
|
+
DeploymentStatus,
|
|
14
15
|
StreamLogError,
|
|
15
16
|
TooManyRetriesError,
|
|
16
17
|
)
|
|
@@ -351,3 +352,55 @@ def test_stream_build_logs_retry_timeout(
|
|
|
351
352
|
|
|
352
353
|
with patch("time.sleep"), pytest.raises(TimeoutError, match="timed out"):
|
|
353
354
|
list(client.stream_build_logs(deployment_id))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@pytest.fixture
|
|
358
|
+
def app_id() -> str:
|
|
359
|
+
return "test-app-456"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@pytest.fixture
|
|
363
|
+
def poll_route(
|
|
364
|
+
respx_mock: respx.MockRouter, app_id: str, deployment_id: str
|
|
365
|
+
) -> respx.Route:
|
|
366
|
+
return respx_mock.get(f"/apps/{app_id}/deployments/{deployment_id}")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_poll_deployment_status_recovers_from_transient_errors(
|
|
370
|
+
poll_route: respx.Route, client: APIClient, app_id: str, deployment_id: str
|
|
371
|
+
) -> None:
|
|
372
|
+
call_count = 0
|
|
373
|
+
|
|
374
|
+
def handler(request: httpx.Request, route: respx.Route) -> Response:
|
|
375
|
+
nonlocal call_count
|
|
376
|
+
call_count += 1
|
|
377
|
+
if call_count <= 2:
|
|
378
|
+
return Response(500)
|
|
379
|
+
return Response(200, json={"status": "success"})
|
|
380
|
+
|
|
381
|
+
poll_route.mock(side_effect=handler)
|
|
382
|
+
|
|
383
|
+
with patch("time.sleep"):
|
|
384
|
+
status = client.poll_deployment_status(app_id, deployment_id)
|
|
385
|
+
|
|
386
|
+
assert status == DeploymentStatus.success
|
|
387
|
+
assert call_count == 3
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_poll_deployment_status_raises_after_max_consecutive_errors(
|
|
391
|
+
poll_route: respx.Route, client: APIClient, app_id: str, deployment_id: str
|
|
392
|
+
) -> None:
|
|
393
|
+
poll_route.mock(return_value=Response(500))
|
|
394
|
+
|
|
395
|
+
with patch("time.sleep"), pytest.raises(TooManyRetriesError):
|
|
396
|
+
client.poll_deployment_status(app_id, deployment_id)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def test_poll_deployment_status_timeout(
|
|
400
|
+
client: APIClient, app_id: str, deployment_id: str
|
|
401
|
+
) -> None:
|
|
402
|
+
with (
|
|
403
|
+
patch("fastapi_cloud_cli.utils.api.POLL_TIMEOUT", timedelta(seconds=-1)),
|
|
404
|
+
pytest.raises(TimeoutError, match="timed out"),
|
|
405
|
+
):
|
|
406
|
+
client.poll_deployment_status(app_id, deployment_id)
|
|
@@ -2,7 +2,7 @@ import random
|
|
|
2
2
|
import string
|
|
3
3
|
from datetime import timedelta
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import
|
|
5
|
+
from typing import TypedDict
|
|
6
6
|
from unittest.mock import patch
|
|
7
7
|
|
|
8
8
|
import httpx
|
|
@@ -37,14 +37,14 @@ class RandomApp(TypedDict):
|
|
|
37
37
|
slug: str
|
|
38
38
|
id: str
|
|
39
39
|
team_id: str
|
|
40
|
-
directory:
|
|
40
|
+
directory: str | None
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def _get_random_app(
|
|
44
44
|
*,
|
|
45
|
-
slug:
|
|
46
|
-
team_id:
|
|
47
|
-
directory:
|
|
45
|
+
slug: str | None = None,
|
|
46
|
+
team_id: str | None = None,
|
|
47
|
+
directory: str | None = None,
|
|
48
48
|
) -> RandomApp:
|
|
49
49
|
name = "".join(random.choices(string.ascii_lowercase, k=10))
|
|
50
50
|
slug = slug or "".join(random.choices(string.ascii_lowercase, k=10))
|
|
@@ -62,7 +62,7 @@ def _get_random_app(
|
|
|
62
62
|
|
|
63
63
|
def _get_random_deployment(
|
|
64
64
|
*,
|
|
65
|
-
app_id:
|
|
65
|
+
app_id: str | None = None,
|
|
66
66
|
status: str = "waiting_upload",
|
|
67
67
|
) -> dict[str, str]:
|
|
68
68
|
id = "".join(random.choices(string.digits, k=10))
|
|
@@ -666,6 +666,10 @@ def test_updates_app_directory_via_api_when_changed(
|
|
|
666
666
|
)
|
|
667
667
|
)
|
|
668
668
|
|
|
669
|
+
respx_mock.get(f"/apps/{app_data['id']}/deployments/{deployment_data['id']}").mock(
|
|
670
|
+
return_value=Response(200, json={**deployment_data, "status": "success"})
|
|
671
|
+
)
|
|
672
|
+
|
|
669
673
|
with (
|
|
670
674
|
changing_dir(tmp_path),
|
|
671
675
|
patch("rich_toolkit.container.getchar") as mock_getchar,
|
|
@@ -733,6 +737,10 @@ def test_does_not_update_app_directory_when_unchanged(
|
|
|
733
737
|
)
|
|
734
738
|
)
|
|
735
739
|
|
|
740
|
+
respx_mock.get(f"/apps/{app_data['id']}/deployments/{deployment_data['id']}").mock(
|
|
741
|
+
return_value=Response(200, json={**deployment_data, "status": "success"})
|
|
742
|
+
)
|
|
743
|
+
|
|
736
744
|
with (
|
|
737
745
|
changing_dir(tmp_path),
|
|
738
746
|
patch("rich_toolkit.container.getchar") as mock_getchar,
|
|
@@ -807,6 +815,10 @@ def test_exits_successfully_when_deployment_is_done(
|
|
|
807
815
|
)
|
|
808
816
|
)
|
|
809
817
|
|
|
818
|
+
respx_mock.get(f"/apps/{app_data['id']}/deployments/{deployment_data['id']}").mock(
|
|
819
|
+
return_value=Response(200, json={**deployment_data, "status": "success"})
|
|
820
|
+
)
|
|
821
|
+
|
|
810
822
|
with (
|
|
811
823
|
changing_dir(tmp_path),
|
|
812
824
|
patch("rich_toolkit.container.getchar") as mock_getchar,
|
|
@@ -817,8 +829,6 @@ def test_exits_successfully_when_deployment_is_done(
|
|
|
817
829
|
|
|
818
830
|
assert result.exit_code == 0
|
|
819
831
|
|
|
820
|
-
# TODO: show a message when the deployment is done (based on the status)
|
|
821
|
-
|
|
822
832
|
|
|
823
833
|
@pytest.mark.respx
|
|
824
834
|
def test_exits_successfully_when_deployment_is_done_when_app_is_configured(
|
|
@@ -867,6 +877,10 @@ def test_exits_successfully_when_deployment_is_done_when_app_is_configured(
|
|
|
867
877
|
f"/deployments/{deployment_data['id']}/upload-complete",
|
|
868
878
|
).mock(return_value=Response(200))
|
|
869
879
|
|
|
880
|
+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
|
|
881
|
+
return_value=Response(200, json={**deployment_data, "status": "success"})
|
|
882
|
+
)
|
|
883
|
+
|
|
870
884
|
with changing_dir(tmp_path):
|
|
871
885
|
result = runner.invoke(app, ["deploy"])
|
|
872
886
|
|
|
@@ -875,10 +889,6 @@ def test_exits_successfully_when_deployment_is_done_when_app_is_configured(
|
|
|
875
889
|
# check that logs are shown
|
|
876
890
|
assert "All good!" in result.output
|
|
877
891
|
|
|
878
|
-
# check that the dashboard URL is shown
|
|
879
|
-
assert "You can also check the app logs at" in result.output
|
|
880
|
-
assert deployment_data["dashboard_url"] in result.output
|
|
881
|
-
|
|
882
892
|
# check that the app URL is shown
|
|
883
893
|
assert deployment_data["url"] in result.output
|
|
884
894
|
|
|
@@ -1311,10 +1321,15 @@ def test_short_wait_messages(
|
|
|
1311
1321
|
side_effect=build_logs_handler
|
|
1312
1322
|
)
|
|
1313
1323
|
|
|
1324
|
+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
|
|
1325
|
+
return_value=Response(200, json={**deployment_data, "status": "success"})
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1314
1328
|
with changing_dir(tmp_path), patch("time.sleep"):
|
|
1315
1329
|
result = runner.invoke(app, ["deploy"])
|
|
1316
1330
|
|
|
1317
|
-
assert
|
|
1331
|
+
assert result.exit_code == 0
|
|
1332
|
+
assert "Ready the chicken!" in result.output
|
|
1318
1333
|
|
|
1319
1334
|
|
|
1320
1335
|
@pytest.mark.respx
|
|
@@ -1379,10 +1394,15 @@ def test_long_wait_messages(
|
|
|
1379
1394
|
side_effect=build_logs_handler
|
|
1380
1395
|
)
|
|
1381
1396
|
|
|
1397
|
+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
|
|
1398
|
+
return_value=Response(200, json={**deployment_data, "status": "success"})
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1382
1401
|
with changing_dir(tmp_path), patch("time.sleep"):
|
|
1383
1402
|
result = runner.invoke(app, ["deploy"])
|
|
1384
1403
|
|
|
1385
|
-
assert
|
|
1404
|
+
assert result.exit_code == 0
|
|
1405
|
+
assert "Ready the chicken!" in result.output
|
|
1386
1406
|
|
|
1387
1407
|
|
|
1388
1408
|
@pytest.mark.respx
|
|
@@ -1512,6 +1532,11 @@ def test_deploy_successfully_with_token(
|
|
|
1512
1532
|
headers={"Authorization": "Bearer hello"},
|
|
1513
1533
|
).mock(return_value=Response(200))
|
|
1514
1534
|
|
|
1535
|
+
respx_mock.get(
|
|
1536
|
+
f"/apps/{app_id}/deployments/{deployment_data['id']}",
|
|
1537
|
+
headers={"Authorization": "Bearer hello"},
|
|
1538
|
+
).mock(return_value=Response(200, json={**deployment_data, "status": "success"}))
|
|
1539
|
+
|
|
1515
1540
|
with changing_dir(tmp_path):
|
|
1516
1541
|
result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_TOKEN": "hello"})
|
|
1517
1542
|
|
|
@@ -1520,10 +1545,6 @@ def test_deploy_successfully_with_token(
|
|
|
1520
1545
|
# check that logs are shown
|
|
1521
1546
|
assert "All good!" in result.output
|
|
1522
1547
|
|
|
1523
|
-
# check that the dashboard URL is shown
|
|
1524
|
-
assert "You can also check the app logs at" in result.output
|
|
1525
|
-
assert deployment_data["dashboard_url"] in result.output
|
|
1526
|
-
|
|
1527
1548
|
# check that the app URL is shown
|
|
1528
1549
|
assert deployment_data["url"] in result.output
|
|
1529
1550
|
|
|
@@ -1596,6 +1617,10 @@ def test_deploy_with_app_id_arg(
|
|
|
1596
1617
|
)
|
|
1597
1618
|
)
|
|
1598
1619
|
|
|
1620
|
+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
|
|
1621
|
+
return_value=Response(200, json={**deployment_data, "status": "success"})
|
|
1622
|
+
)
|
|
1623
|
+
|
|
1599
1624
|
with changing_dir(tmp_path):
|
|
1600
1625
|
result = runner.invoke(app, ["deploy", "--app-id", app_id])
|
|
1601
1626
|
|
|
@@ -1642,6 +1667,10 @@ def test_deploy_with_app_id_from_env_var(
|
|
|
1642
1667
|
)
|
|
1643
1668
|
)
|
|
1644
1669
|
|
|
1670
|
+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
|
|
1671
|
+
return_value=Response(200, json={**deployment_data, "status": "success"})
|
|
1672
|
+
)
|
|
1673
|
+
|
|
1645
1674
|
with changing_dir(tmp_path):
|
|
1646
1675
|
result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_APP_ID": app_id})
|
|
1647
1676
|
|
|
@@ -1693,6 +1722,10 @@ def test_deploy_with_app_id_matching_local_config(
|
|
|
1693
1722
|
)
|
|
1694
1723
|
)
|
|
1695
1724
|
|
|
1725
|
+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
|
|
1726
|
+
return_value=Response(200, json={**deployment_data, "status": "success"})
|
|
1727
|
+
)
|
|
1728
|
+
|
|
1696
1729
|
with changing_dir(tmp_path):
|
|
1697
1730
|
result = runner.invoke(app, ["deploy", "--app-id", app_id])
|
|
1698
1731
|
|
|
@@ -1740,3 +1773,217 @@ def test_deploy_with_app_id_arg_app_not_found(
|
|
|
1740
1773
|
assert "App not found" in result.output
|
|
1741
1774
|
# Should NOT show unlink tip when using --app-id
|
|
1742
1775
|
assert "unlink" not in result.output
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
def _setup_deployment_mocks(
|
|
1779
|
+
respx_mock: respx.MockRouter,
|
|
1780
|
+
app_id: str,
|
|
1781
|
+
team_id: str,
|
|
1782
|
+
deployment_data: dict[str, str],
|
|
1783
|
+
tmp_path: Path,
|
|
1784
|
+
) -> None:
|
|
1785
|
+
"""Set up common deployment mocks for a configured app."""
|
|
1786
|
+
config_path = tmp_path / ".fastapicloud" / "cloud.json"
|
|
1787
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1788
|
+
config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}')
|
|
1789
|
+
|
|
1790
|
+
app_data = _get_random_app()
|
|
1791
|
+
app_data["id"] = app_id
|
|
1792
|
+
|
|
1793
|
+
respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data))
|
|
1794
|
+
respx_mock.post(f"/apps/{app_id}/deployments/").mock(
|
|
1795
|
+
return_value=Response(201, json=deployment_data)
|
|
1796
|
+
)
|
|
1797
|
+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock(
|
|
1798
|
+
return_value=Response(
|
|
1799
|
+
200, json={"url": "http://test.com", "fields": {"key": "value"}}
|
|
1800
|
+
)
|
|
1801
|
+
)
|
|
1802
|
+
respx_mock.post("http://test.com", data={"key": "value"}).mock(
|
|
1803
|
+
return_value=Response(200)
|
|
1804
|
+
)
|
|
1805
|
+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock(
|
|
1806
|
+
return_value=Response(200)
|
|
1807
|
+
)
|
|
1808
|
+
respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock(
|
|
1809
|
+
return_value=Response(
|
|
1810
|
+
200,
|
|
1811
|
+
content=build_logs_response(
|
|
1812
|
+
{"type": "message", "message": "Building...", "id": "1"},
|
|
1813
|
+
{"type": "complete"},
|
|
1814
|
+
),
|
|
1815
|
+
)
|
|
1816
|
+
)
|
|
1817
|
+
|
|
1818
|
+
|
|
1819
|
+
@pytest.mark.respx
|
|
1820
|
+
def test_verification_failure_after_build_complete(
|
|
1821
|
+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
|
|
1822
|
+
) -> None:
|
|
1823
|
+
app_data = _get_random_app()
|
|
1824
|
+
app_id = app_data["id"]
|
|
1825
|
+
team_id = "some-team-id"
|
|
1826
|
+
deployment_data = _get_random_deployment(app_id=app_id)
|
|
1827
|
+
|
|
1828
|
+
_setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
|
|
1829
|
+
|
|
1830
|
+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
|
|
1831
|
+
return_value=Response(
|
|
1832
|
+
200, json={**deployment_data, "status": "verifying_failed"}
|
|
1833
|
+
)
|
|
1834
|
+
)
|
|
1835
|
+
|
|
1836
|
+
with changing_dir(tmp_path):
|
|
1837
|
+
result = runner.invoke(app, ["deploy"])
|
|
1838
|
+
|
|
1839
|
+
assert result.exit_code == 1
|
|
1840
|
+
assert "Deployment failed" in result.output
|
|
1841
|
+
assert "Verifying failed" in result.output
|
|
1842
|
+
assert deployment_data["dashboard_url"] in result.output
|
|
1843
|
+
|
|
1844
|
+
|
|
1845
|
+
@pytest.mark.respx
|
|
1846
|
+
def test_polling_with_intermediate_states(
|
|
1847
|
+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
|
|
1848
|
+
) -> None:
|
|
1849
|
+
app_data = _get_random_app()
|
|
1850
|
+
app_id = app_data["id"]
|
|
1851
|
+
team_id = "some-team-id"
|
|
1852
|
+
deployment_data = _get_random_deployment(app_id=app_id)
|
|
1853
|
+
|
|
1854
|
+
_setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
|
|
1855
|
+
|
|
1856
|
+
call_count = 0
|
|
1857
|
+
|
|
1858
|
+
def poll_handler(request: httpx.Request, route: respx.Route) -> Response:
|
|
1859
|
+
nonlocal call_count
|
|
1860
|
+
call_count += 1
|
|
1861
|
+
if call_count <= 2:
|
|
1862
|
+
return Response(200, json={**deployment_data, "status": "verifying"})
|
|
1863
|
+
return Response(200, json={**deployment_data, "status": "success"})
|
|
1864
|
+
|
|
1865
|
+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
|
|
1866
|
+
side_effect=poll_handler
|
|
1867
|
+
)
|
|
1868
|
+
|
|
1869
|
+
with changing_dir(tmp_path), patch("time.sleep"):
|
|
1870
|
+
result = runner.invoke(app, ["deploy"])
|
|
1871
|
+
|
|
1872
|
+
assert result.exit_code == 0
|
|
1873
|
+
assert deployment_data["url"] in result.output
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
@pytest.mark.respx
|
|
1877
|
+
def test_polling_timeout_shows_dashboard_link(
|
|
1878
|
+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
|
|
1879
|
+
) -> None:
|
|
1880
|
+
app_data = _get_random_app()
|
|
1881
|
+
app_id = app_data["id"]
|
|
1882
|
+
team_id = "some-team-id"
|
|
1883
|
+
deployment_data = _get_random_deployment(app_id=app_id)
|
|
1884
|
+
|
|
1885
|
+
_setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
|
|
1886
|
+
|
|
1887
|
+
with (
|
|
1888
|
+
changing_dir(tmp_path),
|
|
1889
|
+
patch(
|
|
1890
|
+
"fastapi_cloud_cli.utils.api.APIClient.poll_deployment_status",
|
|
1891
|
+
side_effect=TimeoutError("Deployment verification timed out"),
|
|
1892
|
+
),
|
|
1893
|
+
):
|
|
1894
|
+
result = runner.invoke(app, ["deploy"])
|
|
1895
|
+
|
|
1896
|
+
assert result.exit_code == 0
|
|
1897
|
+
assert "Could not confirm deployment status" in result.output
|
|
1898
|
+
assert deployment_data["dashboard_url"] in result.output
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
@pytest.mark.respx
|
|
1902
|
+
def test_verifying_skipped_treated_as_success(
|
|
1903
|
+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
|
|
1904
|
+
) -> None:
|
|
1905
|
+
app_data = _get_random_app()
|
|
1906
|
+
app_id = app_data["id"]
|
|
1907
|
+
team_id = "some-team-id"
|
|
1908
|
+
deployment_data = _get_random_deployment(app_id=app_id)
|
|
1909
|
+
|
|
1910
|
+
_setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
|
|
1911
|
+
|
|
1912
|
+
respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
|
|
1913
|
+
return_value=Response(
|
|
1914
|
+
200, json={**deployment_data, "status": "verifying_skipped"}
|
|
1915
|
+
)
|
|
1916
|
+
)
|
|
1917
|
+
|
|
1918
|
+
with changing_dir(tmp_path):
|
|
1919
|
+
result = runner.invoke(app, ["deploy"])
|
|
1920
|
+
|
|
1921
|
+
assert result.exit_code == 0
|
|
1922
|
+
assert deployment_data["url"] in result.output
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
@pytest.mark.respx
|
|
1926
|
+
def test_ctrl_c_during_verification_shows_cancelled(
|
|
1927
|
+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
|
|
1928
|
+
) -> None:
|
|
1929
|
+
app_data = _get_random_app()
|
|
1930
|
+
app_id = app_data["id"]
|
|
1931
|
+
team_id = "some-team-id"
|
|
1932
|
+
deployment_data = _get_random_deployment(app_id=app_id)
|
|
1933
|
+
|
|
1934
|
+
_setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
|
|
1935
|
+
|
|
1936
|
+
with (
|
|
1937
|
+
changing_dir(tmp_path),
|
|
1938
|
+
patch(
|
|
1939
|
+
"fastapi_cloud_cli.utils.api.APIClient.poll_deployment_status",
|
|
1940
|
+
side_effect=KeyboardInterrupt(),
|
|
1941
|
+
),
|
|
1942
|
+
):
|
|
1943
|
+
result = runner.invoke(app, ["deploy"])
|
|
1944
|
+
|
|
1945
|
+
assert "🟡" in result.output
|
|
1946
|
+
assert "Cancelled" in result.output
|
|
1947
|
+
assert "✅" not in result.output
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
@pytest.mark.respx
|
|
1951
|
+
def test_ctrl_c_during_build_streaming_shows_cancelled(
|
|
1952
|
+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
|
|
1953
|
+
) -> None:
|
|
1954
|
+
app_data = _get_random_app()
|
|
1955
|
+
app_id = app_data["id"]
|
|
1956
|
+
team_id = "some-team-id"
|
|
1957
|
+
deployment_data = _get_random_deployment(app_id=app_id)
|
|
1958
|
+
|
|
1959
|
+
config_path = tmp_path / ".fastapicloud" / "cloud.json"
|
|
1960
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1961
|
+
config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}')
|
|
1962
|
+
|
|
1963
|
+
respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data))
|
|
1964
|
+
respx_mock.post(f"/apps/{app_id}/deployments/").mock(
|
|
1965
|
+
return_value=Response(201, json=deployment_data)
|
|
1966
|
+
)
|
|
1967
|
+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock(
|
|
1968
|
+
return_value=Response(
|
|
1969
|
+
200, json={"url": "http://test.com", "fields": {"key": "value"}}
|
|
1970
|
+
)
|
|
1971
|
+
)
|
|
1972
|
+
respx_mock.post("http://test.com", data={"key": "value"}).mock(
|
|
1973
|
+
return_value=Response(200)
|
|
1974
|
+
)
|
|
1975
|
+
respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock(
|
|
1976
|
+
return_value=Response(200)
|
|
1977
|
+
)
|
|
1978
|
+
|
|
1979
|
+
with (
|
|
1980
|
+
changing_dir(tmp_path),
|
|
1981
|
+
patch(
|
|
1982
|
+
"fastapi_cloud_cli.utils.api.APIClient.stream_build_logs",
|
|
1983
|
+
side_effect=KeyboardInterrupt(),
|
|
1984
|
+
),
|
|
1985
|
+
):
|
|
1986
|
+
result = runner.invoke(app, ["deploy"])
|
|
1987
|
+
|
|
1988
|
+
assert "🟡" in result.output
|
|
1989
|
+
assert "Cancelled." in result.output
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
-
from typing import Optional
|
|
3
2
|
|
|
4
3
|
import pytest
|
|
5
4
|
|
|
6
5
|
from fastapi_cloud_cli.commands.deploy import (
|
|
7
|
-
DeploymentStatus,
|
|
8
6
|
_should_exclude_entry,
|
|
9
7
|
validate_app_directory,
|
|
10
8
|
)
|
|
9
|
+
from fastapi_cloud_cli.utils.api import DeploymentStatus
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
@pytest.mark.parametrize(
|
|
@@ -98,9 +97,7 @@ def test_deployment_status_to_human_readable(
|
|
|
98
97
|
("a/b/c", "a/b/c"),
|
|
99
98
|
],
|
|
100
99
|
)
|
|
101
|
-
def test_validate_app_directory_valid(
|
|
102
|
-
value: Optional[str], expected: Optional[str]
|
|
103
|
-
) -> None:
|
|
100
|
+
def test_validate_app_directory_valid(value: str | None, expected: str | None) -> None:
|
|
104
101
|
"""Should accept valid directory values and normalize them."""
|
|
105
102
|
assert validate_app_directory(value) == expected
|
|
106
103
|
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import json
|
|
3
3
|
import os
|
|
4
|
+
import sys
|
|
4
5
|
from collections.abc import Generator
|
|
5
6
|
from contextlib import contextmanager
|
|
6
7
|
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
|
+
from typing import Any
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
@contextmanager
|
|
11
|
-
def changing_dir(directory:
|
|
12
|
+
def changing_dir(directory: str | Path) -> Generator[None, None, None]:
|
|
12
13
|
initial_dir = os.getcwd()
|
|
13
14
|
os.chdir(directory)
|
|
14
15
|
try:
|
|
@@ -22,13 +23,25 @@ def build_logs_response(*logs: dict[str, Any]) -> str:
|
|
|
22
23
|
return "\n".join(json.dumps(log) for log in logs)
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
if sys.platform == "win32":
|
|
27
|
+
|
|
28
|
+
class Keys:
|
|
29
|
+
RIGHT_ARROW = "\xe0M"
|
|
30
|
+
DOWN_ARROW = "\xe0P"
|
|
31
|
+
ENTER = "\r"
|
|
32
|
+
CTRL_C = "\x03"
|
|
33
|
+
TAB = "\t"
|
|
34
|
+
BACKSPACE = "\x08"
|
|
35
|
+
|
|
36
|
+
else:
|
|
37
|
+
|
|
38
|
+
class Keys:
|
|
39
|
+
RIGHT_ARROW = "\x1b[C"
|
|
40
|
+
DOWN_ARROW = "\x1b[B"
|
|
41
|
+
ENTER = "\r"
|
|
42
|
+
CTRL_C = "\x03"
|
|
43
|
+
TAB = "\t"
|
|
44
|
+
BACKSPACE = "\x7f"
|
|
32
45
|
|
|
33
46
|
|
|
34
47
|
def create_jwt_token(payload: dict[str, Any]) -> str:
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.13.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/link.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/login.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/logout.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/logs.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/unlink.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/whoami.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/broken_package/mod/__init__.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/broken_package/mod/app.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_api/api.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app/api.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app/app.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_main/api.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_main/app.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|