Skip to content

Commit 5768e10

Browse files
authored
✨ Add link command (#148)
1 parent bd2f839 commit 5768e10

3 files changed

Lines changed: 310 additions & 0 deletions

File tree

src/fastapi_cloud_cli/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from .commands.deploy import deploy
44
from .commands.env import env_app
5+
from .commands.link import link
56
from .commands.login import login
67
from .commands.logout import logout
78
from .commands.logs import logs
@@ -25,6 +26,7 @@
2526

2627
# fastapi cloud [command]
2728
cloud_app.command()(deploy)
29+
cloud_app.command()(link)
2830
cloud_app.command()(login)
2931
cloud_app.command()(logs)
3032
cloud_app.command()(logout)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import logging
2+
from pathlib import Path
3+
from typing import Any
4+
5+
import typer
6+
from rich_toolkit.menu import Option
7+
8+
from fastapi_cloud_cli.utils.api import APIClient
9+
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
10+
from fastapi_cloud_cli.utils.auth import Identity
11+
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
def link() -> Any:
17+
"""
18+
Link a local directory to an existing FastAPI Cloud app.
19+
"""
20+
identity = Identity()
21+
22+
with get_rich_toolkit() as toolkit:
23+
if not identity.is_logged_in():
24+
toolkit.print(
25+
"[error]You need to be logged in to link an app.[/]",
26+
)
27+
toolkit.print_line()
28+
toolkit.print(
29+
"Run [bold]fastapi cloud login[/] to authenticate.",
30+
tag="tip",
31+
)
32+
raise typer.Exit(1)
33+
34+
path_to_link = Path.cwd()
35+
36+
if get_app_config(path_to_link):
37+
toolkit.print(
38+
"[error]This directory is already linked to an app.[/]",
39+
)
40+
toolkit.print_line()
41+
toolkit.print(
42+
"Run [bold]fastapi cloud unlink[/] first to remove the existing configuration.",
43+
tag="tip",
44+
)
45+
raise typer.Exit(1)
46+
47+
toolkit.print_title("Link to FastAPI Cloud", tag="FastAPI")
48+
toolkit.print_line()
49+
50+
with toolkit.progress("Fetching teams...") as progress:
51+
with handle_http_errors(
52+
progress, message="Error fetching teams. Please try again later."
53+
):
54+
with APIClient() as client:
55+
response = client.get("/teams/")
56+
response.raise_for_status()
57+
teams_data = response.json()["data"]
58+
59+
if not teams_data:
60+
toolkit.print(
61+
"[error]No teams found. Please create a team first.[/]",
62+
)
63+
raise typer.Exit(1)
64+
65+
toolkit.print_line()
66+
67+
team = toolkit.ask(
68+
"Select the team:",
69+
tag="team",
70+
options=[
71+
Option({"name": t["name"], "value": {"id": t["id"], "name": t["name"]}})
72+
for t in teams_data
73+
],
74+
)
75+
76+
toolkit.print_line()
77+
78+
with toolkit.progress("Fetching apps...") as progress:
79+
with handle_http_errors(
80+
progress, message="Error fetching apps. Please try again later."
81+
):
82+
with APIClient() as client:
83+
response = client.get("/apps/", params={"team_id": team["id"]})
84+
response.raise_for_status()
85+
apps_data = response.json()["data"]
86+
87+
if not apps_data:
88+
toolkit.print(
89+
"[error]No apps found in this team.[/]",
90+
)
91+
toolkit.print_line()
92+
toolkit.print(
93+
"Run [bold]fastapi cloud deploy[/] to create and deploy a new app.",
94+
tag="tip",
95+
)
96+
raise typer.Exit(1)
97+
98+
toolkit.print_line()
99+
100+
app = toolkit.ask(
101+
"Select the app to link:",
102+
tag="app",
103+
options=[
104+
Option({"name": a["slug"], "value": {"id": a["id"], "slug": a["slug"]}})
105+
for a in apps_data
106+
],
107+
)
108+
109+
toolkit.print_line()
110+
111+
app_config = AppConfig(app_id=app["id"], team_id=team["id"])
112+
write_app_config(path_to_link, app_config)
113+
114+
toolkit.print(
115+
f"Successfully linked to app [bold]{app['slug']}[/bold]! 🔗",
116+
)
117+
logger.debug(f"Linked to app: {app['id']} in team: {team['id']}")

tests/test_cli_link.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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 cloud_app as app
10+
from fastapi_cloud_cli.config import Settings
11+
from fastapi_cloud_cli.utils.apps import AppConfig
12+
from tests.conftest import ConfiguredApp
13+
from tests.utils import Keys, changing_dir
14+
15+
runner = CliRunner()
16+
settings = Settings.get()
17+
18+
19+
def test_shows_a_message_if_not_logged_in(logged_out_cli: None) -> None:
20+
result = runner.invoke(app, ["link"])
21+
22+
assert result.exit_code == 1
23+
assert "You need to be logged in to link an app." in result.output
24+
25+
26+
def test_shows_a_message_if_already_linked(
27+
logged_in_cli: None, configured_app: ConfiguredApp
28+
) -> None:
29+
with changing_dir(configured_app.path):
30+
result = runner.invoke(app, ["link"])
31+
32+
assert result.exit_code == 1
33+
assert "This directory is already linked to an app." in result.output
34+
35+
36+
@pytest.mark.respx(base_url=settings.base_api_url)
37+
def test_shows_a_message_if_no_teams(
38+
logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
39+
) -> None:
40+
respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": []}))
41+
42+
with changing_dir(tmp_path):
43+
result = runner.invoke(app, ["link"])
44+
45+
assert result.exit_code == 1
46+
assert "No teams found" in result.output
47+
48+
49+
@pytest.mark.respx(base_url=settings.base_api_url)
50+
def test_shows_a_message_if_no_apps(
51+
logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
52+
) -> None:
53+
steps = [Keys.ENTER]
54+
55+
respx_mock.get("/teams/").mock(
56+
return_value=Response(
57+
200, json={"data": [{"id": "team-1", "name": "My Team", "slug": "my-team"}]}
58+
)
59+
)
60+
respx_mock.get("/apps/", params={"team_id": "team-1"}).mock(
61+
return_value=Response(200, json={"data": []})
62+
)
63+
64+
with (
65+
changing_dir(tmp_path),
66+
patch("rich_toolkit.container.getchar") as mock_getchar,
67+
):
68+
mock_getchar.side_effect = steps
69+
result = runner.invoke(app, ["link"])
70+
71+
assert result.exit_code == 1
72+
assert "No apps found in this team." in result.output
73+
74+
75+
@pytest.mark.respx(base_url=settings.base_api_url)
76+
def test_links_successfully(
77+
logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
78+
) -> None:
79+
steps = [Keys.ENTER, Keys.ENTER]
80+
81+
respx_mock.get("/teams/").mock(
82+
return_value=Response(
83+
200, json={"data": [{"id": "team-1", "name": "My Team", "slug": "my-team"}]}
84+
)
85+
)
86+
respx_mock.get("/apps/", params={"team_id": "team-1"}).mock(
87+
return_value=Response(200, json={"data": [{"id": "app-1", "slug": "my-app"}]})
88+
)
89+
90+
with (
91+
changing_dir(tmp_path),
92+
patch("rich_toolkit.container.getchar") as mock_getchar,
93+
):
94+
mock_getchar.side_effect = steps
95+
result = runner.invoke(app, ["link"])
96+
97+
assert result.exit_code == 0
98+
assert "Successfully linked to app" in result.output
99+
assert "my-app" in result.output
100+
101+
config_path = tmp_path / ".fastapicloud" / "cloud.json"
102+
assert config_path.exists()
103+
config = AppConfig.model_validate_json(config_path.read_text())
104+
assert config.app_id == "app-1"
105+
assert config.team_id == "team-1"
106+
107+
108+
@pytest.mark.respx(base_url=settings.base_api_url)
109+
def test_shows_error_on_teams_api_failure(
110+
logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
111+
) -> None:
112+
respx_mock.get("/teams/").mock(return_value=Response(500))
113+
114+
with changing_dir(tmp_path):
115+
result = runner.invoke(app, ["link"])
116+
117+
assert result.exit_code == 1
118+
assert "Error fetching teams" in result.output
119+
120+
121+
@pytest.mark.respx(base_url=settings.base_api_url)
122+
def test_shows_error_on_apps_api_failure(
123+
logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
124+
) -> None:
125+
steps = [Keys.ENTER]
126+
127+
respx_mock.get("/teams/").mock(
128+
return_value=Response(
129+
200, json={"data": [{"id": "team-1", "name": "My Team", "slug": "my-team"}]}
130+
)
131+
)
132+
respx_mock.get("/apps/", params={"team_id": "team-1"}).mock(
133+
return_value=Response(500)
134+
)
135+
136+
with (
137+
changing_dir(tmp_path),
138+
patch("rich_toolkit.container.getchar") as mock_getchar,
139+
):
140+
mock_getchar.side_effect = steps
141+
result = runner.invoke(app, ["link"])
142+
143+
assert result.exit_code == 1
144+
assert "Error fetching apps" in result.output
145+
146+
147+
@pytest.mark.respx(base_url=settings.base_api_url)
148+
def test_links_with_multiple_teams_and_apps(
149+
logged_in_cli: None, respx_mock: respx.MockRouter, tmp_path: Path
150+
) -> None:
151+
steps = [Keys.DOWN_ARROW, Keys.ENTER, Keys.DOWN_ARROW, Keys.ENTER]
152+
153+
respx_mock.get("/teams/").mock(
154+
return_value=Response(
155+
200,
156+
json={
157+
"data": [
158+
{"id": "team-1", "name": "Team One", "slug": "team-one"},
159+
{"id": "team-2", "name": "Team Two", "slug": "team-two"},
160+
]
161+
},
162+
)
163+
)
164+
respx_mock.get("/apps/", params={"team_id": "team-2"}).mock(
165+
return_value=Response(
166+
200,
167+
json={
168+
"data": [
169+
{"id": "app-1", "slug": "first-app"},
170+
{"id": "app-2", "slug": "second-app"},
171+
]
172+
},
173+
)
174+
)
175+
176+
with (
177+
changing_dir(tmp_path),
178+
patch("rich_toolkit.container.getchar") as mock_getchar,
179+
):
180+
mock_getchar.side_effect = steps
181+
result = runner.invoke(app, ["link"])
182+
183+
assert result.exit_code == 0
184+
assert "Successfully linked to app" in result.output
185+
assert "second-app" in result.output
186+
187+
config_path = tmp_path / ".fastapicloud" / "cloud.json"
188+
assert config_path.exists()
189+
config = AppConfig.model_validate_json(config_path.read_text())
190+
assert config.app_id == "app-2"
191+
assert config.team_id == "team-2"

0 commit comments

Comments
 (0)