fastapi-cloud-cli 0.3.1__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.1 → fastapi_cloud_cli-0.4.0}/PKG-INFO +2 -1
- {fastapi_cloud_cli-0.3.1 → 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.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/cli.py +16 -4
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/deploy.py +27 -19
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/login.py +21 -1
- fastapi_cloud_cli-0.4.0/src/fastapi_cloud_cli/utils/auth.py +122 -0
- {fastapi_cloud_cli-0.3.1 → 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.1 → fastapi_cloud_cli-0.4.0}/tests/test_cli_login.py +53 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_cli_logout.py +1 -1
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_cli_unlink.py +1 -1
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_cli_whoami.py +1 -1
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_deploy_utils.py +1 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_env_delete.py +1 -1
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_env_list.py +1 -1
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_env_set.py +1 -1
- fastapi_cloud_cli-0.4.0/tests/utils.py +42 -0
- fastapi_cloud_cli-0.3.1/src/fastapi_cloud_cli/__init__.py +0 -1
- fastapi_cloud_cli-0.3.1/src/fastapi_cloud_cli/utils/auth.py +0 -61
- fastapi_cloud_cli-0.3.1/tests/test_archive.py +0 -103
- fastapi_cloud_cli-0.3.1/tests/utils.py +0 -22
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/LICENSE +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/README.md +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/requirements-tests.txt +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/requirements.txt +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/scripts/format.sh +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/scripts/lint.sh +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/scripts/test-cov-html.sh +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/scripts/test.sh +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/__main__.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/env.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/config.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/logging.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/py.typed +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/api.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/cli.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/config.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/broken_package/mod/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/broken_package/mod/app.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/broken_package/utils.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_api/api.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app/api.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app/app.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_api/app/api.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_app/app/api.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_app/app/app.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/api.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/app.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/main.py +0 -0
- {fastapi_cloud_cli-0.3.1 → 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.1 → 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.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/api.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/app.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/main.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/non_default/nonstandard.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/core/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/core/utils.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/__init__.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/api.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/app.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/other.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/single_file_api.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/single_file_app.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/single_file_other.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_cli.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_cli_deploy.py +0 -0
- {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_config.py +0 -0
- {fastapi_cloud_cli-0.3.1 → 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.1 → 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
|
|
@@ -35,7 +34,14 @@ def _get_app_name(path: Path) -> str:
|
|
|
35
34
|
|
|
36
35
|
|
|
37
36
|
def _should_exclude_entry(path: Path) -> bool:
|
|
38
|
-
parts_to_exclude = [
|
|
37
|
+
parts_to_exclude = [
|
|
38
|
+
".venv",
|
|
39
|
+
"__pycache__",
|
|
40
|
+
".mypy_cache",
|
|
41
|
+
".pytest_cache",
|
|
42
|
+
".gitignore",
|
|
43
|
+
".fastapicloudignore",
|
|
44
|
+
]
|
|
39
45
|
|
|
40
46
|
if any(part in path.parts for part in parts_to_exclude):
|
|
41
47
|
return True
|
|
@@ -46,29 +52,26 @@ def _should_exclude_entry(path: Path) -> bool:
|
|
|
46
52
|
return False
|
|
47
53
|
|
|
48
54
|
|
|
49
|
-
def archive(path: Path) -> Path:
|
|
55
|
+
def archive(path: Path, tar_path: Path) -> Path:
|
|
50
56
|
logger.debug("Starting archive creation for path: %s", path)
|
|
51
57
|
files = rignore.walk(
|
|
52
58
|
path,
|
|
53
59
|
should_exclude_entry=_should_exclude_entry,
|
|
54
60
|
additional_ignore_paths=[".fastapicloudignore"],
|
|
61
|
+
ignore_hidden=False,
|
|
55
62
|
)
|
|
56
63
|
|
|
57
|
-
temp_dir = tempfile.mkdtemp()
|
|
58
|
-
logger.debug("Created temp directory: %s", temp_dir)
|
|
59
|
-
|
|
60
|
-
name = f"fastapi-cloud-deploy-{uuid.uuid4()}"
|
|
61
|
-
tar_path = Path(temp_dir) / f"{name}.tar"
|
|
62
64
|
logger.debug("Archive will be created at: %s", tar_path)
|
|
63
65
|
|
|
64
66
|
file_count = 0
|
|
65
|
-
with
|
|
67
|
+
with fastar.open(tar_path, "w") as tar:
|
|
66
68
|
for filename in files:
|
|
67
69
|
if filename.is_dir():
|
|
68
70
|
continue
|
|
69
71
|
|
|
70
|
-
|
|
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]
|
|
@@ -494,7 +499,7 @@ def _waitlist_form(toolkit: RichToolkit) -> None:
|
|
|
494
499
|
|
|
495
500
|
with contextlib.suppress(Exception):
|
|
496
501
|
subprocess.run(
|
|
497
|
-
["open", "raycast://confetti"],
|
|
502
|
+
["open", "raycast://confetti?emojis=🐔⚡"],
|
|
498
503
|
stdout=subprocess.DEVNULL,
|
|
499
504
|
stderr=subprocess.DEVNULL,
|
|
500
505
|
check=False,
|
|
@@ -579,16 +584,19 @@ def deploy(
|
|
|
579
584
|
if not app:
|
|
580
585
|
toolkit.print_line()
|
|
581
586
|
toolkit.print(
|
|
582
|
-
"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.",
|
|
583
588
|
tag="tip",
|
|
584
589
|
)
|
|
585
590
|
raise typer.Exit(1)
|
|
586
591
|
|
|
587
|
-
|
|
588
|
-
|
|
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)
|
|
589
596
|
|
|
590
|
-
|
|
591
|
-
|
|
597
|
+
with toolkit.progress(
|
|
598
|
+
title="Creating deployment"
|
|
599
|
+
) as progress, handle_http_errors(progress):
|
|
592
600
|
logger.debug("Creating deployment for app: %s", app.id)
|
|
593
601
|
deployment = _create_deployment(app.id)
|
|
594
602
|
|
|
@@ -600,7 +608,7 @@ def deploy(
|
|
|
600
608
|
|
|
601
609
|
_upload_deployment(deployment.id, archive_path)
|
|
602
610
|
|
|
603
|
-
|
|
611
|
+
progress.log("Deployment uploaded successfully!")
|
|
604
612
|
|
|
605
613
|
toolkit.print_line()
|
|
606
614
|
|
|
@@ -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
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from fastapi_cloud_cli.utils.auth import (
|
|
8
|
+
AuthConfig,
|
|
9
|
+
is_logged_in,
|
|
10
|
+
is_token_expired,
|
|
11
|
+
write_auth_config,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from .utils import create_jwt_token
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_is_token_expired_with_valid_token() -> None:
|
|
18
|
+
future_exp = int(time.time()) + 3600
|
|
19
|
+
|
|
20
|
+
token = create_jwt_token({"exp": future_exp, "sub": "test_user"})
|
|
21
|
+
|
|
22
|
+
assert not is_token_expired(token)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_is_token_expired_with_expired_token() -> None:
|
|
26
|
+
past_exp = int(time.time()) - 3600
|
|
27
|
+
token = create_jwt_token({"exp": past_exp, "sub": "test_user"})
|
|
28
|
+
|
|
29
|
+
assert is_token_expired(token)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_is_token_expired_with_no_exp_claim() -> None:
|
|
33
|
+
token = create_jwt_token({"sub": "test_user"})
|
|
34
|
+
|
|
35
|
+
# Tokens without exp claim should be considered valid
|
|
36
|
+
assert not is_token_expired(token)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize(
|
|
40
|
+
"token",
|
|
41
|
+
[
|
|
42
|
+
"not.a.valid.jwt.token",
|
|
43
|
+
"only.two",
|
|
44
|
+
"invalid",
|
|
45
|
+
"",
|
|
46
|
+
"...",
|
|
47
|
+
],
|
|
48
|
+
)
|
|
49
|
+
def test_is_token_expired_with_malformed_token(token: str) -> None:
|
|
50
|
+
assert is_token_expired(token)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_is_token_expired_with_invalid_base64() -> None:
|
|
54
|
+
token = "header.!!!invalid_signature!!!.signature"
|
|
55
|
+
assert is_token_expired(token)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_is_token_expired_with_invalid_json() -> None:
|
|
59
|
+
header_encoded = base64.urlsafe_b64encode(b'{"alg":"HS256"}').decode().rstrip("=")
|
|
60
|
+
payload_encoded = base64.urlsafe_b64encode(b"{invalid json}").decode().rstrip("=")
|
|
61
|
+
signature = base64.urlsafe_b64encode(b"signature").decode().rstrip("=")
|
|
62
|
+
token = f"{header_encoded}.{payload_encoded}.{signature}"
|
|
63
|
+
|
|
64
|
+
assert is_token_expired(token)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_is_logged_in_with_no_token(temp_auth_config: Path) -> None:
|
|
68
|
+
assert not temp_auth_config.exists()
|
|
69
|
+
assert not is_logged_in()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_is_logged_in_with_valid_token(temp_auth_config: Path) -> None:
|
|
73
|
+
future_exp = int(time.time()) + 3600
|
|
74
|
+
token = create_jwt_token({"exp": future_exp, "sub": "test_user"})
|
|
75
|
+
|
|
76
|
+
write_auth_config(AuthConfig(access_token=token))
|
|
77
|
+
|
|
78
|
+
assert is_logged_in()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_is_logged_in_with_expired_token(temp_auth_config: Path) -> None:
|
|
82
|
+
past_exp = int(time.time()) - 3600
|
|
83
|
+
token = create_jwt_token({"exp": past_exp, "sub": "test_user"})
|
|
84
|
+
|
|
85
|
+
write_auth_config(AuthConfig(access_token=token))
|
|
86
|
+
|
|
87
|
+
assert not is_logged_in()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_is_logged_in_with_malformed_token(temp_auth_config: Path) -> None:
|
|
91
|
+
write_auth_config(AuthConfig(access_token="not.a.valid.token"))
|
|
92
|
+
|
|
93
|
+
assert not is_logged_in()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_is_token_expired_edge_case_exact_expiration() -> None:
|
|
97
|
+
current_time = int(time.time())
|
|
98
|
+
token = create_jwt_token({"exp": current_time, "sub": "test_user"})
|
|
99
|
+
|
|
100
|
+
assert is_token_expired(token)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_is_token_expired_edge_case_one_second_before() -> None:
|
|
104
|
+
current_time = int(time.time())
|
|
105
|
+
token = create_jwt_token({"exp": current_time + 1, "sub": "test_user"})
|
|
106
|
+
|
|
107
|
+
assert not is_token_expired(token)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import time
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
from unittest.mock import patch
|
|
3
4
|
|
|
@@ -9,6 +10,7 @@ from typer.testing import CliRunner
|
|
|
9
10
|
|
|
10
11
|
from fastapi_cloud_cli.cli import app
|
|
11
12
|
from fastapi_cloud_cli.config import Settings
|
|
13
|
+
from tests.utils import create_jwt_token
|
|
12
14
|
|
|
13
15
|
runner = CliRunner()
|
|
14
16
|
settings = Settings.get()
|
|
@@ -162,3 +164,54 @@ def test_fetch_access_token_handles_500_error(respx_mock: respx.MockRouter) -> N
|
|
|
162
164
|
with APIClient() as client:
|
|
163
165
|
with pytest.raises(httpx.HTTPStatusError):
|
|
164
166
|
_fetch_access_token(client, "test_device_code", 5)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
170
|
+
def test_notify_already_logged_in_user(
|
|
171
|
+
respx_mock: respx.MockRouter, logged_in_cli: None
|
|
172
|
+
) -> None:
|
|
173
|
+
result = runner.invoke(app, ["login"])
|
|
174
|
+
|
|
175
|
+
assert result.exit_code == 0
|
|
176
|
+
assert "You are already logged in." in result.output
|
|
177
|
+
assert "Run fastapi logout first if you want to switch accounts." in result.output
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
181
|
+
def test_notify_expired_token_user(
|
|
182
|
+
respx_mock: respx.MockRouter, temp_auth_config: Path
|
|
183
|
+
) -> None:
|
|
184
|
+
past_exp = int(time.time()) - 3600
|
|
185
|
+
expired_token = create_jwt_token({"sub": "test_user_12345", "exp": past_exp})
|
|
186
|
+
|
|
187
|
+
temp_auth_config.write_text(f'{{"access_token": "{expired_token}"}}')
|
|
188
|
+
|
|
189
|
+
with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open:
|
|
190
|
+
respx_mock.post(
|
|
191
|
+
"/login/device/authorization", data={"client_id": settings.client_id}
|
|
192
|
+
).mock(
|
|
193
|
+
return_value=Response(
|
|
194
|
+
200,
|
|
195
|
+
json={
|
|
196
|
+
"verification_uri_complete": "http://test.com",
|
|
197
|
+
"verification_uri": "http://test.com",
|
|
198
|
+
"user_code": "1234",
|
|
199
|
+
"device_code": "5678",
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
respx_mock.post(
|
|
204
|
+
"/login/device/token",
|
|
205
|
+
data={
|
|
206
|
+
"device_code": "5678",
|
|
207
|
+
"client_id": settings.client_id,
|
|
208
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
209
|
+
},
|
|
210
|
+
).mock(return_value=Response(200, json={"access_token": "new_token_1234"}))
|
|
211
|
+
|
|
212
|
+
result = runner.invoke(app, ["login"])
|
|
213
|
+
|
|
214
|
+
assert result.exit_code == 0
|
|
215
|
+
assert "Your session has expired. Logging in again..." in result.output
|
|
216
|
+
assert "Now you are logged in!" in result.output
|
|
217
|
+
assert mock_open.called
|
|
@@ -58,6 +58,7 @@ def test_includes_paths(path: Path) -> None:
|
|
|
58
58
|
(DeploymentStatus.deploying_failed, "Deploying failed"),
|
|
59
59
|
(DeploymentStatus.verifying, "Verifying"),
|
|
60
60
|
(DeploymentStatus.verifying_failed, "Verifying failed"),
|
|
61
|
+
(DeploymentStatus.verifying_skipped, "Verification skipped"),
|
|
61
62
|
(DeploymentStatus.success, "Success"),
|
|
62
63
|
(DeploymentStatus.failed, "Failed"),
|
|
63
64
|
],
|
|
@@ -6,7 +6,7 @@ import respx
|
|
|
6
6
|
from httpx import Response
|
|
7
7
|
from typer.testing import CliRunner
|
|
8
8
|
|
|
9
|
-
from fastapi_cloud_cli.cli import app
|
|
9
|
+
from fastapi_cloud_cli.cli import cloud_app as app
|
|
10
10
|
from fastapi_cloud_cli.config import Settings
|
|
11
11
|
from tests.utils import Keys, changing_dir
|
|
12
12
|
|
|
@@ -5,7 +5,7 @@ import respx
|
|
|
5
5
|
from httpx import Response
|
|
6
6
|
from typer.testing import CliRunner
|
|
7
7
|
|
|
8
|
-
from fastapi_cloud_cli.cli import app
|
|
8
|
+
from fastapi_cloud_cli.cli import cloud_app as app
|
|
9
9
|
from fastapi_cloud_cli.config import Settings
|
|
10
10
|
from tests.conftest import ConfiguredApp
|
|
11
11
|
from tests.utils import changing_dir
|
|
@@ -6,7 +6,7 @@ import respx
|
|
|
6
6
|
from httpx import Response
|
|
7
7
|
from typer.testing import CliRunner
|
|
8
8
|
|
|
9
|
-
from fastapi_cloud_cli.cli import app
|
|
9
|
+
from fastapi_cloud_cli.cli import cloud_app as app
|
|
10
10
|
from fastapi_cloud_cli.config import Settings
|
|
11
11
|
from tests.utils import Keys, changing_dir
|
|
12
12
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Generator, Union
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@contextmanager
|
|
10
|
+
def changing_dir(directory: Union[str, Path]) -> Generator[None, None, None]:
|
|
11
|
+
initial_dir = os.getcwd()
|
|
12
|
+
os.chdir(directory)
|
|
13
|
+
try:
|
|
14
|
+
yield
|
|
15
|
+
finally:
|
|
16
|
+
os.chdir(initial_dir)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Keys:
|
|
20
|
+
RIGHT_ARROW = "\x1b[C"
|
|
21
|
+
DOWN_ARROW = "\x1b[B"
|
|
22
|
+
ENTER = "\r"
|
|
23
|
+
CTRL_C = "\x03"
|
|
24
|
+
TAB = "\t"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_jwt_token(payload: Dict[str, Any]) -> str:
|
|
28
|
+
# Note: This creates a JWT with an invalid signature, but that's OK for our tests
|
|
29
|
+
# since we only parse the payload, not verify the signature.
|
|
30
|
+
|
|
31
|
+
header = {"alg": "HS256", "typ": "JWT"}
|
|
32
|
+
header_encoded = (
|
|
33
|
+
base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip("=")
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
payload_encoded = (
|
|
37
|
+
base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
signature = base64.urlsafe_b64encode(b"signature").decode().rstrip("=")
|
|
41
|
+
|
|
42
|
+
return f"{header_encoded}.{payload_encoded}.{signature}"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.1"
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
from typing import Optional
|
|
3
|
-
|
|
4
|
-
from pydantic import BaseModel
|
|
5
|
-
|
|
6
|
-
from .config import get_auth_path
|
|
7
|
-
|
|
8
|
-
logger = logging.getLogger("fastapi_cli")
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class AuthConfig(BaseModel):
|
|
12
|
-
access_token: str
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def write_auth_config(auth_data: AuthConfig) -> None:
|
|
16
|
-
auth_path = get_auth_path()
|
|
17
|
-
logger.debug("Writing auth config to: %s", auth_path)
|
|
18
|
-
|
|
19
|
-
auth_path.write_text(auth_data.model_dump_json(), encoding="utf-8")
|
|
20
|
-
logger.debug("Auth config written successfully")
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def delete_auth_config() -> None:
|
|
24
|
-
auth_path = get_auth_path()
|
|
25
|
-
logger.debug("Deleting auth config at: %s", auth_path)
|
|
26
|
-
|
|
27
|
-
if auth_path.exists():
|
|
28
|
-
auth_path.unlink()
|
|
29
|
-
logger.debug("Auth config deleted successfully")
|
|
30
|
-
else:
|
|
31
|
-
logger.debug("Auth config file doesn't exist, nothing to delete")
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def read_auth_config() -> Optional[AuthConfig]:
|
|
35
|
-
auth_path = get_auth_path()
|
|
36
|
-
logger.debug("Reading auth config from: %s", auth_path)
|
|
37
|
-
|
|
38
|
-
if not auth_path.exists():
|
|
39
|
-
logger.debug("Auth config file doesn't exist")
|
|
40
|
-
return None
|
|
41
|
-
|
|
42
|
-
logger.debug("Auth config loaded successfully")
|
|
43
|
-
return AuthConfig.model_validate_json(auth_path.read_text(encoding="utf-8"))
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def get_auth_token() -> Optional[str]:
|
|
47
|
-
logger.debug("Getting auth token")
|
|
48
|
-
auth_data = read_auth_config()
|
|
49
|
-
|
|
50
|
-
if auth_data is None:
|
|
51
|
-
logger.debug("No auth data found")
|
|
52
|
-
return None
|
|
53
|
-
|
|
54
|
-
logger.debug("Auth token retrieved successfully")
|
|
55
|
-
return auth_data.access_token
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def is_logged_in() -> bool:
|
|
59
|
-
result = get_auth_token() is not None
|
|
60
|
-
logger.debug("Login status: %s", result)
|
|
61
|
-
return result
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import tarfile
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
from fastapi_cloud_cli.commands.deploy import archive
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def test_archive_creates_tar_file(tmp_path: Path) -> None:
|
|
8
|
-
(tmp_path / "main.py").write_text("print('hello')")
|
|
9
|
-
(tmp_path / "config.json").write_text('{"key": "value"}')
|
|
10
|
-
(tmp_path / "subdir").mkdir()
|
|
11
|
-
(tmp_path / "subdir" / "utils.py").write_text("def helper(): pass")
|
|
12
|
-
|
|
13
|
-
tar_path = archive(tmp_path)
|
|
14
|
-
|
|
15
|
-
assert tar_path.exists()
|
|
16
|
-
assert tar_path.suffix == ".tar"
|
|
17
|
-
assert tar_path.name.startswith("fastapi-cloud-deploy-")
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_archive_excludes_venv_and_similar_folders(tmp_path: Path) -> None:
|
|
21
|
-
"""Should exclude .venv directory from archive."""
|
|
22
|
-
# the only files we want to include
|
|
23
|
-
(tmp_path / "main.py").write_text("print('hello')")
|
|
24
|
-
(tmp_path / "static").mkdir()
|
|
25
|
-
(tmp_path / "static" / "index.html").write_text("<html></html>")
|
|
26
|
-
# virtualenv
|
|
27
|
-
(tmp_path / ".venv").mkdir()
|
|
28
|
-
(tmp_path / ".venv" / "lib").mkdir()
|
|
29
|
-
(tmp_path / ".venv" / "lib" / "package.py").write_text("# package")
|
|
30
|
-
# pycache
|
|
31
|
-
(tmp_path / "__pycache__").mkdir()
|
|
32
|
-
(tmp_path / "__pycache__" / "main.cpython-311.pyc").write_text("bytecode")
|
|
33
|
-
# pyc files
|
|
34
|
-
(tmp_path / "main.pyc").write_text("bytecode")
|
|
35
|
-
# mypy/pytest
|
|
36
|
-
(tmp_path / ".mypy_cache").mkdir()
|
|
37
|
-
(tmp_path / ".mypy_cache" / "file.json").write_text("{}")
|
|
38
|
-
(tmp_path / ".pytest_cache").mkdir()
|
|
39
|
-
(tmp_path / ".pytest_cache" / "cache.db").write_text("data")
|
|
40
|
-
|
|
41
|
-
tar_path = archive(tmp_path)
|
|
42
|
-
|
|
43
|
-
with tarfile.open(tar_path, "r") as tar:
|
|
44
|
-
names = tar.getnames()
|
|
45
|
-
assert set(names) == {"main.py", "static/index.html"}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def test_archive_preserves_relative_paths(tmp_path: Path) -> None:
|
|
49
|
-
(tmp_path / "src").mkdir()
|
|
50
|
-
(tmp_path / "src" / "app").mkdir()
|
|
51
|
-
(tmp_path / "src" / "app" / "main.py").write_text("print('hello')")
|
|
52
|
-
|
|
53
|
-
tar_path = archive(tmp_path)
|
|
54
|
-
|
|
55
|
-
with tarfile.open(tar_path, "r") as tar:
|
|
56
|
-
names = tar.getnames()
|
|
57
|
-
assert names == ["src/app/main.py"]
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def test_archive_respects_fastapicloudignore(tmp_path: Path) -> None:
|
|
61
|
-
"""Should exclude files specified in .fastapicloudignore."""
|
|
62
|
-
# Create test files
|
|
63
|
-
(tmp_path / "main.py").write_text("print('hello')")
|
|
64
|
-
(tmp_path / "config.py").write_text("CONFIG = 'value'")
|
|
65
|
-
(tmp_path / "secrets.env").write_text("SECRET_KEY=xyz")
|
|
66
|
-
(tmp_path / "data").mkdir()
|
|
67
|
-
(tmp_path / "data" / "file.txt").write_text("data")
|
|
68
|
-
|
|
69
|
-
# Create .fastapicloudignore file
|
|
70
|
-
(tmp_path / ".fastapicloudignore").write_text("secrets.env\ndata/\n")
|
|
71
|
-
|
|
72
|
-
# Create archive
|
|
73
|
-
tar_path = archive(tmp_path)
|
|
74
|
-
|
|
75
|
-
# Verify ignored files are excluded
|
|
76
|
-
with tarfile.open(tar_path, "r") as tar:
|
|
77
|
-
names = tar.getnames()
|
|
78
|
-
assert set(names) == {
|
|
79
|
-
"main.py",
|
|
80
|
-
"config.py",
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def test_archive_respects_fastapicloudignore_unignore(tmp_path: Path) -> None:
|
|
85
|
-
"""Test we can use .fastapicloudignore to unignore files inside .gitignore"""
|
|
86
|
-
# Create test files
|
|
87
|
-
(tmp_path / "main.py").write_text("print('hello')")
|
|
88
|
-
(tmp_path / "static/build").mkdir(exist_ok=True, parents=True)
|
|
89
|
-
(tmp_path / "static/build/style.css").write_text("body { background: #bada55 }")
|
|
90
|
-
# Rignore needs a .git folder to make .gitignore work
|
|
91
|
-
(tmp_path / ".git").mkdir(exist_ok=True, parents=True)
|
|
92
|
-
(tmp_path / ".gitignore").write_text("build/")
|
|
93
|
-
|
|
94
|
-
# Create .fastapicloudignore file
|
|
95
|
-
(tmp_path / ".fastapicloudignore").write_text("!static/build")
|
|
96
|
-
|
|
97
|
-
# Create archive
|
|
98
|
-
tar_path = archive(tmp_path)
|
|
99
|
-
|
|
100
|
-
# Verify ignored files are excluded
|
|
101
|
-
with tarfile.open(tar_path, "r") as tar:
|
|
102
|
-
names = tar.getnames()
|
|
103
|
-
assert set(names) == {"main.py", "static/build/style.css"}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from contextlib import contextmanager
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from typing import Generator, Union
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@contextmanager
|
|
8
|
-
def changing_dir(directory: Union[str, Path]) -> Generator[None, None, None]:
|
|
9
|
-
initial_dir = os.getcwd()
|
|
10
|
-
os.chdir(directory)
|
|
11
|
-
try:
|
|
12
|
-
yield
|
|
13
|
-
finally:
|
|
14
|
-
os.chdir(initial_dir)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class Keys:
|
|
18
|
-
RIGHT_ARROW = "\x1b[C"
|
|
19
|
-
DOWN_ARROW = "\x1b[B"
|
|
20
|
-
ENTER = "\r"
|
|
21
|
-
CTRL_C = "\x03"
|
|
22
|
-
TAB = "\t"
|
|
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.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/logout.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/unlink.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/whoami.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.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/broken_package/mod/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_api/api.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app/api.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.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.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/api.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/app.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/main.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
|