fastapi-cloud-cli 0.1.1__tar.gz → 0.1.4__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 (77) hide show
  1. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/PKG-INFO +2 -2
  2. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/pyproject.toml +2 -2
  3. fastapi_cloud_cli-0.1.4/src/fastapi_cloud_cli/__init__.py +1 -0
  4. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/commands/deploy.py +3 -7
  5. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/commands/env.py +1 -3
  6. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/commands/login.py +5 -1
  7. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/commands/whoami.py +2 -2
  8. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/config.py +3 -2
  9. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/utils/api.py +3 -1
  10. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/utils/cli.py +1 -1
  11. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/conftest.py +18 -6
  12. fastapi_cloud_cli-0.1.4/tests/test_cli.py +11 -0
  13. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/test_cli_deploy.py +148 -1
  14. fastapi_cloud_cli-0.1.4/tests/test_cli_login.py +164 -0
  15. fastapi_cloud_cli-0.1.4/tests/test_cli_logout.py +31 -0
  16. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/test_cli_whoami.py +20 -6
  17. fastapi_cloud_cli-0.1.4/tests/test_deploy_utils.py +64 -0
  18. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/test_env_delete.py +2 -1
  19. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/test_env_list.py +2 -1
  20. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/test_env_set.py +2 -1
  21. fastapi_cloud_cli-0.1.4/tests/test_sentry.py +22 -0
  22. fastapi_cloud_cli-0.1.1/src/fastapi_cloud_cli/__init__.py +0 -1
  23. fastapi_cloud_cli-0.1.1/tests/test_cli_login.py +0 -65
  24. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/LICENSE +0 -0
  25. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/README.md +0 -0
  26. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/requirements-tests.txt +0 -0
  27. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/requirements.txt +0 -0
  28. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/scripts/format.sh +0 -0
  29. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/scripts/lint.sh +0 -0
  30. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/scripts/test-cov-html.sh +0 -0
  31. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/scripts/test.sh +0 -0
  32. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/__main__.py +0 -0
  33. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/cli.py +0 -0
  34. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  35. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  36. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/logging.py +0 -0
  37. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/py.typed +0 -0
  38. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  39. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/utils/apps.py +0 -0
  40. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/utils/auth.py +0 -0
  41. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/utils/config.py +0 -0
  42. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/utils/env.py +0 -0
  43. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  44. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/__init__.py +0 -0
  45. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/broken_package/mod/__init__.py +0 -0
  46. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/broken_package/mod/app.py +0 -0
  47. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/broken_package/utils.py +0 -0
  48. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_api/api.py +0 -0
  49. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_app/api.py +0 -0
  50. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_app/app.py +0 -0
  51. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
  52. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_app_dir_api/app/api.py +0 -0
  53. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
  54. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_app_dir_app/app/api.py +0 -0
  55. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_app_dir_app/app/app.py +0 -0
  56. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
  57. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_app_dir_main/app/api.py +0 -0
  58. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_app_dir_main/app/app.py +0 -0
  59. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_app_dir_main/app/main.py +0 -0
  60. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_app_dir_non_default/app/__init__.py +0 -0
  61. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_app_dir_non_default/app/nondefault.py +0 -0
  62. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_main/api.py +0 -0
  63. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_main/app.py +0 -0
  64. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/default_main/main.py +0 -0
  65. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/default_files/non_default/nonstandard.py +0 -0
  66. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/package/__init__.py +0 -0
  67. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/package/core/__init__.py +0 -0
  68. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/package/core/utils.py +0 -0
  69. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/package/mod/__init__.py +0 -0
  70. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/package/mod/api.py +0 -0
  71. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/package/mod/app.py +0 -0
  72. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/package/mod/other.py +0 -0
  73. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/single_file_api.py +0 -0
  74. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/single_file_app.py +0 -0
  75. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/assets/single_file_other.py +0 -0
  76. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/test_config.py +0 -0
  77. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.4}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-cloud-cli
3
- Version: 0.1.1
3
+ Version: 0.1.4
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
@@ -35,7 +35,7 @@ Requires-Python: >=3.8
35
35
  Requires-Dist: typer>=0.12.3
36
36
  Requires-Dist: uvicorn[standard]>=0.15.0
37
37
  Requires-Dist: rignore>=0.5.1
38
- Requires-Dist: httpx<0.28.0,>=0.27.0
38
+ Requires-Dist: httpx>=0.27.0
39
39
  Requires-Dist: rich-toolkit>=0.14.5
40
40
  Requires-Dist: pydantic[email]>=1.6.1
41
41
  Requires-Dist: sentry-sdk>=2.20.0
