fastapi-cloud-cli 0.3.0__tar.gz → 0.4.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.3.0 → fastapi_cloud_cli-0.4.0}/PKG-INFO +2 -1
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/pyproject.toml +2 -1
- fastapi_cloud_cli-0.4.0/src/fastapi_cloud_cli/__init__.py +1 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/cli.py +16 -4
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/deploy.py +27 -67
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/env.py +5 -3
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/login.py +22 -2
- fastapi_cloud_cli-0.4.0/src/fastapi_cloud_cli/utils/auth.py +122 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/conftest.py +5 -1
- fastapi_cloud_cli-0.4.0/tests/test_archive.py +132 -0
- fastapi_cloud_cli-0.4.0/tests/test_auth.py +107 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_cli_deploy.py +0 -95
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_cli_login.py +53 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_cli_logout.py +1 -1
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_cli_unlink.py +1 -1
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_cli_whoami.py +1 -1
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_deploy_utils.py +1 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_env_delete.py +1 -1
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_env_list.py +1 -1
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_env_set.py +4 -10
- fastapi_cloud_cli-0.4.0/tests/utils.py +42 -0
- fastapi_cloud_cli-0.3.0/src/fastapi_cloud_cli/__init__.py +0 -1
- fastapi_cloud_cli-0.3.0/src/fastapi_cloud_cli/utils/auth.py +0 -61
- fastapi_cloud_cli-0.3.0/tests/test_archive.py +0 -81
- fastapi_cloud_cli-0.3.0/tests/utils.py +0 -22
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/LICENSE +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/README.md +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/requirements-tests.txt +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/requirements.txt +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/scripts/format.sh +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/scripts/lint.sh +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/scripts/test-cov-html.sh +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/scripts/test.sh +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/__main__.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/config.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/logging.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/py.typed +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/api.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/cli.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/config.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/broken_package/mod/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/broken_package/mod/app.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/broken_package/utils.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_api/api.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app/api.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app/app.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_api/app/api.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_app/app/api.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_app/app/app.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/api.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/app.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/main.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_non_default/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_non_default/app/nondefault.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/api.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/app.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/main.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/non_default/nonstandard.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/core/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/core/utils.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/api.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/app.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/other.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/single_file_api.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/single_file_app.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/single_file_other.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_cli.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_config.py +0 -0
- {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.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.4.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
|
|
@@ -39,6 +39,7 @@ Requires-Dist: httpx>=0.27.0
|
|
|
39
39
|
Requires-Dist: rich-toolkit>=0.14.5
|
|
40
40
|
Requires-Dist: pydantic[email]>=1.6.1
|
|
41
41
|
Requires-Dist: sentry-sdk>=2.20.0
|
|
42
|
+
Requires-Dist: fastar>=0.5.0
|
|
42
43
|
Provides-Extra: standard
|
|
43
44
|
Requires-Dist: uvicorn[standard]>=0.15.0; extra == "standard"
|
|
44
45
|
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -13,17 +13,29 @@ setup_logging()
|
|
|
13
13
|
|
|
14
14
|
app = typer.Typer(rich_markup_mode="rich")
|
|
15
15
|
|
|
16
|
+
cloud_app = typer.Typer(
|
|
17
|
+
rich_markup_mode="rich",
|
|
18
|
+
help="Manage [bold]FastAPI[/bold] Cloud deployments. 🚀",
|
|
19
|
+
)
|
|
16
20
|
|
|
17
21
|
# TODO: use the app structure
|
|
18
22
|
|
|
19
23
|
# Additional commands
|
|
24
|
+
|
|
25
|
+
# fastapi cloud [command]
|
|
26
|
+
cloud_app.command()(deploy)
|
|
27
|
+
cloud_app.command()(login)
|
|
28
|
+
cloud_app.command()(logout)
|
|
29
|
+
cloud_app.command()(whoami)
|
|
30
|
+
cloud_app.command()(unlink)
|
|
31
|
+
|
|
32
|
+
cloud_app.add_typer(env_app, name="env")
|
|
33
|
+
|
|
34
|
+
# fastapi [command]
|
|
20
35
|
app.command()(deploy)
|
|
21
36
|
app.command()(login)
|
|
22
|
-
app.command()(logout)
|
|
23
|
-
app.command()(whoami)
|
|
24
|
-
app.command()(unlink)
|
|
25
37
|
|
|
26
|
-
app.add_typer(
|
|
38
|
+
app.add_typer(cloud_app, name="cloud")
|
|
27
39
|
|
|
28
40
|
|
|
29
41
|
def main() -> None:
|
{fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/deploy.py
RENAMED
|
@@ -2,15 +2,14 @@ import contextlib
|
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
4
|
import subprocess
|
|
5
|
-
import tarfile
|
|
6
5
|
import tempfile
|
|
7
6
|
import time
|
|
8
|
-
import uuid
|
|
9
7
|
from enum import Enum
|
|
10
8
|
from itertools import cycle
|
|
11
9
|
from pathlib import Path
|
|
12
10
|
from typing import Any, Dict, Generator, List, Optional, Union
|
|
13
11
|
|
|
12
|
+
import fastar
|
|
14
13
|
import rignore
|
|
15
14
|
import typer
|
|
16
15
|
from httpx import Client
|
|
@@ -25,7 +24,6 @@ from fastapi_cloud_cli.utils.api import APIClient
|
|
|
25
24
|
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
|
|
26
25
|
from fastapi_cloud_cli.utils.auth import is_logged_in
|
|
27
26
|
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
|
|
28
|
-
from fastapi_cloud_cli.utils.env import validate_environment_variable_name
|
|
29
27
|
|
|
30
28
|
logger = logging.getLogger(__name__)
|
|
31
29
|
|
|
@@ -36,7 +34,14 @@ def _get_app_name(path: Path) -> str:
|
|
|
36
34
|
|
|
37
35
|
|
|
38
36
|
def _should_exclude_entry(path: Path) -> bool:
|
|
39
|
-
parts_to_exclude = [
|
|
37
|
+
parts_to_exclude = [
|
|
38
|
+
".venv",
|
|
39
|
+
"__pycache__",
|
|
40
|
+
".mypy_cache",
|
|
41
|
+
".pytest_cache",
|
|
42
|
+
".gitignore",
|
|
43
|
+
".fastapicloudignore",
|
|
44
|
+
]
|
|
40
45
|
|
|
41
46
|
if any(part in path.parts for part in parts_to_exclude):
|
|
42
47
|
return True
|
|
@@ -47,28 +52,26 @@ def _should_exclude_entry(path: Path) -> bool:
|
|
|
47
52
|
return False
|
|
48
53
|
|
|
49
54
|
|
|
50
|
-
def archive(path: Path) -> Path:
|
|
55
|
+
def archive(path: Path, tar_path: Path) -> Path:
|
|
51
56
|
logger.debug("Starting archive creation for path: %s", path)
|
|
52
57
|
files = rignore.walk(
|
|
53
58
|
path,
|
|
54
59
|
should_exclude_entry=_should_exclude_entry,
|
|
55
60
|
additional_ignore_paths=[".fastapicloudignore"],
|
|
61
|
+
ignore_hidden=False,
|
|
56
62
|
)
|
|
57
63
|
|
|
58
|
-
temp_dir = tempfile.mkdtemp()
|
|
59
|
-
logger.debug("Created temp directory: %s", temp_dir)
|
|
60
|
-
|
|
61
|
-
name = f"fastapi-cloud-deploy-{uuid.uuid4()}"
|
|
62
|
-
tar_path = Path(temp_dir) / f"{name}.tar"
|
|
63
64
|
logger.debug("Archive will be created at: %s", tar_path)
|
|
64
65
|
|
|
65
66
|
file_count = 0
|
|
66
|
-
with
|
|
67
|
+
with fastar.open(tar_path, "w") as tar:
|
|
67
68
|
for filename in files:
|
|
68
69
|
if filename.is_dir():
|
|
69
70
|
continue
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
arcname = filename.relative_to(path)
|
|
73
|
+
logger.debug("Adding %s to archive", arcname)
|
|
74
|
+
tar.append(filename, arcname=arcname)
|
|
72
75
|
file_count += 1
|
|
73
76
|
|
|
74
77
|
logger.debug("Archive created successfully with %s files", file_count)
|
|
@@ -120,6 +123,7 @@ class DeploymentStatus(str, Enum):
|
|
|
120
123
|
deploying_failed = "deploying_failed"
|
|
121
124
|
verifying = "verifying"
|
|
122
125
|
verifying_failed = "verifying_failed"
|
|
126
|
+
verifying_skipped = "verifying_skipped"
|
|
123
127
|
success = "success"
|
|
124
128
|
failed = "failed"
|
|
125
129
|
|
|
@@ -137,6 +141,7 @@ class DeploymentStatus(str, Enum):
|
|
|
137
141
|
cls.deploying_failed: "Deploying failed",
|
|
138
142
|
cls.verifying: "Verifying",
|
|
139
143
|
cls.verifying_failed: "Verifying failed",
|
|
144
|
+
cls.verifying_skipped: "Verification skipped",
|
|
140
145
|
cls.success: "Success",
|
|
141
146
|
cls.failed: "Failed",
|
|
142
147
|
}[status]
|
|
@@ -229,12 +234,6 @@ def _get_apps(team_id: str) -> List[AppResponse]:
|
|
|
229
234
|
return [AppResponse.model_validate(app) for app in data]
|
|
230
235
|
|
|
231
236
|
|
|
232
|
-
def _create_environment_variables(app_id: str, env_vars: Dict[str, str]) -> None:
|
|
233
|
-
with APIClient() as client:
|
|
234
|
-
response = client.patch(f"/apps/{app_id}/environment-variables/", json=env_vars)
|
|
235
|
-
response.raise_for_status()
|
|
236
|
-
|
|
237
|
-
|
|
238
237
|
def _stream_build_logs(deployment_id: str) -> Generator[str, None, None]:
|
|
239
238
|
with APIClient() as client:
|
|
240
239
|
with client.stream(
|
|
@@ -399,45 +398,6 @@ def _wait_for_deployment(
|
|
|
399
398
|
last_message_changed_at = time.monotonic() # pragma: no cover
|
|
400
399
|
|
|
401
400
|
|
|
402
|
-
def _setup_environment_variables(toolkit: RichToolkit, app_id: str) -> None:
|
|
403
|
-
if not toolkit.confirm("Do you want to setup environment variables?", tag="env"):
|
|
404
|
-
return
|
|
405
|
-
|
|
406
|
-
toolkit.print_line()
|
|
407
|
-
|
|
408
|
-
env_vars = {}
|
|
409
|
-
|
|
410
|
-
while True:
|
|
411
|
-
key = toolkit.input(
|
|
412
|
-
"Enter the environment variable name: [ENTER to skip]", required=False
|
|
413
|
-
)
|
|
414
|
-
|
|
415
|
-
if key.strip() == "":
|
|
416
|
-
break
|
|
417
|
-
|
|
418
|
-
if not validate_environment_variable_name(key):
|
|
419
|
-
toolkit.print(
|
|
420
|
-
"[error]Invalid environment variable name.",
|
|
421
|
-
)
|
|
422
|
-
|
|
423
|
-
else:
|
|
424
|
-
value = toolkit.input(
|
|
425
|
-
"Enter the environment variable value:", password=True
|
|
426
|
-
)
|
|
427
|
-
|
|
428
|
-
env_vars[key] = value
|
|
429
|
-
|
|
430
|
-
toolkit.print_line()
|
|
431
|
-
|
|
432
|
-
toolkit.print_line()
|
|
433
|
-
|
|
434
|
-
with toolkit.progress("Setting up environment variables...") as progress:
|
|
435
|
-
with handle_http_errors(progress):
|
|
436
|
-
_create_environment_variables(app_id, env_vars)
|
|
437
|
-
|
|
438
|
-
progress.log("Environment variables set up successfully!")
|
|
439
|
-
|
|
440
|
-
|
|
441
401
|
class SignupToWaitingList(BaseModel):
|
|
442
402
|
email: EmailStr
|
|
443
403
|
name: Optional[str] = None
|
|
@@ -539,7 +499,7 @@ def _waitlist_form(toolkit: RichToolkit) -> None:
|
|
|
539
499
|
|
|
540
500
|
with contextlib.suppress(Exception):
|
|
541
501
|
subprocess.run(
|
|
542
|
-
["open", "raycast://confetti"],
|
|
502
|
+
["open", "raycast://confetti?emojis=🐔⚡"],
|
|
543
503
|
stdout=subprocess.DEVNULL,
|
|
544
504
|
stderr=subprocess.DEVNULL,
|
|
545
505
|
check=False,
|
|
@@ -605,9 +565,6 @@ def deploy(
|
|
|
605
565
|
logger.debug("No app config found, configuring new app")
|
|
606
566
|
app_config = _configure_app(toolkit, path_to_deploy=path_to_deploy)
|
|
607
567
|
toolkit.print_line()
|
|
608
|
-
|
|
609
|
-
_setup_environment_variables(toolkit, app_config.app_id)
|
|
610
|
-
toolkit.print_line()
|
|
611
568
|
else:
|
|
612
569
|
logger.debug("Existing app config found, proceeding with deployment")
|
|
613
570
|
toolkit.print("Deploying app...")
|
|
@@ -627,16 +584,19 @@ def deploy(
|
|
|
627
584
|
if not app:
|
|
628
585
|
toolkit.print_line()
|
|
629
586
|
toolkit.print(
|
|
630
|
-
"If you deleted this app, you can run [bold]fastapi unlink[/] to unlink the local configuration.",
|
|
587
|
+
"If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration.",
|
|
631
588
|
tag="tip",
|
|
632
589
|
)
|
|
633
590
|
raise typer.Exit(1)
|
|
634
591
|
|
|
635
|
-
|
|
636
|
-
|
|
592
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
593
|
+
logger.debug("Creating archive for deployment")
|
|
594
|
+
archive_path = Path(temp_dir) / "archive.tar"
|
|
595
|
+
archive(path or Path.cwd(), archive_path)
|
|
637
596
|
|
|
638
|
-
|
|
639
|
-
|
|
597
|
+
with toolkit.progress(
|
|
598
|
+
title="Creating deployment"
|
|
599
|
+
) as progress, handle_http_errors(progress):
|
|
640
600
|
logger.debug("Creating deployment for app: %s", app.id)
|
|
641
601
|
deployment = _create_deployment(app.id)
|
|
642
602
|
|
|
@@ -648,7 +608,7 @@ def deploy(
|
|
|
648
608
|
|
|
649
609
|
_upload_deployment(deployment.id, archive_path)
|
|
650
610
|
|
|
651
|
-
|
|
611
|
+
progress.log("Deployment uploaded successfully!")
|
|
652
612
|
|
|
653
613
|
toolkit.print_line()
|
|
654
614
|
|
|
@@ -44,11 +44,13 @@ def _delete_environment_variable(app_id: str, name: str) -> bool:
|
|
|
44
44
|
return True
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
def _set_environment_variable(
|
|
47
|
+
def _set_environment_variable(
|
|
48
|
+
app_id: str, name: str, value: str, is_secret: bool = False
|
|
49
|
+
) -> None:
|
|
48
50
|
with APIClient() as client:
|
|
49
|
-
response = client.
|
|
51
|
+
response = client.post(
|
|
50
52
|
f"/apps/{app_id}/environment-variables/",
|
|
51
|
-
json={name: value},
|
|
53
|
+
json={"name": name, "value": value, "is_secret": is_secret},
|
|
52
54
|
)
|
|
53
55
|
response.raise_for_status()
|
|
54
56
|
|
|
@@ -8,7 +8,13 @@ from pydantic import BaseModel
|
|
|
8
8
|
|
|
9
9
|
from fastapi_cloud_cli.config import Settings
|
|
10
10
|
from fastapi_cloud_cli.utils.api import APIClient
|
|
11
|
-
from fastapi_cloud_cli.utils.auth import
|
|
11
|
+
from fastapi_cloud_cli.utils.auth import (
|
|
12
|
+
AuthConfig,
|
|
13
|
+
get_auth_token,
|
|
14
|
+
is_logged_in,
|
|
15
|
+
is_token_expired,
|
|
16
|
+
write_auth_config,
|
|
17
|
+
)
|
|
12
18
|
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
|
|
13
19
|
|
|
14
20
|
logger = logging.getLogger(__name__)
|
|
@@ -76,6 +82,20 @@ def login() -> Any:
|
|
|
76
82
|
"""
|
|
77
83
|
Login to FastAPI Cloud. 🚀
|
|
78
84
|
"""
|
|
85
|
+
token = get_auth_token()
|
|
86
|
+
if token is not None and is_token_expired(token):
|
|
87
|
+
with get_rich_toolkit(minimal=True) as toolkit:
|
|
88
|
+
toolkit.print("Your session has expired. Logging in again...")
|
|
89
|
+
toolkit.print_line()
|
|
90
|
+
|
|
91
|
+
if is_logged_in():
|
|
92
|
+
with get_rich_toolkit(minimal=True) as toolkit:
|
|
93
|
+
toolkit.print("You are already logged in.")
|
|
94
|
+
toolkit.print(
|
|
95
|
+
"Run [bold]fastapi logout[/bold] first if you want to switch accounts."
|
|
96
|
+
)
|
|
97
|
+
return
|
|
98
|
+
|
|
79
99
|
with get_rich_toolkit() as toolkit, APIClient() as client:
|
|
80
100
|
toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI")
|
|
81
101
|
|
|
@@ -87,7 +107,7 @@ def login() -> Any:
|
|
|
87
107
|
|
|
88
108
|
url = authorization_data.verification_uri_complete
|
|
89
109
|
|
|
90
|
-
progress.log(f"Opening {url}")
|
|
110
|
+
progress.log(f"Opening [link={url}]{url}[/link]")
|
|
91
111
|
|
|
92
112
|
toolkit.print_line()
|
|
93
113
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import binascii
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from .config import get_auth_path
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("fastapi_cli")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthConfig(BaseModel):
|
|
16
|
+
access_token: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write_auth_config(auth_data: AuthConfig) -> None:
|
|
20
|
+
auth_path = get_auth_path()
|
|
21
|
+
logger.debug("Writing auth config to: %s", auth_path)
|
|
22
|
+
|
|
23
|
+
auth_path.write_text(auth_data.model_dump_json(), encoding="utf-8")
|
|
24
|
+
logger.debug("Auth config written successfully")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def delete_auth_config() -> None:
|
|
28
|
+
auth_path = get_auth_path()
|
|
29
|
+
logger.debug("Deleting auth config at: %s", auth_path)
|
|
30
|
+
|
|
31
|
+
if auth_path.exists():
|
|
32
|
+
auth_path.unlink()
|
|
33
|
+
logger.debug("Auth config deleted successfully")
|
|
34
|
+
else:
|
|
35
|
+
logger.debug("Auth config file doesn't exist, nothing to delete")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_auth_config() -> Optional[AuthConfig]:
|
|
39
|
+
auth_path = get_auth_path()
|
|
40
|
+
logger.debug("Reading auth config from: %s", auth_path)
|
|
41
|
+
|
|
42
|
+
if not auth_path.exists():
|
|
43
|
+
logger.debug("Auth config file doesn't exist")
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
logger.debug("Auth config loaded successfully")
|
|
47
|
+
return AuthConfig.model_validate_json(auth_path.read_text(encoding="utf-8"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_auth_token() -> Optional[str]:
|
|
51
|
+
logger.debug("Getting auth token")
|
|
52
|
+
auth_data = read_auth_config()
|
|
53
|
+
|
|
54
|
+
if auth_data is None:
|
|
55
|
+
logger.debug("No auth data found")
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
logger.debug("Auth token retrieved successfully")
|
|
59
|
+
return auth_data.access_token
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_token_expired(token: str) -> bool:
|
|
63
|
+
try:
|
|
64
|
+
parts = token.split(".")
|
|
65
|
+
|
|
66
|
+
if len(parts) != 3:
|
|
67
|
+
logger.debug("Invalid JWT format: expected 3 parts, got %d", len(parts))
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
payload = parts[1]
|
|
71
|
+
|
|
72
|
+
# Add padding if needed (JWT uses base64url encoding without padding)
|
|
73
|
+
if padding := len(payload) % 4:
|
|
74
|
+
payload += "=" * (4 - padding)
|
|
75
|
+
|
|
76
|
+
payload = payload.replace("-", "+").replace("_", "/")
|
|
77
|
+
decoded_bytes = base64.b64decode(payload)
|
|
78
|
+
payload_data = json.loads(decoded_bytes)
|
|
79
|
+
|
|
80
|
+
exp = payload_data.get("exp")
|
|
81
|
+
|
|
82
|
+
if exp is None:
|
|
83
|
+
logger.debug("No 'exp' claim found in token")
|
|
84
|
+
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
if not isinstance(exp, int): # pragma: no cover
|
|
88
|
+
logger.debug("Invalid 'exp' claim: expected int, got %s", type(exp))
|
|
89
|
+
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
current_time = time.time()
|
|
93
|
+
|
|
94
|
+
is_expired = current_time >= exp
|
|
95
|
+
|
|
96
|
+
logger.debug(
|
|
97
|
+
"Token expiration check: current=%d, exp=%d, expired=%s",
|
|
98
|
+
current_time,
|
|
99
|
+
exp,
|
|
100
|
+
is_expired,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return is_expired
|
|
104
|
+
except (binascii.Error, json.JSONDecodeError) as e:
|
|
105
|
+
logger.debug("Error parsing JWT token: %s", e)
|
|
106
|
+
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_logged_in() -> bool:
|
|
111
|
+
token = get_auth_token()
|
|
112
|
+
|
|
113
|
+
if token is None:
|
|
114
|
+
logger.debug("Login status: False (no token)")
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
if is_token_expired(token):
|
|
118
|
+
logger.debug("Login status: False (token expired)")
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
logger.debug("Login status: True")
|
|
122
|
+
return True
|
|
@@ -7,6 +7,8 @@ from unittest.mock import patch
|
|
|
7
7
|
import pytest
|
|
8
8
|
from typer import rich_utils
|
|
9
9
|
|
|
10
|
+
from .utils import create_jwt_token
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
@pytest.fixture(autouse=True)
|
|
12
14
|
def reset_syspath() -> Generator[None, None, None]:
|
|
@@ -26,7 +28,9 @@ def setup_terminal() -> None:
|
|
|
26
28
|
|
|
27
29
|
@pytest.fixture
|
|
28
30
|
def logged_in_cli(temp_auth_config: Path) -> Generator[None, None, None]:
|
|
29
|
-
|
|
31
|
+
valid_token = create_jwt_token({"sub": "test_user_12345"})
|
|
32
|
+
|
|
33
|
+
temp_auth_config.write_text(f'{{"access_token": "{valid_token}"}}')
|
|
30
34
|
|
|
31
35
|
yield
|
|
32
36
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import tarfile
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from fastapi_cloud_cli.commands.deploy import archive
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def src_path(tmp_path: Path) -> Path:
|
|
11
|
+
path = tmp_path / "source"
|
|
12
|
+
path.mkdir()
|
|
13
|
+
return path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def tar_path(tmp_path: Path) -> Path:
|
|
18
|
+
return tmp_path / "archive.tar"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_archive_creates_tar_file(src_path: Path, tar_path: Path) -> None:
|
|
22
|
+
(src_path / "main.py").write_text("print('hello')")
|
|
23
|
+
(src_path / "config.json").write_text('{"key": "value"}')
|
|
24
|
+
(src_path / "subdir").mkdir()
|
|
25
|
+
(src_path / "subdir" / "utils.py").write_text("def helper(): pass")
|
|
26
|
+
|
|
27
|
+
archive(src_path, tar_path)
|
|
28
|
+
assert tar_path.exists()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_archive_excludes_venv_and_similar_folders(
|
|
32
|
+
src_path: Path, tar_path: Path
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Should exclude .venv directory from archive."""
|
|
35
|
+
# the only files we want to include
|
|
36
|
+
(src_path / "main.py").write_text("print('hello')")
|
|
37
|
+
(src_path / "static").mkdir()
|
|
38
|
+
(src_path / "static" / "index.html").write_text("<html></html>")
|
|
39
|
+
# virtualenv
|
|
40
|
+
(src_path / ".venv").mkdir()
|
|
41
|
+
(src_path / ".venv" / "lib").mkdir()
|
|
42
|
+
(src_path / ".venv" / "lib" / "package.py").write_text("# package")
|
|
43
|
+
# pycache
|
|
44
|
+
(src_path / "__pycache__").mkdir()
|
|
45
|
+
(src_path / "__pycache__" / "main.cpython-311.pyc").write_text("bytecode")
|
|
46
|
+
# pyc files
|
|
47
|
+
(src_path / "main.pyc").write_text("bytecode")
|
|
48
|
+
# mypy/pytest
|
|
49
|
+
(src_path / ".mypy_cache").mkdir()
|
|
50
|
+
(src_path / ".mypy_cache" / "file.json").write_text("{}")
|
|
51
|
+
(src_path / ".pytest_cache").mkdir()
|
|
52
|
+
(src_path / ".pytest_cache" / "cache.db").write_text("data")
|
|
53
|
+
|
|
54
|
+
archive(src_path, tar_path)
|
|
55
|
+
|
|
56
|
+
with tarfile.open(tar_path, "r") as tar:
|
|
57
|
+
names = tar.getnames()
|
|
58
|
+
assert set(names) == {"main.py", "static/index.html"}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_archive_preserves_relative_paths(src_path: Path, tar_path: Path) -> None:
|
|
62
|
+
(src_path / "src").mkdir()
|
|
63
|
+
(src_path / "src" / "app").mkdir()
|
|
64
|
+
(src_path / "src" / "app" / "main.py").write_text("print('hello')")
|
|
65
|
+
|
|
66
|
+
archive(src_path, tar_path)
|
|
67
|
+
|
|
68
|
+
with tarfile.open(tar_path, "r") as tar:
|
|
69
|
+
names = tar.getnames()
|
|
70
|
+
assert names == ["src/app/main.py"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_archive_respects_fastapicloudignore(src_path: Path, tar_path: Path) -> None:
|
|
74
|
+
"""Should exclude files specified in .fastapicloudignore."""
|
|
75
|
+
(src_path / "main.py").write_text("print('hello')")
|
|
76
|
+
(src_path / "config.py").write_text("CONFIG = 'value'")
|
|
77
|
+
(src_path / "secrets.env").write_text("SECRET_KEY=xyz")
|
|
78
|
+
(src_path / "data").mkdir()
|
|
79
|
+
(src_path / "data" / "file.txt").write_text("data")
|
|
80
|
+
|
|
81
|
+
(src_path / ".fastapicloudignore").write_text("secrets.env\ndata/\n")
|
|
82
|
+
|
|
83
|
+
archive(src_path, tar_path)
|
|
84
|
+
|
|
85
|
+
with tarfile.open(tar_path, "r") as tar:
|
|
86
|
+
names = tar.getnames()
|
|
87
|
+
assert set(names) == {
|
|
88
|
+
"main.py",
|
|
89
|
+
"config.py",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_archive_respects_fastapicloudignore_unignore(
|
|
94
|
+
src_path: Path, tar_path: Path
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Test we can use .fastapicloudignore to unignore files inside .gitignore"""
|
|
97
|
+
(src_path / "main.py").write_text("print('hello')")
|
|
98
|
+
|
|
99
|
+
(src_path / "ignore_me.txt").write_text("You should ignore me")
|
|
100
|
+
|
|
101
|
+
(src_path / "static/build").mkdir(exist_ok=True, parents=True)
|
|
102
|
+
(src_path / "static/build/style.css").write_text("body { background: #bada55 }")
|
|
103
|
+
|
|
104
|
+
# Rignore needs a .git folder to make .gitignore work
|
|
105
|
+
(src_path / ".git").mkdir(exist_ok=True, parents=True)
|
|
106
|
+
(src_path / ".gitignore").write_text("ignore_me.txt\nbuild/")
|
|
107
|
+
|
|
108
|
+
(src_path / ".fastapicloudignore").write_text("!static/build")
|
|
109
|
+
|
|
110
|
+
archive(src_path, tar_path)
|
|
111
|
+
|
|
112
|
+
with tarfile.open(tar_path, "r") as tar:
|
|
113
|
+
names = tar.getnames()
|
|
114
|
+
assert set(names) == {"main.py", "static/build/style.css"}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_archive_includes_hidden_files(src_path: Path, tar_path: Path) -> None:
|
|
118
|
+
"""Should include hidden files in the archive by default."""
|
|
119
|
+
(src_path / "main.py").write_text("print('hello')")
|
|
120
|
+
(src_path / ".env").write_text("SECRET_KEY=xyz")
|
|
121
|
+
(src_path / ".config").mkdir()
|
|
122
|
+
(src_path / ".config" / "settings.json").write_text('{"setting": "value"}')
|
|
123
|
+
|
|
124
|
+
archive(src_path, tar_path)
|
|
125
|
+
|
|
126
|
+
with tarfile.open(tar_path, "r") as tar:
|
|
127
|
+
names = tar.getnames()
|
|
128
|
+
assert set(names) == {
|
|
129
|
+
"main.py",
|
|
130
|
+
".env",
|
|
131
|
+
".config/settings.json",
|
|
132
|
+
}
|