fastapi-cloud-cli 0.1.1__tar.gz → 0.1.2__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.2}/PKG-INFO +2 -2
  2. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/pyproject.toml +2 -2
  3. fastapi_cloud_cli-0.1.2/src/fastapi_cloud_cli/__init__.py +1 -0
  4. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/commands/deploy.py +3 -3
  5. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/commands/env.py +1 -3
  6. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/commands/whoami.py +2 -2
  7. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/utils/cli.py +1 -1
  8. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/conftest.py +18 -6
  9. fastapi_cloud_cli-0.1.2/tests/test_cli.py +11 -0
  10. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/test_cli_deploy.py +146 -0
  11. fastapi_cloud_cli-0.1.2/tests/test_cli_login.py +163 -0
  12. fastapi_cloud_cli-0.1.2/tests/test_cli_logout.py +31 -0
  13. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/test_cli_whoami.py +18 -5
  14. fastapi_cloud_cli-0.1.2/tests/test_deploy_utils.py +64 -0
  15. fastapi_cloud_cli-0.1.2/tests/test_sentry.py +22 -0
  16. fastapi_cloud_cli-0.1.1/src/fastapi_cloud_cli/__init__.py +0 -1
  17. fastapi_cloud_cli-0.1.1/tests/test_cli_login.py +0 -65
  18. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/LICENSE +0 -0
  19. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/README.md +0 -0
  20. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/requirements-tests.txt +0 -0
  21. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/requirements.txt +0 -0
  22. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/scripts/format.sh +0 -0
  23. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/scripts/lint.sh +0 -0
  24. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/scripts/test-cov-html.sh +0 -0
  25. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/scripts/test.sh +0 -0
  26. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/__main__.py +0 -0
  27. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/cli.py +0 -0
  28. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  29. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/commands/login.py +0 -0
  30. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  31. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/config.py +0 -0
  32. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/logging.py +0 -0
  33. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/py.typed +0 -0
  34. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  35. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/utils/api.py +0 -0
  36. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/utils/apps.py +0 -0
  37. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/utils/auth.py +0 -0
  38. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/utils/config.py +0 -0
  39. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/utils/env.py +0 -0
  40. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  41. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/__init__.py +0 -0
  42. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/broken_package/mod/__init__.py +0 -0
  43. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/broken_package/mod/app.py +0 -0
  44. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/broken_package/utils.py +0 -0
  45. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_api/api.py +0 -0
  46. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_app/api.py +0 -0
  47. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_app/app.py +0 -0
  48. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
  49. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_app_dir_api/app/api.py +0 -0
  50. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
  51. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_app_dir_app/app/api.py +0 -0
  52. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_app_dir_app/app/app.py +0 -0
  53. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
  54. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_app_dir_main/app/api.py +0 -0
  55. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_app_dir_main/app/app.py +0 -0
  56. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_app_dir_main/app/main.py +0 -0
  57. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_app_dir_non_default/app/__init__.py +0 -0
  58. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_app_dir_non_default/app/nondefault.py +0 -0
  59. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_main/api.py +0 -0
  60. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_main/app.py +0 -0
  61. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/default_main/main.py +0 -0
  62. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/default_files/non_default/nonstandard.py +0 -0
  63. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/package/__init__.py +0 -0
  64. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/package/core/__init__.py +0 -0
  65. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/package/core/utils.py +0 -0
  66. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/package/mod/__init__.py +0 -0
  67. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/package/mod/api.py +0 -0
  68. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/package/mod/app.py +0 -0
  69. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/package/mod/other.py +0 -0
  70. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/single_file_api.py +0 -0
  71. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/single_file_app.py +0 -0
  72. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/assets/single_file_other.py +0 -0
  73. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/test_config.py +0 -0
  74. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/test_env_delete.py +0 -0
  75. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/test_env_list.py +0 -0
  76. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/tests/test_env_set.py +0 -0
  77. {fastapi_cloud_cli-0.1.1 → fastapi_cloud_cli-0.1.2}/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.2
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.2"
44
44
 
45
45
  [project.license]
46
46
  text = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.2"
