fastapi-cloud-cli 0.5.2__tar.gz → 0.7.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 (82) hide show
  1. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/PKG-INFO +2 -3
  2. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/pyproject.toml +13 -4
  3. fastapi_cloud_cli-0.7.0/src/fastapi_cloud_cli/__init__.py +1 -0
  4. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/commands/deploy.py +34 -14
  5. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/utils/api.py +1 -2
  6. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_archive.py +60 -28
  7. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_cli_deploy.py +81 -31
  8. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_cli_login.py +3 -1
  9. fastapi_cloud_cli-0.5.2/requirements-tests.txt +0 -8
  10. fastapi_cloud_cli-0.5.2/requirements.txt +0 -5
  11. fastapi_cloud_cli-0.5.2/src/fastapi_cloud_cli/__init__.py +0 -1
  12. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/LICENSE +0 -0
  13. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/README.md +0 -0
  14. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/scripts/format.sh +0 -0
  15. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/scripts/lint.sh +0 -0
  16. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/scripts/test-cov-html.sh +0 -0
  17. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/scripts/test.sh +0 -0
  18. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/__main__.py +0 -0
  19. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/cli.py +0 -0
  20. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  21. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/commands/env.py +0 -0
  22. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/commands/login.py +0 -0
  23. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  24. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
  25. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
  26. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/config.py +0 -0
  27. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/logging.py +0 -0
  28. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/py.typed +0 -0
  29. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  30. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
  31. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/utils/auth.py +0 -0
  32. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/utils/cli.py +0 -0
  33. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/utils/config.py +0 -0
  34. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
  35. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/utils/pydantic_compat.py +0 -0
  36. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  37. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/__init__.py +0 -0
  38. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/broken_package/mod/__init__.py +0 -0
  39. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/broken_package/mod/app.py +0 -0
  40. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/broken_package/utils.py +0 -0
  41. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_api/api.py +0 -0
  42. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_app/api.py +0 -0
  43. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_app/app.py +0 -0
  44. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
  45. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_app_dir_api/app/api.py +0 -0
  46. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
  47. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_app_dir_app/app/api.py +0 -0
  48. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_app_dir_app/app/app.py +0 -0
  49. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
  50. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_app_dir_main/app/api.py +0 -0
  51. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_app_dir_main/app/app.py +0 -0
  52. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_app_dir_main/app/main.py +0 -0
  53. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_app_dir_non_default/app/__init__.py +0 -0
  54. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_app_dir_non_default/app/nondefault.py +0 -0
  55. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_main/api.py +0 -0
  56. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_main/app.py +0 -0
  57. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/default_main/main.py +0 -0
  58. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/default_files/non_default/nonstandard.py +0 -0
  59. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/package/__init__.py +0 -0
  60. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/package/core/__init__.py +0 -0
  61. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/package/core/utils.py +0 -0
  62. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/package/mod/__init__.py +0 -0
  63. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/package/mod/api.py +0 -0
  64. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/package/mod/app.py +0 -0
  65. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/package/mod/other.py +0 -0
  66. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/single_file_api.py +0 -0
  67. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/single_file_app.py +0 -0
  68. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/assets/single_file_other.py +0 -0
  69. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/conftest.py +0 -0
  70. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_api_client.py +0 -0
  71. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_auth.py +0 -0
  72. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_cli.py +0 -0
  73. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_cli_logout.py +0 -0
  74. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_cli_unlink.py +0 -0
  75. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_cli_whoami.py +0 -0
  76. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_config.py +0 -0
  77. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_deploy_utils.py +0 -0
  78. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_env_delete.py +0 -0
  79. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_env_list.py +0 -0
  80. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_env_set.py +0 -0
  81. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.0}/tests/test_sentry.py +0 -0
  82. {fastapi_cloud_cli-0.5.2 → fastapi_cloud_cli-0.7.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.5.2
3
+ Version: 0.7.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
@@ -25,7 +25,6 @@ Classifier: Programming Language :: Python :: 3.10
25
25
  Classifier: Programming Language :: Python :: 3.11
26
26
  Classifier: Programming Language :: Python :: 3.12
27
27
  Classifier: Programming Language :: Python :: 3.13
28
- Classifier: License :: OSI Approved :: MIT License
29
28
  Project-URL: Homepage, https://github.com/fastapilabs/fastapi-cloud-cli
30
29
  Project-URL: Documentation, https://fastapi.tiangolo.com/fastapi-cloud-cli/
31
30
  Project-URL: Repository, https://github.com/fastapilabs/fastapi-cloud-cli
@@ -39,7 +38,7 @@ Requires-Dist: httpx>=0.27.0
39
38
  Requires-Dist: rich-toolkit>=0.14.5
40
39
  Requires-Dist: pydantic[email]>=1.6.1
41
40
  Requires-Dist: sentry-sdk>=2.20.0
42
- Requires-Dist: fastar>=0.5.0
41
+ Requires-Dist: fastar>=0.8.0
43
42
  Provides-Extra: standard
44
43
  Requires-Dist: uvicorn[standard]>=0.15.0; extra == "standard"
45
44
  Description-Content-Type: text/markdown
@@ -29,7 +29,6 @@ classifiers = [
29
29
  "Programming Language :: Python :: 3.11",
30
30
  "Programming Language :: Python :: 3.12",
31
31
  "Programming Language :: Python :: 3.13",
32
- "License :: OSI Approved :: MIT License",
33
32
  ]
34
33
  dependencies = [
35
34
  "typer >= 0.12.3",
@@ -39,9 +38,9 @@ dependencies = [
39
38
  "rich-toolkit >= 0.14.5",
40
39
  "pydantic[email] >= 1.6.1",
41
40
  "sentry-sdk >= 2.20.0",
42
- "fastar >= 0.5.0",
41
+ "fastar >= 0.8.0",
43
42
  ]
44
- version = "0.5.2"
43
+ version = "0.7.0"
45
44
 
46
45
  [project.license]
47
46
  text = "MIT"
@@ -58,6 +57,17 @@ Repository = "https://github.com/fastapilabs/fastapi-cloud-cli"
58
57
  Issues = "https://github.com/fastapilabs/fastapi-cloud-cli/issues"
59
58
  Changelog = "https://github.com/fastapilabs/fastapi-cloud-cli/blob/main/release-notes.md"
60
59
 
60
+ [dependency-groups]
61
+ dev = [
62
+ "pre-commit>=2.17.0,<5.0.0",
63
+ "pytest>=4.4.0,<9.0.0",
64
+ "coverage[toml]>=6.2,<8.0",
65
+ "mypy==1.14.1",
66
+ "ruff==0.13.0",
67
+ "respx==0.22.0",
68
+ "time-machine==2.15.0",
69
+ ]
70
+
61
71
  [build-system]
62
72
  requires = [
63
73
  "pdm-backend",
@@ -74,7 +84,6 @@ path = "src/fastapi_cloud_cli/__init__.py"
74
84
  [tool.pdm.build]
75
85
  source-includes = [
76
86
  "tests/",
77
- "requirements*.txt",
78
87
  "scripts/",
79
88
  ]
80
89
 
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -6,6 +6,7 @@ import time
6
6
  from enum import Enum
7
7
  from itertools import cycle
8
8
  from pathlib import Path
9
+ from textwrap import dedent
9
10
  from typing import Any, Dict, List, Optional, Union
10
11
 
11
12
  import fastar
@@ -32,6 +33,19 @@ from fastapi_cloud_cli.utils.pydantic_compat import (
32
33
  logger = logging.getLogger(__name__)
33
34
 
34
35
 
36
+ def _cancel_upload(deployment_id: str) -> None:
37
+ logger.debug("Cancelling upload for deployment: %s", deployment_id)
38
+
39
+ try:
40
+ with APIClient() as client:
41
+ response = client.post(f"/deployments/{deployment_id}/upload-cancelled")
42
+ response.raise_for_status()
43
+
44
+ logger.debug("Upload cancellation notification sent successfully")
45
+ except Exception as e:
46
+ logger.debug("Failed to notify server about upload cancellation: %s", e)
47
+
48
+
35
49
  def _get_app_name(path: Path) -> str:
36
50
  # TODO: use pyproject.toml to get the app name
37
51
  return path.name
@@ -68,7 +82,7 @@ def archive(path: Path, tar_path: Path) -> Path:
68
82
  logger.debug("Archive will be created at: %s", tar_path)
69
83
 
70
84
  file_count = 0
71
- with fastar.open(tar_path, "w") as tar:
85
+ with fastar.open(tar_path, "w:zst") as tar:
72
86
  for filename in files:
73
87
  if filename.is_dir():
74
88
  continue
@@ -260,8 +274,7 @@ LONG_WAIT_MESSAGES = [
260
274
 
261
275
 
262
276
  def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
263
- if not toolkit.confirm(f"Setup and deploy [blue]{path_to_deploy}[/]?", tag="dir"):
264
- raise typer.Exit(0)
277
+ toolkit.print(f"Setting up and deploying [blue]{path_to_deploy}[/blue]", tag="path")
265
278
 
266
279
  toolkit.print_line()
267
280
 
@@ -389,12 +402,15 @@ def _wait_for_deployment(
389
402
 
390
403
  last_message_changed_at = time.monotonic()
391
404
 
392
- except (BuildLogError, TooManyRetriesError) as e:
393
- logger.error("Build log streaming failed: %s", e)
394
- toolkit.print_line()
395
- toolkit.print(
396
- f"⚠️ Unable to stream build logs. Check the dashboard for status: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
405
+ except (BuildLogError, TooManyRetriesError, TimeoutError) as e:
406
+ progress.set_error(
407
+ dedent(f"""
408
+ [error]Build log streaming failed: {e}[/]
409
+
410
+ Unable to stream build logs. Check the dashboard for status: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]
411
+ """).strip()
397
412
  )
413
+
398
414
  raise typer.Exit(1) from e
399
415
 
400
416
 
@@ -599,15 +615,19 @@ def deploy(
599
615
  logger.debug("Creating deployment for app: %s", app.id)
600
616
  deployment = _create_deployment(app.id)
601
617
 
602
- progress.log(
603
- f"Deployment created successfully! Deployment slug: {deployment.slug}"
604
- )
618
+ try:
619
+ progress.log(
620
+ f"Deployment created successfully! Deployment slug: {deployment.slug}"
621
+ )
605
622
 
606
- progress.log("Uploading deployment...")
623
+ progress.log("Uploading deployment...")
607
624
 
608
- _upload_deployment(deployment.id, archive_path)
625
+ _upload_deployment(deployment.id, archive_path)
609
626
 
610
- progress.log("Deployment uploaded successfully!")
627
+ progress.log("Deployment uploaded successfully!")
628
+ except KeyboardInterrupt:
629
+ _cancel_upload(deployment.id)
630
+ raise
611
631
 
612
632
  toolkit.print_line()
613
633
 
@@ -114,8 +114,7 @@ def attempts(
114
114
  for attempt_number in range(total_attempts):
115
115
  if time.monotonic() - start > timeout.total_seconds():
116
116
  raise TimeoutError(
117
- "Build log streaming timed out after %ds",
118
- timeout.total_seconds(),
117
+ f"Build log streaming timed out after {timeout.total_seconds():.0f}s"
119
118
  )
120
119
 
121
120
  with attempt(attempt_number):
@@ -1,6 +1,6 @@
1
- import tarfile
2
1
  from pathlib import Path
3
2
 
3
+ import fastar
4
4
  import pytest
5
5
 
6
6
  from fastapi_cloud_cli.commands.deploy import archive
@@ -13,6 +13,13 @@ def src_path(tmp_path: Path) -> Path:
13
13
  return path
14
14
 
15
15
 
16
+ @pytest.fixture
17
+ def dst_path(tmp_path: Path) -> Path:
18
+ path = tmp_path / "destination"
19
+ path.mkdir()
20
+ return path
21
+
22
+
16
23
  @pytest.fixture
17
24
  def tar_path(tmp_path: Path) -> Path:
18
25
  return tmp_path / "archive.tar"
@@ -29,7 +36,7 @@ def test_archive_creates_tar_file(src_path: Path, tar_path: Path) -> None:
29
36
 
30
37
 
31
38
  def test_archive_excludes_venv_and_similar_folders(
32
- src_path: Path, tar_path: Path
39
+ src_path: Path, tar_path: Path, dst_path: Path
33
40
  ) -> None:
34
41
  """Should exclude .venv directory from archive."""
35
42
  # the only files we want to include
@@ -53,24 +60,38 @@ def test_archive_excludes_venv_and_similar_folders(
53
60
 
54
61
  archive(src_path, tar_path)
55
62
 
56
- with tarfile.open(tar_path, "r") as tar:
57
- names = tar.getnames()
58
- assert set(names) == {"main.py", "static/index.html"}
63
+ with fastar.open(tar_path, "r") as tar:
64
+ tar.unpack(dst_path)
65
+
66
+ assert set(dst_path.glob("**/*")) == {
67
+ dst_path / "main.py",
68
+ dst_path / "static",
69
+ dst_path / "static" / "index.html",
70
+ }
59
71
 
60
72
 
61
- def test_archive_preserves_relative_paths(src_path: Path, tar_path: Path) -> None:
73
+ def test_archive_preserves_relative_paths(
74
+ src_path: Path, tar_path: Path, dst_path: Path
75
+ ) -> None:
62
76
  (src_path / "src").mkdir()
63
77
  (src_path / "src" / "app").mkdir()
64
78
  (src_path / "src" / "app" / "main.py").write_text("print('hello')")
65
79
 
66
80
  archive(src_path, tar_path)
67
81
 
68
- with tarfile.open(tar_path, "r") as tar:
69
- names = tar.getnames()
70
- assert names == ["src/app/main.py"]
82
+ with fastar.open(tar_path, "r") as tar:
83
+ tar.unpack(dst_path)
71
84
 
85
+ assert set(dst_path.glob("**/*")) == {
86
+ dst_path / "src",
87
+ dst_path / "src" / "app",
88
+ dst_path / "src" / "app" / "main.py",
89
+ }
72
90
 
73
- def test_archive_respects_fastapicloudignore(src_path: Path, tar_path: Path) -> None:
91
+
92
+ def test_archive_respects_fastapicloudignore(
93
+ src_path: Path, tar_path: Path, dst_path: Path
94
+ ) -> None:
74
95
  """Should exclude files specified in .fastapicloudignore."""
75
96
  (src_path / "main.py").write_text("print('hello')")
76
97
  (src_path / "config.py").write_text("CONFIG = 'value'")
@@ -82,16 +103,17 @@ def test_archive_respects_fastapicloudignore(src_path: Path, tar_path: Path) ->
82
103
 
83
104
  archive(src_path, tar_path)
84
105
 
85
- with tarfile.open(tar_path, "r") as tar:
86
- names = tar.getnames()
87
- assert set(names) == {
88
- "main.py",
89
- "config.py",
90
- }
106
+ with fastar.open(tar_path, "r") as tar:
107
+ tar.unpack(dst_path)
108
+
109
+ assert set(dst_path.glob("**/*")) == {
110
+ dst_path / "main.py",
111
+ dst_path / "config.py",
112
+ }
91
113
 
92
114
 
93
115
  def test_archive_respects_fastapicloudignore_unignore(
94
- src_path: Path, tar_path: Path
116
+ src_path: Path, tar_path: Path, dst_path: Path
95
117
  ) -> None:
96
118
  """Test we can use .fastapicloudignore to unignore files inside .gitignore"""
97
119
  (src_path / "main.py").write_text("print('hello')")
@@ -109,12 +131,20 @@ def test_archive_respects_fastapicloudignore_unignore(
109
131
 
110
132
  archive(src_path, tar_path)
111
133
 
112
- with tarfile.open(tar_path, "r") as tar:
113
- names = tar.getnames()
114
- assert set(names) == {"main.py", "static/build/style.css"}
134
+ with fastar.open(tar_path, "r") as tar:
135
+ tar.unpack(dst_path)
115
136
 
137
+ assert set(dst_path.glob("**/*")) == {
138
+ dst_path / "main.py",
139
+ dst_path / "static",
140
+ dst_path / "static" / "build",
141
+ dst_path / "static" / "build" / "style.css",
142
+ }
116
143
 
117
- def test_archive_includes_hidden_files(src_path: Path, tar_path: Path) -> None:
144
+
145
+ def test_archive_includes_hidden_files(
146
+ src_path: Path, tar_path: Path, dst_path: Path
147
+ ) -> None:
118
148
  """Should include hidden files in the archive by default."""
119
149
  (src_path / "main.py").write_text("print('hello')")
120
150
  (src_path / ".env").write_text("SECRET_KEY=xyz")
@@ -123,10 +153,12 @@ def test_archive_includes_hidden_files(src_path: Path, tar_path: Path) -> None:
123
153
 
124
154
  archive(src_path, tar_path)
125
155
 
126
- with tarfile.open(tar_path, "r") as tar:
127
- names = tar.getnames()
128
- assert set(names) == {
129
- "main.py",
130
- ".env",
131
- ".config/settings.json",
132
- }
156
+ with fastar.open(tar_path, "r") as tar:
157
+ tar.unpack(dst_path)
158
+
159
+ assert set(dst_path.glob("**/*")) == {
160
+ dst_path / "main.py",
161
+ dst_path / ".env",
162
+ dst_path / ".config",
163
+ dst_path / ".config" / "settings.json",
164
+ }
@@ -15,6 +15,7 @@ from typer.testing import CliRunner
15
15
 
16
16
  from fastapi_cloud_cli.cli import app
17
17
  from fastapi_cloud_cli.config import Settings
18
+ from fastapi_cloud_cli.utils.api import BuildLogError, TooManyRetriesError
18
19
  from tests.conftest import ConfiguredApp
19
20
  from tests.utils import Keys, build_logs_response, changing_dir
20
21
 
@@ -211,20 +212,6 @@ def test_shows_waitlist_form_when_not_logged_in_longer_flow(
211
212
  assert "Let's go! Thanks for your interest in FastAPI Cloud! 🚀" in result.output
212
213
 
213
214
 
214
- def test_asks_to_setup_the_app(logged_in_cli: None, tmp_path: Path) -> None:
215
- steps = [Keys.RIGHT_ARROW, Keys.ENTER]
216
-
217
- with changing_dir(tmp_path), patch(
218
- "rich_toolkit.container.getchar"
219
- ) as mock_getchar:
220
- mock_getchar.side_effect = steps
221
-
222
- result = runner.invoke(app, ["deploy"])
223
-
224
- assert result.exit_code == 0
225
- assert "Setup and deploy" in result.output
226
-
227
-
228
215
  @pytest.mark.respx(base_url=settings.base_api_url)
229
216
  def test_shows_error_when_trying_to_get_teams(
230
217
  logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
@@ -323,7 +310,7 @@ def test_asks_for_app_name_after_team(
323
310
  def test_creates_app_on_backend(
324
311
  logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
325
312
  ) -> None:
326
- steps = [Keys.ENTER, Keys.ENTER, Keys.ENTER, *"demo", Keys.ENTER]
313
+ steps = [Keys.ENTER, Keys.ENTER, *"demo", Keys.ENTER]
327
314
 
328
315
  team = _get_random_team()
329
316
 
@@ -354,7 +341,7 @@ def test_creates_app_on_backend(
354
341
  def test_uses_existing_app(
355
342
  logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
356
343
  ) -> None:
357
- steps = [Keys.ENTER, Keys.ENTER, Keys.RIGHT_ARROW, Keys.ENTER, *"demo", Keys.ENTER]
344
+ steps = [Keys.ENTER, Keys.RIGHT_ARROW, Keys.ENTER, *"demo", Keys.ENTER]
358
345
 
359
346
  team = _get_random_team()
360
347
 
@@ -384,7 +371,6 @@ def test_exits_successfully_when_deployment_is_done(
384
371
  logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
385
372
  ) -> None:
386
373
  steps = [
387
- Keys.ENTER,
388
374
  Keys.ENTER,
389
375
  Keys.ENTER,
390
376
  *"demo",
@@ -634,7 +620,6 @@ def test_shows_error_when_app_does_not_exist(
634
620
 
635
621
  def _deploy_without_waiting(respx_mock: respx.MockRouter, tmp_path: Path) -> Result:
636
622
  steps = [
637
- Keys.ENTER,
638
623
  Keys.ENTER,
639
624
  Keys.ENTER,
640
625
  *"demo",
@@ -759,7 +744,6 @@ def test_shows_no_apps_found_message_when_team_has_no_apps(
759
744
  logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
760
745
  ) -> None:
761
746
  steps = [
762
- Keys.ENTER, # Setup and deploy
763
747
  Keys.ENTER, # Select team
764
748
  Keys.RIGHT_ARROW, # Choose existing app (No)
765
749
  Keys.ENTER,
@@ -788,11 +772,14 @@ def test_shows_no_apps_found_message_when_team_has_no_apps(
788
772
  )
789
773
 
790
774
 
775
+ @pytest.mark.parametrize(
776
+ "error",
777
+ [BuildLogError, TooManyRetriesError, TimeoutError],
778
+ )
791
779
  @pytest.mark.respx(base_url=settings.base_api_url)
792
- def test_handles_build_log_streaming_error(
793
- logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
780
+ def test_shows_error_message_on_build_exception(
781
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter, error: Exception
794
782
  ) -> None:
795
- """Test that BuildLogError is caught and shows dashboard link (lines 384, 387-392)."""
796
783
  app_data = _get_random_app()
797
784
  team_data = _get_random_team()
798
785
  app_id = app_data["id"]
@@ -819,11 +806,10 @@ def test_handles_build_log_streaming_error(
819
806
  return_value=Response(200)
820
807
  )
821
808
 
822
- respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock(
823
- return_value=Response(422, text="Error")
824
- )
825
-
826
- with changing_dir(tmp_path):
809
+ with changing_dir(tmp_path), patch(
810
+ "fastapi_cloud_cli.utils.api.APIClient.stream_build_logs",
811
+ side_effect=error,
812
+ ):
827
813
  result = runner.invoke(app, ["deploy"])
828
814
 
829
815
  assert result.exit_code == 1
@@ -832,10 +818,8 @@ def test_handles_build_log_streaming_error(
832
818
 
833
819
 
834
820
  @pytest.mark.respx(base_url=settings.base_api_url)
835
- def test_shows_error_message_when_build_log_streaming_fails(
836
- logged_in_cli: None,
837
- tmp_path: Path,
838
- respx_mock: respx.MockRouter,
821
+ def test_shows_error_message_on_build_log_http_error(
822
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
839
823
  ) -> None:
840
824
  app_data = _get_random_app()
841
825
  team_data = _get_random_team()
@@ -870,6 +854,7 @@ def test_shows_error_message_when_build_log_streaming_fails(
870
854
  with changing_dir(tmp_path), patch("time.sleep"):
871
855
  result = runner.invoke(app, ["deploy"])
872
856
 
857
+ assert result.exit_code == 1
873
858
  assert "Unable to stream build logs" in result.output
874
859
  assert deployment_data["dashboard_url"] in result.output
875
860
 
@@ -1007,3 +992,68 @@ def test_long_wait_messages(
1007
992
  result = runner.invoke(app, ["deploy"])
1008
993
 
1009
994
  assert "long wait message" in result.output
995
+
996
+
997
+ @pytest.mark.respx(base_url=settings.base_api_url)
998
+ def test_calls_upload_cancelled_when_user_interrupts(
999
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1000
+ ) -> None:
1001
+ app_data = _get_random_app()
1002
+ team_data = _get_random_team()
1003
+ app_id = app_data["id"]
1004
+ team_id = team_data["id"]
1005
+ deployment_data = _get_random_deployment(app_id=app_id)
1006
+
1007
+ config_path = tmp_path / ".fastapicloud" / "cloud.json"
1008
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1009
+ config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}')
1010
+
1011
+ respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data))
1012
+ respx_mock.post(f"/apps/{app_id}/deployments/").mock(
1013
+ return_value=Response(201, json=deployment_data)
1014
+ )
1015
+
1016
+ upload_cancelled_route = respx_mock.post(
1017
+ f"/deployments/{deployment_data['id']}/upload-cancelled"
1018
+ ).mock(return_value=Response(200))
1019
+
1020
+ with changing_dir(tmp_path), patch(
1021
+ "fastapi_cloud_cli.commands.deploy._upload_deployment",
1022
+ side_effect=KeyboardInterrupt(),
1023
+ ):
1024
+ runner.invoke(app, ["deploy"])
1025
+
1026
+ assert upload_cancelled_route.called
1027
+
1028
+
1029
+ @pytest.mark.respx(base_url=settings.base_api_url)
1030
+ def test_cancel_upload_swallows_exceptions(
1031
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1032
+ ) -> None:
1033
+ app_data = _get_random_app()
1034
+ team_data = _get_random_team()
1035
+ app_id = app_data["id"]
1036
+ team_id = team_data["id"]
1037
+ deployment_data = _get_random_deployment(app_id=app_id)
1038
+
1039
+ config_path = tmp_path / ".fastapicloud" / "cloud.json"
1040
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1041
+ config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}')
1042
+
1043
+ respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data))
1044
+ respx_mock.post(f"/apps/{app_id}/deployments/").mock(
1045
+ return_value=Response(201, json=deployment_data)
1046
+ )
1047
+
1048
+ upload_cancelled_route = respx_mock.post(
1049
+ f"/deployments/{deployment_data['id']}/upload-cancelled"
1050
+ ).mock(return_value=Response(500))
1051
+
1052
+ with changing_dir(tmp_path), patch(
1053
+ "fastapi_cloud_cli.commands.deploy._upload_deployment",
1054
+ side_effect=KeyboardInterrupt(),
1055
+ ):
1056
+ result = runner.invoke(app, ["deploy"])
1057
+
1058
+ assert upload_cancelled_route.called
1059
+ assert "HTTPStatusError" not in result.output
@@ -19,7 +19,9 @@ assets_path = Path(__file__).parent / "assets"
19
19
 
20
20
 
21
21
  @pytest.mark.respx(base_url=settings.base_api_url)
22
- def test_shows_a_message_if_something_is_wrong(respx_mock: respx.MockRouter) -> None:
22
+ def test_shows_a_message_if_something_is_wrong(
23
+ logged_out_cli: None, respx_mock: respx.MockRouter
24
+ ) -> None:
23
25
  with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open:
24
26
  respx_mock.post(
25
27
  "/login/device/authorization", data={"client_id": settings.client_id}
@@ -1,8 +0,0 @@
1
- -e .
2
-
3
- pytest >=4.4.0,<9.0.0
4
- coverage[toml] >=6.2,<8.0
5
- mypy ==1.14.1
6
- ruff ==0.13.0
7
- respx ==0.22.0
8
- time-machine ==2.15.0
@@ -1,5 +0,0 @@
1
- -e .
2
-
3
- -r requirements-tests.txt
4
-
5
- pre-commit >=2.17.0,<5.0.0
@@ -1 +0,0 @@
1
- __version__ = "0.5.2"