@@ -35,12 +35,12 @@ dependencies = [
35
35
  "typer >= 0.12.3",
36
36
  "uvicorn[standard] >= 0.15.0",
37
37
  "rignore >= 0.5.1",
38
- "httpx >= 0.27.0,< 0.28.0",
38
+ "httpx >= 0.27.0",
39
39
  "rich-toolkit >= 0.14.5",
40
40
  "pydantic[email] >= 1.6.1",
41
41
  "sentry-sdk >= 2.20.0",
42
42
  ]
43
- version = "0.1.1"
43
+ version = "0.1.4"
44
44
 
45
45
  [project.license]
46
46
  text = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.4"
@@ -375,12 +375,12 @@ def _wait_for_deployment(
375
375
  raise typer.Exit(1)
376
376
 
377
377
  if time_elapsed > 30:
378
- messages = cycle(LONG_WAIT_MESSAGES)
378
+ messages = cycle(LONG_WAIT_MESSAGES) # pragma: no cover
379
379
 
380
380
  if (time.monotonic() - last_message_changed_at) > 2:
381
- progress.title = next(messages)
381
+ progress.title = next(messages) # pragma: no cover
382
382
 
383
- last_message_changed_at = time.monotonic()
383
+ last_message_changed_at = time.monotonic() # pragma: no cover
384
384
 
385
385
 
386
386
  def _setup_environment_variables(toolkit: RichToolkit, app_id: str) -> None:
@@ -529,10 +529,6 @@ def _waitlist_form(toolkit: RichToolkit) -> None:
529
529
  check=False,
530
530
  )
531
531
 
532
- toolkit.print_line()
533
-
534
- toolkit.print("Thank you for your interest in FastAPI Cloud! 🚀")
535
-
536
532
 
