fastapi-cloud-cli 0.24.0__py3-none-any.whl → 0.26.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. fastapi_cloud_cli/__init__.py +1 -1
  2. fastapi_cloud_cli/_app.py +35 -0
  3. fastapi_cloud_cli/api/__init__.py +24 -0
  4. fastapi_cloud_cli/api/_errors.py +136 -0
  5. fastapi_cloud_cli/api/_models.py +176 -0
  6. fastapi_cloud_cli/api/_retry.py +91 -0
  7. fastapi_cloud_cli/api/client.py +344 -0
  8. fastapi_cloud_cli/cli.py +60 -72
  9. fastapi_cloud_cli/commands/_auth.py +41 -0
  10. fastapi_cloud_cli/commands/_flow.py +1 -1
  11. fastapi_cloud_cli/commands/apps/__init__.py +1 -21
  12. fastapi_cloud_cli/commands/apps/_app.py +6 -0
  13. fastapi_cloud_cli/commands/apps/create.py +76 -81
  14. fastapi_cloud_cli/commands/apps/get.py +42 -46
  15. fastapi_cloud_cli/commands/apps/link.py +6 -2
  16. fastapi_cloud_cli/commands/apps/list.py +48 -53
  17. fastapi_cloud_cli/commands/apps/unlink.py +4 -0
  18. fastapi_cloud_cli/commands/apps/update.py +48 -53
  19. fastapi_cloud_cli/commands/auth/__init__.py +1 -12
  20. fastapi_cloud_cli/commands/auth/_app.py +6 -0
  21. fastapi_cloud_cli/commands/auth/wait.py +3 -1
  22. fastapi_cloud_cli/commands/ci/__init__.py +1 -13
  23. fastapi_cloud_cli/commands/ci/_app.py +6 -0
  24. fastapi_cloud_cli/commands/ci/print_workflow.py +2 -0
  25. fastapi_cloud_cli/commands/deploy/cloud.py +1 -1
  26. fastapi_cloud_cli/commands/deploy/command.py +4 -1
  27. fastapi_cloud_cli/commands/deploy/configure.py +1 -1
  28. fastapi_cloud_cli/commands/deploy/upload.py +1 -1
  29. fastapi_cloud_cli/commands/deploy/wait.py +28 -7
  30. fastapi_cloud_cli/commands/deployments.py +173 -152
  31. fastapi_cloud_cli/commands/domains/__init__.py +3 -0
  32. fastapi_cloud_cli/commands/domains/_app.py +6 -0
  33. fastapi_cloud_cli/commands/domains/_setup.py +211 -0
  34. fastapi_cloud_cli/commands/domains/_shared.py +40 -0
  35. fastapi_cloud_cli/commands/domains/add.py +186 -0
  36. fastapi_cloud_cli/commands/domains/get.py +120 -0
  37. fastapi_cloud_cli/commands/domains/list.py +77 -0
  38. fastapi_cloud_cli/commands/domains/remove.py +174 -0
  39. fastapi_cloud_cli/commands/domains/rendering.py +364 -0
  40. fastapi_cloud_cli/commands/domains/restart.py +150 -0
  41. fastapi_cloud_cli/commands/env/__init__.py +1 -15
  42. fastapi_cloud_cli/commands/env/_app.py +6 -0
  43. fastapi_cloud_cli/commands/env/_shared.py +1 -22
  44. fastapi_cloud_cli/commands/env/delete.py +89 -107
  45. fastapi_cloud_cli/commands/env/get.py +60 -68
  46. fastapi_cloud_cli/commands/env/list.py +25 -33
  47. fastapi_cloud_cli/commands/env/set.py +65 -66
  48. fastapi_cloud_cli/commands/integrations/providers/__init__.py +2 -8
  49. fastapi_cloud_cli/commands/integrations/providers/_app.py +6 -0
  50. fastapi_cloud_cli/commands/integrations/providers/list.py +34 -39
  51. fastapi_cloud_cli/commands/integrations/resources/__init__.py +2 -16
  52. fastapi_cloud_cli/commands/integrations/resources/_app.py +6 -0
  53. fastapi_cloud_cli/commands/integrations/resources/connect.py +55 -60
  54. fastapi_cloud_cli/commands/integrations/resources/disconnect.py +116 -122
  55. fastapi_cloud_cli/commands/integrations/resources/get.py +34 -38
  56. fastapi_cloud_cli/commands/integrations/resources/list.py +30 -35
  57. fastapi_cloud_cli/commands/login.py +6 -1
  58. fastapi_cloud_cli/commands/logout.py +2 -0
  59. fastapi_cloud_cli/commands/logs.py +34 -36
  60. fastapi_cloud_cli/commands/setup_ci.py +209 -220
  61. fastapi_cloud_cli/commands/teams/__init__.py +2 -137
  62. fastapi_cloud_cli/commands/teams/_app.py +6 -0
  63. fastapi_cloud_cli/commands/teams/get.py +24 -28
  64. fastapi_cloud_cli/commands/teams/list.py +119 -0
  65. fastapi_cloud_cli/commands/tokens/__init__.py +1 -13
  66. fastapi_cloud_cli/commands/tokens/_app.py +6 -0
  67. fastapi_cloud_cli/commands/tokens/create.py +44 -48
  68. fastapi_cloud_cli/commands/tokens/delete.py +42 -47
  69. fastapi_cloud_cli/commands/tokens/list.py +27 -32
  70. fastapi_cloud_cli/commands/whoami.py +27 -29
  71. fastapi_cloud_cli/utils/build_logs.py +14 -0
  72. fastapi_cloud_cli/utils/cli.py +5 -1
  73. fastapi_cloud_cli/utils/teams.py +1 -1
  74. {fastapi_cloud_cli-0.24.0.dist-info → fastapi_cloud_cli-0.26.0.dist-info}/METADATA +3 -3
  75. fastapi_cloud_cli-0.26.0.dist-info/RECORD +98 -0
  76. fastapi_cloud_cli/utils/api.py +0 -541
  77. fastapi_cloud_cli-0.24.0.dist-info/RECORD +0 -72
  78. {fastapi_cloud_cli-0.24.0.dist-info → fastapi_cloud_cli-0.26.0.dist-info}/WHEEL +0 -0
  79. {fastapi_cloud_cli-0.24.0.dist-info → fastapi_cloud_cli-0.26.0.dist-info}/entry_points.txt +0 -0
  80. {fastapi_cloud_cli-0.24.0.dist-info → fastapi_cloud_cli-0.26.0.dist-info}/licenses/LICENSE +0 -0
