fix: flatten React Router structure — no nested Routes, each page is a top-level route
This commit is contained in:
@@ -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")
|
||||
+23
@@ -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,
|
||||
)
|
||||
+14
@@ -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"]
|
||||
+45
@@ -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)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from .command import deploy
|
||||
|
||||
__all__ = ["deploy"]
|
||||
+115
@@ -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]
|
||||
+370
@@ -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]"
|
||||
)
|
||||
+161
@@ -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
|
||||
+93
@@ -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)
|
||||
+17
@@ -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"]
|
||||
+53
@@ -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)
|
||||
+194
@@ -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,
|
||||
)
|
||||
+139
@@ -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,
|
||||
)
|
||||
+113
@@ -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,
|
||||
)
|
||||
+215
@@ -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)
|
||||
+138
@@ -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)
|
||||
+15
@@ -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"]
|
||||
+182
@@ -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,
|
||||
)
|
||||
+102
@@ -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
|
||||
Reference in New Issue
Block a user