537
533
  def deploy(
538
534
  path: Annotated[
@@ -161,11 +161,9 @@ def delete(
161
161
  {"name": env_var.name, "value": env_var.name}
162
162
  for env_var in environment_variables.data
163
163
  ],
164
- default=None,
165
164
  )
166
165
 
167
- if not name:
168
- return
166
+ assert name
169
167
  else:
170
168
  if not validate_environment_variable_name(name):
171
169
  toolkit.print(
@@ -6,7 +6,7 @@ import httpx
6
6
  import typer
7
7
  from pydantic import BaseModel
8
8
 
9
- from fastapi_cloud_cli.config import settings
9
+ from fastapi_cloud_cli.config import Settings
10
10
  from fastapi_cloud_cli.utils.api import APIClient
11
11
  from fastapi_cloud_cli.utils.auth import AuthConfig, write_auth_config
12
12
  from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
@@ -29,6 +29,8 @@ class TokenResponse(BaseModel):
29
29
  def _start_device_authorization(
30
30
  client: httpx.Client,
31
31
  ) -> AuthorizationData:
32
+ settings = Settings.get()
33
+
32
34
  response = client.post(
33
35
  "/login/device/authorization", data={"client_id": settings.client_id}
34
36
  )
@@ -39,6 +41,8 @@ def _start_device_authorization(
39
41
 
40
42
 
41
43
  def _fetch_access_token(client: httpx.Client, device_code: str, interval: int) -> str:
44
+ settings = Settings.get()
45
+
42
46
  while True:
43
47
  response = client.post(
44
48
  "/login/device/token",
@@ -17,11 +17,11 @@ def whoami() -> Any:
17
17
  return
18
18
 
19
19
  with APIClient() as client:
20
- with Progress(title="⚡Fetching profile", transient=True) as progress:
20
+ with Progress(title="⚡ Fetching profile", transient=True) as progress:
21
21
  with handle_http_errors(progress, message=""):
22
22
  response = client.get("/users/me")
23
23
  response.raise_for_status()
24
24
 
25
25
  data = response.json()
26
26
 
27
- print(f"⚡[bold]{data['email']}[/bold]")
27
+ print(f"⚡ [bold]{data['email']}[/bold]")
@@ -21,5 +21,6 @@ class Settings(BaseModel):
21
21
 
22
22
  return cls(**user_settings)
23
23
 
24
-
25
- settings = Settings.from_user_settings(get_cli_config_path())
24
+ @classmethod
25
+ def get(cls) -> "Settings":
26
+ return cls.from_user_settings(get_cli_config_path())
@@ -1,12 +1,14 @@
1
1
  import httpx
2
2
 
3
3
  from fastapi_cloud_cli import __version__
4
- from fastapi_cloud_cli.config import settings
4
+ from fastapi_cloud_cli.config import Settings
5
5
  from fastapi_cloud_cli.utils.auth import get_auth_token
6
6
 
7
7
 
8
8
  class APIClient(httpx.Client):
9
9
  def __init__(self) -> None:
10
+ settings = Settings.get()
11
+
10
12
  token = get_auth_token()
11
13
 
12
14
  super().__init__(
@@ -85,7 +85,7 @@ def handle_http_errors(
85
85
 
86
86
  # Handle validation errors from Pydantic models, this should make it easier to debug :)
87
87
  if isinstance(e, HTTPStatusError) and e.response.status_code == 422:
88
- logger.debug(e.response.json())
88
+ logger.debug(e.response.json()) # pragma: no cover
89
89
 
90
90
  if isinstance(e, HTTPStatusError) and e.response.status_code in (401, 403):
91
91
  message = "The specified token is not valid. Use `fastapi login` to generate a new token."
@@ -25,15 +25,17 @@ def setup_terminal() -> None:
25
25
 
26
26
 
27
27
  @pytest.fixture
28
- def logged_in_cli() -> Generator[None, None, None]:
29
- with patch("fastapi_cloud_cli.utils.auth.get_auth_token", return_value=True):
30
- yield
28
+ def logged_in_cli(temp_auth_config: Path) -> Generator[None, None, None]:
29
+ temp_auth_config.write_text('{"access_token": "test_token_12345"}')
30
+
31
+ yield
31
32
 
32
33
 
33
34
  @pytest.fixture
34
- def logged_out_cli() -> Generator[None, None, None]:
35
- with patch("fastapi_cloud_cli.utils.auth.get_auth_token", return_value=None):
36
- yield
35
+ def logged_out_cli(temp_auth_config: Path) -> Generator[None, None, None]:
36
+ assert not temp_auth_config.exists()
37
+
38
+ yield
37
39
 
38
40
 
39
41
  @dataclass
@@ -54,3 +56,13 @@ def configured_app(tmp_path: Path) -> ConfiguredApp:
54
56
  config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}')
55
57
 
56
58
  return ConfiguredApp(app_id=app_id, team_id=team_id, path=tmp_path)
59
+
60
+
61
+ @pytest.fixture
62
+ def temp_auth_config(tmp_path: Path) -> Generator[Path, None, None]:
63
+ """Provides a temporary auth config setup for testing file operations."""
64
+
65
+ with patch(
66
+ "fastapi_cloud_cli.utils.config.get_config_folder", return_value=tmp_path
67
+ ):
68
+ yield tmp_path / "auth.json"
@@ -0,0 +1,11 @@
1
+ import subprocess
2
+ import sys
3
+
4
+
5
+ def test_script() -> None:
6
+ result = subprocess.run(
7
+ [sys.executable, "-m", "coverage", "run", "-m", "fastapi_cloud_cli", "--help"],
8
+ capture_output=True,
9
+ encoding="utf-8",
10
+ )
11
+ assert "Usage" in result.stdout
@@ -11,11 +11,12 @@ from httpx import Response
11
11
  from typer.testing import CliRunner
12
12
 
13
13
  from fastapi_cloud_cli.cli import app
14
- from fastapi_cloud_cli.config import settings
14
+ from fastapi_cloud_cli.config import Settings
15
15
  from tests.conftest import ConfiguredApp
16
16
  from tests.utils import Keys, changing_dir
17
17
 
18
18
  runner = CliRunner()
19
+ settings = Settings.get()
19
20
 
20
21
  assets_path = Path(__file__).parent / "assets"
21
22
 
@@ -652,3 +653,149 @@ def test_does_not_duplicate_entry_in_git_ignore(
652
653
  _deploy_without_waiting(respx_mock, tmp_path)
653
654
 
654
655
  assert git_ignore_path.read_text() == ".fastapicloud\n"
656
+
657
+
658
+ @pytest.mark.respx(base_url=settings.base_api_url)
659
+ def test_creates_environment_variables_during_app_setup(
660
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
661
+ ) -> None:
662
+ steps = [
663
+ Keys.ENTER, # Setup and deploy
664
+ Keys.ENTER, # Select team
665
+ Keys.ENTER, # Create new app
666
+ *"demo", # App name
667
+ Keys.ENTER,
668
+ Keys.ENTER, # Setup environment variables (Yes)
669
+ *"API_KEY", # Environment variable name
670
+ Keys.ENTER,
671
+ *"secret123", # Environment variable value
672
+ Keys.ENTER,
673
+ Keys.ENTER, # Empty key to finish
674
+ Keys.CTRL_C, # Exit before deployment
675
+ ]
676
+
677
+ team = _get_random_team()
678
+ app_data = _get_random_app(team_id=team["id"])
679
+
680
+ respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]}))
681
+
682
+ respx_mock.post("/apps/", json={"name": "demo", "team_id": team["id"]}).mock(
683
+ return_value=Response(201, json=app_data)
684
+ )
685
+
686
+ env_vars_request = respx_mock.patch(
687
+ f"/apps/{app_data['id']}/environment-variables/", json={"API_KEY": "secret123"}
688
+ ).mock(return_value=Response(200))
689
+
690
+ with changing_dir(tmp_path), patch("click.getchar") as mock_getchar:
691
+ mock_getchar.side_effect = steps
692
+
693
+ result = runner.invoke(app, ["deploy"])
694
+
695
+ assert result.exit_code == 1
696
+ assert env_vars_request.called
697
+ assert "Environment variables set up successfully!" in result.output
698
+
699
+
700
+ @pytest.mark.respx(base_url=settings.base_api_url)
701
+ def test_rejects_invalid_environment_variable_names(
702
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
703
+ ) -> None:
704
+ steps = [
705
+ Keys.ENTER, # Setup and deploy
706
+ Keys.ENTER, # Select team
707
+ Keys.ENTER, # Create new app
708
+ *"demo", # App name
709
+ Keys.ENTER,
710
+ Keys.ENTER, # Setup environment variables (Yes)
711
+ *"123-invalid", # Invalid environment variable name (starts with digit, contains hyphen)
712
+ Keys.ENTER,
713
+ *"VALID_KEY", # Valid environment variable name
714
+ Keys.ENTER,
715
+ *"value123", # Environment variable value
716
+ Keys.ENTER,
717
+ Keys.ENTER, # Empty key to finish
718
+ Keys.CTRL_C, # Exit before deployment
719
+ ]
720
+
721
+ team = _get_random_team()
722
+ app_data = _get_random_app(team_id=team["id"])
723
+
724
+ respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]}))
725
+
726
+ respx_mock.post("/apps/", json={"name": "demo", "team_id": team["id"]}).mock(
727
+ return_value=Response(201, json=app_data)
728
+ )
729
+
730
+ env_vars_request = respx_mock.patch(
731
+ f"/apps/{app_data['id']}/environment-variables/", json={"VALID_KEY": "value123"}
732
+ ).mock(return_value=Response(200))
733
+
734
+ with changing_dir(tmp_path), patch("click.getchar") as mock_getchar:
735
+ mock_getchar.side_effect = steps
736
+
737
+ result = runner.invoke(app, ["deploy"])
738
+
739
+ assert result.exit_code == 1
740
+ assert env_vars_request.called
741
+ assert "Invalid environment variable name." in result.output
742
+ assert "Environment variables set up successfully!" in result.output
743
+
744
+
745
+ @pytest.mark.respx(base_url=settings.base_api_url)
746
+ def test_shows_error_for_invalid_waitlist_form_data(
747
+ logged_out_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
748
+ ) -> None:
749
+ steps = [
750
+ *"test@example.com",
751
+ Keys.ENTER,
752
+ Keys.ENTER, # Choose to provide more information
753
+ Keys.CTRL_C, # Interrupt to avoid infinite loop
754
+ ]
755
+
756
+ with changing_dir(tmp_path), patch(
757
+ "rich_toolkit.menu.click.getchar"
758
+ ) as mock_getchar, patch("rich_toolkit.form.Form.run") as mock_form_run:
759
+ mock_getchar.side_effect = steps
760
+ # Simulate form returning data with invalid email field to trigger ValidationError
761
+ mock_form_run.return_value = {
762
+ "email": "invalid-email-format",
763
+ "name": "John Doe",
764
+ }
765
+
766
+ result = runner.invoke(app, ["deploy"])
767
+
768
+ assert result.exit_code == 1
769
+ assert "Invalid form data. Please try again." in result.output
770
+
771
+
772
+ @pytest.mark.respx(base_url=settings.base_api_url)
773
+ def test_shows_no_apps_found_message_when_team_has_no_apps(
774
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
775
+ ) -> None:
776
+ steps = [
777
+ Keys.ENTER, # Setup and deploy
778
+ Keys.ENTER, # Select team
779
+ Keys.RIGHT_ARROW, # Choose existing app (No)
780
+ Keys.ENTER,
781
+ ]
782
+
783
+ team = _get_random_team()
784
+
785
+ respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]}))
786
+
787
+ # Mock empty apps list for the team
788
+ respx_mock.get("/apps/", params={"team_id": team["id"]}).mock(
789
+ return_value=Response(200, json={"data": []})
790
+ )
791
+
792
+ with changing_dir(tmp_path), patch("click.getchar") as mock_getchar:
793
+ mock_getchar.side_effect = steps
794
+
795
+ result = runner.invoke(app, ["deploy"])
796
+
797
+ assert result.exit_code == 1
798
+ assert (
799
+ "No apps found in this team. You can create a new app instead."
800
+ in result.output
801
+ )
@@ -0,0 +1,164 @@
1
+ from pathlib import Path
2
+ from unittest.mock import patch
3
+
4
+ import httpx
5
+ import pytest
6
+ import respx
7
+ from httpx import Response
8
+ from typer.testing import CliRunner
9
+
10
+ from fastapi_cloud_cli.cli import app
11
+ from fastapi_cloud_cli.config import Settings
12
+
13
+ runner = CliRunner()
14
+ settings = Settings.get()
15
+
16
+ assets_path = Path(__file__).parent / "assets"
17
+
18
+
19
+ @pytest.mark.respx(base_url=settings.base_api_url)
20
+ def test_shows_a_message_if_something_is_wrong(respx_mock: respx.MockRouter) -> None:
21
+ with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open:
22
+ respx_mock.post(
23
+ "/login/device/authorization", data={"client_id": settings.client_id}
24
+ ).mock(return_value=Response(500))
25
+
26
+ result = runner.invoke(app, ["login"])
27
+
28
+ assert result.exit_code == 1
29
+ assert (
30
+ "Something went wrong while contacting the FastAPI Cloud server."
31
+ in result.output
32
+ )
33
+
34
+ assert not mock_open.called
35
+
36
+
37
+ @pytest.mark.respx(base_url=settings.base_api_url)
38
+ def test_full_login(respx_mock: respx.MockRouter, temp_auth_config: Path) -> None:
39
+ with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open:
40
+ respx_mock.post(
41
+ "/login/device/authorization", data={"client_id": settings.client_id}
42
+ ).mock(
43
+ return_value=Response(
44
+ 200,
45
+ json={
46
+ "verification_uri_complete": "http://test.com",
47
+ "verification_uri": "http://test.com",
48
+ "user_code": "1234",
49
+ "device_code": "5678",
50
+ },
51
+ )
52
+ )
53
+ respx_mock.post(
54
+ "/login/device/token",
55
+ data={
56
+ "device_code": "5678",
57
+ "client_id": settings.client_id,
58
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
59
+ },
60
+ ).mock(return_value=Response(200, json={"access_token": "test_token_1234"}))
61
+
62
+ # Verify no auth file exists before login
63
+ assert not temp_auth_config.exists()
64
+
65
+ result = runner.invoke(app, ["login"])
66
+
67
+ assert result.exit_code == 0
68
+ assert mock_open.called
69
+ assert mock_open.call_args.args == ("http://test.com",)
70
+ assert "Now you are logged in!" in result.output
71
+
72
+ # Verify auth file was created with correct content
73
+ assert temp_auth_config.exists()
74
+ assert '"access_token":"test_token_1234"' in temp_auth_config.read_text()
75
+
76
+
77
+ @pytest.mark.respx(base_url=settings.base_api_url)
78
+ def test_fetch_access_token_success_immediately(respx_mock: respx.MockRouter) -> None:
79
+ from fastapi_cloud_cli.commands.login import _fetch_access_token
80
+ from fastapi_cloud_cli.utils.api import APIClient
81
+
82
+ respx_mock.post(
83
+ "/login/device/token",
84
+ data={
85
+ "device_code": "test_device_code",
86
+ "client_id": settings.client_id,
87
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
88
+ },
89
+ ).mock(return_value=Response(200, json={"access_token": "test_token_success"}))
90
+
91
+ with APIClient() as client:
92
+ access_token = _fetch_access_token(client, "test_device_code", 5)
93
+
94
+ assert access_token == "test_token_success"
95
+
96
+
97
+ @pytest.mark.respx(base_url=settings.base_api_url)
98
+ def test_fetch_access_token_authorization_pending_then_success(
99
+ respx_mock: respx.MockRouter,
100
+ ) -> None:
101
+ from fastapi_cloud_cli.commands.login import _fetch_access_token
102
+ from fastapi_cloud_cli.utils.api import APIClient
103
+
104
+ # First call returns authorization pending, second call succeeds
105
+ respx_mock.post(
106
+ "/login/device/token",
107
+ data={
108
+ "device_code": "test_device_code",
109
+ "client_id": settings.client_id,
110
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
111
+ },
112
+ ).mock(
113
+ side_effect=[
114
+ Response(400, json={"error": "authorization_pending"}),
115
+ Response(200, json={"access_token": "test_token_after_pending"}),
116
+ ]
117
+ )
118
+
119
+ with patch("fastapi_cloud_cli.commands.login.time.sleep") as mock_sleep:
120
+ with APIClient() as client:
121
+ access_token = _fetch_access_token(client, "test_device_code", 3)
122
+
123
+ assert access_token == "test_token_after_pending"
124
+ mock_sleep.assert_called_once_with(3)
125
+
126
+
127
+ @pytest.mark.respx(base_url=settings.base_api_url)
128
+ def test_fetch_access_token_handles_400_error_not_authorization_pending(
129
+ respx_mock: respx.MockRouter,
130
+ ) -> None:
131
+ from fastapi_cloud_cli.commands.login import _fetch_access_token
132
+ from fastapi_cloud_cli.utils.api import APIClient
133
+
134
+ respx_mock.post(
135
+ "/login/device/token",
136
+ data={
137
+ "device_code": "test_device_code",
138
+ "client_id": settings.client_id,
139
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
140
+ },
141
+ ).mock(return_value=Response(400, json={"error": "access_denied"}))
142
+
143
+ with APIClient() as client:
144
+ with pytest.raises(httpx.HTTPStatusError):
145
+ _fetch_access_token(client, "test_device_code", 5)
146
+
147
+
148
+ @pytest.mark.respx(base_url=settings.base_api_url)
149
+ def test_fetch_access_token_handles_500_error(respx_mock: respx.MockRouter) -> None:
150
+ from fastapi_cloud_cli.commands.login import _fetch_access_token
151
+ from fastapi_cloud_cli.utils.api import APIClient
152
+
153
+ respx_mock.post(
154
+ "/login/device/token",
155
+ data={
156
+ "device_code": "test_device_code",
157
+ "client_id": settings.client_id,
158
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
159
+ },
160
+ ).mock(return_value=Response(500))
161
+
162
+ with APIClient() as client:
163
+ with pytest.raises(httpx.HTTPStatusError):
164
+ _fetch_access_token(client, "test_device_code", 5)
@@ -0,0 +1,31 @@
1
+ from pathlib import Path
2
+
3
+ from typer.testing import CliRunner
4
+
5
+ from fastapi_cloud_cli.cli import app
6
+
7
+ runner = CliRunner()
8
+
9
+
10
+ def test_logout_with_existing_auth_file(temp_auth_config: Path) -> None:
11
+ temp_auth_config.write_text('{"access_token": "test_token"}')
12
+
13
+ assert temp_auth_config.exists()
14
+
15
+ result = runner.invoke(app, ["logout"])
16
+
17
+ assert result.exit_code == 0
18
+ assert "You are now logged out! 🚀" in result.output
19
+
20
+ assert not temp_auth_config.exists()
21
+
22
+
23
+ def test_logout_with_no_auth_file(temp_auth_config: Path) -> None:
24
+ assert not temp_auth_config.exists()
25
+
26
+ result = runner.invoke(app, ["logout"])
27
+
28
+ assert result.exit_code == 0
29
+ assert "You are now logged out! 🚀" in result.output
30
+
31
+ assert not temp_auth_config.exists()
@@ -6,28 +6,33 @@ from httpx import ReadTimeout, Response
6
6
  from typer.testing import CliRunner
