fastapi-cloud-cli 0.17.1__tar.gz → 0.19.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.17.1 → fastapi_cloud_cli-0.19.0}/PKG-INFO +2 -1
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/pyproject.toml +10 -2
- fastapi_cloud_cli-0.19.0/scripts/prepare_release.py +216 -0
- fastapi_cloud_cli-0.19.0/src/fastapi_cloud_cli/__init__.py +1 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/deploy.py +63 -10
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/env.py +59 -10
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/login.py +33 -23
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/logs.py +4 -1
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/setup_ci.py +4 -1
- fastapi_cloud_cli-0.19.0/src/fastapi_cloud_cli/commands/whoami.py +37 -0
- fastapi_cloud_cli-0.19.0/src/fastapi_cloud_cli/utils/cli.py +128 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/config.py +4 -0
- fastapi_cloud_cli-0.19.0/src/fastapi_cloud_cli/utils/dates.py +45 -0
- fastapi_cloud_cli-0.19.0/src/fastapi_cloud_cli/utils/version_check.py +187 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/conftest.py +1 -0
- fastapi_cloud_cli-0.19.0/tests/test_cli.py +84 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_deploy.py +159 -0
- fastapi_cloud_cli-0.19.0/tests/test_dates.py +27 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_deploy_utils.py +52 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_env_list.py +83 -0
- fastapi_cloud_cli-0.19.0/tests/test_prepare_release.py +298 -0
- fastapi_cloud_cli-0.19.0/tests/test_version_check.py +381 -0
- fastapi_cloud_cli-0.17.1/src/fastapi_cloud_cli/__init__.py +0 -1
- fastapi_cloud_cli-0.17.1/src/fastapi_cloud_cli/commands/whoami.py +0 -33
- fastapi_cloud_cli-0.17.1/src/fastapi_cloud_cli/utils/cli.py +0 -68
- fastapi_cloud_cli-0.17.1/tests/test_cli.py +0 -37
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/LICENSE +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/README.md +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/scripts/add_latest_release_date.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/scripts/format.sh +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/scripts/lint.sh +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/scripts/test-cov-html.sh +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/scripts/test.sh +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/__main__.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/cli.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/link.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/config.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/logging.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/py.typed +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/api.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/auth.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/progress_file.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/__init__.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_api_client.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_archive.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_auth.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_link.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_login.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_logout.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_setup_ci.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_unlink.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_whoami.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_config.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_env_delete.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_env_set.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_logs.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_progress_file.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_sentry.py +0 -0
- {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.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.19.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: pydantic[email]>=2.8.0; python_version == "3.13"
|
|
|
39
39
|
Requires-Dist: pydantic[email]>=2.12.0; python_version >= "3.14"
|
|
40
40
|
Requires-Dist: sentry-sdk>=2.20.0
|
|
41
41
|
Requires-Dist: fastar>=0.10.0
|
|
42
|
+
Requires-Dist: detect-installer>=0.1.0
|
|
42
43
|
Provides-Extra: standard
|
|
43
44
|
Requires-Dist: uvicorn[standard]>=0.15.0; extra == "standard"
|
|
44
45
|
Description-Content-Type: text/markdown
|
|
@@ -39,8 +39,9 @@ dependencies = [
|
|
|
39
39
|
"pydantic[email] >= 2.12.0; python_version >= '3.14'",
|
|
40
40
|
"sentry-sdk >= 2.20.0",
|
|
41
41
|
"fastar >= 0.10.0",
|
|
42
|
+
"detect-installer>=0.1.0",
|
|
42
43
|
]
|
|
43
|
-
version = "0.
|
|
44
|
+
version = "0.19.0"
|
|
44
45
|
|
|
45
46
|
[project.license]
|
|
46
47
|
text = "MIT"
|
|
@@ -66,7 +67,11 @@ dev = [
|
|
|
66
67
|
"ruff==0.13.0",
|
|
67
68
|
"respx==0.22.0",
|
|
68
69
|
"time-machine==2.15.0",
|
|
69
|
-
"ty>=0.0.
|
|
70
|
+
"ty>=0.0.25",
|
|
71
|
+
"zizmor>=1.24.1",
|
|
72
|
+
]
|
|
73
|
+
github-actions = [
|
|
74
|
+
"smokeshow>=0.5.0",
|
|
70
75
|
]
|
|
71
76
|
|
|
72
77
|
[build-system]
|
|
@@ -134,6 +139,9 @@ exclude = [
|
|
|
134
139
|
"tests/assets/**",
|
|
135
140
|
]
|
|
136
141
|
|
|
142
|
+
[tool.ty.terminal]
|
|
143
|
+
error-on-warning = true
|
|
144
|
+
|
|
137
145
|
[tool.ruff.lint]
|
|
138
146
|
select = [
|
|
139
147
|
"E",
|
|
@@ -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.19.0"
|
{fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/deploy.py
RENAMED
|
@@ -108,15 +108,19 @@ def _should_exclude_entry(path: Path) -> bool:
|
|
|
108
108
|
return False
|
|
109
109
|
|
|
110
110
|
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
files = rignore.walk(
|
|
111
|
+
def _rignore_walk(path: Path) -> rignore.Walker:
|
|
112
|
+
return rignore.walk(
|
|
114
113
|
path,
|
|
115
114
|
should_exclude_entry=_should_exclude_entry,
|
|
116
115
|
additional_ignore_paths=[".fastapicloudignore"],
|
|
117
116
|
ignore_hidden=False,
|
|
118
117
|
)
|
|
119
118
|
|
|
119
|
+
|
|
120
|
+
def archive(path: Path, tar_path: Path) -> Path:
|
|
121
|
+
logger.debug("Starting archive creation for path: %s", path)
|
|
122
|
+
files = _rignore_walk(path)
|
|
123
|
+
|
|
120
124
|
logger.debug("Archive will be created at: %s", tar_path)
|
|
121
125
|
|
|
122
126
|
file_count = 0
|
|
@@ -134,6 +138,20 @@ def archive(path: Path, tar_path: Path) -> Path:
|
|
|
134
138
|
return tar_path
|
|
135
139
|
|
|
136
140
|
|
|
141
|
+
def _get_large_files(path: Path, threshold_mb: int) -> list[tuple[Path, int]]:
|
|
142
|
+
threshold_bytes = threshold_mb * 1024 * 1024
|
|
143
|
+
large_files = []
|
|
144
|
+
files = _rignore_walk(path)
|
|
145
|
+
for filename in files:
|
|
146
|
+
if filename.is_dir():
|
|
147
|
+
continue
|
|
148
|
+
file_size = filename.stat().st_size
|
|
149
|
+
if file_size > threshold_bytes:
|
|
150
|
+
large_files.append((filename.relative_to(path), file_size))
|
|
151
|
+
|
|
152
|
+
return sorted(large_files, key=lambda x: x[1], reverse=True)
|
|
153
|
+
|
|
154
|
+
|
|
137
155
|
class Team(BaseModel):
|
|
138
156
|
id: str
|
|
139
157
|
slug: str
|
|
@@ -263,8 +281,8 @@ def _upload_deployment(
|
|
|
263
281
|
logger.debug("Upload notification sent successfully")
|
|
264
282
|
|
|
265
283
|
|
|
266
|
-
def _get_app(client: APIClient,
|
|
267
|
-
response = client.get(f"/apps/{
|
|
284
|
+
def _get_app(client: APIClient, app_id: str) -> AppResponse | None:
|
|
285
|
+
response = client.get(f"/apps/{app_id}")
|
|
268
286
|
|
|
269
287
|
if response.status_code == 404:
|
|
270
288
|
return None
|
|
@@ -378,10 +396,12 @@ def _configure_app(
|
|
|
378
396
|
initial_directory = selected_app.directory if selected_app else ""
|
|
379
397
|
|
|
380
398
|
directory_input = toolkit.input(
|
|
381
|
-
title="
|
|
399
|
+
title=("Directory where your app's pyproject.toml file lives (e.g. backend):"),
|
|
382
400
|
tag="dir",
|
|
383
401
|
value=initial_directory or "",
|
|
384
|
-
placeholder=
|
|
402
|
+
placeholder=(
|
|
403
|
+
"[italic]Leave empty if pyproject.toml is in the current directory[/italic]"
|
|
404
|
+
),
|
|
385
405
|
validator=TypeAdapter(AppDirectory),
|
|
386
406
|
)
|
|
387
407
|
|
|
@@ -665,7 +685,10 @@ def deploy(
|
|
|
665
685
|
path: Annotated[
|
|
666
686
|
Path | None,
|
|
667
687
|
typer.Argument(
|
|
668
|
-
help=
|
|
688
|
+
help=(
|
|
689
|
+
"Path to the directory with your app's pyproject.toml "
|
|
690
|
+
"(defaults to current directory)"
|
|
691
|
+
)
|
|
669
692
|
),
|
|
670
693
|
] = None,
|
|
671
694
|
skip_wait: Annotated[
|
|
@@ -679,6 +702,14 @@ def deploy(
|
|
|
679
702
|
envvar="FASTAPI_CLOUD_APP_ID",
|
|
680
703
|
),
|
|
681
704
|
] = None,
|
|
705
|
+
large_file_threshold: Annotated[
|
|
706
|
+
int,
|
|
707
|
+
typer.Option(
|
|
708
|
+
help="File size threshold in MB for warning about large files",
|
|
709
|
+
min=1,
|
|
710
|
+
envvar="FASTAPI_CLOUD_LARGE_FILE_THRESHOLD",
|
|
711
|
+
),
|
|
712
|
+
] = 10, # 10 MB
|
|
682
713
|
) -> Any:
|
|
683
714
|
"""
|
|
684
715
|
Deploy a [bold]FastAPI[/bold] app to FastAPI Cloud. 🚀
|
|
@@ -786,7 +817,7 @@ def deploy(
|
|
|
786
817
|
with toolkit.progress("Checking app...", transient=True) as progress:
|
|
787
818
|
with client.handle_http_errors(progress):
|
|
788
819
|
logger.debug("Checking app with ID: %s", target_app_id)
|
|
789
|
-
app = _get_app(client=client,
|
|
820
|
+
app = _get_app(client=client, app_id=target_app_id)
|
|
790
821
|
|
|
791
822
|
if not app:
|
|
792
823
|
logger.debug("App not found in API")
|
|
@@ -804,10 +835,32 @@ def deploy(
|
|
|
804
835
|
)
|
|
805
836
|
raise typer.Exit(1)
|
|
806
837
|
|
|
838
|
+
large_files = _get_large_files(
|
|
839
|
+
path_to_deploy, threshold_mb=large_file_threshold
|
|
840
|
+
)
|
|
841
|
+
if large_files:
|
|
842
|
+
toolkit.print(
|
|
843
|
+
f"⚠️ Some uploaded files are larger than {large_file_threshold} MB ⚖️ :",
|
|
844
|
+
tag="warning",
|
|
845
|
+
)
|
|
846
|
+
for fname, fsize in large_files[:3]:
|
|
847
|
+
fsize_mb = fsize // (1024 * 1024)
|
|
848
|
+
toolkit.print(f" • {fname} [yellow]({fsize_mb} MB)[/yellow]")
|
|
849
|
+
is_more = len(large_files) > 3
|
|
850
|
+
if is_more:
|
|
851
|
+
toolkit.print(f" [dim]...and {len(large_files) - 3} more[/dim]")
|
|
852
|
+
|
|
853
|
+
large_files_docs_url = "https://fastapicloud.com/docs/fastapi-cloud-cli/deploy/#large-files-warning"
|
|
854
|
+
toolkit.print(
|
|
855
|
+
f"Read more: [link={large_files_docs_url}]{large_files_docs_url}[/link]",
|
|
856
|
+
tag="tip",
|
|
857
|
+
)
|
|
858
|
+
toolkit.print_line()
|
|
859
|
+
|
|
807
860
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
808
861
|
logger.debug("Creating archive for deployment")
|
|
809
862
|
archive_path = Path(temp_dir) / "archive.tar"
|
|
810
|
-
archive(
|
|
863
|
+
archive(path_to_deploy, archive_path)
|
|
811
864
|
|
|
812
865
|
with (
|
|
813
866
|
toolkit.progress(
|
|
@@ -4,19 +4,27 @@ from typing import Annotated, Any
|
|
|
4
4
|
|
|
5
5
|
import typer
|
|
6
6
|
from pydantic import BaseModel
|
|
7
|
+
from rich import box
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
7
10
|
|
|
8
11
|
from fastapi_cloud_cli.utils.api import APIClient
|
|
9
12
|
from fastapi_cloud_cli.utils.apps import get_app_config
|
|
10
13
|
from fastapi_cloud_cli.utils.auth import Identity
|
|
11
14
|
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
|
15
|
+
from fastapi_cloud_cli.utils.dates import format_last_updated
|
|
12
16
|
from fastapi_cloud_cli.utils.env import validate_environment_variable_name
|
|
13
17
|
|
|
14
18
|
logger = logging.getLogger(__name__)
|
|
15
19
|
|
|
20
|
+
ENV_VAR_VALUE_MAX_LENGTH = 40
|
|
21
|
+
|
|
16
22
|
|
|
17
23
|
class EnvironmentVariable(BaseModel):
|
|
18
24
|
name: str
|
|
19
25
|
value: str | None = None
|
|
26
|
+
is_secret: bool = False
|
|
27
|
+
updated_at: str | None = None
|
|
20
28
|
|
|
21
29
|
|
|
22
30
|
class EnvironmentVariableResponse(BaseModel):
|
|
@@ -53,15 +61,54 @@ def _set_environment_variable(
|
|
|
53
61
|
response.raise_for_status()
|
|
54
62
|
|
|
55
63
|
|
|
64
|
+
def _format_env_var_value(env_var: EnvironmentVariable) -> Text:
|
|
65
|
+
if env_var.value is None:
|
|
66
|
+
placeholder = "[secret]" if env_var.is_secret else "-"
|
|
67
|
+
|
|
68
|
+
return Text(placeholder, style="dim")
|
|
69
|
+
|
|
70
|
+
value = env_var.value.replace("\r", "\\r").replace("\n", "\\n")
|
|
71
|
+
|
|
72
|
+
if len(value) > ENV_VAR_VALUE_MAX_LENGTH:
|
|
73
|
+
value = f"{value[: ENV_VAR_VALUE_MAX_LENGTH - 3]}..."
|
|
74
|
+
|
|
75
|
+
return Text(value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_environment_variables_table(
|
|
79
|
+
environment_variables: list[EnvironmentVariable],
|
|
80
|
+
) -> Table:
|
|
81
|
+
table = Table(
|
|
82
|
+
box=box.SIMPLE_HEAD,
|
|
83
|
+
pad_edge=False,
|
|
84
|
+
show_edge=False,
|
|
85
|
+
)
|
|
86
|
+
table.add_column("Key", no_wrap=True)
|
|
87
|
+
table.add_column("Value", overflow="ellipsis", max_width=ENV_VAR_VALUE_MAX_LENGTH)
|
|
88
|
+
table.add_column("Last updated", style="dim", no_wrap=True)
|
|
89
|
+
|
|
90
|
+
for env_var in environment_variables:
|
|
91
|
+
table.add_row(
|
|
92
|
+
Text(env_var.name),
|
|
93
|
+
_format_env_var_value(env_var),
|
|
94
|
+
Text(format_last_updated(env_var.updated_at)),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return table
|
|
98
|
+
|
|
99
|
+
|
|
56
100
|
env_app = typer.Typer()
|
|
57
101
|
|
|
58
102
|
|
|
59
|
-
@env_app.command()
|
|
60
|
-
def
|
|
103
|
+
@env_app.command("list")
|
|
104
|
+
def list_variables(
|
|
61
105
|
path: Annotated[
|
|
62
106
|
Path | None,
|
|
63
107
|
typer.Argument(
|
|
64
|
-
help=
|
|
108
|
+
help=(
|
|
109
|
+
"Path to the directory with your app's pyproject.toml "
|
|
110
|
+
"(defaults to current directory)"
|
|
111
|
+
)
|
|
65
112
|
),
|
|
66
113
|
] = None,
|
|
67
114
|
) -> Any:
|
|
@@ -103,11 +150,7 @@ def list(
|
|
|
103
150
|
toolkit.print("No environment variables found.")
|
|
104
151
|
return
|
|
105
152
|
|
|
106
|
-
toolkit.print(
|
|
107
|
-
toolkit.print_line()
|
|
108
|
-
|
|
109
|
-
for env_var in environment_variables.data:
|
|
110
|
-
toolkit.print(f"[bold]{env_var.name}[/]")
|
|
153
|
+
toolkit.print(_get_environment_variables_table(environment_variables.data))
|
|
111
154
|
|
|
112
155
|
|
|
113
156
|
@env_app.command()
|
|
@@ -119,7 +162,10 @@ def delete(
|
|
|
119
162
|
path: Annotated[
|
|
120
163
|
Path | None,
|
|
121
164
|
typer.Argument(
|
|
122
|
-
help=
|
|
165
|
+
help=(
|
|
166
|
+
"Path to the directory with your app's pyproject.toml "
|
|
167
|
+
"(defaults to current directory)"
|
|
168
|
+
)
|
|
123
169
|
),
|
|
124
170
|
] = None,
|
|
125
171
|
) -> Any:
|
|
@@ -208,7 +254,10 @@ def set(
|
|
|
208
254
|
path: Annotated[
|
|
209
255
|
Path | None,
|
|
210
256
|
typer.Argument(
|
|
211
|
-
help=
|
|
257
|
+
help=(
|
|
258
|
+
"Path to the directory with your app's pyproject.toml "
|
|
259
|
+
"(defaults to current directory)"
|
|
260
|
+
)
|
|
212
261
|
),
|
|
213
262
|
] = None,
|
|
214
263
|
secret: Annotated[
|
{fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/login.py
RENAMED
|
@@ -34,6 +34,7 @@ def _start_device_authorization(
|
|
|
34
34
|
response = client.post(
|
|
35
35
|
"/login/device/authorization", data={"client_id": settings.client_id}
|
|
36
36
|
)
|
|
37
|
+
logger.debug(f"Device authorization response status code: {response.status_code}")
|
|
37
38
|
|
|
38
39
|
response.raise_for_status()
|
|
39
40
|
|
|
@@ -43,6 +44,7 @@ def _start_device_authorization(
|
|
|
43
44
|
def _fetch_access_token(client: httpx.Client, device_code: str, interval: int) -> str:
|
|
44
45
|
settings = Settings.get()
|
|
45
46
|
|
|
47
|
+
logger.debug("Starting to poll for access token")
|
|
46
48
|
while True:
|
|
47
49
|
response = client.post(
|
|
48
50
|
"/login/device/token",
|
|
@@ -52,22 +54,27 @@ def _fetch_access_token(client: httpx.Client, device_code: str, interval: int) -
|
|
|
52
54
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
53
55
|
},
|
|
54
56
|
)
|
|
57
|
+
logger.debug(f"Token response status code: {response.status_code}")
|
|
55
58
|
|
|
56
59
|
if response.status_code not in (200, 400):
|
|
57
60
|
response.raise_for_status()
|
|
58
61
|
|
|
59
62
|
if response.status_code == 400:
|
|
60
63
|
data = response.json()
|
|
64
|
+
error = data.get("error")
|
|
65
|
+
logger.debug(f"Token response error: {error}")
|
|
61
66
|
|
|
62
|
-
if
|
|
67
|
+
if error != "authorization_pending":
|
|
63
68
|
response.raise_for_status()
|
|
64
69
|
|
|
65
70
|
if response.status_code == 200:
|
|
66
71
|
break
|
|
67
72
|
|
|
73
|
+
logger.debug(f"Sleeping for {interval} seconds before retrying...")
|
|
68
74
|
time.sleep(interval)
|
|
69
75
|
|
|
70
76
|
response_data = TokenResponse.model_validate_json(response.text)
|
|
77
|
+
logger.debug("Access token received successfully.")
|
|
71
78
|
|
|
72
79
|
return response_data.access_token
|
|
73
80
|
|
|
@@ -77,18 +84,18 @@ def login() -> Any:
|
|
|
77
84
|
Login to FastAPI Cloud. 🚀
|
|
78
85
|
"""
|
|
79
86
|
identity = Identity()
|
|
87
|
+
is_logged_in = identity.is_logged_in()
|
|
80
88
|
|
|
81
|
-
|
|
82
|
-
|
|
89
|
+
with get_rich_toolkit(minimal=is_logged_in) as toolkit:
|
|
90
|
+
if is_logged_in:
|
|
83
91
|
toolkit.print("You are already logged in.")
|
|
84
92
|
toolkit.print(
|
|
85
93
|
"Run [bold]fastapi cloud logout[/bold] first if you want to switch accounts."
|
|
86
94
|
)
|
|
87
95
|
|
|
88
|
-
|
|
96
|
+
return
|
|
89
97
|
|
|
90
|
-
|
|
91
|
-
with get_rich_toolkit() as toolkit:
|
|
98
|
+
if identity.has_deploy_token():
|
|
92
99
|
toolkit.print(
|
|
93
100
|
"You have [bold blue]FASTAPI_CLOUD_TOKEN[/] environment variable set.\n"
|
|
94
101
|
"This token will take precedence over the user token for "
|
|
@@ -96,29 +103,32 @@ def login() -> Any:
|
|
|
96
103
|
tag="Warning",
|
|
97
104
|
)
|
|
98
105
|
|
|
99
|
-
|
|
100
|
-
|
|
106
|
+
with APIClient() as client:
|
|
107
|
+
toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI")
|
|
101
108
|
|
|
102
|
-
|
|
109
|
+
toolkit.print_line()
|
|
103
110
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
with toolkit.progress("Starting authorization") as progress:
|
|
112
|
+
with client.handle_http_errors(progress):
|
|
113
|
+
authorization_data = _start_device_authorization(client)
|
|
107
114
|
|
|
108
|
-
|
|
115
|
+
url = authorization_data.verification_uri_complete
|
|
109
116
|
|
|
110
|
-
|
|
117
|
+
progress.log(f"Opening [link={url}]{url}[/link]")
|
|
111
118
|
|
|
112
|
-
|
|
119
|
+
toolkit.print_line()
|
|
113
120
|
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
with toolkit.progress("Waiting for user to authorize...") as progress:
|
|
122
|
+
launch_cmd_res = typer.launch(url)
|
|
123
|
+
logger.debug(f"Launch command result: {launch_cmd_res}")
|
|
116
124
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
with client.handle_http_errors(progress):
|
|
126
|
+
access_token = _fetch_access_token(
|
|
127
|
+
client,
|
|
128
|
+
authorization_data.device_code,
|
|
129
|
+
authorization_data.interval,
|
|
130
|
+
)
|
|
121
131
|
|
|
122
|
-
|
|
132
|
+
write_auth_config(AuthConfig(access_token=access_token))
|
|
123
133
|
|
|
124
|
-
|
|
134
|
+
progress.log("Now you are logged in! 🚀")
|
{fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/logs.py
RENAMED
|
@@ -113,7 +113,10 @@ def logs(
|
|
|
113
113
|
path: Annotated[
|
|
114
114
|
Path | None,
|
|
115
115
|
typer.Argument(
|
|
116
|
-
help=
|
|
116
|
+
help=(
|
|
117
|
+
"Path to the directory with your app's pyproject.toml "
|
|
118
|
+
"(defaults to current directory)"
|
|
119
|
+
)
|
|
117
120
|
),
|
|
118
121
|
] = None,
|
|
119
122
|
tail: int = typer.Option(
|
{fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/setup_ci.py
RENAMED
|
@@ -157,7 +157,10 @@ def setup_ci(
|
|
|
157
157
|
path: Annotated[
|
|
158
158
|
Path | None,
|
|
159
159
|
typer.Argument(
|
|
160
|
-
help=
|
|
160
|
+
help=(
|
|
161
|
+
"Path to the directory with your app's pyproject.toml "
|
|
162
|
+
"(defaults to current directory)"
|
|
163
|
+
)
|
|
161
164
|
),
|
|
162
165
|
] = None,
|
|
163
166
|
branch: str | None = typer.Option(
|