@@ -1 +1 @@
1
- __version__ = "0.24.0"
1
+ __version__ = "0.26.0"
@@ -0,0 +1,35 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+ from rich import print
5
+
6
+ from fastapi_cloud_cli import __version__
7
+
8
+ app = typer.Typer(rich_markup_mode="rich")
9
+
10
+
11
+ def version_callback(value: bool) -> None:
12
+ if value:
13
+ print(f"FastAPI Cloud CLI version: [green]{__version__}[/green]")
14
+ raise typer.Exit()
15
+
16
+
17
+ cloud_app = typer.Typer(
18
+ rich_markup_mode="rich",
19
+ help="Manage [bold]FastAPI[/bold] Cloud deployments.",
20
+ no_args_is_help=True,
21
+ )
22
+
23
+
24
+ @cloud_app.callback()
25
+ def cloud_main(
26
+ version: Annotated[
27
+ bool,
28
+ typer.Option(
29
+ "--version",
30
+ callback=version_callback,
31
+ is_eager=True,
32
+ help="Show the version and exit.",
33
+ ),
34
+ ] = False,
35
+ ) -> None: ...
@@ -0,0 +1,24 @@
1
+ from ._errors import StreamLogError as StreamLogError
2
+ from ._errors import TooManyRetriesError as TooManyRetriesError
3
+ from ._errors import _get_response_error_message as _get_response_error_message
4
+ from ._errors import get_http_error_code as get_http_error_code
5
+ from ._errors import get_http_error_hint as get_http_error_hint
6
+ from ._errors import handle_http_error as handle_http_error
7
+ from ._models import BUILD_FAILED_STATUSES as BUILD_FAILED_STATUSES
8
+ from ._models import SUCCESSFUL_STATUSES as SUCCESSFUL_STATUSES
9
+ from ._models import TERMINAL_STATUSES as TERMINAL_STATUSES
10
+ from ._models import AppLogEntry as AppLogEntry
11
+ from ._models import BuildFailure as BuildFailure
12
+ from ._models import BuildLogLineGeneric as BuildLogLineGeneric
13
+ from ._models import BuildLogLineMessage as BuildLogLineMessage
14
+ from ._models import CustomDomain as CustomDomain
15
+ from ._models import CustomDomainRecord as CustomDomainRecord
16
+ from ._models import CustomDomainStatus as CustomDomainStatus
17
+ from ._models import Deployment as Deployment
18
+ from ._models import DeploymentStatus as DeploymentStatus
19
+ from ._models import EnvironmentVariable as EnvironmentVariable
20
+ from ._models import (
21
+ EnvironmentVariableCreatePayload as EnvironmentVariableCreatePayload,
22
+ )
23
+ from ._retry import STREAM_LOGS_MAX_RETRIES as STREAM_LOGS_MAX_RETRIES
24
+ from .client import APIClient as APIClient
@@ -0,0 +1,136 @@
1
+ import json
2
+ import logging
3
+
4
+ import httpx
5
+
6
+ from fastapi_cloud_cli.utils.auth import AuthMode, delete_auth_config
7
+ from fastapi_cloud_cli.utils.errors import ErrorCode
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class StreamLogError(Exception):
13
+ """Raised when there's an error streaming logs (build or app logs)."""
14
+
15
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
16
+ super().__init__(message)
17
+ self.status_code = status_code
18
+
19
+
20
+ class TooManyRetriesError(Exception):
21
+ pass
22
+
23
+
24
+ def _handle_unauthorized(auth_mode: AuthMode) -> str:
25
+ message = "The specified token is not valid. "
26
+
27
+ if auth_mode == "user":
28
+ delete_auth_config()
29
+
30
+ message += "Use `fastapi login` to generate a new token."
31
+ else:
32
+ message += "Make sure to use a valid token."
33
+
34
+ return message
35
+
36
+
37
+ def _get_response_error_message(response: httpx.Response) -> str | None:
38
+ try:
39
+ data = response.json()
40
+ except (json.JSONDecodeError, httpx.ResponseNotRead):
41
+ return None
42
+
43
+ if not isinstance(data, dict):
44
+ return None # pragma: no cover
45
+
46
+ detail = data.get("detail")
47
+ if isinstance(detail, str):
48
+ return detail
49
+
50
+ if (
51
+ isinstance(detail, list)
52
+ and detail
53
+ and isinstance(detail[0], dict)
54
+ and isinstance(message := detail[0].get("msg"), str)
55
+ ):
56
+ return message.removeprefix("Value error, ")
57
+
58
+ return None # pragma: no cover
59
+
60
+
61
+ def handle_http_error(
62
+ error: httpx.HTTPError,
63
+ default_message: str | None = None,
64
+ not_found_message: str | None = None,
65
+ auth_mode: AuthMode = "user",
66
+ ) -> str:
67
+ message: str | None = None
68
+
69
+ if isinstance(error, httpx.HTTPStatusError):
70
+ status_code = error.response.status_code
71
+
72
+ if status_code == 422:
73
+ logger.debug(error.response.json())
74
+ message = _get_response_error_message(error.response)
75
+
76
+ elif status_code == 400:
77
+ message = _get_response_error_message(error.response)
78
+
79
+ elif status_code == 409:
80
+ message = _get_response_error_message(error.response)
81
+
82
+ elif status_code == 401:
83
+ message = _handle_unauthorized(auth_mode=auth_mode)
84
+
85
+ elif status_code == 403:
86
+ message = (
87
+ _get_response_error_message(error.response)
88
+ or "You don't have permissions for this resource"
89
+ )
90
+
91
+ elif status_code == 404:
92
+ message = (
93
+ _get_response_error_message(error.response)
94
+ or not_found_message
95
+ or "Resource not found."
96
+ )
97
+
98
+ if not message:
99
+ message = (
100
+ default_message
101
+ or f"Something went wrong while contacting the FastAPI Cloud server. Please try again later. \n\n{error}"
102
+ )
103
+
104
+ return message
105
+
106
+
107
+ def get_http_error_code(error: httpx.HTTPError) -> ErrorCode:
108
+ if isinstance(error, httpx.TimeoutException | httpx.NetworkError):
109
+ return "network_error"
110
+
111
+ if isinstance(error, httpx.HTTPStatusError):
112
+ status_code = error.response.status_code
113
+
114
+ if status_code in {400, 409, 422}:
115
+ return "invalid_input"
116
+
117
+ if status_code == 401:
118
+ return "invalid_token"
119
+
120
+ if status_code == 403:
121
+ return "permission_denied"
122
+
123
+ if status_code == 404:
124
+ return "not_found"
125
+
126
+ return "api_error"
127
+
128
+
129
+ def get_http_error_hint(code: ErrorCode, *, auth_mode: AuthMode = "user") -> str | None:
130
+ if code == "invalid_token":
131
+ if auth_mode == "user":
132
+ return "Run `fastapi cloud login` to generate a new token."
133
+
134
+ return "Make sure FASTAPI_CLOUD_TOKEN contains a valid token."
135
+
136
+ return None
@@ -0,0 +1,176 @@
1
+ from enum import Enum
2
+ from typing import Annotated, Literal
3
+
4
+ from pydantic import BaseModel, Field, TypeAdapter
5
+
6
+
7
+ class EnvironmentVariable(BaseModel):
8
+ name: str
9
+ value: str | None = None
10
+ is_secret: bool = False
11
+ updated_at: str | None = None
12
+
13
+
14
+ class EnvironmentVariableResponse(BaseModel):
15
+ data: list[EnvironmentVariable]
16
+
17
+
18
+ class EnvironmentVariableCreatePayload(BaseModel):
19
+ value: str
20
+ is_secret: bool = False
21
+
22
+
23
+ class AppLogEntry(BaseModel):
24
+ timestamp: str
25
+ message: str
26
+ level: str
27
+
28
+
29
+ class BuildLogLineGeneric(BaseModel):
30
+ type: Literal["complete", "failed", "timeout", "heartbeat"]
31
+ id: str | None = None
32
+
33
+
34
+ class BuildLogLineMessage(BaseModel):
35
+ type: Literal["message"] = "message"
36
+ message: str
37
+ id: str | None = None
38
+
39
+
40
+ class BuildFailure(BaseModel):
41
+ error_code: str
42
+ error_title: str
43
+ error_message: str
44
+ error_hint: str
45
+
46
+
47
+ BuildLogLine = BuildLogLineMessage | BuildLogLineGeneric
48
+ BuildLogAdapter: TypeAdapter[BuildLogLine] = TypeAdapter(
49
+ Annotated[BuildLogLine, Field(discriminator="type")]
50
+ )
51
+
52
+
53
+ class CustomDomainStatus(str, Enum):
54
+ internal_dcv_pending = "internal_dcv_pending"
55
+ internal_dcv_missing = "internal_dcv_missing"
56
+ internal_dcv_invalid = "internal_dcv_invalid"
57
+ internal_dcv_timeout = "internal_dcv_timeout"
58
+ internal_dcv_revoked = "internal_dcv_revoked"
59
+ external_dcv_pending = "external_dcv_pending"
60
+ external_dcv_proxied = "external_dcv_proxied"
61
+ external_dcv_secured = "external_dcv_secured"
62
+ external_dcv_blocked = "external_dcv_blocked"
63
+ external_dcv_timeout = "external_dcv_timeout"
64
+ origin_setup_pending = "origin_setup_pending"
65
+ origin_setup_missing = "origin_setup_missing"
66
+ origin_setup_invalid = "origin_setup_invalid"
67
+ origin_setup_timeout = "origin_setup_timeout"
68
+ origin_setup_success = "origin_setup_success"
69
+ origin_setup_removed = "origin_setup_removed"
70
+
71
+
72
+ class CustomDomainRecord(BaseModel):
73
+ type: Literal["TXT", "CNAME", "A"]
74
+ name: str | None
75
+ value: str | None
76
+
77
+
78
+ class CustomDomain(BaseModel):
79
+ id: str
80
+ name: str
81
+ status: CustomDomainStatus
82
+ setup_in_progress: bool
83
+ setup_failed: bool
84
+ setup_successful: bool
85
+ is_using_pre_validation: bool
86
+ dns_records: list[CustomDomainRecord]
87
+ created_at: str
88
+ updated_at: str
89
+ setup_started_at: str | None
90
+ setup_checked_at: str | None
91
+ app_id: str
92
+
93
+
94
+ class CustomDomainsAPIResponse(BaseModel):
95
+ data: list[CustomDomain]
96
+ count: int
97
+
98
+
99
+ class DeploymentStatus(str, Enum):
100
+ waiting_upload = "waiting_upload"
101
+ upload_cancelled = "upload_cancelled"
102
+ ready_for_build = "ready_for_build"
103
+ building = "building"
104
+ extracting = "extracting"
105
+ extracting_failed = "extracting_failed"
106
+ extracting_failed_archive_too_large = "extracting_failed_archive_too_large"
107
+ building_image = "building_image"
108
+ building_image_failed = "building_image_failed"
109
+ building_image_failed_timeout = "building_image_failed_timeout"
110
+ deploying = "deploying"
111
+ deploying_skipped = "deploying_skipped"
112
+ deploying_failed = "deploying_failed"
113
+ verifying = "verifying"
114
+ verifying_failed = "verifying_failed"
115
+ verification_failed_oom = "verification_failed_oom"
116
+ verifying_skipped = "verifying_skipped"
117
+ success = "success"
118
+ degraded_oom = "degraded_oom"
119
+ degraded = "degraded"
120
+ expired = "expired"
121
+ failed = "failed"
122
+
123
+ @classmethod
124
+ def to_human_readable(cls, status: "DeploymentStatus") -> str:
125
+ return {
126
+ cls.waiting_upload: "Awaiting Upload",
127
+ cls.upload_cancelled: "Upload Cancelled",
128
+ cls.ready_for_build: "Build Queued",
129
+ cls.building: "Building",
130
+ cls.extracting: "Extracting Upload",
131
+ cls.extracting_failed: "Extraction Failed",
132
+ cls.extracting_failed_archive_too_large: "Archive Too Large",
133
+ cls.building_image: "Building Image",
134
+ cls.building_image_failed: "Build Failed",
135
+ cls.building_image_failed_timeout: "Build Timed Out",
136
+ cls.deploying: "Deploying Image",
137
+ cls.deploying_skipped: "Deployment Skipped",
138
+ cls.deploying_failed: "Deployment Failed",
139
+ cls.verifying: "Verifying Readiness",
140
+ cls.verifying_failed: "Verification Failed",
141
+ cls.verification_failed_oom: "Verification Failed (OOM)",
142
+ cls.verifying_skipped: "Verification Skipped",
143
+ cls.success: "Ready",
144
+ cls.degraded_oom: "Degraded (OOM)",
145
+ cls.degraded: "Degraded",
146
+ cls.expired: "Expired",
147
+ cls.failed: "Failed",
148
+ }[status]
149
+
150
+
151
+ class Deployment(BaseModel):
152
+ id: str
153
+ app_id: str
154
+ slug: str
155
+ status: DeploymentStatus
156
+ created_at: str
157
+ url: str | None = None
158
+ dashboard_url: str | None = None
159
+ failure: BuildFailure | None = None
160
+
161
+
162
+ SUCCESSFUL_STATUSES = {DeploymentStatus.success, DeploymentStatus.verifying_skipped}
163
+ BUILD_FAILED_STATUSES = {
164
+ DeploymentStatus.building_image_failed,
165
+ DeploymentStatus.building_image_failed_timeout,
166
+ DeploymentStatus.extracting_failed,
167
+ DeploymentStatus.extracting_failed_archive_too_large,
168
+ }
169
+ FAILED_STATUSES = BUILD_FAILED_STATUSES | {
170
+ DeploymentStatus.failed,
171
+ DeploymentStatus.verifying_failed,
172
+ DeploymentStatus.verification_failed_oom,
173
+ DeploymentStatus.deploying_failed,
174
+ DeploymentStatus.deploying_skipped,
175
+ }
176
+ TERMINAL_STATUSES = SUCCESSFUL_STATUSES | FAILED_STATUSES
@@ -0,0 +1,91 @@
1
+ import logging
2
+ import time
3
+ from collections.abc import Callable, Generator
4
+ from contextlib import contextmanager
5
+ from datetime import timedelta
6
+ from functools import wraps
7
+ from typing import TypeVar
8
+
9
+ import httpx
10
+ from typing_extensions import ParamSpec
11
+
12
+ from ._errors import StreamLogError, TooManyRetriesError
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ STREAM_LOGS_MAX_RETRIES = 3
17
+ STREAM_LOGS_TIMEOUT = timedelta(minutes=5)
18
+
19
+
20
+ @contextmanager
21
+ def attempt(attempt_number: int) -> Generator[None, None, None]:
22
+ def _backoff() -> None:
23
+ backoff_seconds = min(2**attempt_number, 30)
24
+ logger.debug(
25
+ "Retrying in %ds (attempt %d)",
26
+ backoff_seconds,
27
+ attempt_number,
28
+ )
29
+ time.sleep(backoff_seconds)
30
+
31
+ try:
32
+ yield
33
+
34
+ except (
35
+ httpx.TimeoutException,
36
+ httpx.NetworkError,
37
+ httpx.RemoteProtocolError,
38
+ ) as error:
39
+ logger.debug("Network error (will retry): %s", error)
40
+
41
+ _backoff()
42
+
43
+ except httpx.HTTPStatusError as error:
44
+ if error.response.status_code >= 500:
45
+ logger.debug(
46
+ "Server error %d (will retry): %s",
47
+ error.response.status_code,
48
+ error,
49
+ )
50
+ _backoff()
51
+ else:
52
+ # The streaming callers read the body before raising, so the
53
+ # server's error detail is available here.
54
+ raise StreamLogError(
55
+ f"HTTP {error.response.status_code}: {error.response.text}",
56
+ status_code=error.response.status_code,
57
+ ) from error
58
+
59
+
60
+ P = ParamSpec("P")
61
+ T = TypeVar("T")
62
+
63
+
64
+ def attempts(
65
+ total_attempts: int = 3, timeout: timedelta = timedelta(minutes=5)
66
+ ) -> Callable[
67
+ [Callable[P, Generator[T, None, None]]], Callable[P, Generator[T, None, None]]
68
+ ]:
69
+ def decorator(
70
+ func: Callable[P, Generator[T, None, None]],
71
+ ) -> Callable[P, Generator[T, None, None]]:
72
+ @wraps(func)
73
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Generator[T, None, None]:
74
+ start = time.monotonic()
75
+
76
+ for attempt_number in range(total_attempts):
77
+ if time.monotonic() - start > timeout.total_seconds():
78
+ raise TimeoutError(
79
+ f"Log streaming timed out after {timeout.total_seconds():.0f}s"
80
+ )
81
+
82
+ with attempt(attempt_number):
83
+ yield from func(*args, **kwargs)
84
+ # If we get here without exception, the generator completed successfully
85
+ return
86
+
87
+ raise TooManyRetriesError(f"Failed after {total_attempts} attempts")
88
+
89
+ return wrapper
90
+
91
+ return decorator