7
7
 
8
8
  from fastapi_cloud_cli.cli import app
9
- from fastapi_cloud_cli.config import settings
9
+ from fastapi_cloud_cli.config import Settings
10
10
 
11
11
  runner = CliRunner()
12
+ settings = Settings.get()
12
13
 
13
14
  assets_path = Path(__file__).parent / "assets"
14
15
 
15
16
 
16
17
  @pytest.mark.respx(base_url=settings.base_api_url)
17
- def test_shows_a_message_if_something_is_wrong(respx_mock: respx.MockRouter) -> None:
18
+ def test_shows_a_message_if_something_is_wrong(
19
+ logged_in_cli: None, respx_mock: respx.MockRouter
20
+ ) -> None:
18
21
  respx_mock.get("/users/me").mock(return_value=Response(500))
19
22
 
20
23
  result = runner.invoke(app, ["whoami"])
21
24
 
22
- assert result.exit_code == 1
23
25
  assert (
24
26
  "Something went wrong while contacting the FastAPI Cloud server."
25
27
  in result.output
26
28
  )
29
+ assert result.exit_code == 1
27
30
 
28
31
 
29
32
  @pytest.mark.respx(base_url=settings.base_api_url)
30
- def test_shows_a_message_when_not_logged_in(respx_mock: respx.MockRouter) -> None:
33
+ def test_shows_a_message_when_token_is_invalid(
34
+ logged_in_cli: None, respx_mock: respx.MockRouter
35
+ ) -> None:
31
36
  respx_mock.get("/users/me").mock(return_value=Response(401))
