fastapi-cloud-cli 0.18.0__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.18.0 → fastapi_cloud_cli-0.19.0}/PKG-INFO +1 -1
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/pyproject.toml +1 -1
- 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.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/deploy.py +1 -3
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/env.py +47 -7
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/login.py +10 -2
- fastapi_cloud_cli-0.19.0/src/fastapi_cloud_cli/utils/dates.py +45 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli.py +1 -1
- fastapi_cloud_cli-0.19.0/tests/test_dates.py +27 -0
- {fastapi_cloud_cli-0.18.0 → 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.18.0 → fastapi_cloud_cli-0.19.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 → fastapi_cloud_cli-0.19.0}/LICENSE +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/README.md +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/scripts/add_latest_release_date.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/scripts/format.sh +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/scripts/lint.sh +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/scripts/test-cov-html.sh +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/scripts/test.sh +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/__main__.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/cli.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/link.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/logs.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/setup_ci.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/config.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/logging.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/py.typed +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/api.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/auth.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/cli.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/config.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/progress_file.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/version_check.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/__init__.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/conftest.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_api_client.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_archive.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_auth.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_deploy.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_link.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_login.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_logout.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_setup_ci.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_unlink.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_whoami.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_config.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_deploy_utils.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_env_delete.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_env_set.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_logs.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_progress_file.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_sentry.py +0 -0
- {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/utils.py +0 -0
|
@@ -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.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/deploy.py
RENAMED
|
@@ -396,9 +396,7 @@ def _configure_app(
|
|
|
396
396
|
initial_directory = selected_app.directory if selected_app else ""
|
|
397
397
|
|
|
398
398
|
directory_input = toolkit.input(
|
|
399
|
-
title=(
|
|
400
|
-
"Directory where your app's pyproject.toml file lives (e.g. src, backend):"
|
|
401
|
-
),
|
|
399
|
+
title=("Directory where your app's pyproject.toml file lives (e.g. backend):"),
|
|
402
400
|
tag="dir",
|
|
403
401
|
value=initial_directory or "",
|
|
404
402
|
placeholder=(
|
|
@@ -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,11 +61,47 @@ 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(
|
|
@@ -106,11 +150,7 @@ def list(
|
|
|
106
150
|
toolkit.print("No environment variables found.")
|
|
107
151
|
return
|
|
108
152
|
|
|
109
|
-
toolkit.print(
|
|
110
|
-
toolkit.print_line()
|
|
111
|
-
|
|
112
|
-
for env_var in environment_variables.data:
|
|
113
|
-
toolkit.print(f"[bold]{env_var.name}[/]")
|
|
153
|
+
toolkit.print(_get_environment_variables_table(environment_variables.data))
|
|
114
154
|
|
|
115
155
|
|
|
116
156
|
@env_app.command()
|
{fastapi_cloud_cli-0.18.0 → 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
|
|
|
@@ -112,7 +119,8 @@ def login() -> Any:
|
|
|
112
119
|
toolkit.print_line()
|
|
113
120
|
|
|
114
121
|
with toolkit.progress("Waiting for user to authorize...") as progress:
|
|
115
|
-
typer.launch(url)
|
|
122
|
+
launch_cmd_res = typer.launch(url)
|
|
123
|
+
logger.debug(f"Launch command result: {launch_cmd_res}")
|
|
116
124
|
|
|
117
125
|
with client.handle_http_errors(progress):
|
|
118
126
|
access_token = _fetch_access_token(
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_last_updated(updated_at: str | None) -> str:
|
|
5
|
+
if updated_at is None:
|
|
6
|
+
return "-"
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
updated = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
|
|
10
|
+
except ValueError:
|
|
11
|
+
return updated_at
|
|
12
|
+
|
|
13
|
+
if updated.tzinfo is None:
|
|
14
|
+
updated = updated.replace(tzinfo=timezone.utc)
|
|
15
|
+
|
|
16
|
+
now = datetime.now(timezone.utc)
|
|
17
|
+
seconds = int((now - updated).total_seconds())
|
|
18
|
+
|
|
19
|
+
if seconds < 60:
|
|
20
|
+
return "just now"
|
|
21
|
+
|
|
22
|
+
minutes = seconds // 60
|
|
23
|
+
if minutes < 60:
|
|
24
|
+
return _format_time_ago(minutes, "minute")
|
|
25
|
+
|
|
26
|
+
hours = minutes // 60
|
|
27
|
+
if hours < 24:
|
|
28
|
+
return _format_time_ago(hours, "hour")
|
|
29
|
+
|
|
30
|
+
days = hours // 24
|
|
31
|
+
if days < 30:
|
|
32
|
+
return _format_time_ago(days, "day")
|
|
33
|
+
|
|
34
|
+
months = days // 30
|
|
35
|
+
if months < 12:
|
|
36
|
+
return _format_time_ago(months, "month")
|
|
37
|
+
|
|
38
|
+
years = days // 365
|
|
39
|
+
return _format_time_ago(years, "year")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _format_time_ago(value: int, unit: str) -> str:
|
|
43
|
+
suffix = "" if value == 1 else "s"
|
|
44
|
+
|
|
45
|
+
return f"{value} {unit}{suffix} ago"
|
|
@@ -81,4 +81,4 @@ def test_embedded_fastapi_cli_prints_forced_update_message(
|
|
|
81
81
|
assert result.exit_code == 0, result.output
|
|
82
82
|
assert "No credentials found" in result.output
|
|
83
83
|
assert "A newer FastAPI Cloud CLI version is available" in result.output
|
|
84
|
-
assert "
|
|
84
|
+
assert "→ 999.0.0" in result.output
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import time_machine
|
|
5
|
+
|
|
6
|
+
from fastapi_cloud_cli.utils.dates import format_last_updated
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@time_machine.travel(datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc), tick=False)
|
|
10
|
+
@pytest.mark.parametrize(
|
|
11
|
+
("updated_at", "expected"),
|
|
12
|
+
[
|
|
13
|
+
("2026-05-22T11:59:30Z", "just now"),
|
|
14
|
+
("2026-05-22T11:59:00Z", "1 minute ago"),
|
|
15
|
+
("2026-05-22T11:30:00", "30 minutes ago"),
|
|
16
|
+
("2026-05-22T10:00:00Z", "2 hours ago"),
|
|
17
|
+
("2025-05-22T12:00:00Z", "1 year ago"),
|
|
18
|
+
],
|
|
19
|
+
)
|
|
20
|
+
def test_format_last_updated_formats_relative_time(
|
|
21
|
+
updated_at: str, expected: str
|
|
22
|
+
) -> None:
|
|
23
|
+
assert format_last_updated(updated_at) == expected
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_format_last_updated_returns_invalid_dates_unchanged() -> None:
|
|
27
|
+
assert format_last_updated("not-a-date") == "not-a-date"
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
|
|
3
4
|
import pytest
|
|
4
5
|
import respx
|
|
6
|
+
import time_machine
|
|
5
7
|
from httpx import Response
|
|
6
8
|
from typer.testing import CliRunner
|
|
7
9
|
|
|
@@ -14,6 +16,10 @@ runner = CliRunner()
|
|
|
14
16
|
assets_path = Path(__file__).parent / "assets"
|
|
15
17
|
|
|
16
18
|
|
|
19
|
+
def _normalize_output(output: str) -> str:
|
|
20
|
+
return "\n".join(line.rstrip() for line in output.strip("\n").splitlines())
|
|
21
|
+
|
|
22
|
+
|
|
17
23
|
def test_shows_a_message_if_not_logged_in(logged_out_cli: None) -> None:
|
|
18
24
|
result = runner.invoke(app, ["env", "list"])
|
|
19
25
|
|
|
@@ -85,6 +91,83 @@ def test_shows_environment_variables_names(
|
|
|
85
91
|
assert "API_KEY" in result.output
|
|
86
92
|
|
|
87
93
|
|
|
94
|
+
@pytest.mark.respx
|
|
95
|
+
@time_machine.travel(datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc), tick=False)
|
|
96
|
+
def test_shows_environment_variables_in_compact_table(
|
|
97
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
98
|
+
) -> None:
|
|
99
|
+
respx_mock.get(f"/apps/{configured_app.app_id}/environment-variables/").mock(
|
|
100
|
+
return_value=Response(
|
|
101
|
+
200,
|
|
102
|
+
json={
|
|
103
|
+
"data": [
|
|
104
|
+
{
|
|
105
|
+
"name": "APP_URL",
|
|
106
|
+
"value": "https://tryshot.app",
|
|
107
|
+
"updated_at": "2026-05-10T12:00:00Z",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"name": "SENTRY_ENVIRONMENT",
|
|
111
|
+
"value": "production",
|
|
112
|
+
"updated_at": "2026-03-22T12:00:00Z",
|
|
113
|
+
},
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
with changing_dir(configured_app.path):
|
|
120
|
+
result = runner.invoke(app, ["env", "list"])
|
|
121
|
+
|
|
122
|
+
assert result.exit_code == 0
|
|
123
|
+
assert _normalize_output(result.output) == (
|
|
124
|
+
"Key Value Last updated\n"
|
|
125
|
+
"───────────────────────────────────────────────────────\n"
|
|
126
|
+
"APP_URL https://tryshot.app 12 days ago\n"
|
|
127
|
+
"SENTRY_ENVIRONMENT production 2 months ago"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.respx
|
|
132
|
+
@time_machine.travel(datetime(2026, 5, 22, 12, 0, tzinfo=timezone.utc), tick=False)
|
|
133
|
+
def test_truncates_values_and_marks_secrets_in_compact_table(
|
|
134
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
135
|
+
) -> None:
|
|
136
|
+
long_value = "12345678901234567890123456789012345678901234567890"
|
|
137
|
+
|
|
138
|
+
respx_mock.get(f"/apps/{configured_app.app_id}/environment-variables/").mock(
|
|
139
|
+
return_value=Response(
|
|
140
|
+
200,
|
|
141
|
+
json={
|
|
142
|
+
"data": [
|
|
143
|
+
{
|
|
144
|
+
"name": "LONG_VALUE",
|
|
145
|
+
"value": long_value,
|
|
146
|
+
"updated_at": "2026-03-22T12:00:00Z",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"name": "SECRET_KEY",
|
|
150
|
+
"is_secret": True,
|
|
151
|
+
"updated_at": "2026-04-22T12:00:00Z",
|
|
152
|
+
},
|
|
153
|
+
]
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
with changing_dir(configured_app.path):
|
|
159
|
+
result = runner.invoke(app, ["env", "list"])
|
|
160
|
+
|
|
161
|
+
assert result.exit_code == 0
|
|
162
|
+
assert _normalize_output(result.output) == (
|
|
163
|
+
"Key Value Last updated\n"
|
|
164
|
+
"────────────────────────────────────────────────────────────────────\n"
|
|
165
|
+
"LONG_VALUE 1234567890123456789012345678901234567... 2 months ago\n"
|
|
166
|
+
"SECRET_KEY [secret] 1 month ago"
|
|
167
|
+
)
|
|
168
|
+
assert long_value not in result.output
|
|
169
|
+
|
|
170
|
+
|
|
88
171
|
@pytest.mark.respx
|
|
89
172
|
def test_shows_secret_environment_variables_without_value(
|
|
90
173
|
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from typer.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
from scripts.prepare_release import (
|
|
8
|
+
BumpType,
|
|
9
|
+
app,
|
|
10
|
+
bump_version,
|
|
11
|
+
get_release_notes_body,
|
|
12
|
+
update_release_notes,
|
|
13
|
+
update_version_file,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
runner = CliRunner()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.parametrize(
|
|
20
|
+
("current_version", "bump", "new_version"),
|
|
21
|
+
[
|
|
22
|
+
("0.18.0", BumpType.major, "1.0.0"),
|
|
23
|
+
("0.18.0", BumpType.minor, "0.19.0"),
|
|
24
|
+
("0.18.0", BumpType.patch, "0.18.1"),
|
|
25
|
+
],
|
|
26
|
+
)
|
|
27
|
+
def test_bump_version(current_version: str, bump: BumpType, new_version: str) -> None:
|
|
28
|
+
assert bump_version(current_version, bump) == new_version
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_update_version_file() -> None:
|
|
32
|
+
content = '__version__ = "0.18.0"\n'
|
|
33
|
+
|
|
34
|
+
new_content = update_version_file(
|
|
35
|
+
content, "0.18.1", Path("src/fastapi_cloud_cli/__init__.py")
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
assert new_content == '__version__ = "0.18.1"\n'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_update_version_file_requires_newer_version() -> None:
|
|
42
|
+
content = '__version__ = "0.18.0"\n'
|
|
43
|
+
|
|
44
|
+
with pytest.raises(RuntimeError, match="must be greater"):
|
|
45
|
+
update_version_file(
|
|
46
|
+
content, "0.18.0", Path("src/fastapi_cloud_cli/__init__.py")
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_update_release_notes() -> None:
|
|
51
|
+
content = """# Release Notes
|
|
52
|
+
|
|
53
|
+
## Latest Changes
|
|
54
|
+
|
|
55
|
+
### Fixes
|
|
56
|
+
|
|
57
|
+
* Fix something.
|
|
58
|
+
|
|
59
|
+
## 0.18.0 (2026-05-22)
|
|
60
|
+
|
|
61
|
+
### Fixes
|
|
62
|
+
|
|
63
|
+
* Previous fix.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
new_content = update_release_notes(
|
|
67
|
+
content, "0.18.1", date(2026, 5, 30), Path("release-notes.md")
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
assert (
|
|
71
|
+
new_content
|
|
72
|
+
== """# Release Notes
|
|
73
|
+
|
|
74
|
+
## Latest Changes
|
|
75
|
+
|
|
76
|
+
## 0.18.1 (2026-05-30)
|
|
77
|
+
|
|
78
|
+
### Fixes
|
|
79
|
+
|
|
80
|
+
* Fix something.
|
|
81
|
+
|
|
82
|
+
## 0.18.0 (2026-05-22)
|
|
83
|
+
|
|
84
|
+
### Fixes
|
|
85
|
+
|
|
86
|
+
* Previous fix.
|
|
87
|
+
"""
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_update_release_notes_rejects_existing_version() -> None:
|
|
92
|
+
content = """# Release Notes
|
|
93
|
+
|
|
94
|
+
## Latest Changes
|
|
95
|
+
|
|
96
|
+
## 0.18.1 (2026-05-30)
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
with pytest.raises(RuntimeError, match="already contain"):
|
|
100
|
+
update_release_notes(
|
|
101
|
+
content, "0.18.1", date(2026, 5, 30), Path("release-notes.md")
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_get_release_notes_body_with_dated_heading() -> None:
|
|
106
|
+
content = """# Release Notes
|
|
107
|
+
|
|
108
|
+
## Latest Changes
|
|
109
|
+
|
|
110
|
+
## 0.18.1 (2026-05-30)
|
|
111
|
+
|
|
112
|
+
### Fixes
|
|
113
|
+
|
|
114
|
+
* Fix something.
|
|
115
|
+
|
|
116
|
+
## 0.18.0 (2026-05-22)
|
|
117
|
+
|
|
118
|
+
### Fixes
|
|
119
|
+
|
|
120
|
+
* Previous fix.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
body = get_release_notes_body(content, "0.18.1", Path("release-notes.md"))
|
|
124
|
+
|
|
125
|
+
assert (
|
|
126
|
+
body
|
|
127
|
+
== """### Fixes
|
|
128
|
+
|
|
129
|
+
* Fix something.
|
|
130
|
+
"""
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_get_release_notes_body_with_plain_heading() -> None:
|
|
135
|
+
content = """# Release Notes
|
|
136
|
+
|
|
137
|
+
## Latest Changes
|
|
138
|
+
|
|
139
|
+
## 0.18.1
|
|
140
|
+
|
|
141
|
+
### Fixes
|
|
142
|
+
|
|
143
|
+
* Fix something.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
body = get_release_notes_body(content, "0.18.1", Path("release-notes.md"))
|
|
147
|
+
|
|
148
|
+
assert body == "### Fixes\n\n* Fix something.\n"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_get_release_notes_body_allows_non_version_h2_content() -> None:
|
|
152
|
+
content = """# Release Notes
|
|
153
|
+
|
|
154
|
+
## Latest Changes
|
|
155
|
+
|
|
156
|
+
## 0.18.1
|
|
157
|
+
|
|
158
|
+
## Highlights
|
|
159
|
+
|
|
160
|
+
* Fix something.
|
|
161
|
+
|
|
162
|
+
## 0.18.0
|
|
163
|
+
|
|
164
|
+
* Previous fix.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
body = get_release_notes_body(content, "0.18.1", Path("release-notes.md"))
|
|
168
|
+
|
|
169
|
+
assert body == "## Highlights\n\n* Fix something.\n"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_get_release_notes_body_requires_version_section() -> None:
|
|
173
|
+
content = "# Release Notes\n\n## Latest Changes\n"
|
|
174
|
+
|
|
175
|
+
with pytest.raises(RuntimeError, match="Could not find"):
|
|
176
|
+
get_release_notes_body(content, "0.18.1", Path("release-notes.md"))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_get_release_notes_body_requires_non_empty_section() -> None:
|
|
180
|
+
content = """# Release Notes
|
|
181
|
+
|
|
182
|
+
## Latest Changes
|
|
183
|
+
|
|
184
|
+
## 0.18.1
|
|
185
|
+
|
|
186
|
+
## 0.18.0
|
|
187
|
+
|
|
188
|
+
* Previous fix.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
with pytest.raises(RuntimeError, match="is empty"):
|
|
192
|
+
get_release_notes_body(content, "0.18.1", Path("release-notes.md"))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_cli_updates_configured_files(tmp_path: Path) -> None:
|
|
196
|
+
version_file = tmp_path / "src" / "fastapi_cloud_cli" / "__init__.py"
|
|
197
|
+
version_file.parent.mkdir(parents=True)
|
|
198
|
+
version_file.write_text('__version__ = "0.18.0"\n')
|
|
199
|
+
release_notes_file = tmp_path / "release-notes.md"
|
|
200
|
+
release_notes_file.write_text(
|
|
201
|
+
"""# Release Notes
|
|
202
|
+
|
|
203
|
+
## Latest Changes
|
|
204
|
+
|
|
205
|
+
### Fixes
|
|
206
|
+
|
|
207
|
+
* Fix something.
|
|
208
|
+
"""
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
result = runner.invoke(
|
|
212
|
+
app,
|
|
213
|
+
[
|
|
214
|
+
"prepare",
|
|
215
|
+
"patch",
|
|
216
|
+
"--version-file",
|
|
217
|
+
str(version_file),
|
|
218
|
+
"--release-notes-file",
|
|
219
|
+
str(release_notes_file),
|
|
220
|
+
"--date",
|
|
221
|
+
"2026-05-30",
|
|
222
|
+
],
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
assert result.exit_code == 0, result.output
|
|
226
|
+
assert "Prepared release 0.18.1 (2026-05-30)" in result.output
|
|
227
|
+
assert version_file.read_text() == '__version__ = "0.18.1"\n'
|
|
228
|
+
assert "## 0.18.1 (2026-05-30)" in release_notes_file.read_text()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_cli_accepts_env_vars(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
232
|
+
version_file = tmp_path / "src" / "fastapi_cloud_cli" / "__init__.py"
|
|
233
|
+
version_file.parent.mkdir(parents=True)
|
|
234
|
+
version_file.write_text('__version__ = "0.18.0"\n')
|
|
235
|
+
release_notes_file = tmp_path / "release-notes.md"
|
|
236
|
+
release_notes_file.write_text("# Release Notes\n\n## Latest Changes\n")
|
|
237
|
+
monkeypatch.setenv("PREPARE_RELEASE_BUMP", "minor")
|
|
238
|
+
monkeypatch.setenv("PREPARE_RELEASE_VERSION_FILE", str(version_file))
|
|
239
|
+
monkeypatch.setenv("PREPARE_RELEASE_RELEASE_NOTES_FILE", str(release_notes_file))
|
|
240
|
+
monkeypatch.setenv("PREPARE_RELEASE_DATE", "2026-05-30")
|
|
241
|
+
|
|
242
|
+
result = runner.invoke(app, ["prepare"])
|
|
243
|
+
|
|
244
|
+
assert result.exit_code == 0, result.output
|
|
245
|
+
assert "Prepared release 0.19.0 (2026-05-30)" in result.output
|
|
246
|
+
assert version_file.read_text() == '__version__ = "0.19.0"\n'
|
|
247
|
+
assert "## 0.19.0 (2026-05-30)" in release_notes_file.read_text()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_cli_prints_current_version(tmp_path: Path) -> None:
|
|
251
|
+
version_file = tmp_path / "src" / "fastapi_cloud_cli" / "__init__.py"
|
|
252
|
+
version_file.parent.mkdir(parents=True)
|
|
253
|
+
version_file.write_text('__version__ = "0.18.0"\n')
|
|
254
|
+
|
|
255
|
+
result = runner.invoke(
|
|
256
|
+
app,
|
|
257
|
+
[
|
|
258
|
+
"current-version",
|
|
259
|
+
"--version-file",
|
|
260
|
+
str(version_file),
|
|
261
|
+
],
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
assert result.exit_code == 0, result.output
|
|
265
|
+
assert result.output == "0.18.0\n"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_cli_prints_release_notes(tmp_path: Path) -> None:
|
|
269
|
+
version_file = tmp_path / "src" / "fastapi_cloud_cli" / "__init__.py"
|
|
270
|
+
version_file.parent.mkdir(parents=True)
|
|
271
|
+
version_file.write_text('__version__ = "0.18.1"\n')
|
|
272
|
+
release_notes_file = tmp_path / "release-notes.md"
|
|
273
|
+
release_notes_file.write_text(
|
|
274
|
+
"""# Release Notes
|
|
275
|
+
|
|
276
|
+
## Latest Changes
|
|
277
|
+
|
|
278
|
+
## 0.18.1 (2026-05-30)
|
|
279
|
+
|
|
280
|
+
### Fixes
|
|
281
|
+
|
|
282
|
+
* Fix something.
|
|
283
|
+
"""
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
result = runner.invoke(
|
|
287
|
+
app,
|
|
288
|
+
[
|
|
289
|
+
"release-notes",
|
|
290
|
+
"--version-file",
|
|
291
|
+
str(version_file),
|
|
292
|
+
"--release-notes-file",
|
|
293
|
+
str(release_notes_file),
|
|
294
|
+
],
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
assert result.exit_code == 0, result.output
|
|
298
|
+
assert result.output == "### Fixes\n\n* Fix something.\n"
|
|
@@ -279,7 +279,7 @@ def test_format_update_message(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
279
279
|
message = format_update_message(VersionUpdate(current="0.17.1", latest="0.18.0"))
|
|
280
280
|
|
|
281
281
|
assert not message.startswith("\n")
|
|
282
|
-
assert "
|
|
282
|
+
assert "→ [bold]0.18.0[/]" in message
|
|
283
283
|
assert '\n\nRun "[blue]uv tool upgrade fastapi-cloud-cli[/]" to upgrade.' in message
|
|
284
284
|
assert "https://pypi.org/project/fastapi-cloud-cli/" not in message
|
|
285
285
|
|
|
@@ -340,7 +340,7 @@ def test_background_check_stores_result_and_returns_message(
|
|
|
340
340
|
|
|
341
341
|
assert message is not None
|
|
342
342
|
assert "A newer FastAPI Cloud CLI version is available" in message
|
|
343
|
-
assert "
|
|
343
|
+
assert "→ [bold]0.18.0[/]" in message
|
|
344
344
|
assert check.get_update_message() is None
|
|
345
345
|
|
|
346
346
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.18.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/__init__.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/link.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/logout.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/logs.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/setup_ci.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/unlink.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/whoami.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/progress_file.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/version_check.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|