@@ -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:
@@ -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(
@@ -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]")
@@ -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
@@ -652,3 +652,149 @@ def test_does_not_duplicate_entry_in_git_ignore(
652
652
  _deploy_without_waiting(respx_mock, tmp_path)
653
653
 
654
654
  assert git_ignore_path.read_text() == ".fastapicloud\n"
655
+
656
+
657
+ @pytest.mark.respx(base_url=settings.base_api_url)
658
+ def test_creates_environment_variables_during_app_setup(
659
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
660
+ ) -> None:
661
+ steps = [
662
+ Keys.ENTER, # Setup and deploy
663
+ Keys.ENTER, # Select team
664
+ Keys.ENTER, # Create new app
665
+ *"demo", # App name
666
+ Keys.ENTER,
667
+ Keys.ENTER, # Setup environment variables (Yes)
668
+ *"API_KEY", # Environment variable name
669
+ Keys.ENTER,
670
+ *"secret123", # Environment variable value
671
+ Keys.ENTER,
672
+ Keys.ENTER, # Empty key to finish
673
+ Keys.CTRL_C, # Exit before deployment
674
+ ]
675
+
676
+ team = _get_random_team()
677
+ app_data = _get_random_app(team_id=team["id"])
678
+
679
+ respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]}))
680
+
681
+ respx_mock.post("/apps/", json={"name": "demo", "team_id": team["id"]}).mock(
682
+ return_value=Response(201, json=app_data)
683
+ )
684
+
685
+ env_vars_request = respx_mock.patch(
686
+ f"/apps/{app_data['id']}/environment-variables/", json={"API_KEY": "secret123"}
687
+ ).mock(return_value=Response(200))
688
+
689
+ with changing_dir(tmp_path), patch("click.getchar") as mock_getchar:
690
+ mock_getchar.side_effect = steps
691
+
692
+ result = runner.invoke(app, ["deploy"])
693
+
694
+ assert result.exit_code == 1
695
+ assert env_vars_request.called
696
+ assert "Environment variables set up successfully!" in result.output
697
+
698
+
699
+ @pytest.mark.respx(base_url=settings.base_api_url)
700
+ def test_rejects_invalid_environment_variable_names(
701
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
702
+ ) -> None:
703
+ steps = [
704
+ Keys.ENTER, # Setup and deploy
705
+ Keys.ENTER, # Select team
706
+ Keys.ENTER, # Create new app
707
+ *"demo", # App name
708
+ Keys.ENTER,
709
+ Keys.ENTER, # Setup environment variables (Yes)
710
+ *"123-invalid", # Invalid environment variable name (starts with digit, contains hyphen)
711
+ Keys.ENTER,
712
+ *"VALID_KEY", # Valid environment variable name
713
+ Keys.ENTER,
714
+ *"value123", # Environment variable value
715
+ Keys.ENTER,
716
+ Keys.ENTER, # Empty key to finish
717
+ Keys.CTRL_C, # Exit before deployment
718
+ ]
719
+
720
+ team = _get_random_team()
721
+ app_data = _get_random_app(team_id=team["id"])
722
+
723
+ respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]}))
724
+
725
+ respx_mock.post("/apps/", json={"name": "demo", "team_id": team["id"]}).mock(
726
+ return_value=Response(201, json=app_data)
727
+ )
728
+
729
+ env_vars_request = respx_mock.patch(
730
+ f"/apps/{app_data['id']}/environment-variables/", json={"VALID_KEY": "value123"}
731
+ ).mock(return_value=Response(200))
732
+
733
+ with changing_dir(tmp_path), patch("click.getchar") as mock_getchar:
734
+ mock_getchar.side_effect = steps
735
+
736
+ result = runner.invoke(app, ["deploy"])
737
+
738
+ assert result.exit_code == 1
739
+ assert env_vars_request.called
740
+ assert "Invalid environment variable name." in result.output
741
+ assert "Environment variables set up successfully!" in result.output
742
+
743
+
744
+ @pytest.mark.respx(base_url=settings.base_api_url)
745
+ def test_shows_error_for_invalid_waitlist_form_data(
746
+ logged_out_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
747
+ ) -> None:
748
+ steps = [
749
+ *"test@example.com",
750
+ Keys.ENTER,
751
+ Keys.ENTER, # Choose to provide more information
752
+ Keys.CTRL_C, # Interrupt to avoid infinite loop
753
+ ]
754
+
755
+ with changing_dir(tmp_path), patch(
756
+ "rich_toolkit.menu.click.getchar"
757
+ ) as mock_getchar, patch("rich_toolkit.form.Form.run") as mock_form_run:
758
+ mock_getchar.side_effect = steps
759
+ # Simulate form returning data with invalid email field to trigger ValidationError
760
+ mock_form_run.return_value = {
761
+ "email": "invalid-email-format",
762
+ "name": "John Doe",
763
+ }
764
+
765
+ result = runner.invoke(app, ["deploy"])
766
+
767
+ assert result.exit_code == 1
768
+ assert "Invalid form data. Please try again." in result.output
769
+
770
+
771
+ @pytest.mark.respx(base_url=settings.base_api_url)
772
+ def test_shows_no_apps_found_message_when_team_has_no_apps(
773
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
774
+ ) -> None:
775
+ steps = [
776
+ Keys.ENTER, # Setup and deploy
777
+ Keys.ENTER, # Select team
778
+ Keys.RIGHT_ARROW, # Choose existing app (No)
779
+ Keys.ENTER,
780
+ ]
781
+
782
+ team = _get_random_team()
783
+
784
+ respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]}))
785
+
786
+ # Mock empty apps list for the team
787
+ respx_mock.get("/apps/", params={"team_id": team["id"]}).mock(
788
+ return_value=Response(200, json={"data": []})
789
+ )
790
+
791
+ with changing_dir(tmp_path), patch("click.getchar") as mock_getchar:
792
+ mock_getchar.side_effect = steps
793
+
794
+ result = runner.invoke(app, ["deploy"])
795
+
796
+ assert result.exit_code == 1
797
+ assert (
798
+ "No apps found in this team. You can create a new app instead."
799
+ in result.output
800
+ )
@@ -0,0 +1,163 @@
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
+
15
+ assets_path = Path(__file__).parent / "assets"
16
+
17
+
18
+ @pytest.mark.respx(base_url=settings.base_api_url)
19
+ def test_shows_a_message_if_something_is_wrong(respx_mock: respx.MockRouter) -> None:
20
+ with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open:
21
+ respx_mock.post(
22
+ "/login/device/authorization", data={"client_id": settings.client_id}
23
+ ).mock(return_value=Response(500))
24
+
25
+ result = runner.invoke(app, ["login"])
26
+
27
+ assert result.exit_code == 1
28
+ assert (
29
+ "Something went wrong while contacting the FastAPI Cloud server."
30
+ in result.output
31
+ )
32
+
33
+ assert not mock_open.called
34
+
35
+
36
+ @pytest.mark.respx(base_url=settings.base_api_url)
37
+ def test_full_login(respx_mock: respx.MockRouter, temp_auth_config: Path) -> None:
38
+ with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open:
39
+ respx_mock.post(
40
+ "/login/device/authorization", data={"client_id": settings.client_id}
41
+ ).mock(
42
+ return_value=Response(
43
+ 200,
44
+ json={
45
+ "verification_uri_complete": "http://test.com",
46
+ "verification_uri": "http://test.com",
47
+ "user_code": "1234",
48
+ "device_code": "5678",
49
+ },
50
+ )
51
+ )
52
+ respx_mock.post(
53
+ "/login/device/token",
54
+ data={
55
+ "device_code": "5678",
56
+ "client_id": settings.client_id,
57
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
58
+ },
59
+ ).mock(return_value=Response(200, json={"access_token": "test_token_1234"}))
60
+
61
+ # Verify no auth file exists before login
62
+ assert not temp_auth_config.exists()
63
+
64
+ result = runner.invoke(app, ["login"])
65
+
66
+ assert result.exit_code == 0
67
+ assert mock_open.called
68
+ assert mock_open.call_args.args == ("http://test.com",)
69
+ assert "Now you are logged in!" in result.output
70
+
71
+ # Verify auth file was created with correct content
72
+ assert temp_auth_config.exists()
73
+ assert '"access_token":"test_token_1234"' in temp_auth_config.read_text()
74
+
75
+
76
+ @pytest.mark.respx(base_url=settings.base_api_url)
77
+ def test_fetch_access_token_success_immediately(respx_mock: respx.MockRouter) -> None:
78
+ from fastapi_cloud_cli.commands.login import _fetch_access_token
79
+ from fastapi_cloud_cli.utils.api import APIClient
80
+
81
+ respx_mock.post(
82
+ "/login/device/token",
83
+ data={
84
+ "device_code": "test_device_code",
85
+ "client_id": settings.client_id,
86
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
87
+ },
88
+ ).mock(return_value=Response(200, json={"access_token": "test_token_success"}))
89
+
90
+ with APIClient() as client:
91
+ access_token = _fetch_access_token(client, "test_device_code", 5)
92
+
93
+ assert access_token == "test_token_success"
94
+
95
+
96
+ @pytest.mark.respx(base_url=settings.base_api_url)
97
+ def test_fetch_access_token_authorization_pending_then_success(
98
+ respx_mock: respx.MockRouter,
99
+ ) -> None:
100
+ from fastapi_cloud_cli.commands.login import _fetch_access_token
101
+ from fastapi_cloud_cli.utils.api import APIClient
102
+
103
+ # First call returns authorization pending, second call succeeds
104
+ respx_mock.post(
105
+ "/login/device/token",
106
+ data={
107
+ "device_code": "test_device_code",
108
+ "client_id": settings.client_id,
109
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
110
+ },
111
+ ).mock(
112
+ side_effect=[
113
+ Response(400, json={"error": "authorization_pending"}),
114
+ Response(200, json={"access_token": "test_token_after_pending"}),
115
+ ]
116
+ )
117
+
118
+ with patch("fastapi_cloud_cli.commands.login.time.sleep") as mock_sleep:
119
+ with APIClient() as client:
120
+ access_token = _fetch_access_token(client, "test_device_code", 3)
121
+
122
+ assert access_token == "test_token_after_pending"
123
+ mock_sleep.assert_called_once_with(3)
124
+
125
+
126
+ @pytest.mark.respx(base_url=settings.base_api_url)
127
+ def test_fetch_access_token_handles_400_error_not_authorization_pending(
128
+ respx_mock: respx.MockRouter,
129
+ ) -> None:
130
+ from fastapi_cloud_cli.commands.login import _fetch_access_token
131
+ from fastapi_cloud_cli.utils.api import APIClient
132
+
133
+ respx_mock.post(
134
+ "/login/device/token",
135
+ data={
136
+ "device_code": "test_device_code",
137
+ "client_id": settings.client_id,
138
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
139
+ },
140
+ ).mock(return_value=Response(400, json={"error": "access_denied"}))
141
+
142
+ with APIClient() as client:
143
+ with pytest.raises(httpx.HTTPStatusError):
144
+ _fetch_access_token(client, "test_device_code", 5)
145
+
146
+
147
+ @pytest.mark.respx(base_url=settings.base_api_url)
148
+ def test_fetch_access_token_handles_500_error(respx_mock: respx.MockRouter) -> None:
149
+ from fastapi_cloud_cli.commands.login import _fetch_access_token
150
+ from fastapi_cloud_cli.utils.api import APIClient
151
+
152
+ respx_mock.post(
153
+ "/login/device/token",
154
+ data={
155
+ "device_code": "test_device_code",
156
+ "client_id": settings.client_id,
157
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
158
+ },
159
+ ).mock(return_value=Response(500))
160
+
161
+ with APIClient() as client:
162
+ with pytest.raises(httpx.HTTPStatusError):
163
+ _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()
@@ -14,20 +14,24 @@ assets_path = Path(__file__).parent / "assets"
14
14
 