32
37
 
33
38
  result = runner.invoke(app, ["whoami"])
@@ -37,7 +42,7 @@ def test_shows_a_message_when_not_logged_in(respx_mock: respx.MockRouter) -> Non
37
42
 
38
43
 
39
44
  @pytest.mark.respx(base_url=settings.base_api_url)
40
- def test_shows_email(respx_mock: respx.MockRouter) -> None:
45
+ def test_shows_email(logged_in_cli: None, respx_mock: respx.MockRouter) -> None:
41
46
  respx_mock.get("/users/me").mock(
42
47
  return_value=Response(200, json={"email": "email@fastapi.com"})
43
48
  )
@@ -49,10 +54,19 @@ def test_shows_email(respx_mock: respx.MockRouter) -> None:
49
54
 
50
55
 
51
56
  @pytest.mark.respx(base_url=settings.base_api_url)
52
- def test_handles_read_timeout(respx_mock: respx.MockRouter) -> None:
57
+ def test_handles_read_timeout(
58
+ logged_in_cli: None, respx_mock: respx.MockRouter
59
+ ) -> None:
53
60
  respx_mock.get("/users/me").mock(side_effect=ReadTimeout)
54
61
 
55
62
  result = runner.invoke(app, ["whoami"])
56
63
 
57
64
  assert result.exit_code == 1
