fastapi-cloud-cli 0.18.0__tar.gz → 0.20.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.18.0 → fastapi_cloud_cli-0.20.0}/PKG-INFO +2 -2
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/pyproject.toml +18 -7
- fastapi_cloud_cli-0.20.0/scripts/prepare_release.py +216 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/__init__.py +1 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/cli.py +13 -5
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/_flow.py +159 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/apps/__init__.py +21 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/apps/create.py +179 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/apps/get.py +122 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/apps/link.py +249 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/apps/list.py +268 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/apps/unlink.py +75 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/auth/__init__.py +14 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/auth/wait.py +67 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/deploy/__init__.py +3 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/deploy/archive.py +115 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/deploy/cloud.py +86 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/deploy/command.py +370 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/deploy/configure.py +161 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/deploy/upload.py +90 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/deploy/wait.py +173 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/deployments.py +500 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/env/__init__.py +17 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/env/_shared.py +53 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/env/delete.py +194 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/env/get.py +139 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/env/list.py +113 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/env/set.py +215 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/login.py +120 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/logout.py +15 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/logs.py +273 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/commands/setup_ci.py +33 -32
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/teams/__init__.py +138 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/teams/get.py +91 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/commands/whoami.py +68 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/config.py +1 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/utils/api.py +78 -5
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/utils/apps.py +36 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/utils/cli.py +455 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/utils/dates.py +45 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/utils/errors.py +29 -0
- fastapi_cloud_cli-0.20.0/src/fastapi_cloud_cli/utils/execution.py +22 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/conftest.py +1 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/test_api_client.py +32 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/test_archive.py +1 -1
- fastapi_cloud_cli-0.20.0/tests/test_cli.py +443 -0
- fastapi_cloud_cli-0.20.0/tests/test_cli_apps.py +818 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/test_cli_deploy.py +232 -139
- fastapi_cloud_cli-0.20.0/tests/test_cli_deployments.py +768 -0
- fastapi_cloud_cli-0.20.0/tests/test_cli_link.py +446 -0
- fastapi_cloud_cli-0.20.0/tests/test_cli_login.py +593 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/test_cli_logout.py +2 -2
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/test_cli_setup_ci.py +34 -2
- fastapi_cloud_cli-0.20.0/tests/test_cli_teams.py +226 -0
- fastapi_cloud_cli-0.20.0/tests/test_cli_unlink.py +122 -0
- fastapi_cloud_cli-0.20.0/tests/test_cli_whoami.py +284 -0
- fastapi_cloud_cli-0.20.0/tests/test_dates.py +27 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/test_deploy_utils.py +1 -1
- fastapi_cloud_cli-0.20.0/tests/test_env_delete.py +309 -0
- fastapi_cloud_cli-0.20.0/tests/test_env_get.py +247 -0
- fastapi_cloud_cli-0.20.0/tests/test_env_list.py +288 -0
- fastapi_cloud_cli-0.20.0/tests/test_env_set.py +312 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/test_logs.py +156 -17
- fastapi_cloud_cli-0.20.0/tests/test_prepare_release.py +298 -0
- fastapi_cloud_cli-0.20.0/tests/test_utils_apps.py +29 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/test_version_check.py +2 -2
- fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/__init__.py +0 -1
- fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/commands/deploy.py +0 -907
- fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/commands/env.py +0 -289
- fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/commands/link.py +0 -120
- fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/commands/login.py +0 -126
- fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/commands/logout.py +0 -12
- fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/commands/logs.py +0 -189
- fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/commands/unlink.py +0 -29
- fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/commands/whoami.py +0 -37
- fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/utils/cli.py +0 -128
- fastapi_cloud_cli-0.18.0/tests/test_cli.py +0 -84
- fastapi_cloud_cli-0.18.0/tests/test_cli_link.py +0 -189
- fastapi_cloud_cli-0.18.0/tests/test_cli_login.py +0 -238
- fastapi_cloud_cli-0.18.0/tests/test_cli_unlink.py +0 -50
- fastapi_cloud_cli-0.18.0/tests/test_cli_whoami.py +0 -104
- fastapi_cloud_cli-0.18.0/tests/test_env_delete.py +0 -147
- fastapi_cloud_cli-0.18.0/tests/test_env_list.py +0 -114
- fastapi_cloud_cli-0.18.0/tests/test_env_set.py +0 -144
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/LICENSE +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/README.md +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/scripts/add_latest_release_date.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/scripts/format.sh +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/scripts/lint.sh +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/scripts/test-cov-html.sh +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/scripts/test.sh +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/__main__.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/logging.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/py.typed +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/utils/auth.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/utils/config.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/utils/progress_file.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/src/fastapi_cloud_cli/utils/version_check.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/__init__.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/test_auth.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/test_config.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/test_progress_file.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/test_sentry.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.20.0}/tests/utils.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.20.0
|
|
4
4
|
Summary: Deploy and manage FastAPI Cloud apps from the command line 🚀
|
|
5
5
|
Author-Email: Patrick Arminio <patrick@fastapilabs.com>
|
|
6
6
|
License: MIT
|
|
@@ -33,7 +33,7 @@ Requires-Dist: typer>=0.16.0
|
|
|
33
33
|
Requires-Dist: uvicorn[standard]>=0.17.6
|
|
34
34
|
Requires-Dist: rignore>=0.5.1
|
|
35
35
|
Requires-Dist: httpx>=0.27.0
|
|
36
|
-
Requires-Dist: rich-toolkit>=0.
|
|
36
|
+
Requires-Dist: rich-toolkit>=0.20.1
|
|
37
37
|
Requires-Dist: pydantic[email]>=2.7.4; python_version < "3.13"
|
|
38
38
|
Requires-Dist: pydantic[email]>=2.8.0; python_version == "3.13"
|
|
39
39
|
Requires-Dist: pydantic[email]>=2.12.0; python_version >= "3.14"
|
|
@@ -33,7 +33,7 @@ dependencies = [
|
|
|
33
33
|
"uvicorn[standard] >= 0.17.6",
|
|
34
34
|
"rignore >= 0.5.1",
|
|
35
35
|
"httpx >= 0.27.0",
|
|
36
|
-
"rich-toolkit
|
|
36
|
+
"rich-toolkit>=0.20.1",
|
|
37
37
|
"pydantic[email] >= 2.7.4; python_version < '3.13'",
|
|
38
38
|
"pydantic[email] >= 2.8.0; python_version == '3.13'",
|
|
39
39
|
"pydantic[email] >= 2.12.0; python_version >= '3.14'",
|
|
@@ -41,7 +41,7 @@ dependencies = [
|
|
|
41
41
|
"fastar >= 0.10.0",
|
|
42
42
|
"detect-installer>=0.1.0",
|
|
43
43
|
]
|
|
44
|
-
version = "0.
|
|
44
|
+
version = "0.20.0"
|
|
45
45
|
|
|
46
46
|
[project.license]
|
|
47
47
|
text = "MIT"
|
|
@@ -61,12 +61,12 @@ Changelog = "https://github.com/fastapilabs/fastapi-cloud-cli/blob/main/release-
|
|
|
61
61
|
[dependency-groups]
|
|
62
62
|
dev = [
|
|
63
63
|
"prek>=0.2.24,<1.0.0",
|
|
64
|
-
"pytest>=7.0.0,<
|
|
64
|
+
"pytest>=7.0.0,<10.0.0",
|
|
65
65
|
"coverage[toml]>=7.2,<8.0",
|
|
66
|
-
"mypy==1.
|
|
67
|
-
"ruff==0.
|
|
68
|
-
"respx==0.
|
|
69
|
-
"time-machine==2.
|
|
66
|
+
"mypy==2.1.0",
|
|
67
|
+
"ruff==0.15.15",
|
|
68
|
+
"respx==0.23.1",
|
|
69
|
+
"time-machine==2.19.0",
|
|
70
70
|
"ty>=0.0.25",
|
|
71
71
|
"zizmor>=1.24.1",
|
|
72
72
|
]
|
|
@@ -167,3 +167,14 @@ known-third-party = [
|
|
|
167
167
|
|
|
168
168
|
[tool.ruff.lint.pyupgrade]
|
|
169
169
|
keep-runtime-typing = true
|
|
170
|
+
|
|
171
|
+
[tool.typos.files]
|
|
172
|
+
extend-exclude = [
|
|
173
|
+
"release-notes.md",
|
|
174
|
+
"uv.lock",
|
|
175
|
+
"coverage/",
|
|
176
|
+
"htmlcov/",
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
[tool.typos.default.extend-identifiers]
|
|
180
|
+
alls = "alls"
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Prepare a release by updating the package version and release notes."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import date
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
VERSION_PATTERN = re.compile(r'(?m)^__version__ = "(\d+\.\d+\.\d+)"$')
|
|
12
|
+
VERSION_HEADING_PATTERN = re.compile(r"(?m)^## (\d+\.\d+\.\d+)(?: \([^)]+\))?$")
|
|
13
|
+
RELEASE_NOTES_HEADER = "# Release Notes\n\n"
|
|
14
|
+
LATEST_CHANGES_HEADER = "## Latest Changes"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BumpType(str, Enum):
|
|
18
|
+
major = "major"
|
|
19
|
+
minor = "minor"
|
|
20
|
+
patch = "patch"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
app = typer.Typer()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_version(version: str) -> tuple[int, int, int]:
|
|
27
|
+
match = re.fullmatch(r"\d+\.\d+\.\d+", version)
|
|
28
|
+
if not match:
|
|
29
|
+
raise ValueError(f"Invalid version: {version!r}. Expected format: X.Y.Z")
|
|
30
|
+
major, minor, patch = version.split(".")
|
|
31
|
+
return int(major), int(minor), int(patch)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_current_version(content: str, version_file: Path) -> str:
|
|
35
|
+
matches = list(VERSION_PATTERN.finditer(content))
|
|
36
|
+
if len(matches) != 1:
|
|
37
|
+
raise RuntimeError(
|
|
38
|
+
f"Expected exactly one __version__ assignment in {version_file}, "
|
|
39
|
+
f"found {len(matches)}"
|
|
40
|
+
)
|
|
41
|
+
return matches[0].group(1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def bump_version(version: str, bump: BumpType) -> str:
|
|
45
|
+
major, minor, patch = parse_version(version)
|
|
46
|
+
if bump == BumpType.major:
|
|
47
|
+
return f"{major + 1}.0.0"
|
|
48
|
+
if bump == BumpType.minor:
|
|
49
|
+
return f"{major}.{minor + 1}.0"
|
|
50
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def update_version_file(content: str, version: str, version_file: Path) -> str:
|
|
54
|
+
current_version = get_current_version(content, version_file)
|
|
55
|
+
if parse_version(version) <= parse_version(current_version):
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
f"New version {version} must be greater than current version {current_version}"
|
|
58
|
+
)
|
|
59
|
+
return VERSION_PATTERN.sub(f'__version__ = "{version}"', content, count=1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def update_release_notes(
|
|
63
|
+
content: str, version: str, release_date: date, release_notes_file: Path
|
|
64
|
+
) -> str:
|
|
65
|
+
if not content.startswith(RELEASE_NOTES_HEADER):
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
f"{release_notes_file} must start with {RELEASE_NOTES_HEADER!r}"
|
|
68
|
+
)
|
|
69
|
+
if re.search(rf"^## {re.escape(version)}(?: \([^)]+\))?$", content, re.M):
|
|
70
|
+
raise RuntimeError(f"Release notes already contain a section for {version}")
|
|
71
|
+
|
|
72
|
+
latest_header = f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n"
|
|
73
|
+
if not content.startswith(latest_header):
|
|
74
|
+
raise RuntimeError(f"{release_notes_file} must start with {latest_header!r}")
|
|
75
|
+
|
|
76
|
+
release_header = f"## {version} ({release_date.isoformat()})"
|
|
77
|
+
return content.replace(
|
|
78
|
+
latest_header,
|
|
79
|
+
f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n\n{release_header}\n",
|
|
80
|
+
1,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_release_notes_body(content: str, version: str, release_notes_file: Path) -> str:
|
|
85
|
+
version_heading = re.compile(rf"(?m)^## {re.escape(version)}(?: \([^)]+\))?$")
|
|
86
|
+
match = version_heading.search(content)
|
|
87
|
+
if not match:
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
f"Could not find release notes section for {version} in {release_notes_file}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
next_match = VERSION_HEADING_PATTERN.search(content, match.end())
|
|
93
|
+
end = next_match.start() if next_match else len(content)
|
|
94
|
+
body = content[match.end() : end].strip()
|
|
95
|
+
if not body:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"Release notes section for {version} in {release_notes_file} is empty"
|
|
98
|
+
)
|
|
99
|
+
return f"{body}\n"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def prepare(
|
|
104
|
+
bump: Annotated[
|
|
105
|
+
BumpType,
|
|
106
|
+
typer.Argument(
|
|
107
|
+
envvar="PREPARE_RELEASE_BUMP",
|
|
108
|
+
help="The release bump to make: major, minor, or patch.",
|
|
109
|
+
),
|
|
110
|
+
],
|
|
111
|
+
version_file: Annotated[
|
|
112
|
+
Path,
|
|
113
|
+
typer.Option(
|
|
114
|
+
envvar="PREPARE_RELEASE_VERSION_FILE",
|
|
115
|
+
exists=True,
|
|
116
|
+
file_okay=True,
|
|
117
|
+
dir_okay=False,
|
|
118
|
+
readable=True,
|
|
119
|
+
writable=True,
|
|
120
|
+
help="Path to the Python file containing the __version__ assignment.",
|
|
121
|
+
),
|
|
122
|
+
],
|
|
123
|
+
release_notes_file: Annotated[
|
|
124
|
+
Path,
|
|
125
|
+
typer.Option(
|
|
126
|
+
envvar="PREPARE_RELEASE_RELEASE_NOTES_FILE",
|
|
127
|
+
exists=True,
|
|
128
|
+
file_okay=True,
|
|
129
|
+
dir_okay=False,
|
|
130
|
+
readable=True,
|
|
131
|
+
writable=True,
|
|
132
|
+
help="Path to the release notes Markdown file.",
|
|
133
|
+
),
|
|
134
|
+
],
|
|
135
|
+
release_date: Annotated[
|
|
136
|
+
str,
|
|
137
|
+
typer.Option(
|
|
138
|
+
"--date",
|
|
139
|
+
envvar="PREPARE_RELEASE_DATE",
|
|
140
|
+
help="Release date in YYYY-MM-DD format. Defaults to today.",
|
|
141
|
+
),
|
|
142
|
+
] = date.today().isoformat(),
|
|
143
|
+
) -> None:
|
|
144
|
+
parsed_release_date = date.fromisoformat(release_date or date.today().isoformat())
|
|
145
|
+
|
|
146
|
+
version_file_content = version_file.read_text()
|
|
147
|
+
release_notes_content = release_notes_file.read_text()
|
|
148
|
+
version = bump_version(
|
|
149
|
+
get_current_version(version_file_content, version_file), bump
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
version_file.write_text(
|
|
153
|
+
update_version_file(version_file_content, version, version_file)
|
|
154
|
+
)
|
|
155
|
+
release_notes_file.write_text(
|
|
156
|
+
update_release_notes(
|
|
157
|
+
release_notes_content, version, parsed_release_date, release_notes_file
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
typer.echo(f"Prepared release {version} ({parsed_release_date.isoformat()})")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command()
|
|
165
|
+
def current_version(
|
|
166
|
+
version_file: Annotated[
|
|
167
|
+
Path,
|
|
168
|
+
typer.Option(
|
|
169
|
+
envvar="PREPARE_RELEASE_VERSION_FILE",
|
|
170
|
+
exists=True,
|
|
171
|
+
file_okay=True,
|
|
172
|
+
dir_okay=False,
|
|
173
|
+
readable=True,
|
|
174
|
+
help="Path to the Python file containing the __version__ assignment.",
|
|
175
|
+
),
|
|
176
|
+
],
|
|
177
|
+
) -> None:
|
|
178
|
+
typer.echo(get_current_version(version_file.read_text(), version_file))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command()
|
|
182
|
+
def release_notes(
|
|
183
|
+
version_file: Annotated[
|
|
184
|
+
Path,
|
|
185
|
+
typer.Option(
|
|
186
|
+
envvar="PREPARE_RELEASE_VERSION_FILE",
|
|
187
|
+
exists=True,
|
|
188
|
+
file_okay=True,
|
|
189
|
+
dir_okay=False,
|
|
190
|
+
readable=True,
|
|
191
|
+
help="Path to the Python file containing the __version__ assignment.",
|
|
192
|
+
),
|
|
193
|
+
],
|
|
194
|
+
release_notes_file: Annotated[
|
|
195
|
+
Path,
|
|
196
|
+
typer.Option(
|
|
197
|
+
envvar="PREPARE_RELEASE_RELEASE_NOTES_FILE",
|
|
198
|
+
exists=True,
|
|
199
|
+
file_okay=True,
|
|
200
|
+
dir_okay=False,
|
|
201
|
+
readable=True,
|
|
202
|
+
help="Path to the release notes Markdown file.",
|
|
203
|
+
),
|
|
204
|
+
],
|
|
205
|
+
) -> None:
|
|
206
|
+
version = get_current_version(version_file.read_text(), version_file)
|
|
207
|
+
typer.echo(
|
|
208
|
+
get_release_notes_body(
|
|
209
|
+
release_notes_file.read_text(), version, release_notes_file
|
|
210
|
+
),
|
|
211
|
+
nl=False,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.20.0"
|
|
@@ -4,14 +4,18 @@ import typer
|
|
|
4
4
|
from rich import print
|
|
5
5
|
|
|
6
6
|
from . import __version__
|
|
7
|
+
from .commands.apps import apps_app
|
|
8
|
+
from .commands.apps.link import link_app
|
|
9
|
+
from .commands.apps.unlink import unlink_app
|
|
10
|
+
from .commands.auth import auth_app
|
|
7
11
|
from .commands.deploy import deploy
|
|
12
|
+
from .commands.deployments import deployments_app
|
|
8
13
|
from .commands.env import env_app
|
|
9
|
-
from .commands.link import link
|
|
10
14
|
from .commands.login import login
|
|
11
15
|
from .commands.logout import logout
|
|
12
16
|
from .commands.logs import logs
|
|
13
17
|
from .commands.setup_ci import setup_ci
|
|
14
|
-
from .commands.
|
|
18
|
+
from .commands.teams import teams_app
|
|
15
19
|
from .commands.whoami import whoami
|
|
16
20
|
from .logging import setup_logging
|
|
17
21
|
from .utils.sentry import init_sentry
|
|
@@ -29,7 +33,7 @@ def version_callback(value: bool) -> None:
|
|
|
29
33
|
|
|
30
34
|
cloud_app = typer.Typer(
|
|
31
35
|
rich_markup_mode="rich",
|
|
32
|
-
help="Manage [bold]FastAPI[/bold] Cloud deployments.
|
|
36
|
+
help="Manage [bold]FastAPI[/bold] Cloud deployments.",
|
|
33
37
|
no_args_is_help=True,
|
|
34
38
|
)
|
|
35
39
|
|
|
@@ -54,15 +58,19 @@ def cloud_main(
|
|
|
54
58
|
|
|
55
59
|
# fastapi cloud [command]
|
|
56
60
|
cloud_app.command()(deploy)
|
|
57
|
-
cloud_app.command()(
|
|
61
|
+
cloud_app.command("link")(link_app)
|
|
58
62
|
cloud_app.command()(login)
|
|
59
63
|
cloud_app.command()(logs)
|
|
60
64
|
cloud_app.command()(logout)
|
|
61
65
|
cloud_app.command()(whoami)
|
|
62
|
-
cloud_app.command()(
|
|
66
|
+
cloud_app.command("unlink")(unlink_app)
|
|
63
67
|
cloud_app.command()(setup_ci)
|
|
64
68
|
|
|
65
69
|
cloud_app.add_typer(env_app, name="env")
|
|
70
|
+
cloud_app.add_typer(auth_app, name="auth")
|
|
71
|
+
cloud_app.add_typer(apps_app, name="apps")
|
|
72
|
+
cloud_app.add_typer(deployments_app, name="deployments")
|
|
73
|
+
cloud_app.add_typer(teams_app, name="teams")
|
|
66
74
|
|
|
67
75
|
# fastapi [command]
|
|
68
76
|
app.command()(deploy)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from rich_toolkit import RichToolkit
|
|
7
|
+
from rich_toolkit.progress import Progress
|
|
8
|
+
|
|
9
|
+
from fastapi_cloud_cli.config import Settings
|
|
10
|
+
from fastapi_cloud_cli.utils.api import APIClient
|
|
11
|
+
from fastapi_cloud_cli.utils.auth import AuthConfig, AuthMode, write_auth_config
|
|
12
|
+
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
DEFAULT_LOGIN_TIMEOUT_SECONDS = 300
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthorizationData(BaseModel):
|
|
20
|
+
user_code: str
|
|
21
|
+
device_code: str
|
|
22
|
+
verification_uri: str
|
|
23
|
+
verification_uri_complete: str
|
|
24
|
+
interval: int = 5
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TokenResponse(BaseModel):
|
|
28
|
+
access_token: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LoginOutput(BaseModel):
|
|
32
|
+
authenticated: bool
|
|
33
|
+
auth_mode: AuthMode
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DeviceAuthorizationOutput(BaseModel):
|
|
37
|
+
verification_uri: str
|
|
38
|
+
verification_uri_complete: str
|
|
39
|
+
user_code: str
|
|
40
|
+
device_code: str
|
|
41
|
+
interval: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LoginTimeoutError(Exception):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def render_login_output(data: LoginOutput, toolkit: RichToolkit) -> None:
|
|
49
|
+
toolkit.print("Now you are logged in! 🚀")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def device_authorization_output(
|
|
53
|
+
authorization_data: AuthorizationData,
|
|
54
|
+
) -> DeviceAuthorizationOutput:
|
|
55
|
+
return DeviceAuthorizationOutput(
|
|
56
|
+
verification_uri=authorization_data.verification_uri,
|
|
57
|
+
verification_uri_complete=authorization_data.verification_uri_complete,
|
|
58
|
+
user_code=authorization_data.user_code,
|
|
59
|
+
device_code=authorization_data.device_code,
|
|
60
|
+
interval=authorization_data.interval,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def start_device_authorization(
|
|
65
|
+
client: httpx.Client,
|
|
66
|
+
) -> AuthorizationData:
|
|
67
|
+
settings = Settings.get()
|
|
68
|
+
|
|
69
|
+
response = client.post(
|
|
70
|
+
"/login/device/authorization", data={"client_id": settings.client_id}
|
|
71
|
+
)
|
|
72
|
+
logger.debug(f"Device authorization response status code: {response.status_code}")
|
|
73
|
+
|
|
74
|
+
response.raise_for_status()
|
|
75
|
+
|
|
76
|
+
return AuthorizationData.model_validate_json(response.text)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fetch_access_token(
|
|
80
|
+
client: httpx.Client,
|
|
81
|
+
device_code: str,
|
|
82
|
+
interval: int,
|
|
83
|
+
timeout: int = DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
|
84
|
+
) -> str:
|
|
85
|
+
settings = Settings.get()
|
|
86
|
+
start = time.monotonic()
|
|
87
|
+
|
|
88
|
+
logger.debug("Starting to poll for access token")
|
|
89
|
+
while True:
|
|
90
|
+
response = client.post(
|
|
91
|
+
"/login/device/token",
|
|
92
|
+
data={
|
|
93
|
+
"device_code": device_code,
|
|
94
|
+
"client_id": settings.client_id,
|
|
95
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
logger.debug(f"Token response status code: {response.status_code}")
|
|
99
|
+
|
|
100
|
+
if response.status_code not in (200, 400):
|
|
101
|
+
response.raise_for_status()
|
|
102
|
+
|
|
103
|
+
if response.status_code == 400:
|
|
104
|
+
data = response.json()
|
|
105
|
+
error = data.get("error")
|
|
106
|
+
logger.debug(f"Token response error: {error}")
|
|
107
|
+
|
|
108
|
+
if error != "authorization_pending":
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
|
|
111
|
+
if response.status_code == 200:
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
remaining = timeout - (time.monotonic() - start)
|
|
115
|
+
if remaining <= 0:
|
|
116
|
+
raise LoginTimeoutError
|
|
117
|
+
|
|
118
|
+
sleep_for = min(interval, remaining)
|
|
119
|
+
|
|
120
|
+
logger.debug(f"Sleeping for {sleep_for} seconds before retrying...")
|
|
121
|
+
time.sleep(sleep_for)
|
|
122
|
+
|
|
123
|
+
response_data = TokenResponse.model_validate_json(response.text)
|
|
124
|
+
logger.debug("Access token received successfully.")
|
|
125
|
+
|
|
126
|
+
return response_data.access_token
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def complete_device_login(
|
|
130
|
+
*,
|
|
131
|
+
client: APIClient,
|
|
132
|
+
progress: Progress,
|
|
133
|
+
toolkit: FastAPIRichToolkit,
|
|
134
|
+
device_code: str,
|
|
135
|
+
interval: int,
|
|
136
|
+
timeout: int,
|
|
137
|
+
cancel_hint: str,
|
|
138
|
+
) -> LoginOutput:
|
|
139
|
+
try:
|
|
140
|
+
with client.handle_http_errors(progress, toolkit=toolkit):
|
|
141
|
+
access_token = fetch_access_token(client, device_code, interval, timeout)
|
|
142
|
+
except LoginTimeoutError:
|
|
143
|
+
message = "Login timed out before authorization completed."
|
|
144
|
+
toolkit.fail(
|
|
145
|
+
"timeout",
|
|
146
|
+
message,
|
|
147
|
+
hint="Try again with a longer --timeout value.",
|
|
148
|
+
)
|
|
149
|
+
except KeyboardInterrupt:
|
|
150
|
+
message = "Login cancelled before authorization completed."
|
|
151
|
+
toolkit.fail(
|
|
152
|
+
"cancelled",
|
|
153
|
+
message,
|
|
154
|
+
hint=cancel_hint,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
write_auth_config(AuthConfig(access_token=access_token))
|
|
158
|
+
|
|
159
|
+
return LoginOutput(authenticated=True, auth_mode="user")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from fastapi_cloud_cli.commands.apps.create import create_app
|
|
4
|
+
from fastapi_cloud_cli.commands.apps.get import get_app
|
|
5
|
+
from fastapi_cloud_cli.commands.apps.link import link_app
|
|
6
|
+
from fastapi_cloud_cli.commands.apps.list import list_apps
|
|
7
|
+
from fastapi_cloud_cli.commands.apps.unlink import unlink_app
|
|
8
|
+
from fastapi_cloud_cli.commands.logs import logs
|
|
9
|
+
|
|
10
|
+
apps_app = typer.Typer(
|
|
11
|
+
no_args_is_help=True,
|
|
12
|
+
help="Manage your FastAPI Cloud apps.",
|
|
13
|
+
)
|
|
14
|
+
apps_app.command("create")(create_app)
|
|
15
|
+
apps_app.command("get")(get_app)
|
|
16
|
+
apps_app.command("link")(link_app)
|
|
17
|
+
apps_app.command("list")(list_apps)
|
|
18
|
+
apps_app.command("logs")(logs)
|
|
19
|
+
apps_app.command("unlink")(unlink_app)
|
|
20
|
+
|
|
21
|
+
__all__ = ["apps_app"]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
from rich_toolkit import RichToolkit
|
|
8
|
+
|
|
9
|
+
from fastapi_cloud_cli.commands.apps.list import _prompt_for_team
|
|
10
|
+
from fastapi_cloud_cli.commands.deploy.archive import (
|
|
11
|
+
_get_app_name,
|
|
12
|
+
validate_app_directory,
|
|
13
|
+
)
|
|
14
|
+
from fastapi_cloud_cli.utils.api import APIClient
|
|
15
|
+
from fastapi_cloud_cli.utils.apps import AppConfig, write_app_config
|
|
16
|
+
from fastapi_cloud_cli.utils.auth import Identity
|
|
17
|
+
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
|
18
|
+
from fastapi_cloud_cli.utils.execution import JsonOutputOption
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CreatedApp(BaseModel):
|
|
24
|
+
id: str
|
|
25
|
+
team_id: str
|
|
26
|
+
slug: str
|
|
27
|
+
name: str
|
|
28
|
+
directory: str | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AppsCreateOutput(BaseModel):
|
|
32
|
+
app: CreatedApp
|
|
33
|
+
linked: bool
|
|
34
|
+
path_to_link: Annotated[Path | None, Field(exclude=True)] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _create_app(
|
|
38
|
+
client: APIClient, *, team_id: str, name: str, directory: str | None
|
|
39
|
+
) -> CreatedApp:
|
|
40
|
+
response = client.post(
|
|
41
|
+
"/apps/",
|
|
42
|
+
json={"team_id": team_id, "name": name, "directory": directory},
|
|
43
|
+
)
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
|
|
46
|
+
return CreatedApp.model_validate(response.json())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _render_apps_create_output(data: AppsCreateOutput, toolkit: RichToolkit) -> None:
|
|
50
|
+
toolkit.print(f"Created app [bold]{data.app.name}[/bold]", bullet=False)
|
|
51
|
+
|
|
52
|
+
if data.linked and data.path_to_link is not None:
|
|
53
|
+
toolkit.print(
|
|
54
|
+
f"Linked [bold]{data.path_to_link}[/bold] to [bold]{data.app.name}[/bold]",
|
|
55
|
+
bullet=False,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_app(
|
|
60
|
+
team_id: Annotated[
|
|
61
|
+
str | None,
|
|
62
|
+
typer.Option(
|
|
63
|
+
"--team-id",
|
|
64
|
+
help="ID of the team where the app should be created.",
|
|
65
|
+
),
|
|
66
|
+
] = None,
|
|
67
|
+
name: Annotated[
|
|
68
|
+
str | None,
|
|
69
|
+
typer.Option(
|
|
70
|
+
"--name",
|
|
71
|
+
help="Name of the app to create.",
|
|
72
|
+
),
|
|
73
|
+
] = None,
|
|
74
|
+
directory: Annotated[
|
|
75
|
+
str | None,
|
|
76
|
+
typer.Option(
|
|
77
|
+
"--directory",
|
|
78
|
+
help="Directory containing the app's pyproject.toml.",
|
|
79
|
+
),
|
|
80
|
+
] = None,
|
|
81
|
+
link: Annotated[
|
|
82
|
+
bool | None,
|
|
83
|
+
typer.Option(
|
|
84
|
+
"--link/--no-link",
|
|
85
|
+
help="Link the local directory to the created app.",
|
|
86
|
+
),
|
|
87
|
+
] = None,
|
|
88
|
+
path: Annotated[
|
|
89
|
+
Path | None,
|
|
90
|
+
typer.Option(
|
|
91
|
+
"--path",
|
|
92
|
+
help="Directory to link when --link is enabled.",
|
|
93
|
+
),
|
|
94
|
+
] = None,
|
|
95
|
+
json_output: JsonOutputOption = False,
|
|
96
|
+
) -> Any:
|
|
97
|
+
"""
|
|
98
|
+
Create a FastAPI Cloud app.
|
|
99
|
+
"""
|
|
100
|
+
identity = Identity()
|
|
101
|
+
path_to_link = path or Path.cwd()
|
|
102
|
+
|
|
103
|
+
# JSON output is non-interactive, so it defaults to create-only unless --link is explicit.
|
|
104
|
+
link_app = link if link is not None else not json_output
|
|
105
|
+
|
|
106
|
+
with get_rich_toolkit(json_output=json_output) as toolkit:
|
|
107
|
+
if not identity.is_logged_in():
|
|
108
|
+
toolkit.fail(
|
|
109
|
+
"not_logged_in",
|
|
110
|
+
"No credentials found.",
|
|
111
|
+
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if not link_app and path is not None:
|
|
115
|
+
toolkit.fail(
|
|
116
|
+
"invalid_input",
|
|
117
|
+
"Path can only be used when linking.",
|
|
118
|
+
hint="Pass --link or omit --path.",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
with APIClient() as client:
|
|
122
|
+
if team_id is None:
|
|
123
|
+
if json_output:
|
|
124
|
+
toolkit.fail(
|
|
125
|
+
"missing_required_input",
|
|
126
|
+
"Team ID is required.",
|
|
127
|
+
hint="Pass --team-id to choose a team.",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
team = _prompt_for_team(toolkit, client)
|
|
131
|
+
team_id = team.id
|
|
132
|
+
toolkit.print_line()
|
|
133
|
+
|
|
134
|
+
if name is None:
|
|
135
|
+
if json_output:
|
|
136
|
+
toolkit.fail(
|
|
137
|
+
"missing_required_input",
|
|
138
|
+
"App name is required.",
|
|
139
|
+
hint="Pass --name to choose an app name.",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
name = toolkit.input(
|
|
143
|
+
title="What's your app name?",
|
|
144
|
+
default=_get_app_name(path_to_link),
|
|
145
|
+
bullet=False,
|
|
146
|
+
)
|
|
147
|
+
toolkit.print_line()
|
|
148
|
+
|
|
149
|
+
directory = validate_app_directory(directory)
|
|
150
|
+
|
|
151
|
+
with toolkit.progress(
|
|
152
|
+
title="Creating app",
|
|
153
|
+
transient=True,
|
|
154
|
+
) as progress:
|
|
155
|
+
with client.handle_http_errors(
|
|
156
|
+
progress,
|
|
157
|
+
default_message="Error creating app. Please try again later.",
|
|
158
|
+
toolkit=toolkit,
|
|
159
|
+
):
|
|
160
|
+
app = _create_app(
|
|
161
|
+
client,
|
|
162
|
+
team_id=team_id,
|
|
163
|
+
name=name,
|
|
164
|
+
directory=directory,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if link_app:
|
|
168
|
+
write_app_config(
|
|
169
|
+
path_to_link,
|
|
170
|
+
AppConfig(app_id=app.id, team_id=app.team_id),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
result = AppsCreateOutput(
|
|
174
|
+
app=app,
|
|
175
|
+
linked=link_app,
|
|
176
|
+
path_to_link=path_to_link if link_app else None,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
toolkit.success(result, render_output=_render_apps_create_output)
|