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.
Files changed (66) hide show
  1. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/PKG-INFO +2 -1
  2. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/pyproject.toml +10 -2
  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.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/deploy.py +63 -10
  6. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/env.py +59 -10
  7. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/login.py +33 -23
  8. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/logs.py +4 -1
  9. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/setup_ci.py +4 -1
  10. fastapi_cloud_cli-0.19.0/src/fastapi_cloud_cli/commands/whoami.py +37 -0
  11. fastapi_cloud_cli-0.19.0/src/fastapi_cloud_cli/utils/cli.py +128 -0
  12. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/config.py +4 -0
  13. fastapi_cloud_cli-0.19.0/src/fastapi_cloud_cli/utils/dates.py +45 -0
  14. fastapi_cloud_cli-0.19.0/src/fastapi_cloud_cli/utils/version_check.py +187 -0
  15. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/conftest.py +1 -0
  16. fastapi_cloud_cli-0.19.0/tests/test_cli.py +84 -0
  17. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_deploy.py +159 -0
  18. fastapi_cloud_cli-0.19.0/tests/test_dates.py +27 -0
  19. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_deploy_utils.py +52 -0
  20. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_env_list.py +83 -0
  21. fastapi_cloud_cli-0.19.0/tests/test_prepare_release.py +298 -0
  22. fastapi_cloud_cli-0.19.0/tests/test_version_check.py +381 -0
  23. fastapi_cloud_cli-0.17.1/src/fastapi_cloud_cli/__init__.py +0 -1
  24. fastapi_cloud_cli-0.17.1/src/fastapi_cloud_cli/commands/whoami.py +0 -33
  25. fastapi_cloud_cli-0.17.1/src/fastapi_cloud_cli/utils/cli.py +0 -68
  26. fastapi_cloud_cli-0.17.1/tests/test_cli.py +0 -37
  27. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/LICENSE +0 -0
  28. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/README.md +0 -0
  29. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/scripts/add_latest_release_date.py +0 -0
  30. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/scripts/format.sh +0 -0
  31. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/scripts/lint.sh +0 -0
  32. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/scripts/test-cov-html.sh +0 -0
  33. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/scripts/test.sh +0 -0
  34. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/__main__.py +0 -0
  35. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/cli.py +0 -0
  36. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  37. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/link.py +0 -0
  38. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  39. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
  40. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/config.py +0 -0
  41. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/logging.py +0 -0
  42. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/py.typed +0 -0
  43. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  44. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/api.py +0 -0
  45. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
  46. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/auth.py +0 -0
  47. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
  48. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/progress_file.py +0 -0
  49. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  50. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/__init__.py +0 -0
  51. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_api_client.py +0 -0
  52. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_archive.py +0 -0
  53. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_auth.py +0 -0
  54. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_link.py +0 -0
  55. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_login.py +0 -0
  56. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_logout.py +0 -0
  57. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_setup_ci.py +0 -0
  58. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_unlink.py +0 -0
  59. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_cli_whoami.py +0 -0
  60. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_config.py +0 -0
  61. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_env_delete.py +0 -0
  62. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_env_set.py +0 -0
  63. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_logs.py +0 -0
  64. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_progress_file.py +0 -0
  65. {fastapi_cloud_cli-0.17.1 → fastapi_cloud_cli-0.19.0}/tests/test_sentry.py +0 -0
  66. {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.17.1
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.17.1"
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.9",
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"
@@ -108,15 +108,19 @@ def _should_exclude_entry(path: Path) -> bool:
108
108
  return False
109
109
 
110
110
 
111
- def archive(path: Path, tar_path: Path) -> Path:
112
- logger.debug("Starting archive creation for path: %s", path)
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, app_slug: str) -> AppResponse | None:
267
- response = client.get(f"/apps/{app_slug}")
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="Path to the directory containing your app (e.g. src, backend):",
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="[italic]Leave empty if it's the current directory[/italic]",
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="A path to the folder containing the app you want to deploy"
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, app_slug=target_app_id)
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(path or Path.cwd(), archive_path)
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 list(
103
+ @env_app.command("list")
104
+ def list_variables(
61
105
  path: Annotated[
62
106
  Path | None,
63
107
  typer.Argument(
64
- help="A path to the folder containing the app you want to deploy"
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("Environment variables:")
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="A path to the folder containing the app you want to deploy"
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="A path to the folder containing the app you want to deploy"
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[
@@ -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
 
@@ -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
- if identity.is_logged_in():
82
- with get_rich_toolkit(minimal=True) as toolkit:
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
- return
96
+ return
89
97
 
90
- if identity.has_deploy_token():
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
- with get_rich_toolkit() as toolkit, APIClient() as client:
100
- toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI")
106
+ with APIClient() as client:
107
+ toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI")
101
108
 
102
- toolkit.print_line()
109
+ toolkit.print_line()
103
110
 
104
- with toolkit.progress("Starting authorization") as progress:
105
- with client.handle_http_errors(progress):
106
- authorization_data = _start_device_authorization(client)
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
- url = authorization_data.verification_uri_complete
115
+ url = authorization_data.verification_uri_complete
109
116
 
110
- progress.log(f"Opening [link={url}]{url}[/link]")
117
+ progress.log(f"Opening [link={url}]{url}[/link]")
111
118
 
112
- toolkit.print_line()
119
+ toolkit.print_line()
113
120
 
114
- with toolkit.progress("Waiting for user to authorize...") as progress:
115
- typer.launch(url)
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
- with client.handle_http_errors(progress):
118
- access_token = _fetch_access_token(
119
- client, authorization_data.device_code, authorization_data.interval
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
- write_auth_config(AuthConfig(access_token=access_token))
132
+ write_auth_config(AuthConfig(access_token=access_token))
123
133
 
124
- progress.log("Now you are logged in! 🚀")
134
+ progress.log("Now you are logged in! 🚀")
@@ -113,7 +113,10 @@ def logs(
113
113
  path: Annotated[
114
114
  Path | None,
115
115
  typer.Argument(
116
- help="Path to the folder containing the app (defaults to current directory)"
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(
@@ -157,7 +157,10 @@ def setup_ci(
157
157
  path: Annotated[
158
158
  Path | None,
159
159
  typer.Argument(
160
- help="Path to the folder containing the app (defaults to current directory)"
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(