58
65
  assert "The request to the FastAPI Cloud server timed out" in result.output
66
+
67
+
68
+ def test_prints_not_logged_in(logged_out_cli: None) -> None:
69
+ result = runner.invoke(app, ["whoami"])
70
+
71
+ assert result.exit_code == 0
72
+ assert "No credentials found. Use `fastapi login` to login." in result.output
@@ -0,0 +1,64 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from fastapi_cloud_cli.commands.deploy import DeploymentStatus, _should_exclude_entry
6
+
7
+
8
+ @pytest.mark.parametrize(
9
+ "path",
10
+ [
11
+ Path("/project/.venv/lib/python3.11/site-packages/some_package"),
12
+ Path("/project/src/__pycache__/module.cpython-311.pyc"),
13
+ Path("/project/.mypy_cache/3.11/module.meta.json"),
14
+ Path("/project/.pytest_cache/v/cache/lastfailed"),
15
+ Path("/project/src/module.pyc"),
16
+ Path("/project/src/subdir/another/module.pyc"),
17
+ Path("/project/subproject/.venv/lib/python3.11/site-packages"),
18
+ Path("/project/.venv/lib/__pycache__/module.pyc"),
19
+ Path(".venv"),
20
+ Path("__pycache__"),
21
+ Path("module.pyc"),
22
+ ],
23
+ )
24
+ def test_excludes_paths(path: Path) -> None:
25
+ """Should exclude paths that match exclusion criteria."""
26
+ assert _should_exclude_entry(path) is True
27
+
28
+
29
+ @pytest.mark.parametrize(
30
+ "path",
31
+ [
32
+ Path("/project/src/module.py"),
33
+ Path("/project/src/utils"),
34
+ Path("/project/src/my_cache_utils.py"),
35
+ Path("/project/venv/lib/python3.11/site-packages"), # no leading dot
36
+ Path("/project/pycache/some_file.py"), # no underscores
37
+ Path("/project/src/module.pyx"), # similar to .pyc but different
38
+ Path("/project/config.json"),
39
+ Path("/project/README.md"),
40
+ ],
41
+ )
42
+ def test_includes_paths(path: Path) -> None:
43
+ """Should not exclude paths that don't match exclusion criteria."""
44
+ assert _should_exclude_entry(path) is False
45
+
46
+
47
+ @pytest.mark.parametrize(
48
+ "status,expected",
49
+ [
50
+ (DeploymentStatus.waiting_upload, "Waiting for upload"),
51
+ (DeploymentStatus.ready_for_build, "Ready for build"),
52
+ (DeploymentStatus.building, "Building"),
53
+ (DeploymentStatus.extracting, "Extracting"),
54
+ (DeploymentStatus.building_image, "Building image"),
55
+ (DeploymentStatus.deploying, "Deploying"),
56
+ (DeploymentStatus.success, "Success"),
57
+ (DeploymentStatus.failed, "Failed"),
58
+ ],
59
+ )
60
+ def test_deployment_status_to_human_readable(
61
+ status: DeploymentStatus, expected: str
62
+ ) -> None:
63
+ """Should convert deployment status to human readable format."""
64
+ assert DeploymentStatus.to_human_readable(status) == expected
@@ -7,10 +7,11 @@ from httpx import Response
7
7
  from typer.testing import CliRunner