15
15
 
16
16
  @pytest.mark.respx(base_url=settings.base_api_url)
17
- def test_shows_a_message_if_something_is_wrong(respx_mock: respx.MockRouter) -> None:
17
+ def test_shows_a_message_if_something_is_wrong(
18
+ logged_in_cli: None, respx_mock: respx.MockRouter
19
+ ) -> None:
18
20
  respx_mock.get("/users/me").mock(return_value=Response(500))
19
21
 
20
22
  result = runner.invoke(app, ["whoami"])
21
23
 
22
- assert result.exit_code == 1
23
24
  assert (
24
25
  "Something went wrong while contacting the FastAPI Cloud server."
25
26
  in result.output
26
27
  )
28
+ assert result.exit_code == 1
27
29
 
28
30
 
29
31
  @pytest.mark.respx(base_url=settings.base_api_url)
30
- def test_shows_a_message_when_not_logged_in(respx_mock: respx.MockRouter) -> None:
32
+ def test_shows_a_message_when_token_is_invalid(
33
+ logged_in_cli: None, respx_mock: respx.MockRouter
34
+ ) -> None:
31
35
  respx_mock.get("/users/me").mock(return_value=Response(401))
32
36
 
33
37
  result = runner.invoke(app, ["whoami"])
@@ -37,7 +41,7 @@ def test_shows_a_message_when_not_logged_in(respx_mock: respx.MockRouter) -> Non
37
41
 
