Coverage for src/fastapi_cloud_cli/commands/apps/link.py: 100%
87 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-09 00:25 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-09 00:25 +0000
1import logging 1CDEFGHI
2from pathlib import Path 1CDEFGHI
3from typing import Annotated, Any, NoReturn 1CDEFGHI
5import typer 1CDEFGHI
6from pydantic import BaseModel, Field 1CDEFGHI
7from rich_toolkit import RichToolkit 1CDEFGHI
8from rich_toolkit.menu import Option 1CDEFGHI
10from fastapi_cloud_cli._app import cloud_app 1CDEFGHI
11from fastapi_cloud_cli.api import APIClient 1CDEFGHI
12from fastapi_cloud_cli.commands.apps._app import apps_app 1CDEFGHI
13from fastapi_cloud_cli.commands.apps.list import _get_app 1CDEFGHI
14from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config 1CDEFGHI
15from fastapi_cloud_cli.utils.auth import Identity 1CDEFGHI
16from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit 1CDEFGHI
17from fastapi_cloud_cli.utils.execution import JsonOutputOption 1CDEFGHI
19logger = logging.getLogger(__name__) 1CDEFGHI
22class LinkOutput(BaseModel): 1CDEFGHI
23 app_id: str 1CDEFGHI
24 team_id: str 1CDEFGHI
25 path: Path 1CDEFGHI
26 app_name: Annotated[str, Field(exclude=True)] 1CDEFGHI
27 config_path: Annotated[Path, Field(exclude=True)] 1CDEFGHI
30def _render_link_output(data: LinkOutput, toolkit: RichToolkit) -> None: 1CD4EF5GHI6
31 toolkit.print( 174859!6
32 f"Linked [bold]{data.path}[/bold] to [bold]{data.app_name}[/bold]",
33 bullet=False,
34 )
35 toolkit.print(f"Config: [bold]{data.config_path}[/bold]", bullet=False) 174859!6
38def _fail_not_logged_in_interactive(toolkit: FastAPIRichToolkit) -> NoReturn: 1CDEFGHI
39 toolkit.fail( 2EbFbGbHbIbJbKb
40 "not_logged_in",
41 "You need to be logged in to link an app.",
42 hint="Run [bold]fastapi cloud login[/] to authenticate.",
43 )
46def _fail_already_linked_interactive(toolkit: FastAPIRichToolkit) -> NoReturn: 1CDEFGHI
47 toolkit.fail( 2jbkblbmbnbobpb
48 "already_linked",
49 "This directory is already linked to an app.",
50 hint="Run [bold]fastapi cloud unlink[/] first to remove the existing configuration.",
51 )
54def _link_app_by_id( 1CDEFGHI
55 toolkit: FastAPIRichToolkit,
56 *,
57 app_id: str,
58 path_to_link: Path,
59 force: bool,
60) -> None:
61 if get_app_config(path_to_link) and not force: 2+ qb, - 7 . / rb: ; 4 = ? sb@ [ 8 ] ^ tb_ ` 5 { | ub} ~ 9 abbbvbcbdb! ebfbwbgbhb6 ib
62 toolkit.fail( 2qbrbsbtbubvbwb
63 "already_linked",
64 "This directory is already linked to an app.",
65 hint="Pass --force to replace the existing configuration.",
66 )
68 with APIClient() as client: 2+ , - 7 . / : ; 4 = ? @ [ 8 ] ^ _ ` 5 { | } ~ 9 abbbcbdb! ebfbgbhb6 ib
69 with toolkit.progress( 2+ , - 7 . / : ; 4 = ? @ [ 8 ] ^ _ ` 5 { | } ~ 9 abbbcbdb! ebfbgbhb6 ib
70 title="Fetching app",
71 transient=True,
72 ) as progress:
73 with client.handle_http_errors( 2+ , - 7 . / : ; 4 = ? @ [ 8 ] ^ _ ` 5 { | } ~ 9 abbbcbdb! ebfbgbhb6 ib
74 progress,
75 default_message="Error fetching app. Please try again later.",
76 not_found_message="App not found.",
77 toolkit=toolkit,
78 ):
79 app = _get_app(client, app_id) 2+ , - 7 . / : ; 4 = ? @ [ 8 ] ^ _ ` 5 { | } ~ 9 abbbcbdb! ebfbgbhb6 ib
81 write_app_config( 2+ , - 7 . / : ; 4 = ? @ [ 8 ] ^ _ ` 5 { | } ~ 9 abbbcbdb! ebfbgbhb6 ib
82 path_to_link,
83 AppConfig(app_id=app.id, team_id=app.team_id),
84 )
86 result = LinkOutput( 2+ , - 7 . / : ; 4 = ? @ [ 8 ] ^ _ ` 5 { | } ~ 9 abbbcbdb! ebfbgbhb6 ib
87 app_id=app.id,
88 team_id=app.team_id,
89 path=path_to_link,
90 app_name=app.name,
91 config_path=path_to_link / ".fastapicloud" / "cloud.json",
92 )
94 toolkit.success(result, render_output=_render_link_output) 2+ , - 7 . / : ; 4 = ? @ [ 8 ] ^ _ ` 5 { | } ~ 9 abbbcbdb! ebfbgbhb6 ib
97def _link_app_interactively( 1CDEFGHI
98 toolkit: FastAPIRichToolkit,
99 *,
100 path_to_link: Path,
101 force: bool,
102) -> None:
103 if get_app_config(path_to_link) and not force: 2a b c jbJ X Q # d e f g kbK Y R $ h i j k lbL Z S % l m n o mbM 0 T ' p q r s nbN 1 U ( t u v w obO 2 V ) x y z A pbP 3 W * B
104 _fail_already_linked_interactive(toolkit) 2jbkblbmbnbobpb
106 toolkit.print_title("Link to FastAPI Cloud") 1abcJXQ#defgKYR$hijkLZS%lmnoM0T'pqrsN1U(tuvwO2V)xyzAP3W*B
107 toolkit.print_line() 1abcJXQ#defgKYR$hijkLZS%lmnoM0T'pqrsN1U(tuvwO2V)xyzAP3W*B
109 with APIClient() as client: 1abcJXQ#defgKYR$hijkLZS%lmnoM0T'pqrsN1U(tuvwO2V)xyzAP3W*B
110 with toolkit.progress("Fetching teams...", transient=True) as progress: 1abcJXQ#defgKYR$hijkLZS%lmnoM0T'pqrsN1U(tuvwO2V)xyzAP3W*B
111 with client.handle_http_errors( 1abcJXQ#defgKYR$hijkLZS%lmnoM0T'pqrsN1U(tuvwO2V)xyzAP3W*B
112 progress,
113 default_message="Error fetching teams. Please try again later.",
114 ):
115 response = client.get("/teams/") 1abcJXQ#defgKYR$hijkLZS%lmnoM0T'pqrsN1U(tuvwO2V)xyzAP3W*B
116 response.raise_for_status() 1abcJXQ#defgKYR$hijkLZS%lmnoM0T'pqrsN1U(tuvwO2V)xyzAP3W*B
117 teams_data = response.json()["data"] 1abcJXQdefgKYRhijkLZSlmnoM0TpqrsN1UtuvwO2VxyzAP3WB
119 if not teams_data: 1abcJXQdefgKYRhijkLZSlmnoM0TpqrsN1UtuvwO2VxyzAP3WB
120 toolkit.print( 1XYZ0123
121 "[error]No teams found. Please create a team first.[/]",
122 bullet=False,
123 )
124 raise typer.Exit(1) 1XYZ0123
126 team = toolkit.ask( 1abcJQdefgKRhijkLSlmnoMTpqrsNUtuvwOVxyzAPWB
127 "Select the team:",
128 options=[
129 Option({"name": t["name"], "value": {"id": t["id"], "name": t["name"]}})
130 for t in sorted(teams_data, key=lambda t: t["name"].lower())
131 ],
132 allow_filtering=True,
133 bullet=False,
134 )
136 toolkit.print_line() 1abcJQdefgKRhijkLSlmnoMTpqrsNUtuvwOVxyzAPWB
138 with toolkit.progress("Fetching apps...", transient=True) as progress: 1abcJQdefgKRhijkLSlmnoMTpqrsNUtuvwOVxyzAPWB
139 with client.handle_http_errors( 1abcJQdefgKRhijkLSlmnoMTpqrsNUtuvwOVxyzAPWB
140 progress,
141 default_message="Error fetching apps. Please try again later.",
142 ):
143 response = client.get("/apps/", params={"team_id": team["id"]}) 1abcJQdefgKRhijkLSlmnoMTpqrsNUtuvwOVxyzAPWB
144 response.raise_for_status() 1abcJQdefgKRhijkLSlmnoMTpqrsNUtuvwOVxyzAPWB
145 apps_data = response.json()["data"] 1abcJdefgKhijkLlmnoMpqrsNtuvwOxyzAPB
147 if not apps_data: 1abcJdefgKhijkLlmnoMpqrsNtuvwOxyzAPB
148 toolkit.fail( 1JKLMNOP
149 "not_found",
150 "No apps found in this team.",
151 hint="Run [bold]fastapi cloud apps create[/] to create and deploy a new app.",
152 )
154 app = toolkit.ask( 1abcdefghijklmnopqrstuvwxyzAB
155 "Select the app to link:",
156 options=[
157 Option({"name": a["slug"], "value": {"id": a["id"], "slug": a["slug"]}})
158 for a in sorted(apps_data, key=lambda a: a["slug"].lower())
159 ],
160 allow_filtering=True,
161 bullet=False,
162 )
164 toolkit.print_line() 1abcdefghijklmnopqrstuvwxyzAB
166 app_config = AppConfig(app_id=app["id"], team_id=team["id"]) 1abcdefghijklmnopqrstuvwxyzAB
167 write_app_config(path_to_link, app_config) 1abcdefghijklmnopqrstuvwxyzAB
169 toolkit.print( 1abcdefghijklmnopqrstuvwxyzAB
170 f"Successfully linked to app [bold]{app['slug']}[/bold]!",
171 emoji="🔗",
172 )
173 logger.debug(f"Linked to app: {app['id']} in team: {team['id']}") 1abcdefghijklmnopqrstuvwxyzAB
176@cloud_app.command("link") 1CDEFGHI
177@apps_app.command("link") 1CDEFGHI
178def link_app( 2C D ZbE F 0bG H I 1b
179 app_id: Annotated[
180 str | None,
181 typer.Argument(
182 help="ID of the app to link.",
183 ),
184 ] = None,
185 app_id_option: Annotated[
186 str | None,
187 typer.Option(
188 "--app-id",
189 help="ID of the app to link.",
190 ),
191 ] = None,
192 path: Annotated[
193 Path | None,
194 typer.Option(
195 "--path",
196 help="Directory to link.",
197 ),
198 ] = None,
199 force: Annotated[
200 bool,
201 typer.Option(
202 "--force",
203 help="Replace an existing local app configuration.",
204 ),
205 ] = False,
206 json_output: JsonOutputOption = False,
207) -> Any:
208 """
209 Link a local directory to an existing FastAPI Cloud app.
210 """
211 identity = Identity() 2+ qb, a - 7 . xbLbMbb c jbJ X EbQ # d / rb: e ; 4 = ybNbObf g kbK Y FbR $ h ? sb@ i [ 8 ] zbPbQbj k lbL Z GbS % l ^ tb_ m ` 5 { AbRbSbn o mbM 0 HbT ' p | ub} q ~ 9 abBbTbUbr s nbN 1 IbU ( t bbvbcbu db! ebCbVbWbv w obO 2 JbV ) x fbwbgby hb6 ibDbXbYbz A pbP 3 KbW * B
212 path_to_link = path or Path.cwd() 2+ qb, a - 7 . xbLbMbb c jbJ X EbQ # d / rb: e ; 4 = ybNbObf g kbK Y FbR $ h ? sb@ i [ 8 ] zbPbQbj k lbL Z GbS % l ^ tb_ m ` 5 { AbRbSbn o mbM 0 HbT ' p | ub} q ~ 9 abBbTbUbr s nbN 1 IbU ( t bbvbcbu db! ebCbVbWbv w obO 2 JbV ) x fbwbgby hb6 ibDbXbYbz A pbP 3 KbW * B
213 target_app_id = app_id_option or app_id 2+ qb, a - 7 . xbLbMbb c jbJ X EbQ # d / rb: e ; 4 = ybNbObf g kbK Y FbR $ h ? sb@ i [ 8 ] zbPbQbj k lbL Z GbS % l ^ tb_ m ` 5 { AbRbSbn o mbM 0 HbT ' p | ub} q ~ 9 abBbTbUbr s nbN 1 IbU ( t bbvbcbu db! ebCbVbWbv w obO 2 JbV ) x fbwbgby hb6 ibDbXbYbz A pbP 3 KbW * B
215 with get_rich_toolkit(json_output=json_output) as toolkit: 2+ qb, a - 7 . xbLbMbb c jbJ X EbQ # d / rb: e ; 4 = ybNbObf g kbK Y FbR $ h ? sb@ i [ 8 ] zbPbQbj k lbL Z GbS % l ^ tb_ m ` 5 { AbRbSbn o mbM 0 HbT ' p | ub} q ~ 9 abBbTbUbr s nbN 1 IbU ( t bbvbcbu db! ebCbVbWbv w obO 2 JbV ) x fbwbgby hb6 ibDbXbYbz A pbP 3 KbW * B
216 if not identity.is_logged_in(): 2+ qb, a - 7 . xbLbMbb c jbJ X EbQ # d / rb: e ; 4 = ybNbObf g kbK Y FbR $ h ? sb@ i [ 8 ] zbPbQbj k lbL Z GbS % l ^ tb_ m ` 5 { AbRbSbn o mbM 0 HbT ' p | ub} q ~ 9 abBbTbUbr s nbN 1 IbU ( t bbvbcbu db! ebCbVbWbv w obO 2 JbV ) x fbwbgby hb6 ibDbXbYbz A pbP 3 KbW * B
217 if target_app_id is None and not json_output: 2LbEbNbFbPbGbRbHbTbIbVbJbXbKb
218 _fail_not_logged_in_interactive(toolkit) 2EbFbGbHbIbJbKb
220 toolkit.fail( 2LbNbPbRbTbVbXb
221 "not_logged_in",
222 "No credentials found.",
223 hint="Run `fastapi cloud login`.",
224 )
226 if app_id is not None and app_id_option is not None and app_id != app_id_option: 2+ qb, a - 7 . xbMbb c jbJ X Q # d / rb: e ; 4 = ybObf g kbK Y R $ h ? sb@ i [ 8 ] zbQbj k lbL Z S % l ^ tb_ m ` 5 { AbSbn o mbM 0 T ' p | ub} q ~ 9 abBbUbr s nbN 1 U ( t bbvbcbu db! ebCbWbv w obO 2 V ) x fbwbgby hb6 ibDbYbz A pbP 3 W * B
227 toolkit.fail( 2MbObQbSbUbWbYb
228 "invalid_input",
229 "App ID was provided more than once.",
230 hint="Pass either APP_ID or --app-id, not both.",
231 )
233 if target_app_id is None: 2+ qb, a - 7 . xbb c jbJ X Q # d / rb: e ; 4 = ybf g kbK Y R $ h ? sb@ i [ 8 ] zbj k lbL Z S % l ^ tb_ m ` 5 { Abn o mbM 0 T ' p | ub} q ~ 9 abBbr s nbN 1 U ( t bbvbcbu db! ebCbv w obO 2 V ) x fbwbgby hb6 ibDbz A pbP 3 W * B
234 if json_output: 2a xbb c jbJ X Q # d e ybf g kbK Y R $ h i zbj k lbL Z S % l m Abn o mbM 0 T ' p q Bbr s nbN 1 U ( t u Cbv w obO 2 V ) x y Dbz A pbP 3 W * B
235 toolkit.fail( 2xbybzbAbBbCbDb
236 "missing_required_input",
237 "App ID is required.",
238 hint="Pass an app ID to link an app.",
239 )
241 _link_app_interactively( 2a b c jbJ X Q # d e f g kbK Y R $ h i j k lbL Z S % l m n o mbM 0 T ' p q r s nbN 1 U ( t u v w obO 2 V ) x y z A pbP 3 W * B
242 toolkit,
243 path_to_link=path_to_link,
244 force=force,
245 )
246 return 1abcdefghijklmnopqrstuvwxyzAB
248 _link_app_by_id( 2+ qb, - 7 . / rb: ; 4 = ? sb@ [ 8 ] ^ tb_ ` 5 { | ub} ~ 9 abbbvbcbdb! ebfbwbgbhb6 ib
249 toolkit,
250 app_id=target_app_id,
251 path_to_link=path_to_link,
252 force=force,
253 )