8
8
 
9
9
  from fastapi_cloud_cli.cli import app
10
- from fastapi_cloud_cli.config import settings
10
+ from fastapi_cloud_cli.config import Settings
11
11
  from tests.utils import Keys, changing_dir
12
12
 
13
13
  runner = CliRunner()
14
+ settings = Settings.get()
14
15
 
15
16
  assets_path = Path(__file__).parent / "assets"
16
17
 
@@ -6,11 +6,12 @@ from httpx import Response
6
6
  from typer.testing import CliRunner
7
7
 
8
8
  from fastapi_cloud_cli.cli import app
9
- from fastapi_cloud_cli.config import settings
9
+ from fastapi_cloud_cli.config import Settings
10
10
  from tests.conftest import ConfiguredApp
11
11
  from tests.utils import changing_dir
12
12
 
13
13
  runner = CliRunner()
14
+ settings = Settings.get()
14
15
 
15
16
  assets_path = Path(__file__).parent / "assets"
16
17
 
@@ -7,10 +7,11 @@ from httpx import Response
7
7
  from typer.testing import CliRunner
8
8
 
9
9
  from fastapi_cloud_cli.cli import app
10
- from fastapi_cloud_cli.config import settings
10
+ from fastapi_cloud_cli.config import Settings
11
11
  from tests.utils import Keys, changing_dir
