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.
Files changed (63) hide show
  1. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/PKG-INFO +1 -1
  2. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/pyproject.toml +1 -1
  3. fastapi_cloud_cli-0.19.0/scripts/prepare_release.py +216 -0
  4. fastapi_cloud_cli-0.19.0/src/fastapi_cloud_cli/__init__.py +1 -0
  5. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/deploy.py +1 -3
  6. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/env.py +47 -7
  7. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/login.py +10 -2
  8. fastapi_cloud_cli-0.19.0/src/fastapi_cloud_cli/utils/dates.py +45 -0
  9. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli.py +1 -1
  10. fastapi_cloud_cli-0.19.0/tests/test_dates.py +27 -0
  11. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_env_list.py +83 -0
  12. fastapi_cloud_cli-0.19.0/tests/test_prepare_release.py +298 -0
  13. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_version_check.py +2 -2
  14. fastapi_cloud_cli-0.18.0/src/fastapi_cloud_cli/__init__.py +0 -1
  15. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/LICENSE +0 -0
  16. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/README.md +0 -0
  17. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/scripts/add_latest_release_date.py +0 -0
  18. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/scripts/format.sh +0 -0
  19. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/scripts/lint.sh +0 -0
  20. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/scripts/test-cov-html.sh +0 -0
  21. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/scripts/test.sh +0 -0
  22. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/__main__.py +0 -0
  23. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/cli.py +0 -0
  24. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  25. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/link.py +0 -0
  26. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  27. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/logs.py +0 -0
  28. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/setup_ci.py +0 -0
  29. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
  30. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
  31. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/config.py +0 -0
  32. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/logging.py +0 -0
  33. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/py.typed +0 -0
  34. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  35. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/api.py +0 -0
  36. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
  37. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/auth.py +0 -0
  38. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/cli.py +0 -0
  39. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/config.py +0 -0
  40. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
  41. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/progress_file.py +0 -0
  42. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  43. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/version_check.py +0 -0
  44. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/__init__.py +0 -0
  45. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/conftest.py +0 -0
  46. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_api_client.py +0 -0
  47. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_archive.py +0 -0
  48. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_auth.py +0 -0
  49. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_deploy.py +0 -0
  50. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_link.py +0 -0
  51. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_login.py +0 -0
  52. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_logout.py +0 -0
  53. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_setup_ci.py +0 -0
  54. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_unlink.py +0 -0
  55. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_cli_whoami.py +0 -0
  56. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_config.py +0 -0
  57. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_deploy_utils.py +0 -0
  58. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_env_delete.py +0 -0
  59. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_env_set.py +0 -0
  60. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_logs.py +0 -0
  61. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_progress_file.py +0 -0
  62. {fastapi_cloud_cli-0.18.0 → fastapi_cloud_cli-0.19.0}/tests/test_sentry.py +0 -0
  63. {fastapi_cloud_cli-0.18.0 → 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.18.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
@@ -41,7 +41,7 @@ dependencies = [
41
41
  "fastar >= 0.10.0",
42
42
  "detect-installer>=0.1.0",
43
43
  ]
44
- version = "0.18.0"
44
+ version = "0.19.0"
45
45
 
46
46
  [project.license]
47
47
  text = "MIT"
@@ -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"
@@ -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 list(
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("Environment variables:")
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()
@@ -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 data.get("error") != "authorization_pending":
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 "0.17.1 → 999.0.0" in result.output
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 "0.17.1 → [bold]0.18.0[/]" in message
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 "0.17.1 → [bold]0.18.0[/]" in message
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"