38
42
 
39
43
  @pytest.mark.respx(base_url=settings.base_api_url)
40
- def test_shows_email(respx_mock: respx.MockRouter) -> None:
44
+ def test_shows_email(logged_in_cli: None, respx_mock: respx.MockRouter) -> None:
41
45
  respx_mock.get("/users/me").mock(
42
46
  return_value=Response(200, json={"email": "email@fastapi.com"})
43
47
  )
@@ -49,10 +53,19 @@ def test_shows_email(respx_mock: respx.MockRouter) -> None:
49
53
 
50
54
 
51
55
  @pytest.mark.respx(base_url=settings.base_api_url)
52
- def test_handles_read_timeout(respx_mock: respx.MockRouter) -> None:
56
+ def test_handles_read_timeout(
57
+ logged_in_cli: None, respx_mock: respx.MockRouter
58
+ ) -> None:
53
59
  respx_mock.get("/users/me").mock(side_effect=ReadTimeout)
54
60
 
55
61
  result = runner.invoke(app, ["whoami"])
56
62
 
57
63
  assert result.exit_code == 1
58
64
  assert "The request to the FastAPI Cloud server timed out" in result.output
65
+
66
+
67
+ def test_prints_not_logged_in(logged_out_cli: None) -> None:
68
+ result = runner.invoke(app, ["whoami"])
69
+
70
+ assert result.exit_code == 0
71
+ 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
@@ -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