12
12
 
13
13
  runner = CliRunner()
14
+ settings = Settings.get()
14
15
 
15
16
  assets_path = Path(__file__).parent / "assets"
16
17
 
@@ -0,0 +1,22 @@
1
+ from pathlib import Path
2
+ from unittest.mock import ANY, patch
3
+
4
+ from fastapi_cloud_cli.utils.sentry import SENTRY_DSN, init_sentry
5
+
6
+
7
+ def test_init_sentry_when_logged_in(logged_in_cli: Path) -> None:
8
+ with patch("fastapi_cloud_cli.utils.sentry.sentry_sdk.init") as mock_init:
9
+ init_sentry()
10
+
11
+ mock_init.assert_called_once_with(
12
+ dsn=SENTRY_DSN,
13
+ integrations=[ANY], # TyperIntegration instance
14
+ send_default_pii=False,
15
+ )
16
+
17
+
18
+ def test_init_sentry_when_logged_out(logged_out_cli: Path) -> None:
19
+ with patch("fastapi_cloud_cli.utils.sentry.sentry_sdk.init") as mock_init:
20
+ init_sentry()
21
+
22
+ mock_init.assert_not_called()
@@ -1 +0,0 @@
1
- __version__ = "0.1.1"
@@ -1,65 +0,0 @@
1
- from pathlib import Path
2
- from unittest.mock import patch
3
-
4
- import pytest
5
- import respx
6
- from httpx import Response
7
- from typer.testing import CliRunner
8
-
9
- from fastapi_cloud_cli.cli import app
10
- from fastapi_cloud_cli.config import settings
11
-
12
- runner = CliRunner()
13
-
14
- assets_path = Path(__file__).parent / "assets"
15
-
16
-
17
- @pytest.mark.respx(base_url=settings.base_api_url)
18
- def test_shows_a_message_if_something_is_wrong(respx_mock: respx.MockRouter) -> None:
19
- with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open:
20
- respx_mock.post(
21
- "/login/device/authorization", data={"client_id": settings.client_id}
22
- ).mock(return_value=Response(500))
23
-
24
- result = runner.invoke(app, ["login"])
25
-
26
- assert result.exit_code == 1
27
- assert (
28
- "Something went wrong while contacting the FastAPI Cloud server."
29
- in result.output
30
- )
31
-
32
- assert not mock_open.called
33
-
34
-
35
- @pytest.mark.respx(base_url=settings.base_api_url)
36
- def test_full_login(respx_mock: respx.MockRouter) -> None:
37
- with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open:
38
- respx_mock.post(
39
- "/login/device/authorization", data={"client_id": settings.client_id}
40
- ).mock(
41
- return_value=Response(
42
- 200,
43
- json={
44
- "verification_uri_complete": "http://test.com",
45
- "verification_uri": "http://test.com",
46
- "user_code": "1234",
47
- "device_code": "5678",
48
- },
49
- )
50
- )
51
- respx_mock.post(
52
- "/login/device/token",
53
- data={
54
- "device_code": "5678",
55
- "client_id": settings.client_id,
56
- "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
57
- },
58
- ).mock(return_value=Response(200, json={"access_token": "1234"}))
59
-
60
- result = runner.invoke(app, ["login"])
61
-
62
- assert result.exit_code == 0
63
- assert mock_open.called
64
- assert mock_open.call_args.args == ("http://test.com",)
65
- assert "Now you are logged in!" in result.output