fastapi-cloud-cli 0.15.1__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 (57) hide show
  1. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/PKG-INFO +1 -1
  2. {fastapi_cloud_cli-0.15.1 → 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.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/deploy.py +30 -11
  6. {fastapi_cloud_cli-0.15.1 → 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.1 → fastapi_cloud_cli-0.16.0}/tests/test_cli_deploy.py +98 -1
  9. {fastapi_cloud_cli-0.15.1 → 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.1/src/fastapi_cloud_cli/__init__.py +0 -1
  12. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/LICENSE +0 -0
  13. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/README.md +0 -0
  14. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/scripts/format.sh +0 -0
  15. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/scripts/lint.sh +0 -0
  16. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/scripts/test-cov-html.sh +0 -0
  17. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/scripts/test.sh +0 -0
  18. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/__main__.py +0 -0
  19. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/cli.py +0 -0
  20. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  21. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/env.py +0 -0
  22. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/link.py +0 -0
  23. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/login.py +0 -0
  24. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  25. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/logs.py +0 -0
  26. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/setup_ci.py +0 -0
  27. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
  28. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
  29. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/config.py +0 -0
  30. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/logging.py +0 -0
  31. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/py.typed +0 -0
  32. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  33. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
  34. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/auth.py +0 -0
  35. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/cli.py +0 -0
  36. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/config.py +0 -0
  37. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
  38. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  39. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/__init__.py +0 -0
  40. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/conftest.py +0 -0
  41. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_api_client.py +0 -0
  42. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_archive.py +0 -0
  43. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_auth.py +0 -0
  44. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_cli.py +0 -0
  45. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_cli_link.py +0 -0
  46. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_cli_login.py +0 -0
  47. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_cli_logout.py +0 -0
  48. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_cli_setup_ci.py +0 -0
  49. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_cli_unlink.py +0 -0
  50. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_cli_whoami.py +0 -0
  51. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_config.py +0 -0
  52. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_env_delete.py +0 -0
  53. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_env_list.py +0 -0
  54. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_env_set.py +0 -0
  55. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_logs.py +0 -0
  56. {fastapi_cloud_cli-0.15.1 → fastapi_cloud_cli-0.16.0}/tests/test_sentry.py +0 -0
  57. {fastapi_cloud_cli-0.15.1 → 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.1
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.1"
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()
@@ -769,9 +790,7 @@ def deploy(
769
790
  f"Deployment created successfully! Deployment slug: {deployment.slug}"
770
791
  )
771
792
 
772
- progress.log("Uploading deployment...")
773
-
774
- _upload_deployment(deployment.id, archive_path)
793
+ _upload_deployment(deployment.id, archive_path, progress=progress)
775
794
 
776
795
  progress.log("Deployment uploaded successfully!")
777
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
 
@@ -1656,6 +1658,101 @@ def test_deploy_with_token_fails(
1656
1658
  )
1657
1659
 
1658
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
+
1659
1756
  @pytest.mark.respx
1660
1757
  def test_deploy_with_app_id_arg(
1661
1758
  logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
@@ -1916,7 +2013,7 @@ def test_verification_failure_after_build_complete(
1916
2013
 
1917
2014
  assert result.exit_code == 1
1918
2015
  assert "Deployment failed" in result.output
1919
- assert "Verifying failed" in result.output
2016
+ assert "Verification Failed" in result.output
1920
2017
  assert deployment_data["dashboard_url"] in result.output
1921
2018
 
1922
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.1"