fastapi-cloud-cli 0.15.0__tar.gz → 0.16.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 (88) hide show
  1. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/PKG-INFO +1 -1
  2. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/pyproject.toml +1 -1
  3. fastapi_cloud_cli-0.16.0/scripts/add_latest_release_date.py +40 -0
  4. fastapi_cloud_cli-0.16.0/src/fastapi_cloud_cli/__init__.py +1 -0
  5. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/deploy.py +32 -11
  6. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/api.py +16 -12
  7. fastapi_cloud_cli-0.16.0/src/fastapi_cloud_cli/utils/progress_file.py +30 -0
  8. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_cli_deploy.py +178 -3
  9. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_deploy_utils.py +13 -12
  10. fastapi_cloud_cli-0.16.0/tests/test_progress_file.py +104 -0
  11. fastapi_cloud_cli-0.15.0/src/fastapi_cloud_cli/__init__.py +0 -1
  12. fastapi_cloud_cli-0.15.0/tests/assets/broken_package/mod/__init__.py +0 -1
  13. fastapi_cloud_cli-0.15.0/tests/assets/broken_package/mod/app.py +0 -10
  14. fastapi_cloud_cli-0.15.0/tests/assets/broken_package/utils.py +0 -2
  15. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_api/api.py +0 -8
  16. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_app/api.py +0 -8
  17. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_app/app.py +0 -8
  18. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
  19. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_app_dir_api/app/api.py +0 -8
  20. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
  21. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_app_dir_app/app/api.py +0 -8
  22. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_app_dir_app/app/app.py +0 -8
  23. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
  24. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_app_dir_main/app/api.py +0 -8
  25. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_app_dir_main/app/app.py +0 -8
  26. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_app_dir_main/app/main.py +0 -8
  27. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_app_dir_non_default/app/__init__.py +0 -0
  28. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_app_dir_non_default/app/nondefault.py +0 -8
  29. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_main/api.py +0 -8
  30. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_main/app.py +0 -8
  31. fastapi_cloud_cli-0.15.0/tests/assets/default_files/default_main/main.py +0 -8
  32. fastapi_cloud_cli-0.15.0/tests/assets/default_files/non_default/nonstandard.py +0 -8
  33. fastapi_cloud_cli-0.15.0/tests/assets/package/__init__.py +0 -2
  34. fastapi_cloud_cli-0.15.0/tests/assets/package/core/__init__.py +0 -0
  35. fastapi_cloud_cli-0.15.0/tests/assets/package/core/utils.py +0 -2
  36. fastapi_cloud_cli-0.15.0/tests/assets/package/mod/__init__.py +0 -1
  37. fastapi_cloud_cli-0.15.0/tests/assets/package/mod/api.py +0 -24
  38. fastapi_cloud_cli-0.15.0/tests/assets/package/mod/app.py +0 -32
  39. fastapi_cloud_cli-0.15.0/tests/assets/package/mod/other.py +0 -16
  40. fastapi_cloud_cli-0.15.0/tests/assets/single_file_api.py +0 -24
  41. fastapi_cloud_cli-0.15.0/tests/assets/single_file_app.py +0 -32
  42. fastapi_cloud_cli-0.15.0/tests/assets/single_file_other.py +0 -16
  43. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/LICENSE +0 -0
  44. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/README.md +0 -0
  45. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/scripts/format.sh +0 -0
  46. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/scripts/lint.sh +0 -0
  47. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/scripts/test-cov-html.sh +0 -0
  48. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/scripts/test.sh +0 -0
  49. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/__main__.py +0 -0
  50. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/cli.py +0 -0
  51. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  52. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/env.py +0 -0
  53. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/link.py +0 -0
  54. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/login.py +0 -0
  55. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  56. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/logs.py +0 -0
  57. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/setup_ci.py +0 -0
  58. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
  59. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
  60. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/config.py +0 -0
  61. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/logging.py +0 -0
  62. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/py.typed +0 -0
  63. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  64. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
  65. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/auth.py +0 -0
  66. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/cli.py +0 -0
  67. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/config.py +0 -0
  68. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
  69. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  70. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/__init__.py +0 -0
  71. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/conftest.py +0 -0
  72. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_api_client.py +0 -0
  73. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_archive.py +0 -0
  74. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_auth.py +0 -0
  75. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_cli.py +0 -0
  76. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_cli_link.py +0 -0
  77. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_cli_login.py +0 -0
  78. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_cli_logout.py +0 -0
  79. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_cli_setup_ci.py +0 -0
  80. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_cli_unlink.py +0 -0
  81. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_cli_whoami.py +0 -0
  82. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_config.py +0 -0
  83. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_env_delete.py +0 -0
  84. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_env_list.py +0 -0
  85. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_env_set.py +0 -0
  86. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_logs.py +0 -0
  87. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.0}/tests/test_sentry.py +0 -0
  88. {fastapi_cloud_cli-0.15.0 → fastapi_cloud_cli-0.16.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.15.0
3
+ Version: 0.16.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
@@ -40,7 +40,7 @@ dependencies = [
40
40
  "sentry-sdk >= 2.20.0",
41
41
  "fastar >= 0.8.0",
42
42
  ]
43
- version = "0.15.0"
43
+ version = "0.16.0"
44
44
 
45
45
  [project.license]
46
46
  text = "MIT"
@@ -0,0 +1,40 @@
1
+ """Check release-notes.md and add today's date to the latest release header if missing."""
2
+
3
+ import re
4
+ import sys
5
+ from datetime import date
6
+
7
+ RELEASE_NOTES_FILE = "release-notes.md"
8
+ RELEASE_HEADER_PATTERN = re.compile(r"^## (\d+\.\d+\.\d+)\s*(\(.*\))?\s*$")
9
+
10
+
11
+ def main() -> None:
12
+ with open(RELEASE_NOTES_FILE) as f:
13
+ lines = f.readlines()
14
+
15
+ for i, line in enumerate(lines):
16
+ match = RELEASE_HEADER_PATTERN.match(line)
17
+ if not match:
18
+ continue
19
+
20
+ version = match.group(1)
21
+ date_part = match.group(2)
22
+
23
+ if date_part:
24
+ print(f"Latest release {version} already has a date: {date_part}")
25
+ sys.exit(0)
26
+
27
+ today = date.today().isoformat()
28
+ lines[i] = f"## {version} ({today})\n"
29
+ print(f"Added date: {version} ({today})")
30
+
31
+ with open(RELEASE_NOTES_FILE, "w") as f:
32
+ f.writelines(lines)
33
+ sys.exit(0)
34
+
35
+ print("No release header found")
36
+ sys.exit(1)
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
@@ -0,0 +1 @@
1
+ __version__ = "0.16.0"
@@ -7,7 +7,7 @@ import time
7
7
  from itertools import cycle
8
8
  from pathlib import Path, PurePosixPath
9
9
  from textwrap import dedent
10
- from typing import Annotated, Any
10
+ from typing import Annotated, Any, BinaryIO, cast
11
11
 
12
12
  import fastar
13
13
  import rignore
@@ -17,6 +17,7 @@ from pydantic import AfterValidator, BaseModel, EmailStr, TypeAdapter, Validatio
17
17
  from rich.text import Text
18
18
  from rich_toolkit import RichToolkit
19
19
  from rich_toolkit.menu import Option
20
+ from rich_toolkit.progress import Progress
20
21
 
21
22
  from fastapi_cloud_cli.commands.login import login
22
23
  from fastapi_cloud_cli.utils.api import (
@@ -29,6 +30,7 @@ from fastapi_cloud_cli.utils.api import (
29
30
  from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
30
31
  from fastapi_cloud_cli.utils.auth import Identity
31
32
  from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
33
+ from fastapi_cloud_cli.utils.progress_file import ProgressFile
32
34
 
33
35
  logger = logging.getLogger(__name__)
34
36
 
@@ -201,16 +203,32 @@ class RequestUploadResponse(BaseModel):
201
203
  fields: dict[str, str]
202
204
 
203
205
 
204
- def _upload_deployment(deployment_id: str, archive_path: Path) -> None:
206
+ def _format_size(size_in_bytes: int) -> str:
207
+ if size_in_bytes >= 1024 * 1024:
208
+ return f"{size_in_bytes / (1024 * 1024):.2f} MB"
209
+ elif size_in_bytes >= 1024:
210
+ return f"{size_in_bytes / 1024:.2f} KB"
211
+ else:
212
+ return f"{size_in_bytes} bytes"
213
+
214
+
215
+ def _upload_deployment(
216
+ deployment_id: str, archive_path: Path, progress: Progress
217
+ ) -> None:
218
+ archive_size = archive_path.stat().st_size
219
+ archive_size_str = _format_size(archive_size)
220
+
221
+ progress.log(f"Uploading deployment ({archive_size_str})...")
205
222
  logger.debug(
206
223
  "Starting deployment upload for deployment: %s",
207
224
  deployment_id,
208
225
  )
209
- logger.debug(
210
- "Archive path: %s, size: %s bytes",
211
- archive_path,
212
- archive_path.stat().st_size,
213
- )
226
+ logger.debug("Archive path: %s, size: %s bytes", archive_path, archive_size)
227
+
228
+ def progress_callback(bytes_read: int) -> None:
229
+ progress.log(
230
+ f"Uploading deployment ({_format_size(bytes_read)} of {archive_size_str})..."
231
+ )
214
232
 
215
233
  with APIClient() as fastapi_client, Client() as client:
216
234
  # Get the upload URL
@@ -223,10 +241,13 @@ def _upload_deployment(deployment_id: str, archive_path: Path) -> None:
223
241
 
224
242
  logger.debug("Starting file upload to S3")
225
243
  with open(archive_path, "rb") as archive_file:
244
+ archive_file_with_progress = ProgressFile(
245
+ archive_file, progress_callback=progress_callback
246
+ )
226
247
  upload_response = client.post(
227
248
  upload_data.url,
228
249
  data=upload_data.fields,
229
- files={"file": archive_file},
250
+ files={"file": cast(BinaryIO, archive_file_with_progress)},
230
251
  )
231
252
 
232
253
  upload_response.raise_for_status()
@@ -304,6 +325,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
304
325
  "Select the team you want to deploy to:",
305
326
  tag="team",
306
327
  options=[Option({"name": team.name, "value": team}) for team in teams],
328
+ allow_filtering=True,
307
329
  )
308
330
 
309
331
  toolkit.print_line()
@@ -335,6 +357,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
335
357
  selected_app = toolkit.ask(
336
358
  "Select the app you want to deploy to:",
337
359
  options=[Option({"name": app.slug, "value": app}) for app in apps],
360
+ allow_filtering=True,
338
361
  )
339
362
 
340
363
  app_name = (
@@ -767,9 +790,7 @@ def deploy(
767
790
  f"Deployment created successfully! Deployment slug: {deployment.slug}"
768
791
  )
769
792
 
770
- progress.log("Uploading deployment...")
771
-
772
- _upload_deployment(deployment.id, archive_path)
793
+ _upload_deployment(deployment.id, archive_path, progress=progress)
773
794
 
774
795
  progress.log("Deployment uploaded successfully!")
775
796
  except KeyboardInterrupt:
@@ -141,6 +141,7 @@ def attempts(
141
141
 
142
142
  class DeploymentStatus(str, Enum):
143
143
  waiting_upload = "waiting_upload"
144
+ upload_cancelled = "upload_cancelled"
144
145
  ready_for_build = "ready_for_build"
145
146
  building = "building"
146
147
  extracting = "extracting"
@@ -153,24 +154,27 @@ class DeploymentStatus(str, Enum):
153
154
  verifying_failed = "verifying_failed"
154
155
  verifying_skipped = "verifying_skipped"
155
156
  success = "success"
157
+ expired = "expired"
156
158
  failed = "failed"
157
159
 
158
160
  @classmethod
159
161
  def to_human_readable(cls, status: "DeploymentStatus") -> str:
160
162
  return {
161
- cls.waiting_upload: "Waiting for upload",
162
- cls.ready_for_build: "Ready for build",
163
+ cls.waiting_upload: "Awaiting Upload",
164
+ cls.upload_cancelled: "Upload Cancelled",
165
+ cls.ready_for_build: "Build Queued",
163
166
  cls.building: "Building",
164
- cls.extracting: "Extracting",
165
- cls.extracting_failed: "Extracting failed",
166
- cls.building_image: "Building image",
167
- cls.building_image_failed: "Build failed",
168
- cls.deploying: "Deploying",
169
- cls.deploying_failed: "Deploying failed",
170
- cls.verifying: "Verifying",
171
- cls.verifying_failed: "Verifying failed",
172
- cls.verifying_skipped: "Verification skipped",
173
- cls.success: "Success",
167
+ cls.extracting: "Extracting Upload",
168
+ cls.extracting_failed: "Extraction Failed",
169
+ cls.building_image: "Building Image",
170
+ cls.building_image_failed: "Build Failed",
171
+ cls.deploying: "Deploying Image",
172
+ cls.deploying_failed: "Deployment Failed",
173
+ cls.verifying: "Verifying Readiness",
174
+ cls.verifying_failed: "Verification Failed",
175
+ cls.verifying_skipped: "Verification Skipped",
176
+ cls.success: "Ready",
177
+ cls.expired: "Expired",
174
178
  cls.failed: "Failed",
175
179
  }[status]
176
180
 
@@ -0,0 +1,30 @@
1
+ from collections.abc import Callable
2
+ from datetime import datetime
3
+ from typing import Any, BinaryIO
4
+
5
+
6
+ class ProgressFile:
7
+ """Wrap a binary file object and report upload progress as it is read."""
8
+
9
+ def __init__(
10
+ self,
11
+ file: BinaryIO,
12
+ progress_callback: Callable[[int], None],
13
+ update_interval: float = 0.5,
14
+ ) -> None:
15
+ self._file = file
16
+ self._progress_callback = progress_callback
17
+ self._update_interval = update_interval
18
+ self._last_update_time = 0.0
19
+
20
+ def read(self, n: int = -1) -> bytes:
21
+ data = self._file.read(n)
22
+ now_ = datetime.now().timestamp()
23
+ is_eof = (len(data) == 0) or (n > 0 and len(data) < n)
24
+ if (now_ - self._last_update_time >= self._update_interval) or is_eof:
25
+ self._progress_callback(self._file.tell())
26
+ self._last_update_time = now_
27
+ return data
28
+
29
+ def __getattr__(self, name: str) -> Any:
30
+ return getattr(self._file, name)
@@ -1,4 +1,5 @@
1
1
  import random
2
+ import re
2
3
  import string
3
4
  from datetime import timedelta
4
5
  from pathlib import Path
@@ -10,6 +11,7 @@ import pytest
10
11
  import respx
11
12
  from click.testing import Result
12
13
  from httpx import Response
14
+ from rich_toolkit.progress import Progress
13
15
  from time_machine import TimeMachineFixture
14
16
  from typer.testing import CliRunner
15
17
 
@@ -24,8 +26,8 @@ runner = CliRunner()
24
26
  assets_path = Path(__file__).parent / "assets"
25
27
 
26
28
 
27
- def _get_random_team() -> dict[str, str]:
28
- name = "".join(random.choices(string.ascii_lowercase, k=10))
29
+ def _get_random_team(name: str | None = None) -> dict[str, str]:
30
+ name = name or "".join(random.choices(string.ascii_lowercase, k=10))
29
31
  slug = "".join(random.choices(string.ascii_lowercase, k=10))
30
32
  id = "".join(random.choices(string.digits, k=10))
31
33
 
@@ -323,6 +325,42 @@ def test_shows_teams(
323
325
  assert team_2["name"] in result.output
324
326
 
325
327
 
328
+ @pytest.mark.respx
329
+ def test_filter_teams(
330
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
331
+ ) -> None:
332
+ steps = [*"al", Keys.ENTER, Keys.CTRL_C]
333
+
334
+ team_1 = _get_random_team(name="Alpha Team")
335
+ team_2 = _get_random_team(name="Beta Team")
336
+
337
+ respx_mock.get("/teams/").mock(
338
+ return_value=Response(
339
+ 200,
340
+ json={"data": [team_1, team_2]},
341
+ )
342
+ )
343
+
344
+ with (
345
+ changing_dir(tmp_path),
346
+ patch("rich_toolkit.container.getchar") as mock_getchar,
347
+ ):
348
+ mock_getchar.side_effect = steps
349
+
350
+ result = runner.invoke(app, ["deploy"])
351
+
352
+ assert result.exit_code == 1
353
+
354
+ assert "Filter: al" in result.output
355
+
356
+ # Truncate part of the output before "Filter: al"
357
+ filer_pos = result.output.rfind("Filter: al")
358
+ last_output = result.output[filer_pos:]
359
+
360
+ assert team_1["name"] in last_output
361
+ assert team_2["name"] not in last_output
362
+
363
+
326
364
  @pytest.mark.respx
327
365
  def test_asks_for_app_name_after_team(
328
366
  logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
@@ -349,6 +387,48 @@ def test_asks_for_app_name_after_team(
349
387
  assert "What's your app name?" in result.output
350
388
 
351
389
 
390
+ @pytest.mark.respx
391
+ def test_filter_apps(
392
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
393
+ ) -> None:
394
+ steps = [Keys.ENTER, Keys.RIGHT_ARROW, Keys.ENTER, *"an", Keys.ENTER, Keys.CTRL_C]
395
+
396
+ team = _get_random_team()
397
+
398
+ respx_mock.get("/teams/").mock(
399
+ return_value=Response(
400
+ 200,
401
+ json={"data": [team]},
402
+ )
403
+ )
404
+
405
+ app_1 = _get_random_app(team_id=team["id"], slug="My App")
406
+ app_2 = _get_random_app(team_id=team["id"], slug="Another App")
407
+
408
+ respx_mock.get("/apps/", params={"team_id": team["id"]}).mock(
409
+ return_value=Response(200, json={"data": [app_1, app_2]})
410
+ )
411
+
412
+ with (
413
+ changing_dir(tmp_path),
414
+ patch("rich_toolkit.container.getchar") as mock_getchar,
415
+ ):
416
+ mock_getchar.side_effect = steps
417
+
418
+ result = runner.invoke(app, ["deploy"])
419
+
420
+ assert result.exit_code == 1
421
+
422
+ assert "Filter: an" in result.output
423
+
424
+ # Truncate part of the output before "Filter: an"
425
+ filer_pos = result.output.rfind("Filter: an")
426
+ last_output = result.output[filer_pos:]
427
+
428
+ assert app_1["slug"] not in last_output
429
+ assert app_2["slug"] in last_output
430
+
431
+
352
432
  @pytest.mark.respx
353
433
  def test_creates_app_on_backend(
354
434
  logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
@@ -1578,6 +1658,101 @@ def test_deploy_with_token_fails(
1578
1658
  )
1579
1659
 
1580
1660
 
1661
+ @pytest.mark.parametrize(
1662
+ ("size", "expected_msgs"),
1663
+ [
1664
+ (
1665
+ 100,
1666
+ [
1667
+ r"\(\d+ bytes\)", # e.g. "(123 bytes)"
1668
+ r"\(\d+ bytes of \d+ bytes\)", # e.g. "(123 bytes of 456 bytes)"
1669
+ ],
1670
+ ),
1671
+ (
1672
+ 10 * 1024,
1673
+ [
1674
+ r"\(\d+\.\d+ KB\)", # e.g. "(1.23 KB)"
1675
+ r"\(\d+\.\d+ KB of \d+\.\d+ KB\)", # e.g. "(1.23 KB of 4.56 KB)"
1676
+ ],
1677
+ ),
1678
+ (
1679
+ 10 * 1024 * 1024,
1680
+ [
1681
+ r"\(\d+\.\d+ MB\)", # e.g. "(1.23 MB)"
1682
+ r"\(\d+\.\d+ KB of \d+\.\d+ MB\)", # e.g. "(1.23 KB of 4.56 MB)"
1683
+ r"\(\d+\.\d+ MB of \d+\.\d+ MB\)", # e.g. "(1.23 MB of 4.56 MB)"
1684
+ ],
1685
+ ),
1686
+ ],
1687
+ )
1688
+ @pytest.mark.respx
1689
+ def test_upload_deployment_progress(
1690
+ logged_in_cli: None,
1691
+ tmp_path: Path,
1692
+ respx_mock: respx.MockRouter,
1693
+ size: int,
1694
+ expected_msgs: list[str],
1695
+ ) -> None:
1696
+ app_data = _get_random_app()
1697
+ team_data = _get_random_team()
1698
+ app_id = app_data["id"]
1699
+ team_id = team_data["id"]
1700
+ deployment_data = _get_random_deployment(app_id=app_id)
1701
+ deployment_id = deployment_data["id"]
1702
+
1703
+ config_path = tmp_path / ".fastapicloud" / "cloud.json"
1704
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1705
+ config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}')
1706
+
1707
+ (tmp_path / "file.bin").write_bytes(random.randbytes(size))
1708
+
1709
+ respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data))
1710
+ respx_mock.post(f"/apps/{app_id}/deployments/").mock(
1711
+ return_value=Response(201, json=deployment_data)
1712
+ )
1713
+ respx_mock.post(f"/deployments/{deployment_id}/upload").mock(
1714
+ return_value=Response(
1715
+ 200,
1716
+ json={"url": "http://test.com", "fields": {"key": "value"}},
1717
+ )
1718
+ )
1719
+ respx_mock.post("http://test.com", data={"key": "value"}).mock(
1720
+ return_value=Response(200)
1721
+ )
1722
+ respx_mock.post(f"/deployments/{deployment_id}/upload-complete").mock(
1723
+ return_value=Response(200)
1724
+ )
1725
+ respx_mock.get(f"/deployments/{deployment_id}/build-logs").mock(
1726
+ return_value=Response(
1727
+ 200,
1728
+ content=build_logs_response(
1729
+ {"type": "message", "message": "Building...", "id": "1"},
1730
+ {"type": "complete"},
1731
+ ),
1732
+ )
1733
+ )
1734
+ respx_mock.get(f"/apps/{app_id}/deployments/{deployment_id}").mock(
1735
+ return_value=Response(200, json={**deployment_data, "status": "success"})
1736
+ )
1737
+
1738
+ with (
1739
+ changing_dir(tmp_path),
1740
+ patch.object(Progress, "log") as mock_progress,
1741
+ ):
1742
+ result = runner.invoke(app, ["deploy"])
1743
+ assert result.exit_code == 0
1744
+
1745
+ call_args = [
1746
+ c.args[0] for c in mock_progress.call_args_list if isinstance(c.args[0], str)
1747
+ ]
1748
+
1749
+ for expected_msg in expected_msgs:
1750
+ pattern = re.compile(f"Uploading deployment {expected_msg}\\.\\.\\.")
1751
+ assert any(pattern.match(arg) for arg in call_args), (
1752
+ f"Expected message '{pattern.pattern}' not found in {call_args}"
1753
+ )
1754
+
1755
+
1581
1756
  @pytest.mark.respx
1582
1757
  def test_deploy_with_app_id_arg(
1583
1758
  logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
@@ -1838,7 +2013,7 @@ def test_verification_failure_after_build_complete(
1838
2013
 
1839
2014
  assert result.exit_code == 1
1840
2015
  assert "Deployment failed" in result.output
1841
- assert "Verifying failed" in result.output
2016
+ assert "Verification Failed" in result.output
1842
2017
  assert deployment_data["dashboard_url"] in result.output
1843
2018
 
1844
2019
 
@@ -58,19 +58,20 @@ def test_includes_paths(path: Path) -> None:
58
58
  @pytest.mark.parametrize(
59
59
  "status,expected",
60
60
  [
61
- (DeploymentStatus.waiting_upload, "Waiting for upload"),
62
- (DeploymentStatus.ready_for_build, "Ready for build"),
61
+ (DeploymentStatus.waiting_upload, "Awaiting Upload"),
62
+ (DeploymentStatus.ready_for_build, "Build Queued"),
63
63
  (DeploymentStatus.building, "Building"),
64
- (DeploymentStatus.extracting, "Extracting"),
65
- (DeploymentStatus.extracting_failed, "Extracting failed"),
66
- (DeploymentStatus.building_image, "Building image"),
67
- (DeploymentStatus.building_image_failed, "Build failed"),
68
- (DeploymentStatus.deploying, "Deploying"),
69
- (DeploymentStatus.deploying_failed, "Deploying failed"),
70
- (DeploymentStatus.verifying, "Verifying"),
71
- (DeploymentStatus.verifying_failed, "Verifying failed"),
72
- (DeploymentStatus.verifying_skipped, "Verification skipped"),
73
- (DeploymentStatus.success, "Success"),
64
+ (DeploymentStatus.extracting, "Extracting Upload"),
65
+ (DeploymentStatus.extracting_failed, "Extraction Failed"),
66
+ (DeploymentStatus.building_image, "Building Image"),
67
+ (DeploymentStatus.building_image_failed, "Build Failed"),
68
+ (DeploymentStatus.deploying, "Deploying Image"),
69
+ (DeploymentStatus.deploying_failed, "Deployment Failed"),
70
+ (DeploymentStatus.verifying, "Verifying Readiness"),
71
+ (DeploymentStatus.verifying_failed, "Verification Failed"),
72
+ (DeploymentStatus.verifying_skipped, "Verification Skipped"),
73
+ (DeploymentStatus.success, "Ready"),
74
+ (DeploymentStatus.expired, "Expired"),
74
75
  (DeploymentStatus.failed, "Failed"),
75
76
  ],
76
77
  )
@@ -0,0 +1,104 @@
1
+ import io
2
+ from datetime import datetime, timezone
3
+ from unittest.mock import Mock, call
4
+
5
+ import time_machine
6
+
7
+ from fastapi_cloud_cli.utils.progress_file import ProgressFile
8
+
9
+
10
+ def _make_file(
11
+ content: bytes = b"hello world", name: str = "test.tar.gz"
12
+ ) -> io.BytesIO:
13
+ f = io.BytesIO(content)
14
+ f.name = name
15
+ return f
16
+
17
+
18
+ def test_read_with_size() -> None:
19
+ file = _make_file(b"abcdef")
20
+ pf = ProgressFile(file, progress_callback=lambda _: None)
21
+
22
+ assert pf.read(3) == b"abc"
23
+ assert pf.read(3) == b"def"
24
+
25
+
26
+ def test_callback_not_called_within_interval() -> None:
27
+ file = _make_file(b"abcdef")
28
+ mock_callback = Mock()
29
+ pf = ProgressFile(file, progress_callback=mock_callback)
30
+
31
+ pf.read(3) # Should trigger callback
32
+ pf.read(3) # Should NOT trigger
33
+
34
+ mock_callback.assert_called_once_with(3)
35
+
36
+
37
+ def test_callback_called_after_interval_elapses() -> None:
38
+ file = _make_file(b"abcdef")
39
+ mock_callback = Mock()
40
+
41
+ with time_machine.travel(
42
+ datetime(2026, 1, 1, tzinfo=timezone.utc), tick=False
43
+ ) as traveller:
44
+ pf = ProgressFile(file, progress_callback=mock_callback)
45
+
46
+ pf.read(3)
47
+ traveller.shift(0.6)
48
+ pf.read(3)
49
+
50
+ mock_callback.assert_has_calls([call(3), call(6)])
51
+
52
+
53
+ def test_callback_tracks_cumulative_bytes() -> None:
54
+ file = _make_file(b"a" * 100)
55
+ mock_callback = Mock()
56
+
57
+ with time_machine.travel(
58
+ datetime(2026, 1, 1, tzinfo=timezone.utc), tick=False
59
+ ) as traveller:
60
+ pf = ProgressFile(file, progress_callback=mock_callback)
61
+
62
+ pf.read(10) # Should trigger callback with 10 bytes read
63
+ traveller.shift(0.1)
64
+ pf.read(10)
65
+ traveller.shift(0.5)
66
+ pf.read(10) # Should trigger callback with 10 + 10 + 10 = 30 bytes read
67
+ traveller.shift(0.6)
68
+ pf.read(10) # Should trigger callback with 30 + 10 = 40 bytes read
69
+
70
+ mock_callback.assert_has_calls([call(10), call(30), call(40)])
71
+
72
+
73
+ def test_callback_called_on_eof() -> None:
74
+ file = _make_file(b"abcd")
75
+ mock_callback = Mock()
76
+
77
+ pf = ProgressFile(file, progress_callback=mock_callback)
78
+ pf.read(3)
79
+ pf.read(3)
80
+ mock_callback.assert_has_calls([call(3), call(4)])
81
+
82
+
83
+ def test_name_property() -> None:
84
+ file = _make_file(name="test.tar.gz")
85
+ pf = ProgressFile(file, progress_callback=lambda _: None)
86
+
87
+ assert pf.name == "test.tar.gz"
88
+
89
+
90
+ def test_callback_uses_current_file_position_after_seek() -> None:
91
+ file = _make_file(b"abcde")
92
+ mock_callback = Mock()
93
+
94
+ pf = ProgressFile(file, progress_callback=mock_callback)
95
+
96
+ pf.read(3)
97
+
98
+ # Imitate retrying
99
+ pf.seek(0)
100
+ pf.read(3)
101
+
102
+ pf.read(3)
103
+
104
+ mock_callback.assert_has_calls([call(3), call(5)])
@@ -1 +0,0 @@
1
- __version__ = "0.15.0"
@@ -1 +0,0 @@
1
- from .app import app as app
@@ -1,10 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- from ..utils import get_message
4
-
5
- app = FastAPI()
6
-
7
-
8
- @app.get("/")
9
- def app_root():
10
- return {"message": get_message()}
@@ -1,2 +0,0 @@
1
- def get_message() -> str:
2
- return "Hello World!"
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,8 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- app = FastAPI()
4
-
5
-
6
- @app.get("/")
7
- def app_root():
8
- return {"message": "single file app"}
@@ -1,2 +0,0 @@
1
- from .mod.api import api as api
2
- from .mod.app import app as app
@@ -1,2 +0,0 @@
1
- def get_hello_world() -> str:
2
- return "Hello World"
@@ -1 +0,0 @@
1
- from .app import app as app
@@ -1,24 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- first_other = FastAPI()
4
-
5
-
6
- @first_other.get("/")
7
- def first_other_root():
8
- return {"message": "package first_other"}
9
-
10
-
11
- second_other = FastAPI()
12
-
13
-
14
- @second_other.get("/")
15
- def second_other_root():
16
- return {"message": "package second_other"}
17
-
18
-
19
- api = FastAPI()
20
-
21
-
22
- @api.get("/")
23
- def api_root():
24
- return {"message": "package api"}
@@ -1,32 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- first_other = FastAPI()
4
-
5
-
6
- @first_other.get("/")
7
- def first_other_root():
8
- return {"message": "package first_other"}
9
-
10
-
11
- second_other = FastAPI()
12
-
13
-
14
- @second_other.get("/")
15
- def second_other_root():
16
- return {"message": "package second_other"}
17
-
18
-
19
- api = FastAPI()
20
-
21
-
22
- @api.get("/")
23
- def api_root():
24
- return {"message": "package api"}
25
-
26
-
27
- app = FastAPI()
28
-
29
-
30
- @app.get("/")
31
- def app_root():
32
- return {"message": "package app"}
@@ -1,16 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- first_other = FastAPI()
4
-
5
-
6
- @first_other.get("/")
7
- def first_other_root():
8
- return {"message": "package first_other"}
9
-
10
-
11
- second_other = FastAPI()
12
-
13
-
14
- @second_other.get("/")
15
- def second_other_root():
16
- return {"message": "package second_other"}
@@ -1,24 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- first_other = FastAPI()
4
-
5
-
6
- @first_other.get("/")
7
- def first_other_root():
8
- return {"message": "single file first_other"}
9
-
10
-
11
- second_other = FastAPI()
12
-
13
-
14
- @second_other.get("/")
15
- def second_other_root():
16
- return {"message": "single file second_other"}
17
-
18
-
19
- api = FastAPI()
20
-
21
-
22
- @api.get("/")
23
- def api_root():
24
- return {"message": "single file api"}
@@ -1,32 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- first_other = FastAPI()
4
-
5
-
6
- @first_other.get("/")
7
- def first_other_root():
8
- return {"message": "single file first_other"}
9
-
10
-
11
- second_other = FastAPI()
12
-
13
-
14
- @second_other.get("/")
15
- def second_other_root():
16
- return {"message": "single file second_other"}
17
-
18
-
19
- api = FastAPI()
20
-
21
-
22
- @api.get("/")
23
- def api_root():
24
- return {"message": "single file api"}
25
-
26
-
27
- app = FastAPI()
28
-
29
-
30
- @app.get("/")
31
- def app_root():
32
- return {"message": "single file app"}
@@ -1,16 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- first_other = FastAPI()
4
-
5
-
6
- @first_other.get("/")
7
- def first_other_root():
8
- return {"message": "single file first_other"}
9
-
10
-
11
- second_other = FastAPI()
12
-
13
-
14
- @second_other.get("/")
15
- def second_other_root():
16
- return {"message": "single file second_other"}