fastapi-cloud-cli 0.14.1__tar.gz → 0.15.1__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.14.1 → fastapi_cloud_cli-0.15.1}/PKG-INFO +1 -1
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/pyproject.toml +7 -1
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/scripts/lint.sh +1 -0
- fastapi_cloud_cli-0.15.1/src/fastapi_cloud_cli/__init__.py +1 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/cli.py +2 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/deploy.py +5 -3
- fastapi_cloud_cli-0.15.1/src/fastapi_cloud_cli/commands/setup_ci.py +360 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_api_client.py +3 -3
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli_deploy.py +80 -2
- fastapi_cloud_cli-0.15.1/tests/test_cli_setup_ci.py +700 -0
- fastapi_cloud_cli-0.14.1/src/fastapi_cloud_cli/__init__.py +0 -1
- fastapi_cloud_cli-0.14.1/tests/assets/broken_package/mod/__init__.py +0 -1
- fastapi_cloud_cli-0.14.1/tests/assets/broken_package/mod/app.py +0 -10
- fastapi_cloud_cli-0.14.1/tests/assets/broken_package/utils.py +0 -2
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_api/api.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app/api.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app/app.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_api/app/api.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_app/app/api.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_app/app/app.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_main/app/api.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_main/app/app.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_main/app/main.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_non_default/app/__init__.py +0 -0
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_app_dir_non_default/app/nondefault.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_main/api.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_main/app.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/default_main/main.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/default_files/non_default/nonstandard.py +0 -8
- fastapi_cloud_cli-0.14.1/tests/assets/package/__init__.py +0 -2
- fastapi_cloud_cli-0.14.1/tests/assets/package/core/__init__.py +0 -0
- fastapi_cloud_cli-0.14.1/tests/assets/package/core/utils.py +0 -2
- fastapi_cloud_cli-0.14.1/tests/assets/package/mod/__init__.py +0 -1
- fastapi_cloud_cli-0.14.1/tests/assets/package/mod/api.py +0 -24
- fastapi_cloud_cli-0.14.1/tests/assets/package/mod/app.py +0 -32
- fastapi_cloud_cli-0.14.1/tests/assets/package/mod/other.py +0 -16
- fastapi_cloud_cli-0.14.1/tests/assets/single_file_api.py +0 -24
- fastapi_cloud_cli-0.14.1/tests/assets/single_file_app.py +0 -32
- fastapi_cloud_cli-0.14.1/tests/assets/single_file_other.py +0 -16
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/LICENSE +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/README.md +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/scripts/format.sh +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/scripts/test-cov-html.sh +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/scripts/test.sh +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/__main__.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/env.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/link.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/login.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/logout.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/logs.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/config.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/logging.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/py.typed +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/api.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/apps.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/auth.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/cli.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/config.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/env.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/__init__.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/conftest.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_archive.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_auth.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli_link.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli_login.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli_logout.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli_unlink.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_cli_whoami.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_config.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_deploy_utils.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_env_delete.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_env_list.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_env_set.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_logs.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/test_sentry.py +0 -0
- {fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/tests/utils.py +0 -0
|
@@ -40,7 +40,7 @@ dependencies = [
|
|
|
40
40
|
"sentry-sdk >= 2.20.0",
|
|
41
41
|
"fastar >= 0.8.0",
|
|
42
42
|
]
|
|
43
|
-
version = "0.
|
|
43
|
+
version = "0.15.1"
|
|
44
44
|
|
|
45
45
|
[project.license]
|
|
46
46
|
text = "MIT"
|
|
@@ -66,6 +66,7 @@ dev = [
|
|
|
66
66
|
"ruff==0.13.0",
|
|
67
67
|
"respx==0.22.0",
|
|
68
68
|
"time-machine==2.15.0",
|
|
69
|
+
"ty>=0.0.9",
|
|
69
70
|
]
|
|
70
71
|
|
|
71
72
|
[build-system]
|
|
@@ -128,6 +129,11 @@ exclude = [
|
|
|
128
129
|
"tests/assets/*",
|
|
129
130
|
]
|
|
130
131
|
|
|
132
|
+
[tool.ty.src]
|
|
133
|
+
exclude = [
|
|
134
|
+
"tests/assets/**",
|
|
135
|
+
]
|
|
136
|
+
|
|
131
137
|
[tool.ruff.lint]
|
|
132
138
|
select = [
|
|
133
139
|
"E",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.15.1"
|
|
@@ -6,6 +6,7 @@ from .commands.link import link
|
|
|
6
6
|
from .commands.login import login
|
|
7
7
|
from .commands.logout import logout
|
|
8
8
|
from .commands.logs import logs
|
|
9
|
+
from .commands.setup_ci import setup_ci
|
|
9
10
|
from .commands.unlink import unlink
|
|
10
11
|
from .commands.whoami import whoami
|
|
11
12
|
from .logging import setup_logging
|
|
@@ -32,6 +33,7 @@ cloud_app.command()(logs)
|
|
|
32
33
|
cloud_app.command()(logout)
|
|
33
34
|
cloud_app.command()(whoami)
|
|
34
35
|
cloud_app.command()(unlink)
|
|
36
|
+
cloud_app.command()(setup_ci)
|
|
35
37
|
|
|
36
38
|
cloud_app.add_typer(env_app, name="env")
|
|
37
39
|
|
{fastapi_cloud_cli-0.14.1 → fastapi_cloud_cli-0.15.1}/src/fastapi_cloud_cli/commands/deploy.py
RENAMED
|
@@ -304,6 +304,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
|
|
|
304
304
|
"Select the team you want to deploy to:",
|
|
305
305
|
tag="team",
|
|
306
306
|
options=[Option({"name": team.name, "value": team}) for team in teams],
|
|
307
|
+
allow_filtering=True,
|
|
307
308
|
)
|
|
308
309
|
|
|
309
310
|
toolkit.print_line()
|
|
@@ -335,6 +336,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
|
|
|
335
336
|
selected_app = toolkit.ask(
|
|
336
337
|
"Select the app you want to deploy to:",
|
|
337
338
|
options=[Option({"name": app.slug, "value": app}) for app in apps],
|
|
339
|
+
allow_filtering=True,
|
|
338
340
|
)
|
|
339
341
|
|
|
340
342
|
app_name = (
|
|
@@ -478,7 +480,7 @@ def _wait_for_deployment(
|
|
|
478
480
|
time_elapsed = time.monotonic() - started_at
|
|
479
481
|
|
|
480
482
|
if log.type == "message":
|
|
481
|
-
progress.log(Text.from_ansi(log.message.rstrip()))
|
|
483
|
+
progress.log(Text.from_ansi(log.message.rstrip())) # ty: ignore[unresolved-attribute]
|
|
482
484
|
|
|
483
485
|
if log.type == "complete":
|
|
484
486
|
build_complete = True
|
|
@@ -581,13 +583,13 @@ def _waitlist_form(toolkit: RichToolkit) -> None:
|
|
|
581
583
|
)
|
|
582
584
|
form.add_input("secret_code", label="Secret code", placeholder="123456")
|
|
583
585
|
|
|
584
|
-
result = form.run() # type: ignore
|
|
586
|
+
result = form.run() # type: ignore # ty: ignore[unused-ignore-comment]
|
|
585
587
|
|
|
586
588
|
try:
|
|
587
589
|
result = SignupToWaitingList.model_validate(
|
|
588
590
|
{
|
|
589
591
|
"email": email,
|
|
590
|
-
**result, # type: ignore
|
|
592
|
+
**result, # type: ignore # ty: ignore[unused-ignore-comment]
|
|
591
593
|
},
|
|
592
594
|
)
|
|
593
595
|
except ValidationError:
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from fastapi_cloud_cli.utils.api import APIClient
|
|
11
|
+
from fastapi_cloud_cli.utils.apps import get_app_config
|
|
12
|
+
from fastapi_cloud_cli.utils.auth import Identity
|
|
13
|
+
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
TOKEN_EXPIRES_DAYS = 365
|
|
18
|
+
DEFAULT_WORKFLOW_PATH = Path(".github/workflows/deploy.yml")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitHubSecretError(Exception):
|
|
22
|
+
"""Raised when setting a GitHub Actions secret fails."""
|
|
23
|
+
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_github_host(origin: str) -> str:
|
|
28
|
+
"""Extract the GitHub host from a git remote URL.
|
|
29
|
+
|
|
30
|
+
Supports both github.com and GitHub Enterprise hosts.
|
|
31
|
+
Examples:
|
|
32
|
+
git@github.com:owner/repo.git -> github.com
|
|
33
|
+
https://github.com/owner/repo.git -> github.com
|
|
34
|
+
git@enterprise.github.com:owner/repo.git -> enterprise.github.com
|
|
35
|
+
"""
|
|
36
|
+
# Match git@HOST:owner/repo or https://HOST/owner/repo
|
|
37
|
+
match = re.search(r"(?:git@|https://)([^:/]+)", origin)
|
|
38
|
+
return match.group(1) if match else "github.com"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _repo_slug_from_origin(origin: str) -> str | None:
|
|
42
|
+
"""Extract 'owner/repo' from a GitHub remote URL."""
|
|
43
|
+
# Handles URLs like: git@github.com:owner/repo.git or https://github.com/owner/repo.git
|
|
44
|
+
# Also supports GitHub Enterprise hosts like git@github.enterprise.com:owner/repo.git
|
|
45
|
+
# Match the part after the last : or / (which is owner/repo)
|
|
46
|
+
match = re.search(r"[:/]([^:/]+/[^/]+?)(?:\.git)?$", origin)
|
|
47
|
+
return match.group(1) if match else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check_git_installed() -> bool:
|
|
51
|
+
"""Check if git is installed and available."""
|
|
52
|
+
return shutil.which("git") is not None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _check_gh_cli_installed() -> bool:
|
|
56
|
+
"""Check if the GitHub CLI (gh) is installed and available."""
|
|
57
|
+
return shutil.which("gh") is not None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_remote_origin() -> str:
|
|
61
|
+
"""Get the remote origin URL of the Git repository."""
|
|
62
|
+
try:
|
|
63
|
+
# Try gh first (to respect gh repo set-default)
|
|
64
|
+
result = subprocess.run(
|
|
65
|
+
["gh", "repo", "view", "--json", "url", "-q", ".url"],
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
check=True,
|
|
69
|
+
)
|
|
70
|
+
return result.stdout.strip()
|
|
71
|
+
# CalledProcessError if gh command fails, FileNotFoundError if gh is not installed
|
|
72
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
73
|
+
# Fallback to git command
|
|
74
|
+
result = subprocess.run(
|
|
75
|
+
["git", "config", "--get", "remote.origin.url"],
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
check=True,
|
|
79
|
+
)
|
|
80
|
+
return result.stdout.strip()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _set_github_secret(name: str, value: str) -> None:
|
|
84
|
+
"""Set a GitHub Actions secret via the gh CLI.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
GitHubSecretError: If setting the secret fails.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
subprocess.run(
|
|
91
|
+
["gh", "secret", "set", name, "--body", value],
|
|
92
|
+
capture_output=True,
|
|
93
|
+
check=True,
|
|
94
|
+
)
|
|
95
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
96
|
+
raise GitHubSecretError(f"Failed to set GitHub secret '{name}'") from e
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _create_token(app_id: str, token_name: str) -> dict[str, str]:
|
|
100
|
+
"""Create a new deploy token.
|
|
101
|
+
|
|
102
|
+
Returns token_data dict with 'value' and 'expired_at' keys.
|
|
103
|
+
"""
|
|
104
|
+
with APIClient() as client:
|
|
105
|
+
response = client.post(
|
|
106
|
+
f"/apps/{app_id}/tokens",
|
|
107
|
+
json={"name": token_name, "expires_in_days": TOKEN_EXPIRES_DAYS},
|
|
108
|
+
)
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
data = response.json()
|
|
111
|
+
return {"value": data["value"], "expired_at": data["expired_at"]}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _get_default_branch() -> str:
|
|
115
|
+
"""Get the default branch of the Git repository."""
|
|
116
|
+
try:
|
|
117
|
+
result = subprocess.run(
|
|
118
|
+
[
|
|
119
|
+
"gh",
|
|
120
|
+
"repo",
|
|
121
|
+
"view",
|
|
122
|
+
"--json",
|
|
123
|
+
"defaultBranchRef",
|
|
124
|
+
"-q",
|
|
125
|
+
".defaultBranchRef.name",
|
|
126
|
+
],
|
|
127
|
+
capture_output=True,
|
|
128
|
+
text=True,
|
|
129
|
+
check=True,
|
|
130
|
+
)
|
|
131
|
+
return result.stdout.strip()
|
|
132
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
133
|
+
return "main"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _write_workflow_file(branch: str, workflow_path: Path) -> None:
|
|
137
|
+
workflow_content = f"""\
|
|
138
|
+
name: Deploy to FastAPI Cloud
|
|
139
|
+
on:
|
|
140
|
+
push:
|
|
141
|
+
branches: [{branch}]
|
|
142
|
+
jobs:
|
|
143
|
+
deploy:
|
|
144
|
+
runs-on: ubuntu-latest
|
|
145
|
+
steps:
|
|
146
|
+
- uses: actions/checkout@v5
|
|
147
|
+
- uses: astral-sh/setup-uv@v7
|
|
148
|
+
- run: uv run fastapi deploy
|
|
149
|
+
env:
|
|
150
|
+
FASTAPI_CLOUD_TOKEN: ${{{{ secrets.FASTAPI_CLOUD_TOKEN }}}}
|
|
151
|
+
FASTAPI_CLOUD_APP_ID: ${{{{ secrets.FASTAPI_CLOUD_APP_ID }}}}
|
|
152
|
+
"""
|
|
153
|
+
workflow_path.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
workflow_path.write_text(workflow_content)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def setup_ci(
|
|
158
|
+
path: Annotated[
|
|
159
|
+
Path | None,
|
|
160
|
+
typer.Argument(
|
|
161
|
+
help="Path to the folder containing the app (defaults to current directory)"
|
|
162
|
+
),
|
|
163
|
+
] = None,
|
|
164
|
+
branch: str | None = typer.Option(
|
|
165
|
+
None,
|
|
166
|
+
"--branch",
|
|
167
|
+
"-b",
|
|
168
|
+
help="Branch that triggers deploys (defaults to the repo's default branch)",
|
|
169
|
+
),
|
|
170
|
+
secrets_only: bool = typer.Option(
|
|
171
|
+
False,
|
|
172
|
+
"--secrets-only",
|
|
173
|
+
"-s",
|
|
174
|
+
help="Provisions token and sets secrets, skips writing the workflow file",
|
|
175
|
+
show_default=True,
|
|
176
|
+
),
|
|
177
|
+
dry_run: bool = typer.Option(
|
|
178
|
+
False,
|
|
179
|
+
"--dry-run",
|
|
180
|
+
"-d",
|
|
181
|
+
help="Prints steps that would be taken without actually performing them",
|
|
182
|
+
show_default=True,
|
|
183
|
+
),
|
|
184
|
+
file: str | None = typer.Option(
|
|
185
|
+
None,
|
|
186
|
+
"--file",
|
|
187
|
+
"-f",
|
|
188
|
+
help="Custom workflow filename (written to .github/workflows/)",
|
|
189
|
+
),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Configures a GitHub Actions workflow for deploying the app on push to the specified branch.
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
fastapi cloud setup-ci # Provisions token, sets secrets, and writes workflow file for the 'main' branch
|
|
195
|
+
fastapi cloud setup-ci --branch develop # Same as above but for the 'develop' branch
|
|
196
|
+
fastapi cloud setup-ci --secrets-only # Only provisions token and sets secrets, does not write workflow file
|
|
197
|
+
fastapi cloud setup-ci --dry-run # Prints the steps that would be taken without performing them
|
|
198
|
+
fastapi cloud setup-ci --file ci.yml # Writes workflow to .github/workflows/ci.yml
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
identity = Identity()
|
|
202
|
+
|
|
203
|
+
with get_rich_toolkit() as toolkit:
|
|
204
|
+
if not identity.is_logged_in():
|
|
205
|
+
toolkit.print(
|
|
206
|
+
"No credentials found. Use [blue]`fastapi login`[/] to login.",
|
|
207
|
+
tag="auth",
|
|
208
|
+
)
|
|
209
|
+
raise typer.Exit(1)
|
|
210
|
+
|
|
211
|
+
app_path = path or Path.cwd()
|
|
212
|
+
app_config = get_app_config(app_path)
|
|
213
|
+
|
|
214
|
+
if not app_config:
|
|
215
|
+
toolkit.print(
|
|
216
|
+
"No app linked to this directory. Run [blue]`fastapi deploy`[/] first.",
|
|
217
|
+
tag="error",
|
|
218
|
+
)
|
|
219
|
+
raise typer.Exit(1)
|
|
220
|
+
|
|
221
|
+
if not _check_git_installed():
|
|
222
|
+
toolkit.print(
|
|
223
|
+
"git is not installed. Please install git to use this command.",
|
|
224
|
+
tag="error",
|
|
225
|
+
)
|
|
226
|
+
raise typer.Exit(1)
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
origin = _get_remote_origin()
|
|
230
|
+
except subprocess.CalledProcessError:
|
|
231
|
+
toolkit.print(
|
|
232
|
+
"Error retrieving git remote origin URL. Make sure you're in a git repository with a remote origin set.",
|
|
233
|
+
tag="error",
|
|
234
|
+
)
|
|
235
|
+
raise typer.Exit(1) from None
|
|
236
|
+
|
|
237
|
+
# Check if it's a GitHub host (github.com or GitHub Enterprise)
|
|
238
|
+
if "github" not in origin.lower():
|
|
239
|
+
toolkit.print(
|
|
240
|
+
"Remote origin is not a GitHub repository. Please set up a GitHub repo and add it as the remote origin.",
|
|
241
|
+
tag="error",
|
|
242
|
+
)
|
|
243
|
+
raise typer.Exit(1)
|
|
244
|
+
|
|
245
|
+
repo_slug = _repo_slug_from_origin(origin) or origin
|
|
246
|
+
github_host = _get_github_host(origin)
|
|
247
|
+
has_gh = _check_gh_cli_installed()
|
|
248
|
+
|
|
249
|
+
if not branch:
|
|
250
|
+
branch = _get_default_branch()
|
|
251
|
+
|
|
252
|
+
if dry_run:
|
|
253
|
+
toolkit.print(
|
|
254
|
+
"[yellow]This is a dry run — no changes will be made[/yellow]"
|
|
255
|
+
)
|
|
256
|
+
toolkit.print_line()
|
|
257
|
+
|
|
258
|
+
toolkit.print_title("Configuring CI", tag="FastAPI")
|
|
259
|
+
toolkit.print_line()
|
|
260
|
+
|
|
261
|
+
toolkit.print(f"Setting up CI for [bold]{repo_slug}[/bold] (branch: {branch})")
|
|
262
|
+
toolkit.print_line()
|
|
263
|
+
|
|
264
|
+
msg_token = "Created deploy token"
|
|
265
|
+
msg_secrets = (
|
|
266
|
+
"Set [bold]FASTAPI_CLOUD_TOKEN[/bold] and [bold]FASTAPI_CLOUD_APP_ID[/bold]"
|
|
267
|
+
)
|
|
268
|
+
workflow_file = file or DEFAULT_WORKFLOW_PATH.name
|
|
269
|
+
msg_workflow = (
|
|
270
|
+
f"Wrote [bold].github/workflows/{workflow_file}[/bold] (branch: {branch})"
|
|
271
|
+
)
|
|
272
|
+
msg_done = "Done — commit and push to start deploying."
|
|
273
|
+
|
|
274
|
+
if dry_run:
|
|
275
|
+
toolkit.print(msg_token)
|
|
276
|
+
toolkit.print(msg_secrets)
|
|
277
|
+
if not secrets_only:
|
|
278
|
+
toolkit.print(msg_workflow)
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
from datetime import datetime, timezone
|
|
282
|
+
|
|
283
|
+
# Create unique token name with timestamp to avoid duplicates
|
|
284
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
285
|
+
token_name = f"GitHub Actions — {repo_slug} ({timestamp})"
|
|
286
|
+
|
|
287
|
+
with (
|
|
288
|
+
toolkit.progress(title="Generating deploy token...") as progress,
|
|
289
|
+
handle_http_errors(
|
|
290
|
+
progress, default_message="Error creating deploy token."
|
|
291
|
+
),
|
|
292
|
+
):
|
|
293
|
+
token_data = _create_token(app_config.app_id, token_name)
|
|
294
|
+
progress.log(msg_token)
|
|
295
|
+
|
|
296
|
+
toolkit.print_line()
|
|
297
|
+
|
|
298
|
+
if has_gh:
|
|
299
|
+
with toolkit.progress(title="Setting repo secrets...") as progress:
|
|
300
|
+
try:
|
|
301
|
+
_set_github_secret("FASTAPI_CLOUD_TOKEN", token_data["value"])
|
|
302
|
+
_set_github_secret("FASTAPI_CLOUD_APP_ID", app_config.app_id)
|
|
303
|
+
except GitHubSecretError:
|
|
304
|
+
progress.set_error("Failed to set GitHub secrets via gh CLI.")
|
|
305
|
+
raise typer.Exit(1) from None
|
|
306
|
+
progress.log(msg_secrets)
|
|
307
|
+
else:
|
|
308
|
+
secrets_url = f"https://{github_host}/{repo_slug}/settings/secrets/actions"
|
|
309
|
+
toolkit.print(
|
|
310
|
+
"[yellow]gh CLI not found. Set these secrets manually:[/yellow]",
|
|
311
|
+
tag="info",
|
|
312
|
+
)
|
|
313
|
+
toolkit.print_line()
|
|
314
|
+
toolkit.print(f" Repository: [blue]{secrets_url}[/]")
|
|
315
|
+
toolkit.print_line()
|
|
316
|
+
toolkit.print(f" [bold]FASTAPI_CLOUD_TOKEN[/bold] = {token_data['value']}")
|
|
317
|
+
toolkit.print(f" [bold]FASTAPI_CLOUD_APP_ID[/bold] = {app_config.app_id}")
|
|
318
|
+
|
|
319
|
+
toolkit.print_line()
|
|
320
|
+
|
|
321
|
+
if not secrets_only:
|
|
322
|
+
if file:
|
|
323
|
+
workflow_path = Path(f".github/workflows/{file}")
|
|
324
|
+
else:
|
|
325
|
+
workflow_path = DEFAULT_WORKFLOW_PATH
|
|
326
|
+
|
|
327
|
+
write_workflow = True
|
|
328
|
+
if not file and workflow_path.exists():
|
|
329
|
+
overwrite = toolkit.confirm(
|
|
330
|
+
f"Workflow file [bold]{workflow_path}[/bold] already exists. Overwrite?",
|
|
331
|
+
tag="workflow",
|
|
332
|
+
default=False,
|
|
333
|
+
)
|
|
334
|
+
if not overwrite:
|
|
335
|
+
new_name = toolkit.input(
|
|
336
|
+
"Enter a new filename (without path) or leave blank to skip writing the workflow file:",
|
|
337
|
+
tag="workflow",
|
|
338
|
+
).strip()
|
|
339
|
+
if new_name:
|
|
340
|
+
workflow_path = Path(f".github/workflows/{new_name}")
|
|
341
|
+
else:
|
|
342
|
+
toolkit.print("Skipped writing workflow file.")
|
|
343
|
+
toolkit.print_line()
|
|
344
|
+
write_workflow = False
|
|
345
|
+
toolkit.print_line()
|
|
346
|
+
if write_workflow:
|
|
347
|
+
msg_workflow = f"Wrote [bold]{workflow_path}[/bold] (branch: {branch})"
|
|
348
|
+
with toolkit.progress(title="Writing workflow file...") as progress:
|
|
349
|
+
_write_workflow_file(branch, workflow_path)
|
|
350
|
+
progress.log(msg_workflow)
|
|
351
|
+
|
|
352
|
+
toolkit.print_line()
|
|
353
|
+
|
|
354
|
+
toolkit.print(msg_done)
|
|
355
|
+
toolkit.print_line()
|
|
356
|
+
# Token expiration date is in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ), extract date portion
|
|
357
|
+
toolkit.print(
|
|
358
|
+
f"Your deploy token expires on [bold]{token_data['expired_at'][:10]}[/bold]. "
|
|
359
|
+
"Regenerate it from the dashboard or re-run this command before then.",
|
|
360
|
+
)
|
|
@@ -55,10 +55,10 @@ def test_stream_build_logs_successful(
|
|
|
55
55
|
assert len(logs) == 3
|
|
56
56
|
|
|
57
57
|
assert logs[0].type == "message"
|
|
58
|
-
assert logs[0].message == "Building..."
|
|
58
|
+
assert logs[0].message == "Building..." # ty: ignore[unresolved-attribute]
|
|
59
59
|
|
|
60
60
|
assert logs[1].type == "message"
|
|
61
|
-
assert logs[1].message == "Done!"
|
|
61
|
+
assert logs[1].message == "Done!" # ty: ignore[unresolved-attribute]
|
|
62
62
|
|
|
63
63
|
assert logs[2].type == "complete"
|
|
64
64
|
|
|
@@ -201,7 +201,7 @@ def test_stream_build_logs_network_error_retry(
|
|
|
201
201
|
|
|
202
202
|
assert len(logs) == 2
|
|
203
203
|
assert logs[0].type == "message"
|
|
204
|
-
assert logs[0].message == "Success after retry"
|
|
204
|
+
assert logs[0].message == "Success after retry" # ty: ignore[unresolved-attribute]
|
|
205
205
|
|
|
206
206
|
|
|
207
207
|
def test_stream_build_logs_server_error_retry(
|
|
@@ -24,8 +24,8 @@ runner = CliRunner()
|
|
|
24
24
|
assets_path = Path(__file__).parent / "assets"
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
def _get_random_team() -> dict[str, str]:
|
|
28
|
-
name = "".join(random.choices(string.ascii_lowercase, k=10))
|
|
27
|
+
def _get_random_team(name: str | None = None) -> dict[str, str]:
|
|
28
|
+
name = name or "".join(random.choices(string.ascii_lowercase, k=10))
|
|
29
29
|
slug = "".join(random.choices(string.ascii_lowercase, k=10))
|
|
30
30
|
id = "".join(random.choices(string.digits, k=10))
|
|
31
31
|
|
|
@@ -323,6 +323,42 @@ def test_shows_teams(
|
|
|
323
323
|
assert team_2["name"] in result.output
|
|
324
324
|
|
|
325
325
|
|
|
326
|
+
@pytest.mark.respx
|
|
327
|
+
def test_filter_teams(
|
|
328
|
+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
|
|
329
|
+
) -> None:
|
|
330
|
+
steps = [*"al", Keys.ENTER, Keys.CTRL_C]
|
|
331
|
+
|
|
332
|
+
team_1 = _get_random_team(name="Alpha Team")
|
|
333
|
+
team_2 = _get_random_team(name="Beta Team")
|
|
334
|
+
|
|
335
|
+
respx_mock.get("/teams/").mock(
|
|
336
|
+
return_value=Response(
|
|
337
|
+
200,
|
|
338
|
+
json={"data": [team_1, team_2]},
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
with (
|
|
343
|
+
changing_dir(tmp_path),
|
|
344
|
+
patch("rich_toolkit.container.getchar") as mock_getchar,
|
|
345
|
+
):
|
|
346
|
+
mock_getchar.side_effect = steps
|
|
347
|
+
|
|
348
|
+
result = runner.invoke(app, ["deploy"])
|
|
349
|
+
|
|
350
|
+
assert result.exit_code == 1
|
|
351
|
+
|
|
352
|
+
assert "Filter: al" in result.output
|
|
353
|
+
|
|
354
|
+
# Truncate part of the output before "Filter: al"
|
|
355
|
+
filer_pos = result.output.rfind("Filter: al")
|
|
356
|
+
last_output = result.output[filer_pos:]
|
|
357
|
+
|
|
358
|
+
assert team_1["name"] in last_output
|
|
359
|
+
assert team_2["name"] not in last_output
|
|
360
|
+
|
|
361
|
+
|
|
326
362
|
@pytest.mark.respx
|
|
327
363
|
def test_asks_for_app_name_after_team(
|
|
328
364
|
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
|
|
@@ -349,6 +385,48 @@ def test_asks_for_app_name_after_team(
|
|
|
349
385
|
assert "What's your app name?" in result.output
|
|
350
386
|
|
|
351
387
|
|
|
388
|
+
@pytest.mark.respx
|
|
389
|
+
def test_filter_apps(
|
|
390
|
+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
|
|
391
|
+
) -> None:
|
|
392
|
+
steps = [Keys.ENTER, Keys.RIGHT_ARROW, Keys.ENTER, *"an", Keys.ENTER, Keys.CTRL_C]
|
|
393
|
+
|
|
394
|
+
team = _get_random_team()
|
|
395
|
+
|
|
396
|
+
respx_mock.get("/teams/").mock(
|
|
397
|
+
return_value=Response(
|
|
398
|
+
200,
|
|
399
|
+
json={"data": [team]},
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
app_1 = _get_random_app(team_id=team["id"], slug="My App")
|
|
404
|
+
app_2 = _get_random_app(team_id=team["id"], slug="Another App")
|
|
405
|
+
|
|
406
|
+
respx_mock.get("/apps/", params={"team_id": team["id"]}).mock(
|
|
407
|
+
return_value=Response(200, json={"data": [app_1, app_2]})
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
with (
|
|
411
|
+
changing_dir(tmp_path),
|
|
412
|
+
patch("rich_toolkit.container.getchar") as mock_getchar,
|
|
413
|
+
):
|
|
414
|
+
mock_getchar.side_effect = steps
|
|
415
|
+
|
|
416
|
+
result = runner.invoke(app, ["deploy"])
|
|
417
|
+
|
|
418
|
+
assert result.exit_code == 1
|
|
419
|
+
|
|
420
|
+
assert "Filter: an" in result.output
|
|
421
|
+
|
|
422
|
+
# Truncate part of the output before "Filter: an"
|
|
423
|
+
filer_pos = result.output.rfind("Filter: an")
|
|
424
|
+
last_output = result.output[filer_pos:]
|
|
425
|
+
|
|
426
|
+
assert app_1["slug"] not in last_output
|
|
427
|
+
assert app_2["slug"] in last_output
|
|
428
|
+
|
|
429
|
+
|
|
352
430
|
@pytest.mark.respx
|
|
353
431
|
def test_creates_app_on_backend(
|
|
354
432
|
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
|