fix: flatten React Router structure — no nested Routes, each page is a top-level route

This commit is contained in:
enzo
2026-07-22 19:21:29 +08:00
parent 07ff8c2a12
commit 1de5fcf698
3720 changed files with 1254827 additions and 49 deletions
@@ -0,0 +1 @@
__version__ = "0.22.2"
@@ -0,0 +1,3 @@
from .cli import main
main()
@@ -0,0 +1,88 @@
from typing import Annotated
import typer
from rich import print
from . import __version__
from .commands.apps import apps_app
from .commands.apps.link import link_app
from .commands.apps.unlink import unlink_app
from .commands.auth import auth_app
from .commands.ci import ci_app
from .commands.deploy import deploy
from .commands.deployments import deployments_app
from .commands.env import env_app
from .commands.login import login
from .commands.logout import logout
from .commands.logs import logs
from .commands.setup_ci import setup_ci
from .commands.teams import teams_app
from .commands.tokens import tokens_app
from .commands.whoami import whoami
from .logging import setup_logging
from .utils.sentry import init_sentry
setup_logging()
app = typer.Typer(rich_markup_mode="rich")
def version_callback(value: bool) -> None:
if value:
print(f"FastAPI Cloud CLI version: [green]{__version__}[/green]")
raise typer.Exit()
cloud_app = typer.Typer(
rich_markup_mode="rich",
help="Manage [bold]FastAPI[/bold] Cloud deployments.",
no_args_is_help=True,
)
@cloud_app.callback()
def cloud_main(
version: Annotated[
bool,
typer.Option(
"--version",
callback=version_callback,
is_eager=True,
help="Show the version and exit.",
),
] = False,
) -> None: ...
# TODO: use the app structure
# Additional commands
# fastapi cloud [command]
cloud_app.command()(deploy)
cloud_app.command("link")(link_app)
cloud_app.command()(login)
cloud_app.command()(logs)
cloud_app.command()(logout)
cloud_app.command()(whoami)
cloud_app.command("unlink")(unlink_app)
cloud_app.command()(setup_ci)
cloud_app.add_typer(env_app, name="env")
cloud_app.add_typer(auth_app, name="auth")
cloud_app.add_typer(apps_app, name="apps")
cloud_app.add_typer(ci_app, name="ci")
cloud_app.add_typer(deployments_app, name="deployments")
cloud_app.add_typer(teams_app, name="teams")
cloud_app.add_typer(tokens_app, name="tokens")
# fastapi [command]
app.command()(deploy)
app.command()(login)
app.add_typer(cloud_app, name="cloud")
def main() -> None:
init_sentry()
app()
@@ -0,0 +1,159 @@
import logging
import time
import httpx
from pydantic import BaseModel
from rich_toolkit import RichToolkit
from rich_toolkit.progress import Progress
from fastapi_cloud_cli.config import Settings
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.auth import AuthConfig, AuthMode, write_auth_config
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit
logger = logging.getLogger(__name__)
DEFAULT_LOGIN_TIMEOUT_SECONDS = 300
class AuthorizationData(BaseModel):
user_code: str
device_code: str
verification_uri: str
verification_uri_complete: str
interval: int = 5
class TokenResponse(BaseModel):
access_token: str
class LoginOutput(BaseModel):
authenticated: bool
auth_mode: AuthMode
class DeviceAuthorizationOutput(BaseModel):
verification_uri: str
verification_uri_complete: str
user_code: str
device_code: str
interval: int
class LoginTimeoutError(Exception):
pass
def render_login_output(data: LoginOutput, toolkit: RichToolkit) -> None:
toolkit.print("Now you are logged in! 🚀")
def device_authorization_output(
authorization_data: AuthorizationData,
) -> DeviceAuthorizationOutput:
return DeviceAuthorizationOutput(
verification_uri=authorization_data.verification_uri,
verification_uri_complete=authorization_data.verification_uri_complete,
user_code=authorization_data.user_code,
device_code=authorization_data.device_code,
interval=authorization_data.interval,
)
def start_device_authorization(
client: httpx.Client,
) -> AuthorizationData:
settings = Settings.get()
response = client.post(
"/login/device/authorization", data={"client_id": settings.client_id}
)
logger.debug(f"Device authorization response status code: {response.status_code}")
response.raise_for_status()
return AuthorizationData.model_validate_json(response.text)
def fetch_access_token(
client: httpx.Client,
device_code: str,
interval: int,
timeout: int = DEFAULT_LOGIN_TIMEOUT_SECONDS,
) -> str:
settings = Settings.get()
start = time.monotonic()
logger.debug("Starting to poll for access token")
while True:
response = client.post(
"/login/device/token",
data={
"device_code": device_code,
"client_id": settings.client_id,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
},
)
logger.debug(f"Token response status code: {response.status_code}")
if response.status_code not in (200, 400):
response.raise_for_status()
if response.status_code == 400:
data = response.json()
error = data.get("error")
logger.debug(f"Token response error: {error}")
if error != "authorization_pending":
response.raise_for_status()
if response.status_code == 200:
break
remaining = timeout - (time.monotonic() - start)
if remaining <= 0:
raise LoginTimeoutError
sleep_for = min(interval, remaining)
logger.debug(f"Sleeping for {sleep_for} seconds before retrying...")
time.sleep(sleep_for)
response_data = TokenResponse.model_validate_json(response.text)
logger.debug("Access token received successfully.")
return response_data.access_token
def complete_device_login(
*,
client: APIClient,
progress: Progress,
toolkit: FastAPIRichToolkit,
device_code: str,
interval: int,
timeout: int,
cancel_hint: str,
) -> LoginOutput:
try:
with client.handle_http_errors(progress, toolkit=toolkit):
access_token = fetch_access_token(client, device_code, interval, timeout)
except LoginTimeoutError:
message = "Login timed out before authorization completed."
toolkit.fail(
"timeout",
message,
hint="Try again with a longer --timeout value.",
)
except KeyboardInterrupt:
message = "Login cancelled before authorization completed."
toolkit.fail(
"cancelled",
message,
hint=cancel_hint,
)
write_auth_config(AuthConfig(access_token=access_token))
return LoginOutput(authenticated=True, auth_mode="user")
@@ -0,0 +1,23 @@
import typer
from fastapi_cloud_cli.commands.apps.create import create_app
from fastapi_cloud_cli.commands.apps.get import get_app
from fastapi_cloud_cli.commands.apps.link import link_app
from fastapi_cloud_cli.commands.apps.list import list_apps
from fastapi_cloud_cli.commands.apps.unlink import unlink_app
from fastapi_cloud_cli.commands.apps.update import update_app
from fastapi_cloud_cli.commands.logs import logs
apps_app = typer.Typer(
no_args_is_help=True,
help="Manage your FastAPI Cloud apps.",
)
apps_app.command("create")(create_app)
apps_app.command("get")(get_app)
apps_app.command("link")(link_app)
apps_app.command("list")(list_apps)
apps_app.command("logs")(logs)
apps_app.command("unlink")(unlink_app)
apps_app.command("update")(update_app)
__all__ = ["apps_app"]
@@ -0,0 +1,192 @@
import logging
from pathlib import Path
from typing import Annotated, Any
import typer
from pydantic import BaseModel, Field
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.commands.apps.list import _prompt_for_team
from fastapi_cloud_cli.commands.deploy.archive import (
_get_app_name,
validate_app_directory,
)
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.apps import AppConfig, write_app_config
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
logger = logging.getLogger(__name__)
class CreatedApp(BaseModel):
id: str
team_id: str
slug: str
name: str
directory: str | None
class AppsCreateOutput(BaseModel):
app: CreatedApp
linked: bool
path_to_link: Annotated[Path | None, Field(exclude=True)] = None
def _create_app(
client: APIClient, *, team_id: str, name: str, directory: str | None
) -> CreatedApp:
response = client.post(
"/apps/",
json={"team_id": team_id, "name": name, "directory": directory},
)
response.raise_for_status()
return CreatedApp.model_validate(response.json())
def _render_apps_create_output(data: AppsCreateOutput, toolkit: RichToolkit) -> None:
toolkit.print(f"Created app [bold]{data.app.name}[/bold]", bullet=False)
if data.linked and data.path_to_link is not None:
toolkit.print(
f"Linked [bold]{data.path_to_link}[/bold] to [bold]{data.app.name}[/bold]",
bullet=False,
)
def create_app(
team_id: Annotated[
str | None,
typer.Option(
"--team-id",
help="ID of the team where the app should be created.",
),
] = None,
name: Annotated[
str | None,
typer.Option(
"--name",
help="Name of the app to create.",
),
] = None,
directory: Annotated[
str | None,
typer.Option(
"--directory",
help=(
"Relative app directory containing the pyproject.toml "
"(for example: backend or webserver)."
),
),
] = None,
link: Annotated[
bool | None,
typer.Option(
"--link/--no-link",
help="Link the local directory to the created app.",
),
] = None,
path: Annotated[
Path | None,
typer.Option(
"--path",
help="Directory to link when --link is enabled.",
),
] = None,
json_output: JsonOutputOption = False,
) -> Any:
"""
Create a FastAPI Cloud app.
"""
identity = Identity()
path_to_link = path or Path.cwd()
# JSON output is non-interactive, so it defaults to create-only unless --link is explicit.
link_app = link if link is not None else not json_output
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
if not link_app and path is not None:
toolkit.fail(
"invalid_input",
"Path can only be used when linking.",
hint="Pass --link or omit --path.",
)
with APIClient() as client:
if team_id is None:
if json_output:
toolkit.fail(
"missing_required_input",
"Team ID is required.",
hint="Pass --team-id to choose a team.",
)
team = _prompt_for_team(toolkit, client)
team_id = team.id
toolkit.print_line()
if name is None:
if json_output:
toolkit.fail(
"missing_required_input",
"App name is required.",
hint="Pass --name to choose an app name.",
)
name = toolkit.input(
title="What's your app name?",
default=_get_app_name(path_to_link),
bullet=False,
)
toolkit.print_line()
try:
directory = validate_app_directory(directory)
except ValueError as e:
toolkit.fail(
"invalid_input",
f"Invalid app directory: {e}",
hint=(
"Pass a relative app directory such as `backend` or `webserver`; "
"use --path with --link to choose a local filesystem path."
),
)
with toolkit.progress(
title="Creating app",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error creating app. Please try again later.",
toolkit=toolkit,
):
app = _create_app(
client,
team_id=team_id,
name=name,
directory=directory,
)
if link_app:
write_app_config(
path_to_link,
AppConfig(app_id=app.id, team_id=app.team_id),
)
result = AppsCreateOutput(
app=app,
linked=link_app,
path_to_link=path_to_link if link_app else None,
)
toolkit.success(result, render_output=_render_apps_create_output)
@@ -0,0 +1,122 @@
import logging
from typing import Annotated, Any
import typer
from pydantic import BaseModel, Field
from rich.text import Text
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.commands.apps.list import (
App,
_get_app,
_get_app_dashboard_url,
_get_team,
)
from fastapi_cloud_cli.config import Settings
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import get_details_table, get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
logger = logging.getLogger(__name__)
class AppGetOutput(BaseModel):
app: App
dashboard_url: Annotated[str | None, Field(exclude=True)] = None
def _render_app_get_output(data: AppGetOutput, toolkit: RichToolkit) -> None:
app = data.app
toolkit.print(f"[bold]{app.name}[/bold]", emoji="📦")
toolkit.print_line()
toolkit.print(
get_details_table(
[
("id", app.id),
("slug", app.slug),
(
"directory",
app.directory
if app.directory is not None
else Text("-", style="dim"),
),
("url", app.url if app.url is not None else Text("-", style="dim")),
(
"dashboard",
Text(data.dashboard_url, style=f"link {data.dashboard_url}")
if data.dashboard_url is not None
else Text("-", style="dim"),
),
("team id", app.team_id),
]
)
)
def get_app(
app_id: Annotated[
str | None,
typer.Argument(
help="ID of the app to return (defaults to the app linked to the current directory).",
),
] = None,
json_output: JsonOutputOption = False,
) -> Any:
"""
Get a FastAPI Cloud app by ID.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
target_app_id = resolve_app_id_or_fail(
toolkit,
app_id=app_id,
hint="Pass an app ID or run `fastapi cloud apps create --link` first.",
)
with APIClient() as client:
with toolkit.progress(
title="Fetching app",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching app. Please try again later.",
not_found_message="App not found.",
toolkit=toolkit,
):
app = _get_app(client, target_app_id)
dashboard_url = None
if not json_output:
with toolkit.progress(
title="Fetching team",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching team. Please try again later.",
not_found_message="Team not found.",
toolkit=toolkit,
):
team = _get_team(client, app.team_id)
dashboard_url = _get_app_dashboard_url(
app,
team_slug=team.slug,
settings=Settings.get(),
)
result = AppGetOutput(app=app, dashboard_url=dashboard_url)
toolkit.success(result, render_output=_render_app_get_output)
@@ -0,0 +1,249 @@
import logging
from pathlib import Path
from typing import Annotated, Any, NoReturn
import typer
from pydantic import BaseModel, Field
from rich_toolkit import RichToolkit
from rich_toolkit.menu import Option
from fastapi_cloud_cli.commands.apps.list import _get_app
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
logger = logging.getLogger(__name__)
class LinkOutput(BaseModel):
app_id: str
team_id: str
path: Path
app_name: Annotated[str, Field(exclude=True)]
config_path: Annotated[Path, Field(exclude=True)]
def _render_link_output(data: LinkOutput, toolkit: RichToolkit) -> None:
toolkit.print(
f"Linked [bold]{data.path}[/bold] to [bold]{data.app_name}[/bold]",
bullet=False,
)
toolkit.print(f"Config: [bold]{data.config_path}[/bold]", bullet=False)
def _fail_not_logged_in_interactive(toolkit: FastAPIRichToolkit) -> NoReturn:
toolkit.fail(
"not_logged_in",
"You need to be logged in to link an app.",
hint="Run [bold]fastapi cloud login[/] to authenticate.",
)
def _fail_already_linked_interactive(toolkit: FastAPIRichToolkit) -> NoReturn:
toolkit.fail(
"already_linked",
"This directory is already linked to an app.",
hint="Run [bold]fastapi cloud unlink[/] first to remove the existing configuration.",
)
def _link_app_by_id(
toolkit: FastAPIRichToolkit,
*,
app_id: str,
path_to_link: Path,
force: bool,
) -> None:
if get_app_config(path_to_link) and not force:
toolkit.fail(
"already_linked",
"This directory is already linked to an app.",
hint="Pass --force to replace the existing configuration.",
)
with APIClient() as client:
with toolkit.progress(
title="Fetching app",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching app. Please try again later.",
not_found_message="App not found.",
toolkit=toolkit,
):
app = _get_app(client, app_id)
write_app_config(
path_to_link,
AppConfig(app_id=app.id, team_id=app.team_id),
)
result = LinkOutput(
app_id=app.id,
team_id=app.team_id,
path=path_to_link,
app_name=app.name,
config_path=path_to_link / ".fastapicloud" / "cloud.json",
)
toolkit.success(result, render_output=_render_link_output)
def _link_app_interactively(
toolkit: FastAPIRichToolkit,
*,
path_to_link: Path,
force: bool,
) -> None:
if get_app_config(path_to_link) and not force:
_fail_already_linked_interactive(toolkit)
toolkit.print_title("Link to FastAPI Cloud")
toolkit.print_line()
with APIClient() as client:
with toolkit.progress("Fetching teams...", transient=True) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching teams. Please try again later.",
):
response = client.get("/teams/")
response.raise_for_status()
teams_data = response.json()["data"]
if not teams_data:
toolkit.print(
"[error]No teams found. Please create a team first.[/]",
bullet=False,
)
raise typer.Exit(1)
team = toolkit.ask(
"Select the team:",
options=[
Option({"name": t["name"], "value": {"id": t["id"], "name": t["name"]}})
for t in sorted(teams_data, key=lambda t: t["name"].lower())
],
allow_filtering=True,
bullet=False,
)
toolkit.print_line()
with toolkit.progress("Fetching apps...", transient=True) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching apps. Please try again later.",
):
response = client.get("/apps/", params={"team_id": team["id"]})
response.raise_for_status()
apps_data = response.json()["data"]
if not apps_data:
toolkit.fail(
"not_found",
"No apps found in this team.",
hint="Run [bold]fastapi cloud apps create[/] to create and deploy a new app.",
)
app = toolkit.ask(
"Select the app to link:",
options=[
Option({"name": a["slug"], "value": {"id": a["id"], "slug": a["slug"]}})
for a in sorted(apps_data, key=lambda a: a["slug"].lower())
],
allow_filtering=True,
bullet=False,
)
toolkit.print_line()
app_config = AppConfig(app_id=app["id"], team_id=team["id"])
write_app_config(path_to_link, app_config)
toolkit.print(
f"Successfully linked to app [bold]{app['slug']}[/bold]!",
emoji="🔗",
)
logger.debug(f"Linked to app: {app['id']} in team: {team['id']}")
def link_app(
app_id: Annotated[
str | None,
typer.Argument(
help="ID of the app to link.",
),
] = None,
app_id_option: Annotated[
str | None,
typer.Option(
"--app-id",
help="ID of the app to link.",
),
] = None,
path: Annotated[
Path | None,
typer.Option(
"--path",
help="Directory to link.",
),
] = None,
force: Annotated[
bool,
typer.Option(
"--force",
help="Replace an existing local app configuration.",
),
] = False,
json_output: JsonOutputOption = False,
) -> Any:
"""
Link a local directory to an existing FastAPI Cloud app.
"""
identity = Identity()
path_to_link = path or Path.cwd()
target_app_id = app_id_option or app_id
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
if target_app_id is None and not json_output:
_fail_not_logged_in_interactive(toolkit)
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
if app_id is not None and app_id_option is not None and app_id != app_id_option:
toolkit.fail(
"invalid_input",
"App ID was provided more than once.",
hint="Pass either APP_ID or --app-id, not both.",
)
if target_app_id is None:
if json_output:
toolkit.fail(
"missing_required_input",
"App ID is required.",
hint="Pass an app ID to link an app.",
)
_link_app_interactively(
toolkit,
path_to_link=path_to_link,
force=force,
)
return
_link_app_by_id(
toolkit,
app_id=target_app_id,
path_to_link=path_to_link,
force=force,
)
@@ -0,0 +1,268 @@
import logging
from typing import Annotated, Any
import typer
from pydantic import BaseModel, Field
from rich.table import Table
from rich.text import Text
from rich_toolkit import RichToolkit
from rich_toolkit.menu import Option
from fastapi_cloud_cli.config import Settings
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
logger = logging.getLogger(__name__)
DEFAULT_LIMIT = 100
DEFAULT_OFFSET = 0
class App(BaseModel):
id: str
team_id: str
slug: str
name: str
directory: str | None
url: str | None = None
region: str | None = None
updated_at: str | None = None
class Team(BaseModel):
id: str
slug: str
name: str
class AppsListAPIResponse(BaseModel):
data: list[App]
count: int
class AppsListOutput(BaseModel):
apps: list[App]
total_count: int
limit: int
offset: int
team_slug: Annotated[str, Field(exclude=True)]
def _get_app_dashboard_url(app: App, *, team_slug: str, settings: Settings) -> str:
return f"{settings.dashboard_base_url}/{team_slug}/apps/{app.slug}"
def _format_app_name(app: App, *, team_slug: str, settings: Settings) -> str:
dashboard_url = _get_app_dashboard_url(
app,
team_slug=team_slug,
settings=settings,
)
return f"[link={dashboard_url}]{app.name}[/link]"
def _get_apps_list_table(
apps: list[App], *, team_slug: str, settings: Settings
) -> Table:
table = Table.grid(padding=(0, 2), pad_edge=False)
table.add_column("Name", no_wrap=True)
table.add_column("ID", no_wrap=True, overflow="ignore")
table.add_row(
"[bold]Name[/bold]",
"[bold]ID[/bold]",
)
table.add_row("", "")
for app in apps:
table.add_row(
_format_app_name(app, team_slug=team_slug, settings=settings),
Text(app.id),
)
return table
def _get_teams(client: APIClient) -> list[Team]:
response = client.get("/teams/")
response.raise_for_status()
data = response.json()["data"]
return [Team.model_validate(team) for team in data]
def _get_team(client: APIClient, team_id: str) -> Team:
response = client.get(f"/teams/{team_id}")
response.raise_for_status()
return Team.model_validate(response.json())
def _get_app(client: APIClient, app_id: str) -> App:
response = client.get(f"/apps/{app_id}")
response.raise_for_status()
return App.model_validate(response.json())
def _get_apps(
client: APIClient, *, team_id: str, limit: int, offset: int, team_slug: str
) -> AppsListOutput:
response = client.get(
"/apps/",
params={
"team_id": team_id,
"limit": limit,
"skip": offset,
},
)
response.raise_for_status()
data = AppsListAPIResponse.model_validate(response.json())
return AppsListOutput(
apps=data.data,
total_count=data.count,
limit=limit,
offset=offset,
team_slug=team_slug,
)
def _render_apps_list_output(data: AppsListOutput, toolkit: RichToolkit) -> None:
toolkit.print_title("apps")
toolkit.print_line()
if not data.apps:
toolkit.print("No apps found.", bullet=False)
return
toolkit.print(
_get_apps_list_table(
data.apps,
team_slug=data.team_slug,
settings=Settings.get(),
),
bullet=False,
)
def _prompt_for_team(toolkit: FastAPIRichToolkit, client: APIClient) -> Team:
with toolkit.progress(
title="Fetching teams",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching teams. Please try again later.",
toolkit=toolkit,
):
teams = _get_teams(client)
if not teams:
toolkit.fail(
"missing_required_input",
"No teams found.",
hint="Create a team before listing apps.",
)
return toolkit.ask(
"Select the team:",
options=[
Option({"name": team.name, "value": team})
for team in sorted(teams, key=lambda team: team.name.lower())
],
allow_filtering=True,
bullet=False,
)
def list_apps(
team_id: Annotated[
str | None,
typer.Option(
"--team-id",
help="ID of the team whose apps should be listed.",
),
] = None,
limit: Annotated[
int,
typer.Option(
"--limit",
help="Maximum number of apps to return.",
min=1,
),
] = DEFAULT_LIMIT,
offset: Annotated[
int,
typer.Option(
"--offset",
help="Offset into the app result set.",
min=0,
),
] = DEFAULT_OFFSET,
json_output: JsonOutputOption = False,
) -> Any:
"""
List FastAPI Cloud apps.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
with APIClient() as client:
team_slug: str | None = None
if team_id is None:
if json_output:
toolkit.fail(
"missing_required_input",
"Team ID is required.",
hint="Pass --team-id to choose a team.",
)
team = _prompt_for_team(toolkit, client)
team_id = team.id
team_slug = team.slug
toolkit.print_line()
else:
with toolkit.progress(
title="Fetching team",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching team. Please try again later.",
not_found_message="Team not found.",
toolkit=toolkit,
):
team = _get_team(client, team_id)
team_slug = team.slug
with toolkit.progress(
title="Fetching apps",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching apps. Please try again later.",
toolkit=toolkit,
):
result = _get_apps(
client,
team_id=team_id,
limit=limit,
offset=offset,
team_slug=team_slug,
)
toolkit.success(result, render_output=_render_apps_list_output)
@@ -0,0 +1,75 @@
import logging
from pathlib import Path
from typing import Annotated, Any
import typer
from pydantic import BaseModel, Field
from rich.text import Text
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
logger = logging.getLogger(__name__)
class UnlinkOutput(BaseModel):
unlinked: bool
path: Annotated[Path, Field(exclude=True)]
removed_path: Path
path_provided: Annotated[bool, Field(exclude=True)] = False
def _render_unlink_output(data: UnlinkOutput, toolkit: RichToolkit) -> None:
removed_path = (
data.removed_path
if data.path_provided
else data.removed_path.relative_to(Path.cwd())
)
toolkit.print("Removed app link", emoji="🔗")
toolkit.print_line()
toolkit.print(Text(f"Deleted {removed_path}", style="dim"))
def _fail_not_linked(toolkit: FastAPIRichToolkit) -> None:
toolkit.fail(
"not_linked",
"No app is linked to this directory.",
hint="Run `fastapi cloud link` to link an app.",
)
def unlink_app(
path: Annotated[
Path | None,
typer.Option(
"--path",
help="Directory to unlink.",
),
] = None,
json_output: JsonOutputOption = False,
) -> Any:
"""
Unlink by deleting the `.fastapicloud/cloud.json` file.
"""
path_to_unlink = path or Path.cwd()
config_path = path_to_unlink / ".fastapicloud/cloud.json"
with get_rich_toolkit(json_output=json_output) as toolkit:
if not config_path.exists():
logger.debug(f"Configuration file not found: {config_path}")
_fail_not_linked(toolkit)
config_path.unlink()
logger.debug(f"Deleted configuration file: {config_path}")
toolkit.success(
UnlinkOutput(
unlinked=True,
path=path_to_unlink,
removed_path=config_path,
path_provided=path is not None,
),
render_output=_render_unlink_output,
)
@@ -0,0 +1,122 @@
import logging
from typing import Annotated, Any
import typer
from pydantic import BaseModel
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.commands.deploy.archive import validate_app_directory
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
logger = logging.getLogger(__name__)
class UpdatedApp(BaseModel):
id: str
team_id: str
slug: str
name: str
directory: str | None
class AppsUpdateOutput(BaseModel):
app: UpdatedApp
def _update_app(client: APIClient, *, app_id: str, directory: str | None) -> UpdatedApp:
response = client.patch(
f"/apps/{app_id}",
json={"directory": directory},
)
response.raise_for_status()
return UpdatedApp.model_validate(response.json())
def _render_apps_update_output(data: AppsUpdateOutput, toolkit: RichToolkit) -> None:
toolkit.print(f"Updated app [bold]{data.app.name}[/bold]", bullet=False)
toolkit.print(
f"Directory: [bold]{data.app.directory if data.app.directory is not None else '.'}[/bold]",
bullet=False,
)
def update_app(
app_id: Annotated[
str | None,
typer.Argument(
help="ID of the app to update (defaults to the app linked to the current directory).",
),
] = None,
directory: Annotated[
str | None,
typer.Option(
"--directory",
help=(
"Relative app directory containing the pyproject.toml "
"(for example: src or backend)."
),
),
] = None,
json_output: JsonOutputOption = False,
) -> Any:
"""
Update FastAPI Cloud app metadata.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
if directory is None:
toolkit.fail(
"missing_required_input",
"No updates provided.",
hint="Pass --directory to update the app directory.",
)
target_app_id = resolve_app_id_or_fail(
toolkit,
app_id=app_id,
hint="Pass an app ID or run `fastapi cloud apps create --link` first.",
)
try:
directory = validate_app_directory(directory)
except ValueError as e:
toolkit.fail(
"invalid_input",
f"Invalid app directory: {e}",
hint="Pass a relative app directory such as `src` or `backend`.",
)
with APIClient() as client:
with toolkit.progress(
title="Updating app",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error updating app. Please try again later.",
not_found_message="App not found.",
toolkit=toolkit,
):
app = _update_app(
client,
app_id=target_app_id,
directory=directory,
)
toolkit.success(
AppsUpdateOutput(app=app),
render_output=_render_apps_update_output,
)
@@ -0,0 +1,14 @@
import typer
from fastapi_cloud_cli.commands.auth import wait as wait_command
from fastapi_cloud_cli.commands.login import login
auth_app = typer.Typer(
no_args_is_help=True,
help="Authenticate with FastAPI Cloud.",
)
auth_app.command()(login)
auth_app.command("wait")(wait_command.wait)
__all__ = ["auth_app"]
@@ -0,0 +1,67 @@
from typing import Annotated, Any
import typer
from fastapi_cloud_cli.commands._flow import (
DEFAULT_LOGIN_TIMEOUT_SECONDS,
complete_device_login,
render_login_output,
)
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
def wait(
device_code: Annotated[
str,
typer.Option(
"--device-code",
help="Device code returned by `fastapi cloud auth login --json`.",
),
],
interval: Annotated[
int,
typer.Option(
"--interval",
help="Seconds between authorization polling attempts.",
min=5,
),
] = 5,
timeout: Annotated[
int,
typer.Option(
"--timeout",
help="Maximum seconds to wait for authorization.",
min=10,
),
] = DEFAULT_LOGIN_TIMEOUT_SECONDS,
json_output: JsonOutputOption = False,
) -> Any:
"""
Wait for a device authorization flow to complete.
"""
with get_rich_toolkit(json_output=json_output) as toolkit:
with APIClient() as client:
toolkit.print_title(
"Login to FastAPI Cloud", tag="FastAPI Cloud", emoji="🔐"
)
toolkit.print_line()
with toolkit.progress(
"Waiting for user to authorize...", transient=True
) as progress:
result = complete_device_login(
client=client,
progress=progress,
toolkit=toolkit,
device_code=device_code,
interval=interval,
timeout=timeout,
cancel_hint="Run `fastapi cloud auth wait --json` again to retry.",
)
toolkit.success(
result,
render_output=render_login_output,
)
@@ -0,0 +1,15 @@
import typer
from fastapi_cloud_cli.commands.ci.print_workflow import (
print_workflow as print_workflow_command,
)
from fastapi_cloud_cli.commands.setup_ci import setup_ci
ci_app = typer.Typer(
no_args_is_help=True,
help="Manage CI integration helpers.",
)
ci_app.command("print-workflow")(print_workflow_command)
ci_app.command("setup")(setup_ci)
__all__ = ["ci_app"]
@@ -0,0 +1,45 @@
from typing import Annotated, Any
import typer
from pydantic import BaseModel
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.commands.setup_ci import (
DEFAULT_WORKFLOW_PATH,
_get_default_branch,
_get_workflow_content,
)
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
class CIWorkflowOutput(BaseModel):
filename: str
content: str
def _render_workflow_output(data: CIWorkflowOutput, toolkit: RichToolkit) -> None:
toolkit.console.print(data.content, markup=False, end="")
def print_workflow(
branch: Annotated[
str | None,
typer.Option(
"--branch",
"-b",
help="Branch that triggers deploys (defaults to the repo's default branch).",
),
] = None,
json_output: JsonOutputOption = False,
) -> Any:
"""Prints the GitHub Actions workflow YAML without writing files or secrets."""
branch = branch or _get_default_branch()
workflow = CIWorkflowOutput(
filename=DEFAULT_WORKFLOW_PATH.name,
content=_get_workflow_content(branch),
)
with get_rich_toolkit(minimal=True, json_output=json_output) as toolkit:
toolkit.success(workflow, render_output=_render_workflow_output)
@@ -0,0 +1,3 @@
from .command import deploy
__all__ = ["deploy"]
@@ -0,0 +1,115 @@
import logging
import re
from pathlib import Path, PurePosixPath
from typing import Annotated
import fastar
import rignore
from pydantic import AfterValidator
logger = logging.getLogger(__name__)
def validate_app_directory(v: str | None) -> str | None:
if v is None:
return None
v = v.strip()
if not v:
return None
if v.startswith("~"):
raise ValueError("cannot start with '~'")
path = PurePosixPath(v)
if path.is_absolute():
raise ValueError("must be a relative path, not absolute")
if ".." in path.parts:
raise ValueError("cannot contain '..' path segments")
normalized = path.as_posix()
if not re.fullmatch(r"[A-Za-z0-9._/ -]+", normalized):
raise ValueError(
"contains invalid characters (allowed: letters, numbers, space, / . _ -)"
)
return normalized
AppDirectory = Annotated[str | None, AfterValidator(validate_app_directory)]
def _get_app_name(path: Path) -> str:
# TODO: use pyproject.toml to get the app name
return path.name
def _should_exclude_entry(path: Path) -> bool:
parts_to_exclude = [
".venv",
"__pycache__",
".mypy_cache",
".pytest_cache",
".git",
".gitignore",
".fastapicloudignore",
]
if any(part in path.parts for part in parts_to_exclude):
return True
if path.suffix == ".pyc":
return True
if path.name == ".env" or path.name.startswith(".env."):
return True
return False
def _rignore_walk(path: Path) -> rignore.Walker:
return rignore.walk(
path,
should_exclude_entry=_should_exclude_entry,
additional_ignore_paths=[".fastapicloudignore"],
ignore_hidden=False,
)
def archive(path: Path, tar_path: Path) -> Path:
logger.debug("Starting archive creation for path: %s", path)
files = _rignore_walk(path)
logger.debug("Archive will be created at: %s", tar_path)
file_count = 0
with fastar.open(tar_path, "w:zst", sparse=False) as tar:
for filename in files:
if filename.is_dir():
continue
arcname = filename.relative_to(path)
logger.debug("Adding %s to archive", arcname)
tar.append(filename, arcname=arcname)
file_count += 1
logger.debug("Archive created successfully with %s files", file_count)
return tar_path
def _get_large_files(path: Path, threshold_mb: int) -> list[tuple[Path, int]]:
threshold_bytes = threshold_mb * 1024 * 1024
large_files = []
files = _rignore_walk(path)
for filename in files:
if filename.is_dir():
continue
file_size = filename.stat().st_size
if file_size > threshold_bytes:
large_files.append((filename.relative_to(path), file_size))
return sorted(large_files, key=lambda x: x[1], reverse=True)
@@ -0,0 +1,86 @@
from pydantic import BaseModel
from fastapi_cloud_cli.utils.api import APIClient, DeploymentStatus
class Team(BaseModel):
id: str
slug: str
name: str
class AppResponse(BaseModel):
id: str
slug: str
directory: str | None
class CreateDeploymentResponse(BaseModel):
id: str
app_id: str
slug: str
status: DeploymentStatus
dashboard_url: str
url: str
def _get_teams(client: APIClient) -> list[Team]:
response = client.get("/teams/")
response.raise_for_status()
data = response.json()["data"]
return [Team.model_validate(team) for team in data]
def _update_app(client: APIClient, app_id: str, directory: str | None) -> AppResponse:
response = client.patch(
f"/apps/{app_id}",
json={"directory": directory},
)
response.raise_for_status()
return AppResponse.model_validate(response.json())
def _create_app(
client: APIClient, team_id: str, app_name: str, directory: str | None
) -> AppResponse:
response = client.post(
"/apps/",
json={"name": app_name, "team_id": team_id, "directory": directory},
)
response.raise_for_status()
return AppResponse.model_validate(response.json())
def _create_deployment(client: APIClient, app_id: str) -> CreateDeploymentResponse:
response = client.post(f"/apps/{app_id}/deployments/")
response.raise_for_status()
return CreateDeploymentResponse.model_validate(response.json())
def _get_app(client: APIClient, app_id: str) -> AppResponse | None:
response = client.get(f"/apps/{app_id}")
if response.status_code == 404:
return None
response.raise_for_status()
data = response.json()
return AppResponse.model_validate(data)
def _get_apps(client: APIClient, team_id: str) -> list[AppResponse]:
response = client.get("/apps/", params={"team_id": team_id})
response.raise_for_status()
data = response.json()["data"]
return [AppResponse.model_validate(app) for app in data]
@@ -0,0 +1,370 @@
import logging
import tempfile
from pathlib import Path
from typing import Annotated, Any, cast
import typer
from pydantic import BaseModel
from fastapi_cloud_cli.commands.deploy.archive import _get_large_files, archive
from fastapi_cloud_cli.commands.deploy.cloud import (
AppResponse,
CreateDeploymentResponse,
_create_deployment,
_get_app,
)
from fastapi_cloud_cli.commands.deploy.configure import _configure_app
from fastapi_cloud_cli.commands.deploy.upload import _cancel_upload, _upload_deployment
from fastapi_cloud_cli.commands.deploy.wait import _wait_for_deployment
from fastapi_cloud_cli.commands.login import _interactive_login
from fastapi_cloud_cli.utils.api import APIClient, DeploymentStatus
from fastapi_cloud_cli.utils.apps import get_app_config
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
from fastapi_cloud_cli.utils.errors import ErrorCode
from fastapi_cloud_cli.utils.execution import JsonOutputOption, is_ci_enabled
logger = logging.getLogger(__name__)
class DeployOutput(BaseModel):
deployment_id: str
app_id: str
slug: str
status: DeploymentStatus
dashboard_url: str
url: str
def _get_deploy_output(deployment: CreateDeploymentResponse) -> DeployOutput:
return DeployOutput(
deployment_id=deployment.id,
app_id=deployment.app_id,
slug=deployment.slug,
status=deployment.status,
dashboard_url=deployment.dashboard_url,
url=deployment.url,
)
def _get_large_file_warnings(
large_files: list[tuple[Path, int]],
*,
threshold_mb: int,
) -> list[dict[str, Any]]:
if not large_files:
return []
count = len(large_files)
message = (
f"1 uploaded file is larger than {threshold_mb} MB."
if count == 1
else f"{count} uploaded files are larger than {threshold_mb} MB."
)
return [
{
"code": "large_files",
"message": message,
"files": [
{"path": path.as_posix(), "size_bytes": size}
for path, size in large_files
],
}
]
def _render_app_id_mismatch(
toolkit: FastAPIRichToolkit, *, code: ErrorCode, message: str, hint: str
) -> None:
toolkit.print_error(message)
toolkit.print_line()
toolkit.print_hint(hint)
def _render_app_not_found(
toolkit: FastAPIRichToolkit, *, code: ErrorCode, message: str, hint: str
) -> None:
toolkit.print_line()
def _render_linked_app_not_found(
toolkit: FastAPIRichToolkit, *, code: ErrorCode, message: str, hint: str
) -> None:
_render_app_not_found(toolkit, code=code, message=message, hint=hint)
toolkit.print_hint(
"If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration."
)
def deploy(
path: Annotated[
Path | None,
typer.Argument(
help=(
"Path to the directory with your app's pyproject.toml "
"(defaults to current directory)"
)
),
] = None,
skip_wait: Annotated[
bool, typer.Option("--no-wait", help="Skip waiting for deployment status")
] = False,
provided_app_id: Annotated[
str | None,
typer.Option(
"--app-id",
help="Application ID to deploy to",
envvar="FASTAPI_CLOUD_APP_ID",
),
] = None,
large_file_threshold: Annotated[
int,
typer.Option(
help="File size threshold in MB for warning about large files",
min=1,
envvar="FASTAPI_CLOUD_LARGE_FILE_THRESHOLD",
),
] = 10,
json_output: JsonOutputOption = False,
) -> Any:
"""
Deploy a [bold]FastAPI[/bold] app to FastAPI Cloud.
"""
logger.debug("Deploy command started")
logger.debug(
"Deploy path: %s, skip_wait: %s, app_id: %s", path, skip_wait, provided_app_id
)
identity = Identity()
use_deploy_token = identity.has_deploy_token()
has_auth = use_deploy_token or identity.is_logged_in()
logger.debug(
"Authentication mode: %s", "deploy token" if use_deploy_token else "user token"
)
with get_rich_toolkit(json_output=json_output) as toolkit:
if not has_auth:
logger.debug("User not logged in, starting login")
if is_ci_enabled():
toolkit.fail(
"not_logged_in",
"FASTAPI_CLOUD_TOKEN is required to deploy from CI.",
hint=(
"Run `fastapi cloud setup-ci` to configure a deploy token, "
"or set FASTAPI_CLOUD_TOKEN in your CI secrets."
),
)
if json_output:
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
toolkit.print_title(
"Welcome to FastAPI Cloud!",
tag="FastAPI Cloud",
emoji="👋",
animate=True,
)
toolkit.print_line()
if identity.user_token and identity.is_user_token_expired():
toolkit.print("Your session has expired. Please log in again.")
else:
toolkit.print("You need to be logged in to deploy to FastAPI Cloud.")
toolkit.print_line()
should_login = toolkit.confirm(
"Do you want to log in now?",
default=True,
)
if not should_login:
toolkit.print_line()
toolkit.print("Deployment cancelled.")
raise typer.Exit(0)
toolkit.print_line()
_interactive_login(toolkit)
toolkit.print_line()
with APIClient(use_deploy_token=use_deploy_token) as client:
# the welcome title already shows the header when logging in
if has_auth:
toolkit.print_title("FastAPI Cloud", animate=True)
toolkit.print_line()
if use_deploy_token:
toolkit.print(
"Using token from [bold blue]FASTAPI_CLOUD_TOKEN[/] environment variable",
)
toolkit.print_line()
path_to_deploy = path or Path.cwd()
logger.debug("Deploying from path: %s", path_to_deploy)
app_config = get_app_config(path_to_deploy)
if app_config and provided_app_id and app_config.app_id != provided_app_id:
toolkit.fail(
"invalid_input",
f"Provided app ID ({provided_app_id}) does not match the local config ({app_config.app_id}).",
hint=(
"Run `fastapi cloud unlink` to remove the local config, "
"or remove --app-id / unset FASTAPI_CLOUD_APP_ID to use the configured app."
),
render_output=_render_app_id_mismatch,
)
if provided_app_id:
target_app_id = provided_app_id
elif app_config:
target_app_id = app_config.app_id
else:
if json_output:
toolkit.fail(
"missing_required_input",
"App ID is required.",
hint="Pass --app-id or run `fastapi cloud apps create --link` first.",
)
logger.debug("No app config found, configuring new app")
app_config = _configure_app(
toolkit=toolkit,
client=client,
path_to_deploy=path_to_deploy,
)
toolkit.print_line()
target_app_id = app_config.app_id
if provided_app_id:
toolkit.print(
f"Deploying to app [blue]{target_app_id}[/blue]...", emoji="🚀"
)
else:
toolkit.print("Deploying app...", emoji="🚀")
toolkit.print_line()
with toolkit.progress("Checking app...", transient=True) as progress:
with client.handle_http_errors(progress, toolkit=toolkit):
logger.debug("Checking app with ID: %s", target_app_id)
app = _get_app(client=client, app_id=target_app_id)
if app is None:
logger.debug("App not found in API")
progress.set_error(
"App not found. Make sure you're logged in the correct account."
)
if app is None:
toolkit.fail(
"not_found",
"App not found. Make sure you're logged in the correct account.",
render_output=(
_render_app_not_found
if provided_app_id
else _render_linked_app_not_found
),
)
app = cast(AppResponse, app)
large_files = _get_large_files(
path_to_deploy, threshold_mb=large_file_threshold
)
warnings = _get_large_file_warnings(
large_files,
threshold_mb=large_file_threshold,
)
if large_files:
toolkit.print(
f"Some uploaded files are larger than {large_file_threshold} MB:",
emoji="⚠️",
)
toolkit.print_line()
for fname, fsize in large_files[:3]:
fsize_mb = fsize // (1024 * 1024)
toolkit.print(
f"• [bold]{fname}[/bold] [yellow]({fsize_mb} MB)[/yellow]"
)
is_more = len(large_files) > 3
if is_more:
toolkit.print(f"[dim]...and {len(large_files) - 3} more[/dim]")
large_files_docs_url = "https://fastapicloud.com/docs/fastapi-cloud-cli/deploy/#large-files-warning"
toolkit.print_line()
toolkit.print(
f"Read more: [link={large_files_docs_url}]{large_files_docs_url}[/link]",
emoji="💡",
)
toolkit.print_line()
will_wait = not skip_wait and not json_output
with tempfile.TemporaryDirectory() as temp_dir:
logger.debug("Creating archive for deployment")
archive_path = Path(temp_dir) / "archive.tar"
archive(path_to_deploy, archive_path)
with (
toolkit.progress(
title="Creating deployment",
# the build status replaces this when waiting
transient=will_wait,
done_emoji="📦",
) as progress,
client.handle_http_errors(progress, toolkit=toolkit),
):
logger.debug("Creating deployment for app: %s", app.id)
deployment = _create_deployment(client=client, app_id=app.id)
try:
progress.log(
f"Deployment created successfully! Deployment slug: {deployment.slug}"
)
deployment = _upload_deployment(
fastapi_client=client,
deployment_id=deployment.id,
archive_path=archive_path,
progress=progress,
)
progress.log("Deployment uploaded successfully!")
except KeyboardInterrupt:
_cancel_upload(client=client, deployment_id=deployment.id)
raise
if will_wait:
logger.debug("Waiting for deployment to complete")
_wait_for_deployment(
toolkit=toolkit,
client=client,
app_id=app.id,
deployment=deployment,
)
else:
toolkit.print_line()
logger.debug("Skipping deployment wait as requested")
if json_output:
toolkit.success(
_get_deploy_output(deployment),
warnings=warnings,
hint=(
"Check deployment status in the FastAPI Cloud dashboard: "
f"{deployment.dashboard_url}"
),
)
return
toolkit.print(
f"Check the status of your deployment at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
)
@@ -0,0 +1,161 @@
from pathlib import Path
import typer
from pydantic import TypeAdapter
from rich_toolkit.menu import Option
from fastapi_cloud_cli.commands.deploy.archive import AppDirectory, _get_app_name
from fastapi_cloud_cli.commands.deploy.cloud import (
AppResponse,
_create_app,
_get_apps,
_get_teams,
_update_app,
)
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.apps import AppConfig, write_app_config
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit
def _configure_app(
toolkit: FastAPIRichToolkit,
client: APIClient,
path_to_deploy: Path,
) -> AppConfig:
toolkit.print(f"Setting up and deploying [blue]{path_to_deploy}[/blue]", emoji="📁")
toolkit.print_line()
with toolkit.progress("Fetching teams...", transient=True) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching teams. Please try again later.",
):
teams = _get_teams(client)
team = toolkit.ask(
"Select the team you want to deploy to:",
options=[
Option({"name": team.name, "value": team})
for team in sorted(teams, key=lambda team: team.name.lower())
],
allow_filtering=True,
emoji="🏢",
)
toolkit.print_line()
create_new_app = toolkit.confirm(
"Do you want to create a new app?", default=True, emoji="📦"
)
toolkit.print_line()
selected_app: AppResponse | None = None
if not create_new_app:
with toolkit.progress("Fetching apps...", transient=True) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching apps. Please try again later.",
):
apps = _get_apps(client=client, team_id=team.id)
if not apps:
toolkit.fail(
"not_found",
"No apps found in this team. You can create a new app instead.",
)
selected_app = toolkit.ask(
"Select the app you want to deploy to:",
options=[
Option({"name": app.slug, "value": app})
for app in sorted(apps, key=lambda app: app.slug.lower())
],
allow_filtering=True,
emoji="📦",
)
app_name = (
selected_app.slug
if selected_app
else toolkit.input(
title="What's your app name?",
default=_get_app_name(path_to_deploy),
emoji="✏️",
)
)
toolkit.print_line()
initial_directory = selected_app.directory if selected_app else ""
directory_input = toolkit.input(
title=(
"Directory where your app's pyproject.toml file lives (e.g. src, backend):"
),
value=initial_directory or "",
placeholder=(
"[italic]Leave empty if pyproject.toml is in the current directory[/italic]"
),
validator=TypeAdapter(AppDirectory),
emoji="📂",
)
directory: str | None = directory_input if directory_input else None
toolkit.print_line()
toolkit.print("Deployment configuration:", emoji="📋")
toolkit.print_line()
toolkit.print(f"Team: [bold]{team.name}[/bold]")
toolkit.print(f"App name: [bold]{app_name}[/bold]")
toolkit.print(f"Directory: [bold]{directory or '.'}[/bold]")
toolkit.print_line()
choice = toolkit.ask(
"Does everything look right?",
options=[
Option({"name": "Yes, start the deployment!", "value": "deploy"}),
Option({"name": "No, let me start over", "value": "cancel"}),
],
emoji="👀",
)
toolkit.print_line()
if choice == "cancel":
toolkit.print("Deployment cancelled.")
raise typer.Exit(0)
if selected_app:
if directory != selected_app.directory:
with (
toolkit.progress(title="Updating app directory...") as progress,
client.handle_http_errors(progress),
):
app = _update_app(
client=client, app_id=selected_app.id, directory=directory
)
progress.log(f"App directory updated to '{directory or '.'}'")
else:
app = selected_app
else:
with toolkit.progress(title="Creating app...") as progress:
with client.handle_http_errors(progress):
app = _create_app(
client=client,
team_id=team.id,
app_name=app_name,
directory=directory,
)
progress.log(f"App created successfully! App slug: {app.slug}")
app_config = AppConfig(app_id=app.id, team_id=team.id)
write_app_config(path_to_deploy, app_config)
return app_config
@@ -0,0 +1,93 @@
import logging
from pathlib import Path
from typing import BinaryIO, cast
from httpx import Client
from pydantic import BaseModel
from rich_toolkit.progress import Progress
from fastapi_cloud_cli.commands.deploy.cloud import CreateDeploymentResponse
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.progress_file import ProgressFile
logger = logging.getLogger(__name__)
class RequestUploadResponse(BaseModel):
url: str
fields: dict[str, str]
def _cancel_upload(client: APIClient, deployment_id: str) -> None:
logger.debug("Cancelling upload for deployment: %s", deployment_id)
try:
response = client.post(f"/deployments/{deployment_id}/upload-cancelled")
response.raise_for_status()
logger.debug("Upload cancellation notification sent successfully")
except Exception as e:
logger.debug("Failed to notify server about upload cancellation: %s", e)
def _format_size(size_in_bytes: int) -> str:
if size_in_bytes >= 1024 * 1024:
return f"{size_in_bytes / (1024 * 1024):.2f} MB"
elif size_in_bytes >= 1024:
return f"{size_in_bytes / 1024:.2f} KB"
else:
return f"{size_in_bytes} bytes"
def _upload_deployment(
fastapi_client: APIClient,
deployment_id: str,
archive_path: Path,
progress: Progress,
) -> CreateDeploymentResponse:
archive_size = archive_path.stat().st_size
archive_size_str = _format_size(archive_size)
progress.log(f"Uploading deployment ({archive_size_str})...")
logger.debug(
"Starting deployment upload for deployment: %s",
deployment_id,
)
logger.debug("Archive path: %s, size: %s bytes", archive_path, archive_size)
def progress_callback(bytes_read: int) -> None:
progress.log(
f"Uploading deployment ({_format_size(bytes_read)} of {archive_size_str})..."
)
logger.debug("Requesting upload URL from API")
response = fastapi_client.post(f"/deployments/{deployment_id}/upload")
response.raise_for_status()
upload_data = RequestUploadResponse.model_validate(response.json())
logger.debug("Received upload URL: %s", upload_data.url)
logger.debug("Starting file upload to S3")
with Client() as s3_client:
with open(archive_path, "rb") as archive_file:
archive_file_with_progress = ProgressFile(
archive_file, progress_callback=progress_callback
)
upload_response = s3_client.post(
upload_data.url,
data=upload_data.fields,
files={"file": cast(BinaryIO, archive_file_with_progress)},
)
upload_response.raise_for_status()
logger.debug("File upload completed successfully")
logger.debug("Notifying API that upload is complete")
notify_response = fastapi_client.post(
f"/deployments/{deployment_id}/upload-complete"
)
notify_response.raise_for_status()
logger.debug("Upload notification sent successfully")
return CreateDeploymentResponse.model_validate(notify_response.json())
@@ -0,0 +1,170 @@
import time
from itertools import cycle
from textwrap import dedent
import typer
from rich.text import Text
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.commands.deploy.cloud import CreateDeploymentResponse
from fastapi_cloud_cli.utils.api import (
SUCCESSFUL_STATUSES,
APIClient,
DeploymentStatus,
StreamLogError,
TooManyRetriesError,
)
# (bullet emoji, message) — the emoji replaces the progress animation
WAITING_MESSAGES = [
("🚀", "Preparing for liftoff! Almost there..."),
("👹", "Sneaking past the dependency gremlins... Don't wake them up!"),
("🤏", "Squishing code into a tiny digital sandwich. Nom nom nom."),
("🐱", "Removing cat videos from our servers to free up space."),
("🐢", "Uploading at blazing speeds of 1 byte per hour. Patience, young padawan."),
("🔌", "Connecting to server... Please stand by while we argue with the firewall."),
(
"💥",
"Oops! We've angered the Python God. Sacrificing a rubber duck to appease it.",
),
("🧙", "Sprinkling magic deployment dust. Abracadabra!"),
("👀", "Hoping that @tiangolo doesn't find out about this deployment."),
("🍪", "Cookie monster detected on server. Deploying anti-cookie shields."),
]
LONG_WAIT_MESSAGES = [
(
"😅",
"Well, that's embarrassing. We're still waiting for the deployment to finish...",
),
("🤔", "Maybe we should have brought snacks for this wait..."),
("🥱", "Yawn... Still waiting..."),
("🤯", "Time is relative... Especially when you're waiting for a deployment..."),
]
def _verify_deployment(
toolkit: RichToolkit,
client: APIClient,
deployment: CreateDeploymentResponse,
) -> None:
failed_status: str | None = None
with toolkit.progress(
title="Verifying deployment...",
inline_logs=True,
done_emoji="✅",
) as progress:
try:
final_status = client.poll_deployment_status(deployment.id)
except (TimeoutError, TooManyRetriesError, StreamLogError):
progress.metadata["done_emoji"] = "⚠️"
progress.current_message = (
f"Could not confirm deployment status. "
f"Check the dashboard: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
)
return
if final_status in SUCCESSFUL_STATUSES:
progress.current_message = "Ready the chicken! 🐔"
else:
progress.metadata["done_emoji"] = "❌"
progress.current_message = "Deployment failed"
failed_status = DeploymentStatus.to_human_readable(final_status)
if failed_status is not None:
toolkit.print_line()
toolkit.print(
f"Oh no! Deployment failed: {failed_status}. "
f"Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]",
emoji="😔",
)
raise typer.Exit(1)
toolkit.print_line()
toolkit.print(
f"Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
)
def _wait_for_deployment(
toolkit: RichToolkit,
client: APIClient,
app_id: str,
deployment: CreateDeploymentResponse,
) -> None:
messages = cycle(WAITING_MESSAGES)
time_elapsed = 0.0
started_at = time.monotonic()
last_message_changed_at = time.monotonic()
with (
toolkit.progress(
"Checking the status of your deployment",
inline_logs=True,
lines_to_show=20,
emoji="👀",
done_emoji="🚀",
) as progress,
):
build_complete = False
build_failed = False
try:
for log in client.stream_build_logs(deployment.id):
time_elapsed = time.monotonic() - started_at
if log.type == "message":
progress.log(Text.from_ansi(log.message.rstrip())) # ty: ignore[unresolved-attribute]
if log.type == "complete":
build_complete = True
progress.title = "Build complete!"
break
if log.type == "failed":
build_failed = True
# the headline comes from the title once there are log
# lines, and from current_message when there are none
progress.title = "Build failed"
progress.current_message = "Build failed"
progress.metadata["done_emoji"] = "❌"
break
if time_elapsed > 30:
messages = cycle(LONG_WAIT_MESSAGES)
if (time.monotonic() - last_message_changed_at) > 2:
emoji, title = next(messages)
progress.metadata["emoji"] = emoji
progress.title = title
last_message_changed_at = time.monotonic()
except (StreamLogError, TooManyRetriesError, TimeoutError) as e:
progress.set_error(
dedent(f"""
[error]Build log streaming failed: {e}[/]
Unable to stream build logs. Check the dashboard for status: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]
""").strip()
)
raise typer.Exit(1) from None
if build_failed:
toolkit.print_line()
toolkit.print(
f"Oh no! Something went wrong. Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]",
emoji="😔",
)
raise typer.Exit(1)
if build_complete:
toolkit.print_line()
_verify_deployment(toolkit=toolkit, client=client, deployment=deployment)
@@ -0,0 +1,497 @@
import json
import logging
from typing import Annotated, Any
import typer
from httpx import HTTPError
from pydantic import BaseModel
from rich.table import Table
from rich.text import Text
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.utils.api import (
APIClient,
BuildLogLineMessage,
DeploymentStatus,
StreamLogError,
TooManyRetriesError,
get_http_error_code,
get_http_error_hint,
handle_http_error,
)
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import (
FastAPIRichToolkit,
get_details_table,
get_rich_toolkit,
)
from fastapi_cloud_cli.utils.dates import format_last_updated
from fastapi_cloud_cli.utils.errors import ErrorCode
from fastapi_cloud_cli.utils.execution import JsonOutputOption
logger = logging.getLogger(__name__)
DEFAULT_LIMIT = 100
DEFAULT_OFFSET = 0
class Deployment(BaseModel):
id: str
app_id: str
slug: str
status: DeploymentStatus
created_at: str
url: str | None = None
dashboard_url: str | None = None
class DeploymentsListAPIResponse(BaseModel):
data: list[Deployment]
count: int
class DeploymentsListOutput(BaseModel):
deployments: list[Deployment]
total_count: int
limit: int
offset: int
class DeploymentGetOutput(BaseModel):
deployment: Deployment
class BuildLogOutput(BaseModel):
id: str | None = None
message: str
class BuildLogsOutput(BaseModel):
deployment_id: str
failed: bool
logs: list[BuildLogOutput]
def _get_deployments(
client: APIClient, *, app_id: str, limit: int, offset: int
) -> DeploymentsListOutput:
response = client.get(
f"/apps/{app_id}/deployments/",
params={
"limit": limit,
"skip": offset,
},
)
response.raise_for_status()
data = DeploymentsListAPIResponse.model_validate(response.json())
return DeploymentsListOutput(
deployments=data.data,
total_count=data.count,
limit=limit,
offset=offset,
)
def _get_deployment(client: APIClient, *, deployment_id: str) -> DeploymentGetOutput:
response = client.get(f"/deployments/{deployment_id}")
response.raise_for_status()
return DeploymentGetOutput(deployment=Deployment.model_validate(response.json()))
def _render_deployments_list_output(
data: DeploymentsListOutput, toolkit: RichToolkit
) -> None:
toolkit.print_title("deployments")
toolkit.print_line()
if not data.deployments:
toolkit.print("No deployments found.", bullet=False)
return
table = Table.grid(padding=(0, 2), pad_edge=False)
table.add_column("ID", no_wrap=True)
table.add_column("Status", no_wrap=True)
table.add_column("Created", style="dim", no_wrap=True)
table.add_row("[bold]ID[/bold]", "[bold]Status[/bold]", "[bold]Created[/bold]")
table.add_row("", "", "")
for deployment in data.deployments:
table.add_row(
deployment.id,
deployment.status.value,
Text(format_last_updated(deployment.created_at)),
)
toolkit.print(table, bullet=False)
def _render_deployment_get_output(
data: DeploymentGetOutput, toolkit: RichToolkit
) -> None:
deployment = data.deployment
toolkit.print_title("deployment")
toolkit.print_line()
toolkit.print(f"[bold]{deployment.id}[/bold]", emoji="🚀")
toolkit.print_line()
toolkit.print(
get_details_table(
[
("app id", deployment.app_id),
("slug", deployment.slug),
("status", deployment.status.value),
("created", format_last_updated(deployment.created_at)),
(
"url",
deployment.url
if deployment.url is not None
else Text("-", style="dim"),
),
(
"dashboard",
Text(
deployment.dashboard_url,
style=f"link {deployment.dashboard_url}",
)
if deployment.dashboard_url is not None
else Text("-", style="dim"),
),
]
)
)
def _print_build_log_json(
deployment_id: str,
record_type: str,
*,
log_id: str | None,
message: str | None = None,
) -> None:
record = {
"type": record_type,
"deployment_id": deployment_id,
"id": log_id,
"message": message,
}
typer.echo(
json.dumps(
{key: value for key, value in record.items() if value is not None},
separators=(",", ":"),
)
)
BUILD_LOG_BULLET = "[dim]▕[/dim]"
def _print_build_log_line(toolkit: RichToolkit, message: str) -> None:
toolkit.print(Text.from_ansi(message.rstrip()), emoji=BUILD_LOG_BULLET)
def _render_build_logs_output(
data: BuildLogsOutput, toolkit: FastAPIRichToolkit
) -> None:
if not data.logs:
toolkit.print("No build logs found.")
return
for log in data.logs:
_print_build_log_line(toolkit, log.message)
if data.failed:
toolkit.print_line()
toolkit.print_error("Build failed.")
def _stream_build_logs(
toolkit: FastAPIRichToolkit,
client: APIClient,
deployment_id: str,
) -> bool:
failed = False
for log in client.stream_build_logs(deployment_id, follow=True):
if isinstance(log, BuildLogLineMessage):
if toolkit.mode == "json":
_print_build_log_json(
deployment_id,
"log",
log_id=log.id,
message=log.message,
)
else:
_print_build_log_line(toolkit, log.message)
elif log.type == "complete":
if toolkit.mode == "json":
_print_build_log_json(
deployment_id,
"complete",
log_id=log.id,
)
elif log.type == "failed":
failed = True
if toolkit.mode == "json":
_print_build_log_json(
deployment_id,
"failed",
log_id=log.id,
)
else:
toolkit.print_line()
toolkit.print_error("Build failed.")
return failed
def _fetch_build_logs(client: APIClient, deployment_id: str) -> BuildLogsOutput:
logs: list[BuildLogOutput] = []
failed = False
for log in client.stream_build_logs(deployment_id, follow=False):
if isinstance(log, BuildLogLineMessage):
logs.append(BuildLogOutput(id=log.id, message=log.message))
elif log.type == "failed":
failed = True
return BuildLogsOutput(deployment_id=deployment_id, failed=failed, logs=logs)
def _handle_build_log_error(
toolkit: FastAPIRichToolkit,
error: StreamLogError,
) -> None:
hint: str | None = None
if error.status_code == 404:
code: ErrorCode = "not_found"
message = "Deployment not found."
elif isinstance(error.__cause__, HTTPError):
code = get_http_error_code(error.__cause__)
message = handle_http_error(error.__cause__)
hint = get_http_error_hint(code)
else:
code = "api_error"
message = f"Error streaming build logs: {error}"
toolkit.fail(
code,
message,
hint=hint,
render_output=_render_build_log_error,
)
def _render_build_log_error(
toolkit: FastAPIRichToolkit,
*,
code: ErrorCode,
message: str,
hint: str,
) -> None:
toolkit.print_error(message)
if hint:
toolkit.print_line()
toolkit.print_hint(hint)
deployments_app = typer.Typer(
no_args_is_help=True,
help="Manage the deployments of your app.",
)
@deployments_app.command("get")
def get_deployment(
deployment_id: Annotated[
str,
typer.Argument(
help="ID of the deployment to return.",
),
],
app_id: Annotated[
str | None,
typer.Option(
"--app-id",
help="ID of the app that owns the deployment.",
),
] = None,
json_output: JsonOutputOption = False,
) -> Any:
"""
Get a FastAPI Cloud deployment by ID.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
resolve_app_id_or_fail(toolkit, app_id=app_id)
with APIClient() as client:
with toolkit.progress(
title="Fetching deployment",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching deployment. Please try again later.",
not_found_message="Deployment not found.",
toolkit=toolkit,
):
result = _get_deployment(
client,
deployment_id=deployment_id,
)
toolkit.success(result, render_output=_render_deployment_get_output)
@deployments_app.command("build-logs")
def build_logs(
deployment_id: Annotated[
str,
typer.Argument(
help="ID of the deployment whose build logs should be returned.",
),
],
follow: Annotated[
bool,
typer.Option(
"--follow/--no-follow",
"-f",
help="Stream build logs until the build reaches a terminal state.",
),
] = True,
json_output: JsonOutputOption = False,
) -> None:
"""
Stream or fetch build logs for a FastAPI Cloud deployment.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
if follow:
toolkit.print(
f"Streaming build logs for [bold]{deployment_id}[/bold]...",
emoji="📡",
)
else:
toolkit.print(
f"Fetching build logs for [bold]{deployment_id}[/bold]...",
emoji="📜",
)
toolkit.print_line()
try:
with APIClient() as client:
if follow:
failed = _stream_build_logs(toolkit, client, deployment_id)
else:
result = _fetch_build_logs(client, deployment_id)
toolkit.success(result, render_output=_render_build_logs_output)
failed = result.failed
except KeyboardInterrupt: # pragma: no cover
toolkit.print_line()
return
except StreamLogError as e:
_handle_build_log_error(toolkit, e)
except (TooManyRetriesError, TimeoutError):
message = "Lost connection to build log stream. Please try again later."
toolkit.fail(
"network_error",
message,
hint="Please try again later.",
render_output=_render_build_log_error,
)
if failed:
raise typer.Exit(1)
@deployments_app.command("list")
def list_deployments(
app_id: Annotated[
str | None,
typer.Option(
"--app-id",
help="ID of the app whose deployments should be listed.",
),
] = None,
limit: Annotated[
int,
typer.Option(
"--limit",
help="Maximum number of deployments to return.",
min=1,
),
] = DEFAULT_LIMIT,
offset: Annotated[
int,
typer.Option(
"--offset",
help="Offset into the deployment result set.",
min=0,
),
] = DEFAULT_OFFSET,
json_output: JsonOutputOption = False,
) -> Any:
"""
List FastAPI Cloud deployments for an app.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id)
with APIClient() as client:
with toolkit.progress(
title="Fetching deployments",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching deployments. Please try again later.",
not_found_message="App not found.",
toolkit=toolkit,
):
result = _get_deployments(
client,
app_id=target_app_id,
limit=limit,
offset=offset,
)
toolkit.success(result, render_output=_render_deployments_list_output)
@@ -0,0 +1,17 @@
import typer
from fastapi_cloud_cli.commands.env.delete import delete
from fastapi_cloud_cli.commands.env.get import get_variable
from fastapi_cloud_cli.commands.env.list import list_variables
from fastapi_cloud_cli.commands.env.set import set
env_app = typer.Typer(
no_args_is_help=True,
help="Manage the environment variables of your app.",
)
env_app.command("list")(list_variables)
env_app.command("get")(get_variable)
env_app.command()(delete)
env_app.command()(set)
__all__ = ["env_app"]
@@ -0,0 +1,53 @@
from pydantic import BaseModel
from rich.text import Text
from fastapi_cloud_cli.utils.api import APIClient
ENV_VAR_VALUE_MAX_LENGTH = 40
class EnvironmentVariable(BaseModel):
name: str
value: str | None = None
is_secret: bool = False
updated_at: str | None = None
class EnvironmentVariableResponse(BaseModel):
data: list[EnvironmentVariable]
def _get_environment_variables(
client: APIClient, app_id: str
) -> EnvironmentVariableResponse:
response = client.get(f"/apps/{app_id}/environment-variables/")
response.raise_for_status()
return EnvironmentVariableResponse.model_validate(response.json())
def _find_environment_variable(
environment_variables: list[EnvironmentVariable], name: str
) -> EnvironmentVariable | None:
return next(
(
environment_variable
for environment_variable in environment_variables
if environment_variable.name == name
),
None,
)
def _format_env_var_value(env_var: EnvironmentVariable) -> Text:
if env_var.value is None:
placeholder = "[secret]" if env_var.is_secret else "-"
return Text(placeholder, style="dim")
value = env_var.value.replace("\r", "\\r").replace("\n", "\\n")
if len(value) > ENV_VAR_VALUE_MAX_LENGTH:
value = f"{value[: ENV_VAR_VALUE_MAX_LENGTH - 3]}..."
return Text(value)
@@ -0,0 +1,194 @@
from pathlib import Path
from typing import Annotated, Any
import typer
from pydantic import BaseModel, Field
from rich_toolkit import RichToolkit
from rich_toolkit.menu import Option
from fastapi_cloud_cli.commands.env._shared import _get_environment_variables
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
from fastapi_cloud_cli.utils.env import validate_environment_variable_name
from fastapi_cloud_cli.utils.execution import JsonOutputOption
class EnvironmentVariableDeleteOutput(BaseModel):
app_id: str
name: str
deleted: bool = True
show_tag: Annotated[bool, Field(exclude=True)] = True
def _render_environment_variable_delete_output(
data: EnvironmentVariableDeleteOutput, toolkit: RichToolkit
) -> None:
if data.show_tag:
toolkit.print_title("environment variables")
toolkit.print_line()
toolkit.print(f"Environment variable [bold]{data.name}[/] deleted.", bullet=False)
def _delete_environment_variable(client: APIClient, app_id: str, name: str) -> bool:
response = client.delete(f"/apps/{app_id}/environment-variables/{name}")
if response.status_code == 404:
return False
response.raise_for_status()
return True
def delete(
name: str | None = typer.Argument(
None,
help="The name of the environment variable to delete",
),
path_arg: Annotated[
Path | None,
typer.Argument(
help=(
"Path to the directory with your app's pyproject.toml "
"(defaults to current directory)"
),
),
] = None,
path: Annotated[
Path | None,
typer.Option(
"--path",
help=(
"Path to the directory with your app's pyproject.toml "
"(defaults to current directory)"
),
),
] = None,
app_id: Annotated[
str | None,
typer.Option(
"--app-id",
help="ID of the app whose environment variable should be deleted.",
),
] = None,
yes: Annotated[
bool,
typer.Option(
"--yes",
"-y",
help="Confirm deletion without prompting.",
),
] = False,
json_output: JsonOutputOption = False,
) -> Any:
"""
Delete an environment variable from the app.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
target_app_id = resolve_app_id_or_fail(
toolkit, app_id=app_id, path=path or path_arg
)
name_provided = name is not None
with APIClient() as client:
if not name:
if toolkit.mode == "json":
toolkit.fail(
"missing_required_input",
"Environment variable name is required.",
hint="Pass NAME to choose an environment variable.",
)
with toolkit.progress(
"Fetching environment variables...", transient=True
) as progress:
with client.handle_http_errors(progress):
environment_variables = _get_environment_variables(
client=client, app_id=target_app_id
)
toolkit.print_title("environment variables")
toolkit.print_line()
if not environment_variables.data:
toolkit.print("No environment variables found.", bullet=False)
return
name = toolkit.ask(
"Select the environment variable to delete:",
options=[
Option({"name": env_var.name, "value": env_var.name})
for env_var in environment_variables.data
],
bullet=False,
)
assert name
else:
if not validate_environment_variable_name(name):
toolkit.fail(
"invalid_input",
f"The environment variable name [bold]{name}[/] is invalid.",
)
toolkit.print_line()
if name_provided and not yes:
if toolkit.mode == "json":
toolkit.fail(
"missing_required_input",
"Deletion confirmation is required.",
hint="Pass --yes to confirm deletion.",
)
should_delete = toolkit.confirm(
f"Delete [bold]{name}[/]?",
default=False,
bullet=False,
)
if not should_delete:
toolkit.print_title("environment variables")
toolkit.print_line()
toolkit.print("Deletion cancelled.", bullet=False)
raise typer.Exit(0)
toolkit.print_line()
with toolkit.progress(
"Deleting environment variable", transient=True
) as progress:
with client.handle_http_errors(progress):
deleted = _delete_environment_variable(
client=client, app_id=target_app_id, name=name
)
if not deleted:
message = (
f"Environment variable {name} not found."
if toolkit.mode == "json"
else "Environment variable not found."
)
toolkit.fail(
"not_found",
message,
hint="Run `fastapi cloud env list` to see available variables.",
)
toolkit.success(
EnvironmentVariableDeleteOutput(
app_id=target_app_id, name=name, show_tag=name_provided
),
render_output=_render_environment_variable_delete_output,
)
@@ -0,0 +1,139 @@
from pathlib import Path
from typing import Annotated, Any
import typer
from pydantic import BaseModel, Field
from rich.table import Table
from rich_toolkit import RichToolkit
from rich_toolkit.menu import Option
from fastapi_cloud_cli.commands.env._shared import (
EnvironmentVariable,
_find_environment_variable,
_format_env_var_value,
_get_environment_variables,
)
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
class EnvironmentVariableGetOutput(BaseModel):
app_id: str
variable: EnvironmentVariable
show_tag: Annotated[bool, Field(exclude=True)] = True
def _render_environment_variable_get_output(
data: EnvironmentVariableGetOutput, toolkit: RichToolkit
) -> None:
variable = data.variable
table = Table.grid(padding=(0, 2), pad_edge=False)
table.add_column(no_wrap=True)
table.add_column()
table.add_row("name:", variable.name)
table.add_row("value:", _format_env_var_value(variable))
if data.show_tag:
toolkit.print_title("environment variables")
toolkit.print_line()
toolkit.print(table, bullet=False)
def get_variable(
name: Annotated[
str | None,
typer.Argument(
help="The name of the environment variable to return.",
),
] = None,
path: Annotated[
Path | None,
typer.Option(
"--path",
help=(
"Path to the directory with your app's pyproject.toml "
"(defaults to current directory)"
),
),
] = None,
app_id: Annotated[
str | None,
typer.Option(
"--app-id",
help="ID of the app whose environment variable should be returned.",
),
] = None,
json_output: JsonOutputOption = False,
) -> Any:
"""
Get an environment variable for the app.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id, path=path)
name_provided = name is not None
if name is None and toolkit.mode == "json":
toolkit.fail(
"missing_required_input",
"Environment variable name is required.",
hint="Pass NAME to choose an environment variable.",
)
with APIClient() as client:
with toolkit.progress(
"Fetching environment variables...", transient=True
) as progress:
with client.handle_http_errors(progress):
environment_variables = _get_environment_variables(
client=client, app_id=target_app_id
)
if name is None:
toolkit.print_title("environment variables")
toolkit.print_line()
if not environment_variables.data:
toolkit.print("No environment variables found.", bullet=False)
return
name = toolkit.ask(
"Select the environment variable to get:",
options=[
Option({"name": env_var.name, "value": env_var.name})
for env_var in environment_variables.data
],
bullet=False,
)
variable = _find_environment_variable(environment_variables.data, name)
if variable is None:
toolkit.fail(
"not_found",
f"Environment variable {name} not found.",
hint="Run `fastapi cloud env list` to see available variables.",
)
assert variable is not None
toolkit.success(
EnvironmentVariableGetOutput(
app_id=target_app_id,
variable=variable,
show_tag=name_provided,
),
render_output=_render_environment_variable_get_output,
)
@@ -0,0 +1,113 @@
from pathlib import Path
from typing import Annotated, Any
import typer
from pydantic import BaseModel
from rich.table import Table
from rich.text import Text
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.commands.env._shared import (
ENV_VAR_VALUE_MAX_LENGTH,
EnvironmentVariable,
_format_env_var_value,
_get_environment_variables,
)
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
from fastapi_cloud_cli.utils.dates import format_last_updated
from fastapi_cloud_cli.utils.execution import JsonOutputOption
class EnvironmentVariablesListOutput(BaseModel):
app_id: str
variables: list[EnvironmentVariable]
def _get_environment_variables_table(
environment_variables: list[EnvironmentVariable],
) -> Table:
table = Table.grid(padding=(0, 2), pad_edge=False)
table.add_column("Key", no_wrap=True)
table.add_column("Value", overflow="ellipsis", max_width=ENV_VAR_VALUE_MAX_LENGTH)
table.add_column("Last updated", style="dim", no_wrap=True)
table.add_row("[bold]Key[/bold]", "[bold]Value[/bold]", "[bold]Last updated[/bold]")
table.add_row("", "", "")
for env_var in environment_variables:
table.add_row(
Text(env_var.name),
_format_env_var_value(env_var),
Text(format_last_updated(env_var.updated_at)),
)
return table
def _render_environment_variables_list_output(
data: EnvironmentVariablesListOutput, toolkit: RichToolkit
) -> None:
toolkit.print_title("environment variables")
toolkit.print_line()
if not data.variables:
toolkit.print("No environment variables found.", bullet=False)
return
toolkit.print(_get_environment_variables_table(data.variables), bullet=False)
def list_variables(
path: Annotated[
Path | None,
typer.Option(
"--path",
help=(
"Path to the directory with your app's pyproject.toml "
"(defaults to current directory)"
),
),
] = None,
app_id: Annotated[
str | None,
typer.Option(
"--app-id",
help="ID of the app whose environment variables should be listed.",
),
] = None,
json_output: JsonOutputOption = False,
) -> Any:
"""
List the environment variables for the app.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id, path=path)
with APIClient() as client:
with toolkit.progress(
"Fetching environment variables...", transient=True
) as progress:
with client.handle_http_errors(progress):
environment_variables = _get_environment_variables(
client=client, app_id=target_app_id
)
toolkit.success(
EnvironmentVariablesListOutput(
app_id=target_app_id,
variables=environment_variables.data,
),
render_output=_render_environment_variables_list_output,
)
@@ -0,0 +1,215 @@
import sys
from pathlib import Path
from typing import Annotated, Any
import typer
from pydantic import BaseModel, Field
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
class EnvironmentVariableSetOutput(BaseModel):
app_id: str
name: str
is_secret: bool
show_tag: Annotated[bool, Field(exclude=True)] = True
def _render_environment_variable_set_output(
data: EnvironmentVariableSetOutput, toolkit: RichToolkit
) -> None:
kind = "Secret environment variable" if data.is_secret else "Environment variable"
message = f"{kind} [bold]{data.name}[/] set."
if data.show_tag:
toolkit.print_title("environment variables")
toolkit.print_line()
toolkit.print(message, bullet=False)
def _input(
toolkit: FastAPIRichToolkit,
prompt: str,
*,
password: bool = False,
) -> str:
return toolkit.input(prompt, password=password, bullet=False)
def _resolve_environment_variable_name(
toolkit: FastAPIRichToolkit, *, name: str | None, secret: bool
) -> str:
if name is not None:
return name
if toolkit.mode == "json":
toolkit.fail(
"missing_required_input",
"Environment variable name is required.",
hint="Pass NAME to choose an environment variable.",
)
if secret:
return _input(toolkit, "Enter the name of the secret to set:")
return _input(toolkit, "Enter the name of the environment variable to set:")
def _resolve_environment_variable_value(
toolkit: FastAPIRichToolkit,
*,
value: str | None,
value_stdin: bool,
secret: bool,
) -> str:
if value is not None and value_stdin:
toolkit.fail(
"invalid_input",
"Only one environment variable value source can be used.",
hint="Pass either VALUE or --value-stdin.",
)
if value is not None:
return value
if value_stdin:
return sys.stdin.read().rstrip("\r\n")
if toolkit.mode == "json":
toolkit.fail(
"missing_required_input",
"Environment variable value is required.",
hint="Pass VALUE or --value-stdin to set the environment variable.",
)
if secret:
return _input(toolkit, "Enter the secret value:", password=True)
return _input(toolkit, "Enter the value of the environment variable:")
def _set_environment_variable(
client: APIClient, app_id: str, name: str, value: str, is_secret: bool = False
) -> None:
response = client.post(
f"/apps/{app_id}/environment-variables/",
json={"name": name, "value": value, "is_secret": is_secret},
)
response.raise_for_status()
def set(
name: str | None = typer.Argument(
None,
help="The name of the environment variable to set",
),
value: str | None = typer.Argument(
None,
help="The value of the environment variable to set",
),
path_arg: Annotated[
Path | None,
typer.Argument(
help=(
"Path to the directory with your app's pyproject.toml "
"(defaults to current directory)"
)
),
] = None,
value_stdin: Annotated[
bool,
typer.Option(
"--value-stdin",
help="Read the environment variable value from stdin.",
),
] = False,
path: Annotated[
Path | None,
typer.Option(
"--path",
help=(
"Path to the directory with your app's pyproject.toml "
"(defaults to current directory)"
),
),
] = None,
app_id: Annotated[
str | None,
typer.Option(
"--app-id",
help="ID of the app whose environment variable should be set.",
),
] = None,
secret: Annotated[
bool,
typer.Option(
"--secret",
help="Mark the environment variable as secret",
),
] = False,
json_output: JsonOutputOption = False,
) -> Any:
"""
Set an environment variable for the app.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
target_app_id = resolve_app_id_or_fail(
toolkit, app_id=app_id, path=path or path_arg
)
name_needs_prompt = name is None
value_needs_prompt = value is None and not value_stdin
prompts_user = name_needs_prompt or value_needs_prompt
if prompts_user and toolkit.mode != "json":
toolkit.print_title("environment variables")
toolkit.print_line()
name = _resolve_environment_variable_name(
toolkit,
name=name,
secret=secret,
)
value = _resolve_environment_variable_value(
toolkit,
value=value,
value_stdin=value_stdin,
secret=secret,
)
with APIClient() as client:
with toolkit.progress(
"Setting environment variable", transient=True
) as progress:
with client.handle_http_errors(progress):
_set_environment_variable(
client=client,
app_id=target_app_id,
name=name,
value=value,
is_secret=secret,
)
toolkit.success(
EnvironmentVariableSetOutput(
app_id=target_app_id,
name=name,
is_secret=secret,
show_tag=not prompts_user,
),
render_output=_render_environment_variable_set_output,
)
@@ -0,0 +1,120 @@
import logging
from typing import Annotated, Any
import typer
from fastapi_cloud_cli.commands._flow import (
DEFAULT_LOGIN_TIMEOUT_SECONDS,
complete_device_login,
device_authorization_output,
render_login_output,
start_device_authorization,
)
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
logger = logging.getLogger(__name__)
def _interactive_login(
toolkit: FastAPIRichToolkit,
*,
no_open: bool = False,
timeout: int = DEFAULT_LOGIN_TIMEOUT_SECONDS,
) -> Any:
with APIClient() as client:
with toolkit.progress("Starting authorization", transient=True) as progress:
with client.handle_http_errors(progress, toolkit=toolkit):
authorization_data = start_device_authorization(client)
url = authorization_data.verification_uri_complete
if no_open:
toolkit.print(f"Open {url}")
else:
launch_cmd_res = typer.launch(url)
logger.debug(f"Launch command result: {launch_cmd_res}")
toolkit.print(f"Opening [link={url}]{url}[/link]")
toolkit.print_line()
with toolkit.progress(
"Waiting for user to authorize...", transient=True
) as progress:
result = complete_device_login(
client=client,
progress=progress,
toolkit=toolkit,
device_code=authorization_data.device_code,
interval=authorization_data.interval,
timeout=timeout,
cancel_hint="Run `fastapi cloud login` again to retry.",
)
toolkit.success(
result,
render_output=render_login_output,
)
def login(
no_open: Annotated[
bool,
typer.Option(
"--no-open",
help="Do not open the browser automatically.",
),
] = False,
timeout: Annotated[
int,
typer.Option(
"--timeout",
help="Maximum seconds to wait for authorization.",
min=10,
),
] = DEFAULT_LOGIN_TIMEOUT_SECONDS,
json_output: JsonOutputOption = False,
) -> Any:
"""
Login to FastAPI Cloud.
"""
if json_output:
with get_rich_toolkit(json_output=json_output, minimal=True) as toolkit:
with APIClient() as client:
with toolkit.progress(
"Starting authorization", transient=True
) as progress:
with client.handle_http_errors(progress, toolkit=toolkit):
authorization_data = start_device_authorization(client)
toolkit.success(device_authorization_output(authorization_data))
return
identity = Identity()
with get_rich_toolkit() as toolkit:
toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI Cloud", emoji="🔐")
toolkit.print_line()
if identity.is_logged_in():
toolkit.print("You are already logged in.")
toolkit.print_line()
toolkit.print(
"Run [bold]fastapi cloud logout[/bold] first if you want to switch accounts.",
emoji="💡",
)
return
if identity.has_deploy_token():
toolkit.print(
"You have [bold blue]FASTAPI_CLOUD_TOKEN[/] environment variable set.\n"
"This token will take precedence over the user token for "
"[blue]`fastapi deploy`[/] command.",
emoji="⚠️",
)
toolkit.print_line()
_interactive_login(toolkit, no_open=no_open, timeout=timeout)
@@ -0,0 +1,30 @@
from typing import Any
from pydantic import BaseModel
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.utils.auth import delete_auth_config
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
class LogoutOutput(BaseModel):
logged_out: bool
def _render_logout_output(data: LogoutOutput, toolkit: RichToolkit) -> None:
toolkit.print_title("FastAPI Cloud")
toolkit.print_line()
toolkit.print("You are now logged out!", emoji="👋")
def logout(json_output: JsonOutputOption = False) -> Any:
"""
Logout from FastAPI Cloud.
"""
with get_rich_toolkit(json_output=json_output) as toolkit:
delete_auth_config()
toolkit.success(
LogoutOutput(logged_out=True),
render_output=_render_logout_output,
)
@@ -0,0 +1,294 @@
import json
import logging
import re
from datetime import datetime
from pathlib import Path
from typing import Annotated
import typer
from httpx import HTTPError
from pydantic import BaseModel
from rich.markup import escape
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.utils.api import (
APIClient,
AppLogEntry,
StreamLogError,
TooManyRetriesError,
get_http_error_code,
get_http_error_hint,
handle_http_error,
)
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
from fastapi_cloud_cli.utils.errors import ErrorCode
from fastapi_cloud_cli.utils.execution import JsonOutputOption
logger = logging.getLogger(__name__)
LOG_LEVEL_COLORS = {
"debug": "blue",
"info": "cyan",
"warning": "yellow",
"warn": "yellow",
"error": "red",
"critical": "magenta",
"fatal": "magenta",
}
SINCE_PATTERN = re.compile(r"^\d+[smhd]$")
MIN_LOG_TAIL = 1
MAX_LOG_TAIL = 1000
class AppLogsOutput(BaseModel):
app_id: str
logs: list[AppLogEntry]
def _validate_since(value: str) -> str:
"""Validate the --since parameter format."""
if not SINCE_PATTERN.match(value):
raise typer.BadParameter(
"Invalid format. Use a number followed by s, m, h, or d (e.g., '5m', '1h', '2d')."
)
return value
def _validate_tail(value: int) -> int:
if not MIN_LOG_TAIL <= value <= MAX_LOG_TAIL:
raise typer.BadParameter(
f"Invalid value. Use a number between {MIN_LOG_TAIL} and {MAX_LOG_TAIL}."
)
return value
def _get_log_bullet(log: AppLogEntry) -> str:
"""Colored indicator rendered in the emoji bullet column.
▕ draws at the right edge of its cell, centering the bar under the
double-width emojis.
"""
color = LOG_LEVEL_COLORS.get(log.level.lower(), "dim")
return f"[{color}]▕[/{color}]"
def _format_log_line(log: AppLogEntry) -> str:
"""Format a log entry for display"""
# Parse the timestamp string to format it consistently
timestamp = datetime.fromisoformat(log.timestamp.replace("Z", "+00:00"))
timestamp_str = timestamp.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
return f"[dim]{timestamp_str}[/dim] {escape(log.message)}"
def _print_log_line(toolkit: RichToolkit, log: AppLogEntry) -> None:
toolkit.print(_format_log_line(log), emoji=_get_log_bullet(log))
def _render_app_logs_output(data: AppLogsOutput, toolkit: RichToolkit) -> None:
if not data.logs:
toolkit.print("No logs found for the specified time range.")
return
for log in data.logs:
_print_log_line(toolkit, log)
def _print_app_log_json(app_id: str, log: AppLogEntry) -> None:
typer.echo(
json.dumps(
{
"type": "log",
"app_id": app_id,
**log.model_dump(mode="json"),
},
separators=(",", ":"),
)
)
def _render_plain_error(
toolkit: FastAPIRichToolkit,
*,
code: ErrorCode,
message: str,
hint: str,
) -> None:
toolkit.print_error(message)
if hint:
toolkit.print_line()
toolkit.print_hint(hint)
def _handle_stream_log_error(
toolkit: FastAPIRichToolkit,
error: StreamLogError,
) -> None:
hint: str | None = None
if error.status_code == 404:
code: ErrorCode = "not_found"
message = "App not found. Make sure to use the correct account."
elif isinstance(error.__cause__, HTTPError):
code = get_http_error_code(error.__cause__)
message = handle_http_error(error.__cause__)
hint = get_http_error_hint(code)
if error.status_code == 400 and hint is None:
hint = (
"Try a shorter time range (e.g. `--since 1d`). "
"Log retention depends on your plan."
)
else:
code = "api_error"
message = f"[red]Error:[/] {escape(str(error))}"
toolkit.fail(code, message, hint=hint, render_output=_render_plain_error)
def _process_log_stream(
toolkit: FastAPIRichToolkit,
app_id: str,
tail: int,
since: str,
follow: bool,
) -> None:
"""Stream app logs and print them to the console."""
logs: list[AppLogEntry] = []
try:
with APIClient() as client:
for log in client.stream_app_logs(
app_id=app_id,
tail=tail,
since=since,
follow=follow,
):
if follow:
if toolkit.mode == "json":
_print_app_log_json(app_id, log)
else:
_print_log_line(toolkit, log)
continue
logs.append(log)
if not follow:
toolkit.success(
AppLogsOutput(app_id=app_id, logs=logs),
render_output=_render_app_logs_output,
)
return
except KeyboardInterrupt: # pragma: no cover
toolkit.print_line()
return
except StreamLogError as e:
_handle_stream_log_error(toolkit, e)
except (TooManyRetriesError, TimeoutError):
message = "Lost connection to log stream. Please try again later."
toolkit.fail(
"network_error",
message,
hint="Please try again later.",
render_output=_render_plain_error,
)
def logs(
path: Annotated[
Path | None,
typer.Argument(
help=(
"Path to the directory with your app's pyproject.toml "
"(defaults to current directory)"
)
),
] = None,
app_id: Annotated[
str | None,
typer.Option(
"--app-id",
help="ID of the app whose logs should be fetched.",
),
] = None,
tail: int = typer.Option(
100,
"--tail",
"-t",
help=f"Number of log lines to show before streaming (max {MAX_LOG_TAIL}).",
show_default=True,
callback=_validate_tail,
),
since: str = typer.Option(
"5m",
"--since",
"-s",
help=(
"Show logs since a specific time (e.g., '5m', '1h', '2d'). "
"Limited by your plan's log retention."
),
show_default=True,
callback=_validate_since,
),
follow: bool = typer.Option(
True,
"--follow/--no-follow",
"-f",
help="Stream logs in real-time (use --no-follow to fetch and exit).",
),
json_output: JsonOutputOption = False,
) -> None:
"""Stream or fetch logs from your deployed app.
Examples:
fastapi cloud logs # Stream logs in real-time
fastapi cloud logs --no-follow # Fetch recent logs and exit
fastapi cloud logs --tail 50 --since 1h # Last 50 logs from the past hour
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
target_app_id = resolve_app_id_or_fail(
toolkit,
app_id=app_id,
path=path,
hint="Pass --app-id or run `fastapi cloud link` to link an app.",
)
logger.debug("Fetching logs for app ID: %s", target_app_id)
if follow:
toolkit.print(
f"Streaming logs for [bold]{target_app_id}[/bold] (Ctrl+C to exit)...",
emoji="📡",
)
else:
toolkit.print(
f"Fetching logs for [bold]{target_app_id}[/bold]...",
emoji="📜",
)
toolkit.print_line()
_process_log_stream(
toolkit=toolkit,
app_id=target_app_id,
tail=tail,
since=since,
follow=follow,
)
@@ -0,0 +1,536 @@
import logging
import re
import shutil
import subprocess
from datetime import datetime, timezone
from pathlib import Path, PurePath
from typing import Annotated, Any
import typer
from pydantic import BaseModel
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
logger = logging.getLogger(__name__)
TOKEN_EXPIRES_DAYS = 365
DEFAULT_WORKFLOW_PATH = Path(".github/workflows/deploy.yml")
class CISetupOutput(BaseModel):
app_id: str
repo: str
branch: str
workflow_path: str
created_token: bool
set_github_secrets: bool
wrote_workflow: bool
token_expired_at: str | None = None
def _render_ci_setup_output(data: CISetupOutput, toolkit: FastAPIRichToolkit) -> None:
if data.wrote_workflow and data.set_github_secrets:
toolkit.print("Done! Commit and push to start deploying.", emoji="✅")
elif data.wrote_workflow:
toolkit.print(
"Done — workflow file is ready, but GitHub secrets were not set.",
emoji="✅",
)
elif data.set_github_secrets:
toolkit.print("Done! GitHub Actions secrets are configured.", emoji="✅")
else:
toolkit.print("Done!", emoji="✅")
if data.token_expired_at:
toolkit.print_line()
toolkit.print(
f"Your deploy token expires on [bold]{data.token_expired_at[:10]}[/bold]. "
"Regenerate it from the dashboard or re-run this command before then.",
)
class GitHubSecretError(Exception):
"""Raised when setting a GitHub Actions secret fails."""
pass
def _get_github_host(origin: str) -> str:
match = re.search(r"(?:git@|https://)([^:/]+)", origin)
return match.group(1) if match else "github.com"
def _repo_slug_from_origin(origin: str) -> str | None:
"""Extract 'owner/repo' from a GitHub remote URL."""
# Handles URLs like: git@github.com:owner/repo.git or https://github.com/owner/repo.git
# Also supports GitHub Enterprise hosts like git@github.enterprise.com:owner/repo.git
# Match the part after the last : or / (which is owner/repo)
match = re.search(r"[:/]([^:/]+/[^/]+?)(?:\.git)?$", origin)
return match.group(1) if match else None
def _check_git_installed() -> bool:
"""Check if git is installed and available."""
return shutil.which("git") is not None
def _check_gh_cli_installed() -> bool:
"""Check if the GitHub CLI (gh) is installed and available."""
return shutil.which("gh") is not None
def _get_remote_origin() -> str:
"""Get the remote origin URL of the Git repository."""
try:
# Try gh first (to respect gh repo set-default)
result = subprocess.run(
["gh", "repo", "view", "--json", "url", "-q", ".url"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
# CalledProcessError if gh command fails, FileNotFoundError if gh is not installed
except (subprocess.CalledProcessError, FileNotFoundError):
# Fallback to git command
result = subprocess.run(
["git", "config", "--get", "remote.origin.url"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def _set_github_secret(name: str, value: str) -> None:
"""Set a GitHub Actions secret via the gh CLI.
Raises:
GitHubSecretError: If setting the secret fails.
"""
try:
subprocess.run(
["gh", "secret", "set", name, "--body", value],
capture_output=True,
check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError) as e:
raise GitHubSecretError(f"Failed to set GitHub secret '{name}'") from e
def _create_token(client: APIClient, app_id: str, token_name: str) -> dict[str, str]:
"""Create a new deploy token.
Returns token_data dict with 'value' and 'expired_at' keys.
"""
response = client.post(
f"/apps/{app_id}/tokens",
json={"name": token_name, "expires_in_days": TOKEN_EXPIRES_DAYS},
)
response.raise_for_status()
data = response.json()
return {"value": data["value"], "expired_at": data["expired_at"]}
def _get_default_branch() -> str:
"""Get the default branch of the Git repository."""
try:
result = subprocess.run(
[
"gh",
"repo",
"view",
"--json",
"defaultBranchRef",
"-q",
".defaultBranchRef.name",
],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return "main"
def _get_workflow_content(branch: str) -> str:
return f"""\
name: Deploy to FastAPI Cloud
on:
push:
branches: [{branch}]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v7
- run: uv run fastapi deploy
env:
FASTAPI_CLOUD_TOKEN: ${{{{ secrets.FASTAPI_CLOUD_TOKEN }}}}
FASTAPI_CLOUD_APP_ID: ${{{{ secrets.FASTAPI_CLOUD_APP_ID }}}}
"""
def _write_workflow_file(branch: str, workflow_path: Path) -> None:
workflow_content = _get_workflow_content(branch)
workflow_path.parent.mkdir(parents=True, exist_ok=True)
workflow_path.write_text(workflow_content)
def _get_workflow_path(file: str | None) -> Path:
if file:
return Path(f".github/workflows/{file}")
return DEFAULT_WORKFLOW_PATH
def _format_workflow_path(workflow_path: PurePath) -> str:
return workflow_path.as_posix()
def _resolve_existing_workflow_path(
toolkit: FastAPIRichToolkit, workflow_path: Path
) -> Path | None:
if toolkit.confirm(
f"Workflow file [bold]{_format_workflow_path(workflow_path)}[/bold] already exists. Overwrite?",
default=False,
emoji="🗂️",
):
toolkit.print_line()
return workflow_path
toolkit.print_line()
if new_name := toolkit.input(
"Enter a new filename (without path) or leave blank to skip writing the workflow file:",
emoji="✏️",
).strip():
toolkit.print_line()
return Path(f".github/workflows/{new_name}")
toolkit.print_line()
toolkit.print("Skipped writing workflow file.", emoji="⏭️")
toolkit.print_line()
return None
def setup_ci(
path: Annotated[
Path | None,
typer.Argument(
help=(
"Path to the directory with your app's pyproject.toml "
"(defaults to current directory)"
)
),
] = None,
app_id: str | None = typer.Option(
None,
"--app-id",
help="ID of the app to set up CI for (defaults to the app linked to the directory)",
),
branch: str | None = typer.Option(
None,
"--branch",
"-b",
help="Branch that triggers deploys (defaults to the repo's default branch)",
),
secrets_only: bool = typer.Option(
False,
"--secrets-only",
"-s",
help="Provisions token and sets secrets, skips writing the workflow file",
show_default=True,
),
workflow_only: bool = typer.Option(
False,
"--workflow-only",
help="Writes the workflow file without creating a token or setting secrets",
show_default=True,
),
dry_run: bool = typer.Option(
False,
"--dry-run",
"-d",
help="Prints steps that would be taken without actually performing them",
show_default=True,
),
file: str | None = typer.Option(
None,
"--file",
"-f",
help="Custom workflow filename (written to .github/workflows/)",
),
json_output: JsonOutputOption = False,
) -> Any:
"""Configures a GitHub Actions workflow for deploying the app on push to the specified branch.
Examples:
fastapi cloud setup-ci # Provisions token, sets secrets, and writes workflow file for the 'main' branch
fastapi cloud setup-ci --branch develop # Same as above but for the 'develop' branch
fastapi cloud setup-ci --secrets-only # Only provisions token and sets secrets, does not write workflow file
fastapi cloud setup-ci --workflow-only # Only writes the workflow file
fastapi cloud setup-ci --dry-run # Prints the steps that would be taken without performing them
fastapi cloud setup-ci --file ci.yml # Writes workflow to .github/workflows/ci.yml
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
if secrets_only and workflow_only:
toolkit.fail(
"invalid_input",
"--secrets-only and --workflow-only cannot be used together.",
)
target_app_id = resolve_app_id_or_fail(
toolkit,
app_id=app_id,
path=path,
hint="Pass --app-id or run `fastapi deploy` first.",
)
if not _check_git_installed():
toolkit.fail(
"not_found",
"git is not installed. Please install git to use this command.",
)
try:
origin = _get_remote_origin()
except subprocess.CalledProcessError:
toolkit.fail(
"not_found",
"Could not retrieve the git remote origin URL. Make sure you're in a git repository with a remote origin set.",
)
# Check if it's a GitHub host (github.com or GitHub Enterprise)
if "github" not in origin.lower():
toolkit.fail(
"invalid_input",
"Remote origin is not a GitHub repository. Please set up a GitHub repo and add it as the remote origin.",
)
repo_slug = _repo_slug_from_origin(origin) or origin
if not branch:
branch = _get_default_branch()
workflow_path = _get_workflow_path(file)
needs_secrets = not workflow_only
needs_workflow = not secrets_only
has_gh = _check_gh_cli_installed() if needs_secrets and not dry_run else True
if (
toolkit.mode == "json"
and needs_workflow
and not dry_run
and not file
and workflow_path.exists()
):
toolkit.fail(
"invalid_input",
f"Workflow file {_format_workflow_path(workflow_path)} already exists.",
hint="Pass --file to choose another workflow file or remove the existing file.",
)
if needs_secrets and not dry_run and toolkit.mode == "json" and not has_gh:
toolkit.fail(
"dependency_missing",
"GitHub CLI (`gh`) is required to set GitHub Actions secrets.",
hint="Install gh or use --workflow-only to write only the workflow file.",
)
if dry_run:
toolkit.print(
"[yellow]This is a dry run — no changes will be made[/yellow]"
)
toolkit.print_line()
toolkit.print_title("Configuring CI")
toolkit.print_line()
toolkit.print(
f"Setting up CI for [bold]{repo_slug}[/bold] (branch: {branch})",
emoji="⚙️",
)
toolkit.print_line()
msg_token = "Created deploy token"
msg_secrets = (
"Set GitHub Actions secrets [bold blue]FASTAPI_CLOUD_TOKEN[/] "
"and [bold blue]FASTAPI_CLOUD_APP_ID[/]"
)
msg_workflow = f"Wrote [bold]{workflow_path}[/bold] (branch: {branch})"
if dry_run:
if needs_secrets:
toolkit.print(msg_token)
toolkit.print(msg_secrets)
if needs_workflow:
toolkit.print(msg_workflow)
toolkit.success(
CISetupOutput(
app_id=target_app_id,
repo=repo_slug,
branch=branch,
workflow_path=_format_workflow_path(workflow_path),
created_token=False,
set_github_secrets=False,
wrote_workflow=False,
),
render_output=lambda _data, _toolkit: None,
)
return
token_expired_at: str | None = None
created_token = False
set_github_secrets = False
wrote_workflow = False
if needs_secrets:
should_create_token = (
True
if toolkit.mode == "json"
else toolkit.confirm(
"Create a FastAPI Cloud deploy token for GitHub Actions?",
default=True,
)
)
if toolkit.mode != "json":
toolkit.print_line()
if should_create_token:
# Create unique token name with timestamp to avoid duplicates
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
token_name = f"GitHub Actions — {repo_slug} ({timestamp})"
with (
APIClient() as client,
toolkit.progress(
title="Generating deploy token...", done_emoji="🔑"
) as progress,
client.handle_http_errors(
progress, default_message="Error creating deploy token."
),
):
token_data = _create_token(
client=client, app_id=target_app_id, token_name=token_name
)
token_expired_at = token_data["expired_at"]
created_token = True
progress.log(msg_token)
toolkit.print_line()
if has_gh:
should_set_secrets = (
True
if toolkit.mode == "json"
else toolkit.confirm(
"Set GitHub Actions secrets "
"[bold blue]FASTAPI_CLOUD_TOKEN[/] and "
"[bold blue]FASTAPI_CLOUD_APP_ID[/] via gh?",
default=True,
)
)
if toolkit.mode != "json":
toolkit.print_line()
else:
should_set_secrets = False
secrets_url = (
f"https://{_get_github_host(origin)}/{repo_slug}"
"/settings/secrets/actions"
)
toolkit.print(
"[yellow]gh CLI not found. Set these secrets manually:[/yellow]",
)
toolkit.print_line()
toolkit.print(f"Repository: [blue]{secrets_url}[/]")
toolkit.print_line()
toolkit.print(
f"[bold blue]FASTAPI_CLOUD_TOKEN[/] = {token_data['value']}"
)
toolkit.print(
f"[bold blue]FASTAPI_CLOUD_APP_ID[/] = {target_app_id}"
)
if should_set_secrets:
with toolkit.progress(
title="Setting repo secrets...", done_emoji="🔒"
) as progress:
try:
_set_github_secret(
"FASTAPI_CLOUD_TOKEN", token_data["value"]
)
_set_github_secret("FASTAPI_CLOUD_APP_ID", target_app_id)
progress.log(msg_secrets)
except GitHubSecretError:
progress.set_error(
"Failed to set GitHub secrets via gh CLI."
)
toolkit.fail(
"api_error",
"Failed to set GitHub secrets via gh CLI.",
)
set_github_secrets = True
else:
toolkit.print("Skipped setting GitHub Actions secrets.", emoji="⏭️")
else:
toolkit.print(
"Skipped creating deploy token and GitHub secrets.", emoji="⏭️"
)
toolkit.print_line()
if needs_workflow:
if not file and workflow_path.exists():
resolved_workflow_path = _resolve_existing_workflow_path(
toolkit, workflow_path
)
if resolved_workflow_path is None:
needs_workflow = False
else:
workflow_path = resolved_workflow_path
if needs_workflow:
msg_workflow = f"Wrote [bold]{workflow_path}[/bold] (branch: {branch})"
_write_workflow_file(branch, workflow_path)
wrote_workflow = True
toolkit.print(msg_workflow)
toolkit.print_line()
output = CISetupOutput(
app_id=target_app_id,
repo=repo_slug,
branch=branch,
workflow_path=_format_workflow_path(workflow_path),
created_token=created_token,
set_github_secrets=set_github_secrets,
wrote_workflow=wrote_workflow,
token_expired_at=token_expired_at,
)
toolkit.success(output, render_output=_render_ci_setup_output)
@@ -0,0 +1,138 @@
import logging
from typing import Annotated, Any
import typer
from pydantic import BaseModel
from rich.markup import escape
from rich.table import Table
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.commands.teams.get import (
Team,
_get_team_dashboard_url,
get_team,
)
from fastapi_cloud_cli.config import Settings
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
logger = logging.getLogger(__name__)
DEFAULT_LIMIT = 100
DEFAULT_OFFSET = 0
class TeamsListAPIResponse(BaseModel):
data: list[Team]
count: int
class TeamsListOutput(BaseModel):
teams: list[Team]
total_count: int
limit: int
offset: int
def _get_teams(client: APIClient, *, limit: int, offset: int) -> TeamsListOutput:
response = client.get(
"/teams/",
params={
"limit": limit,
"skip": offset,
},
)
response.raise_for_status()
data = TeamsListAPIResponse.model_validate(response.json())
return TeamsListOutput(
teams=data.data,
total_count=data.count,
limit=limit,
offset=offset,
)
def _render_teams_list_output(data: TeamsListOutput, toolkit: RichToolkit) -> None:
toolkit.print_title("teams")
toolkit.print_line()
if not data.teams:
toolkit.print("No teams found.", bullet=False)
return
settings = Settings.get()
table = Table.grid(padding=(0, 2), pad_edge=False)
table.add_column("Name")
table.add_column("ID")
table.add_row("[bold]Name[/bold]", "[bold]ID[/bold]")
table.add_row("", "")
for team in data.teams:
table.add_row(
f"[link={_get_team_dashboard_url(team, settings=settings)}]{escape(team.name)}[/link]",
team.id,
)
toolkit.print(table, bullet=False)
teams_app = typer.Typer(
no_args_is_help=True,
help="Manage your FastAPI Cloud teams.",
)
@teams_app.command("list")
def list_teams(
limit: Annotated[
int,
typer.Option(
"--limit",
help="Maximum number of teams to return.",
min=1,
),
] = DEFAULT_LIMIT,
offset: Annotated[
int,
typer.Option(
"--offset",
help="Offset into the team result set.",
min=0,
),
] = DEFAULT_OFFSET,
json_output: JsonOutputOption = False,
) -> Any:
"""
List FastAPI Cloud teams.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
with APIClient() as client:
with toolkit.progress(
title="Fetching teams",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching teams. Please try again later.",
toolkit=toolkit,
):
result = _get_teams(client, limit=limit, offset=offset)
toolkit.success(result, render_output=_render_teams_list_output)
teams_app.command("get")(get_team)
@@ -0,0 +1,91 @@
import logging
from typing import Annotated, Any
import typer
from pydantic import BaseModel
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.config import Settings
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import get_details_table, get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
logger = logging.getLogger(__name__)
class Team(BaseModel):
id: str
slug: str
name: str
class TeamGetOutput(BaseModel):
team: Team
def _get_team_dashboard_url(team: Team, *, settings: Settings) -> str:
return f"{settings.dashboard_base_url}/{team.slug}/apps"
def _get_team(client: APIClient, team_id: str) -> TeamGetOutput:
response = client.get(f"/teams/{team_id}")
response.raise_for_status()
team = Team.model_validate(response.json())
return TeamGetOutput(team=team)
def _render_team_get_output(data: TeamGetOutput, toolkit: RichToolkit) -> None:
toolkit.print(f"[bold]{data.team.name}[/bold]", emoji="🏢")
toolkit.print_line()
toolkit.print(
get_details_table(
[
("id", data.team.id),
("slug", data.team.slug),
("url", _get_team_dashboard_url(data.team, settings=Settings.get())),
]
)
)
def get_team(
team_id: Annotated[
str,
typer.Argument(
help="ID of the team to return.",
),
],
json_output: JsonOutputOption = False,
) -> Any:
"""
Get a FastAPI Cloud team by ID.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
with (
APIClient() as client,
toolkit.progress(
title="Fetching team",
transient=True,
) as progress,
):
with client.handle_http_errors(
progress,
default_message="Error fetching team. Please try again later.",
not_found_message="Team not found.",
toolkit=toolkit,
):
result = _get_team(client, team_id)
toolkit.success(result, render_output=_render_team_get_output)
@@ -0,0 +1,15 @@
import typer
from fastapi_cloud_cli.commands.tokens.create import create_token
from fastapi_cloud_cli.commands.tokens.delete import delete_token
from fastapi_cloud_cli.commands.tokens.list import list_tokens
tokens_app = typer.Typer(
no_args_is_help=True,
help="Manage deploy tokens for your app.",
)
tokens_app.command("create")(create_token)
tokens_app.command("delete")(delete_token)
tokens_app.command("list")(list_tokens)
__all__ = ["tokens_app"]
@@ -0,0 +1,182 @@
from pathlib import Path
from typing import Annotated, Any, Literal
import typer
from pydantic import BaseModel, Field
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
DEFAULT_EXPIRES_IN_DAYS = 365
class CreatedDeployToken(BaseModel):
id: str
name: str
expired_at: str
class DeployTokenCreateAPIResponse(CreatedDeployToken):
value: str
class StoredDeployTokenSecret(BaseModel):
provider: Literal["file"] = "file"
path: Path
class DeployTokenCreateOutput(BaseModel):
app_id: str
token: CreatedDeployToken
stored_secret: StoredDeployTokenSecret
output_file: Annotated[Path, Field(exclude=True)]
def _resolve_token_name(toolkit: FastAPIRichToolkit, *, name: str | None) -> str:
if name is not None:
return name
if toolkit.mode == "json":
toolkit.fail(
"missing_required_input",
"Deploy token name is required.",
hint="Pass --name to choose a deploy token name.",
)
return toolkit.input(
"What's the deploy token name?",
default="Deploy token",
bullet=False,
)
def _resolve_output_file(
toolkit: FastAPIRichToolkit, *, output_file: Path | None
) -> Path:
if output_file is not None:
return output_file
toolkit.fail(
"missing_required_input",
"Output file is required.",
hint="Pass --output-file to store the deploy token value.",
)
def _create_deploy_token(
client: APIClient, *, app_id: str, name: str, expires_in_days: int
) -> DeployTokenCreateAPIResponse:
response = client.post(
f"/apps/{app_id}/tokens",
json={"name": name, "expires_in_days": expires_in_days},
)
response.raise_for_status()
return DeployTokenCreateAPIResponse.model_validate(response.json())
def _write_token_value(output_file: Path, value: str) -> None:
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text(value, encoding="utf-8")
output_file.chmod(0o600)
def _render_deploy_token_create_output(
data: DeployTokenCreateOutput, toolkit: RichToolkit
) -> None:
toolkit.print(f"Created deploy token [bold]{data.token.name}[/bold]", bullet=False)
toolkit.print(
f"Stored deploy token value in [bold]{data.output_file}[/bold]",
bullet=False,
)
def create_token(
app_id: Annotated[
str | None,
typer.Option(
"--app-id",
help="ID of the app whose deploy token should be created.",
),
] = None,
name: Annotated[
str | None,
typer.Option(
"--name",
help="Name of the deploy token to create.",
),
] = None,
expires_in_days: Annotated[
int,
typer.Option(
"--expires-in-days",
help="Number of days before the deploy token expires.",
min=1,
),
] = DEFAULT_EXPIRES_IN_DAYS,
output_file: Annotated[
Path | None,
typer.Option(
"--output-file",
help="File path where the deploy token value should be stored.",
),
] = None,
json_output: JsonOutputOption = False,
) -> Any:
"""
Create a deploy token for an app.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id)
toolkit.print_title("deploy tokens")
toolkit.print_line()
output_file = _resolve_output_file(toolkit, output_file=output_file)
name_needs_prompt = name is None
name = _resolve_token_name(toolkit, name=name)
if name_needs_prompt:
toolkit.print_line()
with APIClient() as client:
with toolkit.progress(
title="Creating deploy token",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error creating deploy token. Please try again later.",
not_found_message="App not found.",
toolkit=toolkit,
):
token = _create_deploy_token(
client,
app_id=target_app_id,
name=name,
expires_in_days=expires_in_days,
)
_write_token_value(output_file, token.value)
toolkit.success(
DeployTokenCreateOutput(
app_id=target_app_id,
token=CreatedDeployToken.model_validate(token),
stored_secret=StoredDeployTokenSecret(path=output_file),
output_file=output_file,
),
render_output=_render_deploy_token_create_output,
)
@@ -0,0 +1,102 @@
from typing import Annotated, Any
import typer
from pydantic import BaseModel
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
class DeployTokenDeleteOutput(BaseModel):
token_id: str
deleted: bool = True
def _delete_deploy_token(client: APIClient, *, app_id: str, token_id: str) -> bool:
response = client.delete(f"/apps/{app_id}/tokens/{token_id}")
if response.status_code == 404:
return False
response.raise_for_status()
return True
def _render_deploy_token_delete_output(
data: DeployTokenDeleteOutput, toolkit: RichToolkit
) -> None:
toolkit.print(
f"Deleted deploy token [bold]{data.token_id}[/bold]",
bullet=False,
)
def delete_token(
token_id: Annotated[
str,
typer.Argument(
help="ID of the deploy token to delete.",
),
],
app_id: Annotated[
str | None,
typer.Option(
"--app-id",
help="ID of the app that owns the deploy token.",
),
] = None,
json_output: JsonOutputOption = False,
) -> Any:
"""
Delete a deploy token for an app.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id)
with APIClient() as client:
with toolkit.progress(
title="Deleting deploy token",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error deleting deploy token. Please try again later.",
not_found_message="Deploy token not found.",
toolkit=toolkit,
):
deleted = _delete_deploy_token(
client,
app_id=target_app_id,
token_id=token_id,
)
if not deleted:
message = (
f"Deploy token {token_id} not found."
if toolkit.mode == "json"
else "Deploy token not found."
)
toolkit.fail(
"not_found",
message,
hint="Run `fastapi cloud tokens list` to see available deploy tokens.",
)
toolkit.success(
DeployTokenDeleteOutput(token_id=token_id),
render_output=_render_deploy_token_delete_output,
)
@@ -0,0 +1,115 @@
from typing import Annotated, Any
import typer
from pydantic import BaseModel
from rich.table import Table
from rich.text import Text
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
class DeployToken(BaseModel):
id: str
name: str
created_at: str
expired_at: str
class DeployTokensListAPIResponse(BaseModel):
data: list[DeployToken]
class DeployTokensListOutput(BaseModel):
app_id: str
tokens: list[DeployToken]
def _get_deploy_tokens(client: APIClient, app_id: str) -> DeployTokensListAPIResponse:
response = client.get(f"/apps/{app_id}/tokens")
response.raise_for_status()
return DeployTokensListAPIResponse.model_validate(response.json())
def _get_deploy_tokens_table(tokens: list[DeployToken]) -> Table:
table = Table.grid(padding=(0, 2), pad_edge=False)
table.add_column("Name", no_wrap=True)
table.add_column("Expiration", no_wrap=True)
table.add_column("ID", no_wrap=True, overflow="ignore")
table.add_row(
Text("Name", style="bold"),
Text("Expiration", style="bold"),
Text("ID", style="bold"),
)
table.add_row("", "", "")
for token in tokens:
table.add_row(
Text(token.name),
Text(token.expired_at[:10], style="dim"),
Text(token.id),
)
return table
def _render_deploy_tokens_list_output(
data: DeployTokensListOutput, toolkit: RichToolkit
) -> None:
toolkit.print_title("deploy tokens")
toolkit.print_line()
if not data.tokens:
toolkit.print("No deploy tokens found.", bullet=False)
return
toolkit.print(_get_deploy_tokens_table(data.tokens), bullet=False)
def list_tokens(
app_id: Annotated[
str | None,
typer.Option(
"--app-id",
help="ID of the app whose deploy tokens should be listed.",
),
] = None,
json_output: JsonOutputOption = False,
) -> Any:
"""
List deploy tokens for an app.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
)
target_app_id = resolve_app_id_or_fail(toolkit, app_id=app_id)
with APIClient() as client:
with toolkit.progress(
title="Fetching deploy tokens",
transient=True,
) as progress:
with client.handle_http_errors(
progress,
default_message="Error fetching deploy tokens. Please try again later.",
not_found_message="App not found.",
toolkit=toolkit,
):
tokens = _get_deploy_tokens(client=client, app_id=target_app_id)
toolkit.success(
DeployTokensListOutput(app_id=target_app_id, tokens=tokens.data),
render_output=_render_deploy_tokens_list_output,
)
@@ -0,0 +1,68 @@
import logging
from typing import Any
from pydantic import BaseModel
from rich_toolkit import RichToolkit
from fastapi_cloud_cli.utils.api import APIClient
from fastapi_cloud_cli.utils.auth import Identity
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
from fastapi_cloud_cli.utils.execution import JsonOutputOption
logger = logging.getLogger(__name__)
class WhoAmIOutput(BaseModel):
email: str | None = None
has_deploy_token: bool
def _render_whoami_output(data: WhoAmIOutput, toolkit: RichToolkit) -> None:
toolkit.print(f"[bold]{data.email}[/bold]", emoji="⚡")
if data.has_deploy_token:
toolkit.print(
"[bold]Using API token from environment variable for "
"[blue]`fastapi deploy`[/blue] command.[/bold]",
emoji="⚡",
)
def whoami(
json_output: JsonOutputOption = False,
) -> Any:
"""
Show the currently logged in user.
"""
identity = Identity()
with get_rich_toolkit(json_output=json_output) as toolkit:
if not identity.is_logged_in():
toolkit.fail(
"not_logged_in",
"No credentials found.",
hint="Run [blue]`fastapi login`[/] or set [blue]FASTAPI_CLOUD_TOKEN.[/]",
)
with (
APIClient() as client,
toolkit.progress(
title="Fetching profile",
transient=True,
) as progress,
):
with client.handle_http_errors(
progress,
default_message="",
toolkit=toolkit,
):
response = client.get("/users/me")
response.raise_for_status()
data = response.json()
result = WhoAmIOutput(
has_deploy_token=identity.has_deploy_token(), email=data["email"]
)
toolkit.success(result, render_output=_render_whoami_output)
@@ -0,0 +1,27 @@
import json
from pathlib import Path
from pydantic import BaseModel
from .utils.config import get_cli_config_path
class Settings(BaseModel):
base_api_url: str = "https://api.fastapicloud.com/api/v1"
dashboard_base_url: str = "https://dashboard.fastapicloud.com"
client_id: str = "fastapi-cli"
@classmethod
def from_user_settings(cls, config_path: Path) -> "Settings":
try:
content = config_path.read_bytes() if config_path.exists() else b"{}"
user_settings = json.loads(content)
except json.JSONDecodeError:
user_settings = {}
return cls(**user_settings)
@classmethod
def get(cls) -> "Settings":
return cls.from_user_settings(get_cli_config_path())
@@ -0,0 +1,28 @@
import logging
import os
from rich.console import Console
from rich.logging import RichHandler
def setup_logging(terminal_width: int | None = None, level: int | None = None) -> None:
if level is None:
level = (
logging.DEBUG if os.getenv("FASTAPI_CLOUD_DEBUG") == "1" else logging.INFO
)
logger = logging.getLogger("fastapi_cloud_cli")
console = Console(width=terminal_width) if terminal_width else None
rich_handler = RichHandler(
show_time=False,
rich_tracebacks=True,
tracebacks_show_locals=True,
markup=True,
show_path=False,
console=console,
)
rich_handler.setFormatter(logging.Formatter("{message}", style="{"))
logger.addHandler(rich_handler)
logger.setLevel(level)
logger.propagate = False
@@ -0,0 +1,515 @@
import json
import logging
import time
from collections.abc import Callable, Generator
from contextlib import contextmanager
from datetime import timedelta
from enum import Enum
from functools import wraps
from typing import (
Annotated,
Literal,
TypeVar,
)
import httpx
import typer
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
from rich_toolkit.progress import Progress
from typing_extensions import ParamSpec
from fastapi_cloud_cli import __version__
from fastapi_cloud_cli.config import Settings
from fastapi_cloud_cli.utils.errors import ErrorCode, ErrorToolkit
from .auth import AuthMode, Identity, delete_auth_config
logger = logging.getLogger(__name__)
STREAM_LOGS_MAX_RETRIES = 3
STREAM_LOGS_TIMEOUT = timedelta(minutes=5)
class StreamLogError(Exception):
"""Raised when there's an error streaming logs (build or app logs)."""
def __init__(self, message: str, *, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
class TooManyRetriesError(Exception):
pass
class AppLogEntry(BaseModel):
timestamp: str
message: str
level: str
class BuildLogLineGeneric(BaseModel):
type: Literal["complete", "failed", "timeout", "heartbeat"]
id: str | None = None
class BuildLogLineMessage(BaseModel):
type: Literal["message"] = "message"
message: str
id: str | None = None
BuildLogLine = BuildLogLineMessage | BuildLogLineGeneric
BuildLogAdapter: TypeAdapter[BuildLogLine] = TypeAdapter(
Annotated[BuildLogLine, Field(discriminator="type")]
)
@contextmanager
def attempt(attempt_number: int) -> Generator[None, None, None]:
def _backoff() -> None:
backoff_seconds = min(2**attempt_number, 30)
logger.debug(
"Retrying in %ds (attempt %d)",
backoff_seconds,
attempt_number,
)
time.sleep(backoff_seconds)
try:
yield
except (
httpx.TimeoutException,
httpx.NetworkError,
httpx.RemoteProtocolError,
) as error:
logger.debug("Network error (will retry): %s", error)
_backoff()
except httpx.HTTPStatusError as error:
if error.response.status_code >= 500:
logger.debug(
"Server error %d (will retry): %s",
error.response.status_code,
error,
)
_backoff()
else:
# The streaming callers read the body before raising, so the
# server's error detail is available here.
raise StreamLogError(
f"HTTP {error.response.status_code}: {error.response.text}",
status_code=error.response.status_code,
) from error
P = ParamSpec("P")
T = TypeVar("T")
def attempts(
total_attempts: int = 3, timeout: timedelta = timedelta(minutes=5)
) -> Callable[
[Callable[P, Generator[T, None, None]]], Callable[P, Generator[T, None, None]]
]:
def decorator(
func: Callable[P, Generator[T, None, None]],
) -> Callable[P, Generator[T, None, None]]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Generator[T, None, None]:
start = time.monotonic()
for attempt_number in range(total_attempts):
if time.monotonic() - start > timeout.total_seconds():
raise TimeoutError(
f"Log streaming timed out after {timeout.total_seconds():.0f}s"
)
with attempt(attempt_number):
yield from func(*args, **kwargs)
# If we get here without exception, the generator completed successfully
return
raise TooManyRetriesError(f"Failed after {total_attempts} attempts")
return wrapper
return decorator
class DeploymentStatus(str, Enum):
waiting_upload = "waiting_upload"
upload_cancelled = "upload_cancelled"
ready_for_build = "ready_for_build"
building = "building"
extracting = "extracting"
extracting_failed = "extracting_failed"
building_image = "building_image"
building_image_failed = "building_image_failed"
deploying = "deploying"
deploying_failed = "deploying_failed"
verifying = "verifying"
verifying_failed = "verifying_failed"
verifying_skipped = "verifying_skipped"
success = "success"
expired = "expired"
failed = "failed"
@classmethod
def to_human_readable(cls, status: "DeploymentStatus") -> str:
return {
cls.waiting_upload: "Awaiting Upload",
cls.upload_cancelled: "Upload Cancelled",
cls.ready_for_build: "Build Queued",
cls.building: "Building",
cls.extracting: "Extracting Upload",
cls.extracting_failed: "Extraction Failed",
cls.building_image: "Building Image",
cls.building_image_failed: "Build Failed",
cls.deploying: "Deploying Image",
cls.deploying_failed: "Deployment Failed",
cls.verifying: "Verifying Readiness",
cls.verifying_failed: "Verification Failed",
cls.verifying_skipped: "Verification Skipped",
cls.success: "Ready",
cls.expired: "Expired",
cls.failed: "Failed",
}[status]
SUCCESSFUL_STATUSES = {DeploymentStatus.success, DeploymentStatus.verifying_skipped}
FAILED_STATUSES = {
DeploymentStatus.failed,
DeploymentStatus.verifying_failed,
DeploymentStatus.deploying_failed,
DeploymentStatus.building_image_failed,
DeploymentStatus.extracting_failed,
}
TERMINAL_STATUSES = SUCCESSFUL_STATUSES | FAILED_STATUSES
POLL_INTERVAL = 2.0
POLL_TIMEOUT = timedelta(seconds=120)
POLL_MAX_RETRIES = 5
def _handle_unauthorized(auth_mode: AuthMode) -> str:
message = "The specified token is not valid. "
if auth_mode == "user":
delete_auth_config()
message += "Use `fastapi login` to generate a new token."
else:
message += "Make sure to use a valid token."
return message
def _get_response_error_message(response: httpx.Response) -> str | None:
try:
data = response.json()
except (json.JSONDecodeError, httpx.ResponseNotRead):
return None
if not isinstance(data, dict):
return None # pragma: no cover
detail = data.get("detail")
if not isinstance(detail, str):
return None # pragma: no cover
return detail
def handle_http_error(
error: httpx.HTTPError,
default_message: str | None = None,
not_found_message: str | None = None,
auth_mode: AuthMode = "user",
) -> str:
message: str | None = None
if isinstance(error, httpx.HTTPStatusError):
status_code = error.response.status_code
# Handle validation errors from Pydantic models, this should make it easier to debug :)
if status_code == 422:
logger.debug(error.response.json()) # pragma: no cover
elif status_code == 400:
message = _get_response_error_message(error.response)
elif status_code == 409:
message = _get_response_error_message(error.response)
elif status_code == 401:
message = _handle_unauthorized(auth_mode=auth_mode)
elif status_code == 403:
message = (
_get_response_error_message(error.response)
or "You don't have permissions for this resource"
)
elif status_code == 404:
message = (
_get_response_error_message(error.response)
or not_found_message
or "Resource not found."
)
if not message:
message = (
default_message
or f"Something went wrong while contacting the FastAPI Cloud server. Please try again later. \n\n{error}"
)
return message
def get_http_error_code(error: httpx.HTTPError) -> ErrorCode:
if isinstance(error, httpx.TimeoutException | httpx.NetworkError):
return "network_error"
if isinstance(error, httpx.HTTPStatusError):
status_code = error.response.status_code
if status_code in {400, 409}:
return "invalid_input"
if status_code == 401:
return "invalid_token"
if status_code == 403:
return "permission_denied"
if status_code == 404:
return "not_found"
return "api_error"
def get_http_error_hint(code: ErrorCode, *, auth_mode: AuthMode = "user") -> str | None:
if code == "invalid_token":
if auth_mode == "user":
return "Run `fastapi cloud login` to generate a new token."
return "Make sure FASTAPI_CLOUD_TOKEN contains a valid token."
return None
class APIClient(httpx.Client):
auth_mode: AuthMode
def __init__(self, use_deploy_token: bool = False) -> None:
settings = Settings.get()
identity = Identity()
token: str | None
if use_deploy_token and identity.deploy_token:
token = identity.deploy_token
self.auth_mode = "token"
else:
token = identity.user_token
self.auth_mode = "user"
headers = {"User-Agent": f"fastapi-cloud-cli/{__version__}"}
if token:
headers["Authorization"] = f"Bearer {token}"
super().__init__(
base_url=settings.base_api_url,
timeout=httpx.Timeout(20),
headers=headers,
)
@contextmanager
def handle_http_errors(
self,
progress: Progress,
default_message: str | None = None,
*,
not_found_message: str | None = None,
toolkit: ErrorToolkit | None = None,
) -> Generator[None, None, None]:
# TODO: Once every command supports JSON output, require toolkit here
# and let it be the single human/JSON error rendering boundary.
mode = toolkit.mode if toolkit else "human"
try:
yield
except httpx.ReadTimeout as e:
logger.debug(e)
message = (
"The request to the FastAPI Cloud server timed out."
" Please try again later."
)
if mode == "json" and toolkit:
toolkit.fail(
"network_error",
message,
hint="Please try again later.",
)
progress.set_error(message)
raise typer.Exit(1) from None # pragma: no cover
except httpx.HTTPError as e:
logger.debug(e)
message = handle_http_error(
e,
default_message,
not_found_message=not_found_message,
auth_mode=self.auth_mode,
)
code = get_http_error_code(e)
if mode == "json" and toolkit:
toolkit.fail(
code,
message,
hint=get_http_error_hint(code, auth_mode=self.auth_mode),
)
else:
progress.set_error(message)
raise typer.Exit(1) from None
@attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT)
def stream_build_logs(
self, deployment_id: str, *, follow: bool = True
) -> Generator[BuildLogLine, None, None]:
last_id = None
while True:
params = {"last_id": last_id} if last_id else None
with self.stream(
"GET",
f"/deployments/{deployment_id}/build-logs",
timeout=60,
params=params,
) as response:
if response.is_error:
# Load the body while the stream is open so error handlers
# can surface the server's error detail.
response.read()
response.raise_for_status()
for line in response.iter_lines():
if not line or not line.strip():
continue
if log_line := self._parse_log_line(line):
if log_line.id:
last_id = log_line.id
if log_line.type == "message":
yield log_line
if log_line.type in ("complete", "failed"):
yield log_line
return
if log_line.type == "timeout":
logger.debug("Received timeout; reconnecting")
if not follow:
return
break # Breaks for loop to reconnect
else:
if not follow:
return
logger.debug("Connection closed by server unexpectedly; will retry")
raise httpx.NetworkError("Connection closed without terminal state")
time.sleep(0.5)
def _parse_log_line(self, line: str) -> BuildLogLine | None:
try:
return BuildLogAdapter.validate_json(line)
except (ValidationError, json.JSONDecodeError) as e:
logger.debug("Skipping malformed log: %s (error: %s)", line[:100], e)
return None
@attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT)
def stream_app_logs(
self,
app_id: str,
tail: int,
since: str,
follow: bool,
) -> Generator[AppLogEntry, None, None]:
timeout = 120 if follow else 30
with self.stream(
"GET",
f"/apps/{app_id}/logs/stream",
params={
"tail": tail,
"since": since,
"follow": follow,
},
timeout=timeout,
) as response:
if response.is_error:
# Load the body while the stream is open so error handlers
# can surface the server's error detail.
response.read()
response.raise_for_status()
for line in response.iter_lines():
if not line or not line.strip(): # pragma: no cover
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
logger.debug("Failed to parse log line: %s", line)
continue
if data.get("type") == "heartbeat":
continue
if data.get("type") == "error":
raise StreamLogError(data.get("message", "Unknown error"))
try:
yield AppLogEntry.model_validate(data)
except ValidationError as e: # pragma: no cover
logger.debug("Failed to parse log entry: %s - %s", data, e)
continue
def poll_deployment_status(
self,
deployment_id: str,
) -> DeploymentStatus:
start = time.monotonic()
error_count = 0
while True:
if time.monotonic() - start > POLL_TIMEOUT.total_seconds():
raise TimeoutError("Deployment verification timed out")
with attempt(error_count):
response = self.get(f"/deployments/{deployment_id}")
response.raise_for_status()
status = DeploymentStatus(response.json()["status"])
error_count = 0
if status in TERMINAL_STATUSES:
return status
time.sleep(POLL_INTERVAL)
continue
error_count += 1
if error_count >= POLL_MAX_RETRIES:
raise TooManyRetriesError(
f"Failed after {POLL_MAX_RETRIES} attempts polling deployment status"
)
@@ -0,0 +1,94 @@
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from pydantic import BaseModel
if TYPE_CHECKING:
from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit
logger = logging.getLogger("fastapi_cli")
class AppConfig(BaseModel):
app_id: str
team_id: str
def get_app_config(path_to_deploy: Path) -> AppConfig | None:
config_path = path_to_deploy / ".fastapicloud/cloud.json"
logger.debug("Looking for app config at: %s", config_path)
if not config_path.exists():
logger.debug("App config file doesn't exist")
return None
logger.debug("App config loaded successfully")
return AppConfig.model_validate_json(config_path.read_text(encoding="utf-8"))
def resolve_app_id(
*, app_id: str | None = None, path: Path | None = None
) -> str | None:
if app_id is not None:
return app_id
app_config = get_app_config(path or Path.cwd())
if app_config is None:
return None
return app_config.app_id
def resolve_app_id_or_fail(
toolkit: "FastAPIRichToolkit",
*,
app_id: str | None = None,
path: Path | None = None,
hint: str = "Pass --app-id or run `fastapi cloud apps create --link` first.",
) -> str:
target_app_id = resolve_app_id(app_id=app_id, path=path)
if target_app_id is None:
toolkit.fail(
"missing_required_input",
"App ID is required.",
hint=hint,
)
return target_app_id
README = """
> Why do I have a folder named ".fastapicloud" in my project? 🤔
The ".fastapicloud" folder is created when you link a directory to a FastAPI Cloud project.
> What does the "cloud.json" file contain?
The "cloud.json" file contains:
- The ID of the FastAPI app that you linked ("app_id")
- The ID of the team your FastAPI Cloud project is owned by ("team_id")
> Should I commit the ".fastapicloud" folder?
No, you should not commit the ".fastapicloud" folder to your version control system.
That's why there's a ".gitignore" file in this folder.
"""
def write_app_config(path_to_deploy: Path, app_config: AppConfig) -> None:
config_path = path_to_deploy / ".fastapicloud/cloud.json"
readme_path = path_to_deploy / ".fastapicloud/README.md"
gitignore_path = path_to_deploy / ".fastapicloud/.gitignore"
logger.debug("Writing app config to: %s", config_path)
logger.debug("App config data: %s", app_config)
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
app_config.model_dump_json(),
encoding="utf-8",
)
readme_path.write_text(README, encoding="utf-8")
gitignore_path.write_text("*")
logger.debug("App config files written successfully")
@@ -0,0 +1,150 @@
import base64
import binascii
import json
import logging
import os
import time
from typing import Literal
from pydantic import BaseModel
from .config import get_auth_path
logger = logging.getLogger("fastapi_cli")
AuthMode = Literal["token", "user"]
class AuthConfig(BaseModel):
access_token: str
def write_auth_config(auth_data: AuthConfig) -> None:
auth_path = get_auth_path()
logger.debug("Writing auth config to: %s", auth_path)
auth_path.write_text(auth_data.model_dump_json(), encoding="utf-8")
logger.debug("Auth config written successfully")
def delete_auth_config() -> None:
auth_path = get_auth_path()
logger.debug("Deleting auth config at: %s", auth_path)
if auth_path.exists():
auth_path.unlink()
logger.debug("Auth config deleted successfully")
else:
logger.debug("Auth config file doesn't exist, nothing to delete")
def read_auth_config() -> AuthConfig | None:
auth_path = get_auth_path()
logger.debug("Reading auth config from: %s", auth_path)
if not auth_path.exists():
logger.debug("Auth config file doesn't exist")
return None
logger.debug("Auth config loaded successfully")
return AuthConfig.model_validate_json(auth_path.read_text(encoding="utf-8"))
def _get_auth_token() -> str | None:
logger.debug("Getting auth token")
auth_data = read_auth_config()
if auth_data is None:
logger.debug("No auth data found")
return None
logger.debug("Auth token retrieved successfully")
return auth_data.access_token
def _is_jwt_expired(token: str) -> bool:
try:
parts = token.split(".")
if len(parts) != 3:
logger.debug("Invalid JWT format: expected 3 parts, got %d", len(parts))
return True
payload = parts[1]
# Add padding if needed (JWT uses base64url encoding without padding)
if padding := len(payload) % 4:
payload += "=" * (4 - padding)
payload = payload.replace("-", "+").replace("_", "/")
decoded_bytes = base64.b64decode(payload)
payload_data = json.loads(decoded_bytes)
exp = payload_data.get("exp")
if exp is None:
logger.debug("No 'exp' claim found in token")
return False
if not isinstance(exp, int): # pragma: no cover
logger.debug("Invalid 'exp' claim: expected int, got %s", type(exp))
return True
current_time = time.time()
is_expired = current_time >= exp
logger.debug(
"Token expiration check: current=%d, exp=%d, expired=%s",
current_time,
exp,
is_expired,
)
return is_expired
except (binascii.Error, json.JSONDecodeError) as e:
logger.debug("Error parsing JWT token: %s", e)
return True
class Identity:
def __init__(self) -> None:
self._user_token = _get_auth_token()
self._deploy_token: str | None = os.environ.get("FASTAPI_CLOUD_TOKEN")
@property
def user_token(self) -> str | None:
return self._user_token
@property
def deploy_token(self) -> str | None:
return self._deploy_token
def is_user_token_expired(self) -> bool:
if not self._user_token:
return True
return _is_jwt_expired(self._user_token)
def is_logged_in(self) -> bool:
if self._user_token is None:
logger.debug("Login status: False (no token)")
return False
if self.is_user_token_expired():
logger.debug("Login status: False (token expired)")
return False
logger.debug("Login status: True")
return True
def has_deploy_token(self) -> bool:
if self._deploy_token is None:
logger.debug("Deploy token is not provided")
return False
logger.debug("Deploy token found")
return True
@@ -0,0 +1,458 @@
import logging
import os
import time
from collections.abc import Callable, Iterable, Iterator
from types import TracebackType
from typing import Any, Literal, NoReturn, Protocol, TypeVar, cast
import typer
from pydantic import BaseModel
from rich._loop import loop_first_last
from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
from rich.padding import Padding
from rich.segment import Segment
from rich.style import Style
from rich.table import Table
from rich.text import Text
from rich_toolkit import RichToolkit, RichToolkitTheme
from rich_toolkit.container import Container
from rich_toolkit.element import CursorOffset, Element
from rich_toolkit.input import Input
from rich_toolkit.progress import Progress
from rich_toolkit.styles import BaseStyle, MinimalStyle
from fastapi_cloud_cli.utils.errors import ErrorCode
from fastapi_cloud_cli.utils.execution import is_ci_enabled
from fastapi_cloud_cli.utils.version_check import (
DISABLE_VERSION_CHECK_ENV,
BackgroundVersionCheck,
)
logger = logging.getLogger(__name__)
OutputT = TypeVar("OutputT", bound=BaseModel)
OutputRenderer = Callable[[OutputT, "FastAPIRichToolkit"], None]
class ErrorRenderer(Protocol):
def __call__(
self,
toolkit: "FastAPIRichToolkit",
*,
code: ErrorCode,
message: str,
hint: str,
) -> None: ...
# the leading space right-aligns the one-cell ✗ within the two-cell emoji
# slot, so its right edge and gap to the text match the emoji bullets
ERROR_BULLET = " [bold][error]✗[/][/]"
TITLE_SWEEP_SHADES = ("█", "▓", "▓", "▒", "░")
TITLE_SWEEP_DELAY = 0.015
def _title_sweep_frames(text: str) -> Iterator[tuple[str, str, str]]:
"""Frames of a gradient sweep painting the title chip into existence.
Each frame is split into the part of the chip already swept (rendered
with the chip's background), the visible sweep shades, and the still
untouched tail, all together exactly as wide as the chip (one space of
padding around the text) so the real chip prints cleanly over the last
frame."""
chip = f" {text} "
width = len(chip)
for light_pos in range(-len(TITLE_SWEEP_SHADES), width + len(TITLE_SWEEP_SHADES)):
sweep_start = light_pos - len(TITLE_SWEEP_SHADES) + 1
chip_end = max(0, min(width, sweep_start))
shades = "".join(
shade
for index, shade in enumerate(TITLE_SWEEP_SHADES)
if 0 <= sweep_start + index < width
)
tail = " " * (width - chip_end - len(shades))
yield chip[:chip_end], shades, tail
def _strip_rich_markup(value: str | None) -> str | None:
if value is None:
return None
return Text.from_markup(value).plain
class IndentedBlock:
"""Indent a renderable, hanging a prefix (e.g. an emoji bullet) on the
first line.
Blank lines stay truly empty: live renders (inputs, menus) don't end
with a newline, so any padding on a final blank line would leave the
terminal cursor mid-line and shift whatever gets printed next.
"""
def __init__(
self,
renderable: RenderableType,
*,
first_prefix: Text,
prefix: Text,
) -> None:
self.renderable = renderable
self.first_prefix = first_prefix
self.prefix = prefix
# Text renders its `end` ("\n" by default), which would break lines
self.first_prefix.end = ""
self.prefix.end = ""
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
prefix_width = max(self.first_prefix.cell_len, self.prefix.cell_len)
lines = console.render_lines(
self.renderable,
options.update_width(options.max_width - prefix_width),
pad=False,
)
new_line = Segment.line()
for first, last, line in loop_first_last(lines):
if any(segment.text.strip() for segment in line):
yield from console.render(
self.first_prefix if first else self.prefix, options
)
yield from line
elif last:
# a zero-width space stops live renders from stripping the
# final blank line, keeping the cursor at column 0
yield Segment("​")
yield new_line
class FastAPIStyle(BaseStyle):
"""Header chip + uniform indent, without the per-line tag gutter.
Titles render as a single chip at the top of the command's output and
everything else gets a fixed left indent, so renderables don't need to
be wrapped in `Padding` manually. Emojis (`emoji=` metadata, or the
progress animation/done emoji) hang to the left of the text like list
bullets.
"""
content_padding = 1
emoji_column_width = 3
animation_emojis = [
"🥚",
"🐣",
"🐤",
"🐥",
"🐓",
"🐔",
]
def render_element(
self,
element: Any,
is_active: bool = False,
done: bool = False,
parent: Element | None = None,
**kwargs: Any,
) -> RenderableType:
rendered = super().render_element(
element=element, is_active=is_active, done=done, parent=parent, **kwargs
)
# progress log lines and container children are already part of
# their parent's render, which gets indented as a whole
if isinstance(parent, (Progress, Container)):
return rendered
metadata = kwargs
if isinstance(element, Element) and element.metadata:
metadata = {**element.metadata, **metadata}
# Input.ask wraps the element in a metadata-less Container; pull the
# child's metadata so flags like bullet= still apply
if isinstance(element, Container) and element.elements:
child = element.elements[0]
if isinstance(child, Element) and child.metadata:
metadata = {**child.metadata, **metadata}
if metadata.get("title", False):
return self._render_title(element, metadata)
if isinstance(element, Progress):
emoji = self._get_progress_status_emoji(element, done)
else:
emoji = metadata.get("emoji", "")
if not emoji and not metadata.get("bullet", True):
# skip the bullet column and align with the title chip's text
indent = Text(" " * (self.title_padding + 1))
return IndentedBlock(rendered, first_prefix=indent, prefix=indent)
return self._render_with_emoji_bullet(rendered, emoji)
@property
def title_padding(self) -> int:
# align the chip with the emoji bullet column
return self.content_padding
def _render_title(self, title: Any, metadata: dict[str, Any]) -> RenderableType:
tag = metadata.get("tag", "")
if metadata.get("animate", False):
self._animate_title_sweep(tag or title)
chip = Padding(
Text(f" {tag or title} ", style="tag.title"),
(0, 0, 0, self.title_padding),
expand=False,
)
if not (tag and title):
return chip
title_text = self._render_with_emoji_bullet(
Text.from_markup(f"[bold]{title}[/bold]"),
metadata.get("emoji", ""),
)
return Group(chip, "", title_text)
def _animate_title_sweep(self, text: str) -> None:
"""Sweep a gradient across the chip's line right before it prints,
painting the chip background in behind the light."""
if not self.console.is_terminal or is_ci_enabled():
return
indent = " " * self.title_padding
# the shades sweep in the chip's background color over the bare
# terminal, so the solid trailing edge blends into the painted chip
sweep_style = Style(color=self.console.get_style("tag.title").bgcolor)
self.console.show_cursor(False)
try:
for chip, shades, tail in _title_sweep_frames(text):
self.console.print(
Text.assemble(
indent, (chip, "tag.title"), (shades, sweep_style), tail
),
end="\r",
)
time.sleep(TITLE_SWEEP_DELAY)
finally:
self.console.show_cursor(True)
def _get_progress_status_emoji(self, element: Progress, done: bool) -> str:
if element._cancelled:
return "🟡"
if element.is_error:
return ERROR_BULLET
if done:
return cast(str, element.metadata.get("done_emoji", "🐔"))
if emoji := element.metadata.get("emoji"):
return cast(str, emoji)
return self.animation_emojis[
self.animation_counter % len(self.animation_emojis)
]
def _render_with_emoji_bullet(
self, rendered: RenderableType, emoji: str
) -> RenderableType:
return IndentedBlock(
rendered,
first_prefix=self._get_bullet_prefix(emoji),
prefix=Text(" " * (self.content_padding + self.emoji_column_width)),
)
def _get_bullet_prefix(self, emoji: str) -> Text:
prefix = Text(" " * self.content_padding)
if emoji:
prefix.append_text(Text.from_markup(emoji))
prefix.pad_right(
self.content_padding + self.emoji_column_width - prefix.cell_len
)
return prefix
def get_cursor_offset_for_element(
self, element: Element, parent: Element | None = None
) -> CursorOffset:
has_bullet_column = bool(element.metadata.get("emoji")) or element.metadata.get(
"bullet", True
)
decoration_width = (
self.content_padding + self.emoji_column_width
if has_bullet_column
else self.title_padding + 1
)
offset = element.cursor_offset
top = offset.top
left = decoration_width + offset.left
if isinstance(element, Input) and not element.inline and element.label:
label_lines = self._count_label_lines(
element.label, decoration_width=decoration_width
)
top = label_lines + 1
return CursorOffset(top=top, left=left)
class FastAPIRichToolkit(RichToolkit):
mode: Literal["human", "json"]
def __init__(
self,
style: BaseStyle | None = None,
theme: RichToolkitTheme | None = None,
mode: Literal["human", "json"] = "human",
) -> None:
super().__init__(style=style, theme=theme, mode=mode)
self._version_check = self._get_version_check()
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> bool | None:
self._print_update_message()
return super().__exit__(
exc_type,
exc_value,
traceback,
)
def _get_version_check(self) -> BackgroundVersionCheck | None:
if os.environ.get(DISABLE_VERSION_CHECK_ENV) == "1":
return None
version_check = BackgroundVersionCheck()
version_check.start()
return version_check
def _print_update_message(self) -> None:
if self._version_check is None:
return
if message := self._version_check.get_update_message():
self.print_line()
self.print(Text.from_markup(message), emoji="⬆️")
def print_error(self, message: str) -> None:
self.print(f"[bold][error]error:[/][/] {message}", emoji=ERROR_BULLET)
def print_hint(self, message: str) -> None:
self.print(f"[dim]hint: {message}[/]")
def success(
self,
data: OutputT,
*,
warnings: list[dict[str, Any]] | None = None,
hint: str | None = None,
render_output: OutputRenderer[OutputT] | None = None,
) -> None:
if self.mode != "json":
# the base class types render_output as taking a plain RichToolkit,
# but output() always passes self, so the narrowing is safe
self.output(data, render_output=cast(Any, render_output))
return
output: dict[str, Any] = {"data": data}
if warnings:
output["warnings"] = warnings
if hint is not None:
output["hint"] = hint
self.output(output)
def fail(
self,
code: ErrorCode,
message: str,
*,
render_output: ErrorRenderer | None = None,
hint: str | None = None,
exit_code: int = 1,
) -> NoReturn:
if self.mode == "json":
self.output(
{
"error": {
"code": code,
"message": _strip_rich_markup(message),
"hint": _strip_rich_markup(hint),
}
}
)
elif render_output is not None:
render_output(self, code=code, message=message, hint=hint or "")
else:
self.print_error(message)
if hint:
self.print_line()
self.print_hint(hint)
raise typer.Exit(exit_code)
def get_details_table(rows: Iterable[tuple[str, RenderableType]]) -> Table:
"""Build a label/value grid for `get` views (labels dimmed)."""
table = Table.grid(padding=(0, 2), pad_edge=False)
table.add_column(style="dim", no_wrap=True)
table.add_column(overflow="fold")
for label, value in rows:
table.add_row(label, value)
return table
def get_rich_toolkit(
minimal: bool = False,
*,
json_output: bool | None = None,
) -> FastAPIRichToolkit:
style: BaseStyle = MinimalStyle() if minimal else FastAPIStyle()
theme = RichToolkitTheme(
style=style,
theme={
"tag.title": "#ffffff on #009485",
"placeholder": "grey62",
"text": "white",
"selected": "#007166",
"result": "grey85",
"progress": "on #007166",
"error": "red",
"cancelled": "indian_red italic",
},
)
mode: Literal["human", "json"] = "json" if json_output else "human"
return FastAPIRichToolkit(theme=theme, mode=mode)
@@ -0,0 +1,31 @@
import os
from pathlib import Path
import typer
def get_config_folder() -> Path:
config_dir = os.getenv("FASTAPI_CLOUD_CLI_CONFIG_DIR")
if config_dir:
return Path(config_dir).expanduser()
return Path(typer.get_app_dir("fastapi-cli"))
def get_auth_path() -> Path:
auth_path = get_config_folder() / "auth.json"
auth_path.parent.mkdir(parents=True, exist_ok=True)
return auth_path
def get_cli_config_path() -> Path:
cli_config_path = get_config_folder() / "cli.json"
cli_config_path.parent.mkdir(parents=True, exist_ok=True)
return cli_config_path
def get_version_check_cache_path() -> Path:
return get_config_folder() / "version-check.json"
@@ -0,0 +1,45 @@
from datetime import datetime, timezone
def format_last_updated(updated_at: str | None) -> str:
if updated_at is None:
return "-"
try:
updated = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
except ValueError:
return updated_at
if updated.tzinfo is None:
updated = updated.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
seconds = int((now - updated).total_seconds())
if seconds < 60:
return "just now"
minutes = seconds // 60
if minutes < 60:
return _format_time_ago(minutes, "minute")
hours = minutes // 60
if hours < 24:
return _format_time_ago(hours, "hour")
days = hours // 24
if days < 30:
return _format_time_ago(days, "day")
months = days // 30
if months < 12:
return _format_time_ago(months, "month")
years = days // 365
return _format_time_ago(years, "year")
def _format_time_ago(value: int, unit: str) -> str:
suffix = "" if value == 1 else "s"
return f"{value} {unit}{suffix} ago"
@@ -0,0 +1,5 @@
def validate_environment_variable_name(name: str) -> bool:
if name.isidentifier():
return True
return False
@@ -0,0 +1,30 @@
from typing import Literal, NoReturn, Protocol
ErrorCode = Literal[
"already_linked",
"api_error",
"cancelled",
"dependency_missing",
"invalid_token",
"invalid_input",
"missing_required_input",
"network_error",
"not_found",
"not_linked",
"not_logged_in",
"permission_denied",
"timeout",
]
class ErrorToolkit(Protocol):
mode: Literal["json", "human"]
def fail(
self,
code: ErrorCode,
message: str,
*,
hint: str | None = None,
exit_code: int = 1,
) -> NoReturn: ...
@@ -0,0 +1,22 @@
import os
from typing import Annotated
import typer
JsonOutputOption = Annotated[
bool,
typer.Option(
"--json",
envvar="FASTAPI_CLOUD_JSON",
help="Print structured JSON to stdout.",
),
]
def is_ci_enabled() -> bool:
value = os.environ.get("CI")
if value is None:
return False
return value.lower() not in {"", "0", "false", "no", "off"}
@@ -0,0 +1,30 @@
from collections.abc import Callable
from datetime import datetime
from typing import Any, BinaryIO
class ProgressFile:
"""Wrap a binary file object and report upload progress as it is read."""
def __init__(
self,
file: BinaryIO,
progress_callback: Callable[[int], None],
update_interval: float = 0.5,
) -> None:
self._file = file
self._progress_callback = progress_callback
self._update_interval = update_interval
self._last_update_time = 0.0
def read(self, n: int = -1) -> bytes:
data = self._file.read(n)
now_ = datetime.now().timestamp()
is_eof = (len(data) == 0) or (n > 0 and len(data) < n)
if (now_ - self._last_update_time >= self._update_interval) or is_eof:
self._progress_callback(self._file.tell())
self._last_update_time = now_
return data
def __getattr__(self, name: str) -> Any:
return getattr(self._file, name)
@@ -0,0 +1,20 @@
import sentry_sdk
from sentry_sdk.integrations.typer import TyperIntegration
from fastapi_cloud_cli.utils.auth import Identity
SENTRY_DSN = "https://230250605ea4b58a0b69c768e9ec1168@o4506985151856640.ingest.us.sentry.io/4508449198899200"
def init_sentry() -> None:
"""Initialize Sentry error tracking only if user is logged in or has a deploy token."""
identity = Identity()
if not (identity.is_logged_in() or identity.has_deploy_token()):
return
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[TyperIntegration()],
send_default_pii=False,
)
@@ -0,0 +1,187 @@
import json
import logging
import re
import threading
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
import httpx
from detect_installer import detect_installer
from pydantic import AwareDatetime, BaseModel, ValidationError
from fastapi_cloud_cli import __version__
from fastapi_cloud_cli.utils.config import get_version_check_cache_path
logger = logging.getLogger(__name__)
PACKAGE_NAME = "fastapi-cloud-cli"
DEFAULT_UPGRADE_COMMAND = f"pip install --upgrade {PACKAGE_NAME}"
PYPI_JSON_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
VERSION_CHECK_TIMEOUT_SECONDS = 2.0
VERSION_CHECK_JOIN_TIMEOUT_SECONDS = 0.2
VERSION_CHECK_CACHE_TTL = timedelta(hours=24)
DISABLE_VERSION_CHECK_ENV = "FASTAPI_CLOUD_DISABLE_VERSION_CHECK"
SIMPLE_RELEASE_VERSION_RE = re.compile(r"\d+(?:\.\d+)*")
@dataclass(frozen=True)
class VersionUpdate:
current: str
latest: str
class VersionCheckCache(BaseModel):
latest_version: str
checked_at: AwareDatetime
class PyPIProjectInfo(BaseModel):
version: str
class PyPIProjectResponse(BaseModel):
info: PyPIProjectInfo
def _parse_simple_release_version(version: str) -> tuple[int, ...] | None:
if not SIMPLE_RELEASE_VERSION_RE.fullmatch(version):
logger.debug("Skipping non-simple version string: %r", version)
return None
return tuple(int(part) for part in version.split("."))
def is_newer_version(latest: str, current: str) -> bool:
latest_parts = _parse_simple_release_version(latest)
current_parts = _parse_simple_release_version(current)
if latest_parts is None or current_parts is None:
return False
return latest_parts > current_parts
def read_cached_latest_version(
cache_path: Path,
*,
ttl: timedelta = VERSION_CHECK_CACHE_TTL,
) -> str | None:
now = datetime.now(timezone.utc)
try:
cache = VersionCheckCache.model_validate_json(
cache_path.read_text(encoding="utf-8")
)
except (OSError, ValidationError) as error:
logger.debug("Could not read CLI version cache: %s", error)
return None
if _parse_simple_release_version(cache.latest_version) is None:
return None
if now - cache.checked_at > ttl:
return None
return cache.latest_version
def write_latest_version_cache(
cache_path: Path,
*,
latest_version: str,
now: datetime | None = None,
) -> None:
now = now or datetime.now(timezone.utc)
data = {
"latest_version": latest_version,
"checked_at": now.isoformat(),
}
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(json.dumps(data), encoding="utf-8")
except OSError as error:
logger.debug("Could not write CLI version cache: %s", error)
def fetch_latest_version() -> str | None:
headers = {"User-Agent": f"fastapi-cloud-cli/{__version__}"}
try:
with httpx.Client(
timeout=httpx.Timeout(VERSION_CHECK_TIMEOUT_SECONDS),
headers=headers,
) as client:
response = client.get(PYPI_JSON_URL)
response.raise_for_status()
data = PyPIProjectResponse.model_validate_json(response.text)
except (httpx.HTTPError, ValidationError) as error:
logger.debug("Could not check latest CLI version: %s", error)
return None
return data.info.version
def check_for_update() -> VersionUpdate | None:
cache_path = get_version_check_cache_path()
if (latest_version := read_cached_latest_version(cache_path)) is None:
if (latest_version := fetch_latest_version()) is None:
return None
write_latest_version_cache(
cache_path,
latest_version=latest_version,
)
if not is_newer_version(latest_version, __version__):
return None
return VersionUpdate(current=__version__, latest=latest_version)
def get_upgrade_command() -> str:
installer_info = detect_installer(PACKAGE_NAME)
if installer_info is None or installer_info.upgrade_cmd is None:
return DEFAULT_UPGRADE_COMMAND
return installer_info.upgrade_cmd
def format_update_message(
update: VersionUpdate,
) -> str:
return (
"A newer FastAPI Cloud CLI version is available: "
f"{update.current} → [bold]{update.latest}[/]\n\n"
f'Run "[blue]{get_upgrade_command()}[/]" to upgrade.'
)
class BackgroundVersionCheck:
def __init__(self) -> None:
self._thread = threading.Thread(target=self._run, daemon=True)
self._update: VersionUpdate | None = None
self._message_returned = False
def start(self) -> None:
self._thread.start()
def _run(self) -> None:
with suppress(Exception):
self._update = check_for_update()
def get_update_message(self) -> str | None:
if self._message_returned:
return None
self._thread.join(timeout=VERSION_CHECK_JOIN_TIMEOUT_SECONDS)
if self._update:
self._message_returned = True
return format_update_message(self._update)
return None