fastapi-cloud-cli 0.25.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.
@@ -1 +1 @@
1
- __version__ = "0.25.0"
1
+ __version__ = "0.26.0"
@@ -4,12 +4,21 @@ from ._errors import _get_response_error_message as _get_response_error_message
4
4
  from ._errors import get_http_error_code as get_http_error_code
5
5
  from ._errors import get_http_error_hint as get_http_error_hint
6
6
  from ._errors import handle_http_error as handle_http_error
7
+ from ._models import BUILD_FAILED_STATUSES as BUILD_FAILED_STATUSES
7
8
  from ._models import SUCCESSFUL_STATUSES as SUCCESSFUL_STATUSES
9
+ from ._models import TERMINAL_STATUSES as TERMINAL_STATUSES
8
10
  from ._models import AppLogEntry as AppLogEntry
11
+ from ._models import BuildFailure as BuildFailure
12
+ from ._models import BuildLogLineGeneric as BuildLogLineGeneric
9
13
  from ._models import BuildLogLineMessage as BuildLogLineMessage
10
14
  from ._models import CustomDomain as CustomDomain
11
15
  from ._models import CustomDomainRecord as CustomDomainRecord
12
16
  from ._models import CustomDomainStatus as CustomDomainStatus
17
+ from ._models import Deployment as Deployment
13
18
  from ._models import DeploymentStatus as DeploymentStatus
19
+ from ._models import EnvironmentVariable as EnvironmentVariable
20
+ from ._models import (
21
+ EnvironmentVariableCreatePayload as EnvironmentVariableCreatePayload,
22
+ )
14
23
  from ._retry import STREAM_LOGS_MAX_RETRIES as STREAM_LOGS_MAX_RETRIES
15
24
  from .client import APIClient as APIClient
@@ -4,6 +4,22 @@ from typing import Annotated, Literal
4
4
  from pydantic import BaseModel, Field, TypeAdapter
5
5
 
6
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
+
7
23
  class AppLogEntry(BaseModel):
8
24
  timestamp: str
9
25
  message: str
@@ -21,6 +37,13 @@ class BuildLogLineMessage(BaseModel):
21
37
  id: str | None = None
22
38
 
23
39
 
40
+ class BuildFailure(BaseModel):
41
+ error_code: str
42
+ error_title: str
43
+ error_message: str
44
+ error_hint: str
45
+
46
+
24
47
  BuildLogLine = BuildLogLineMessage | BuildLogLineGeneric
25
48
  BuildLogAdapter: TypeAdapter[BuildLogLine] = TypeAdapter(
26
49
  Annotated[BuildLogLine, Field(discriminator="type")]
@@ -125,16 +148,29 @@ class DeploymentStatus(str, Enum):
125
148
  }[status]
126
149
 
127
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
+
128
162
  SUCCESSFUL_STATUSES = {DeploymentStatus.success, DeploymentStatus.verifying_skipped}
129
- FAILED_STATUSES = {
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 | {
130
170
  DeploymentStatus.failed,
131
171
  DeploymentStatus.verifying_failed,
132
172
  DeploymentStatus.verification_failed_oom,
133
173
  DeploymentStatus.deploying_failed,
134
174
  DeploymentStatus.deploying_skipped,
135
- DeploymentStatus.building_image_failed,
136
- DeploymentStatus.building_image_failed_timeout,
137
- DeploymentStatus.extracting_failed,
138
- DeploymentStatus.extracting_failed_archive_too_large,
139
175
  }
140
176
  TERMINAL_STATUSES = SUCCESSFUL_STATUSES | FAILED_STATUSES
@@ -30,7 +30,10 @@ from ._models import (
30
30
  BuildLogLine,
31
31
  CustomDomain,
32
32
  CustomDomainsAPIResponse,
33
+ Deployment,
33
34
  DeploymentStatus,
35
+ EnvironmentVariableCreatePayload,
36
+ EnvironmentVariableResponse,
34
37
  )
35
38
  from ._retry import (
36
39
  STREAM_LOGS_MAX_RETRIES,
@@ -136,6 +139,39 @@ class APIClient(httpx.Client):
136
139
 
137
140
  raise typer.Exit(1) from None
138
141
 
142
+ def get_deployment(self, deployment_id: str) -> Deployment:
143
+ response = self.get(f"/deployments/{deployment_id}")
144
+ response.raise_for_status()
145
+ return Deployment.model_validate(response.json())
146
+
147
+ def get_environment_variables(self, *, app_id: str) -> EnvironmentVariableResponse:
148
+ response = self.get(f"/apps/{app_id}/environment-variables/")
149
+ response.raise_for_status()
150
+
151
+ return EnvironmentVariableResponse.model_validate(response.json())
152
+
153
+ def batch_environment_variables(
154
+ self,
155
+ *,
156
+ app_id: str,
157
+ upsert: dict[str, EnvironmentVariableCreatePayload],
158
+ delete: list[str],
159
+ redeploy: bool = True,
160
+ ) -> EnvironmentVariableResponse:
161
+ response = self.put(
162
+ f"/apps/{app_id}/environment-variables/",
163
+ json={
164
+ "upsert": {
165
+ name: payload.model_dump() for name, payload in upsert.items()
166
+ },
167
+ "delete": delete,
168
+ "redeploy": redeploy,
169
+ },
170
+ )
171
+ response.raise_for_status()
172
+
173
+ return EnvironmentVariableResponse.model_validate(response.json())
174
+
139
175
  def get_custom_domains(self, *, app_id: str) -> CustomDomainsAPIResponse:
140
176
  response = self.get(f"/apps/{app_id}/custom-domains")
141
177
  response.raise_for_status()
@@ -1,19 +1,25 @@
1
+ import contextlib
1
2
  import time
2
3
  from itertools import cycle
3
4
  from textwrap import dedent
4
5
 
5
6
  import typer
7
+ from httpx import HTTPError
6
8
  from rich.text import Text
7
9
  from rich_toolkit import RichToolkit
8
10
 
9
11
  from fastapi_cloud_cli.api import (
10
12
  SUCCESSFUL_STATUSES,
11
13
  APIClient,
14
+ BuildFailure,
12
15
  DeploymentStatus,
13
16
  StreamLogError,
14
17
  TooManyRetriesError,
15
18
  )
16
19
  from fastapi_cloud_cli.commands.deploy.cloud import CreateDeploymentResponse
20
+ from fastapi_cloud_cli.utils.build_logs import print_build_error
21
+ from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit
22
+ from fastapi_cloud_cli.utils.execution import is_ci_enabled
17
23
 
18
24
  # (bullet emoji, message) — the emoji replaces the progress animation
19
25
  WAITING_MESSAGES = [
@@ -89,7 +95,7 @@ def _verify_deployment(
89
95
 
90
96
 
91
97
  def _wait_for_deployment(
92
- toolkit: RichToolkit,
98
+ toolkit: FastAPIRichToolkit,
93
99
  client: APIClient,
94
100
  app_id: str,
95
101
  deployment: CreateDeploymentResponse,
@@ -107,12 +113,15 @@ def _wait_for_deployment(
107
113
  "Checking the status of your deployment",
108
114
  inline_logs=True,
109
115
  lines_to_show=20,
116
+ # Keep the log panel replaceable by a diagnosis, but retain CI logs.
117
+ preserve_logs=is_ci_enabled(),
110
118
  emoji="👀",
111
119
  done_emoji="🚀",
112
120
  ) as progress,
113
121
  ):
114
122
  build_complete = False
115
123
  build_failed = False
124
+ build_error: BuildFailure | None = None
116
125
 
117
126
  try:
118
127
  for log in client.stream_build_logs(deployment.id):
@@ -128,6 +137,12 @@ def _wait_for_deployment(
128
137
 
129
138
  if log.type == "failed":
130
139
  build_failed = True
140
+ build_error = None
141
+
142
+ with contextlib.suppress(HTTPError):
143
+ build_error = client.get_deployment(deployment.id).failure
144
+
145
+ progress.transient = build_error is not None
131
146
  # the headline comes from the title once there are log
132
147
  # lines, and from current_message when there are none
133
148
  progress.title = "Build failed"
@@ -157,10 +172,16 @@ def _wait_for_deployment(
157
172
  raise typer.Exit(1) from None
158
173
 
159
174
  if build_failed:
160
- toolkit.print_line()
175
+ if build_error is not None:
176
+ print_build_error(toolkit, build_error)
177
+ toolkit.print_line()
178
+ message = "Check out the logs at"
179
+ else:
180
+ toolkit.print_line()
181
+ message = "Oh no! Something went wrong. Check out the logs at"
161
182
  toolkit.print(
162
- f"Oh no! Something went wrong. Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]",
163
- emoji="😔",
183
+ f"{message} [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]",
184
+ emoji="👀" if build_error is not None else "😔",
164
185
  )
165
186
  raise typer.Exit(1)
166
187
 
@@ -5,14 +5,19 @@ from typing import Annotated, Any
5
5
  import typer
6
6
  from httpx import HTTPError
7
7
  from pydantic import BaseModel
8
+ from rich.markup import escape
8
9
  from rich.table import Table
9
10
  from rich.text import Text
10
11
  from rich_toolkit import RichToolkit
11
12
 
12
13
  from fastapi_cloud_cli.api import (
14
+ BUILD_FAILED_STATUSES,
15
+ TERMINAL_STATUSES,
13
16
  APIClient,
17
+ BuildFailure,
18
+ BuildLogLineGeneric,
14
19
  BuildLogLineMessage,
15
- DeploymentStatus,
20
+ Deployment,
16
21
  StreamLogError,
17
22
  TooManyRetriesError,
18
23
  get_http_error_code,
@@ -35,16 +40,6 @@ DEFAULT_LIMIT = 100
35
40
  DEFAULT_OFFSET = 0
36
41
 
37
42
 
38
- class Deployment(BaseModel):
39
- id: str
40
- app_id: str
41
- slug: str
42
- status: DeploymentStatus
43
- created_at: str
44
- url: str | None = None
45
- dashboard_url: str | None = None
46
-
47
-
48
43
  class DeploymentsListAPIResponse(BaseModel):
49
44
  data: list[Deployment]
50
45
  count: int
@@ -70,6 +65,7 @@ class BuildLogsOutput(BaseModel):
70
65
  deployment_id: str
71
66
  failed: bool
72
67
  logs: list[BuildLogOutput]
68
+ failure: BuildFailure | None = None
73
69
 
74
70
 
75
71
  def _get_deployments(
@@ -94,13 +90,6 @@ def _get_deployments(
94
90
  )
95
91
 
96
92
 
97
- def _get_deployment(client: APIClient, *, deployment_id: str) -> DeploymentGetOutput:
98
- response = client.get(f"/deployments/{deployment_id}")
99
- response.raise_for_status()
100
-
101
- return DeploymentGetOutput(deployment=Deployment.model_validate(response.json()))
102
-
103
-
104
93
  def _render_deployments_list_output(
105
94
  data: DeploymentsListOutput, toolkit: RichToolkit
106
95
  ) -> None:
@@ -129,7 +118,7 @@ def _render_deployments_list_output(
129
118
 
130
119
 
131
120
  def _render_deployment_get_output(
132
- data: DeploymentGetOutput, toolkit: RichToolkit
121
+ data: DeploymentGetOutput, toolkit: FastAPIRichToolkit
133
122
  ) -> None:
134
123
  deployment = data.deployment
135
124
 
@@ -164,6 +153,23 @@ def _render_deployment_get_output(
164
153
  )
165
154
  )
166
155
 
156
+ if deployment.failure is not None:
157
+ toolkit.print_line()
158
+ _print_deployment_failure(toolkit, deployment.failure)
159
+
160
+
161
+ def _print_deployment_failure(
162
+ toolkit: FastAPIRichToolkit, failure: BuildFailure
163
+ ) -> None:
164
+ toolkit.print("This deployment failed with the following error:", emoji="🚨")
165
+ toolkit.print_line()
166
+ toolkit.print(Text(failure.error_title, style="bold"))
167
+ toolkit.print_line()
168
+ toolkit.print(Text(failure.error_message))
169
+ if failure.error_hint:
170
+ toolkit.print_line()
171
+ toolkit.print_hint(escape(failure.error_hint))
172
+
167
173
 
168
174
  def _print_build_log_json(
169
175
  deployment_id: str,
@@ -171,12 +177,14 @@ def _print_build_log_json(
171
177
  *,
172
178
  log_id: str | None,
173
179
  message: str | None = None,
180
+ failure: BuildFailure | None = None,
174
181
  ) -> None:
175
182
  record = {
176
183
  "type": record_type,
177
184
  "deployment_id": deployment_id,
178
185
  "id": log_id,
179
186
  "message": message,
187
+ "failure": failure.model_dump(mode="json") if failure is not None else None,
180
188
  }
181
189
 
182
190
  typer.echo(
@@ -191,22 +199,31 @@ BUILD_LOG_BULLET = "[dim]▕[/dim]"
191
199
 
192
200
 
193
201
  def _print_build_log_line(toolkit: RichToolkit, message: str) -> None:
194
- toolkit.print(Text.from_ansi(message.rstrip()), emoji=BUILD_LOG_BULLET)
202
+ text = Text.from_ansi(message.rstrip())
203
+
204
+ if not text.plain.strip():
205
+ # Keep the log marker when the style would otherwise omit an empty line.
206
+ text = Text("\u200b")
207
+
208
+ toolkit.print(text, emoji=BUILD_LOG_BULLET)
195
209
 
196
210
 
197
211
  def _render_build_logs_output(
198
212
  data: BuildLogsOutput, toolkit: FastAPIRichToolkit
199
213
  ) -> None:
200
- if not data.logs:
201
- toolkit.print("No build logs found.")
202
- return
203
-
204
214
  for log in data.logs:
205
215
  _print_build_log_line(toolkit, log.message)
206
216
 
207
- if data.failed:
217
+ if data.failure:
218
+ if data.logs:
219
+ toolkit.print_line()
220
+
221
+ _print_deployment_failure(toolkit, data.failure)
222
+ elif data.failed:
208
223
  toolkit.print_line()
209
224
  toolkit.print_error("Build failed.")
225
+ elif not data.logs:
226
+ toolkit.print("No build logs found.")
210
227
 
211
228
 
212
229
  def _stream_build_logs(
@@ -214,9 +231,12 @@ def _stream_build_logs(
214
231
  client: APIClient,
215
232
  deployment_id: str,
216
233
  ) -> bool:
217
- failed = False
234
+ deployment = client.get_deployment(deployment_id)
235
+ terminal_log: BuildLogLineGeneric | None = None
218
236
 
219
- for log in client.stream_build_logs(deployment_id, follow=True):
237
+ for log in client.stream_build_logs(
238
+ deployment_id, follow=deployment.status not in TERMINAL_STATUSES
239
+ ):
220
240
  if isinstance(log, BuildLogLineMessage):
221
241
  if toolkit.mode == "json":
222
242
  _print_build_log_json(
@@ -227,26 +247,30 @@ def _stream_build_logs(
227
247
  )
228
248
  else:
229
249
  _print_build_log_line(toolkit, log.message)
230
-
231
- elif log.type == "complete":
232
- if toolkit.mode == "json":
233
- _print_build_log_json(
234
- deployment_id,
235
- "complete",
236
- log_id=log.id,
237
- )
238
-
239
- elif log.type == "failed":
240
- failed = True
241
- if toolkit.mode == "json":
242
- _print_build_log_json(
243
- deployment_id,
244
- "failed",
245
- log_id=log.id,
246
- )
247
- else:
248
- toolkit.print_line()
249
- toolkit.print_error("Build failed.")
250
+ else:
251
+ terminal_log = log
252
+
253
+ if deployment.status not in TERMINAL_STATUSES:
254
+ deployment = client.get_deployment(deployment_id)
255
+
256
+ failed = (
257
+ terminal_log is not None and terminal_log.type == "failed"
258
+ ) or deployment.status in BUILD_FAILED_STATUSES
259
+
260
+ if toolkit.mode == "json":
261
+ if failed or terminal_log is not None or deployment.status in TERMINAL_STATUSES:
262
+ _print_build_log_json(
263
+ deployment_id,
264
+ "failed" if failed else "complete",
265
+ log_id=terminal_log.id if terminal_log is not None else None,
266
+ failure=deployment.failure,
267
+ )
268
+ elif deployment.failure is not None:
269
+ toolkit.print_line()
270
+ _print_deployment_failure(toolkit, deployment.failure)
271
+ elif failed:
272
+ toolkit.print_line()
273
+ toolkit.print_error("Build failed.")
250
274
 
251
275
  return failed
252
276
 
@@ -262,7 +286,13 @@ def _fetch_build_logs(client: APIClient, deployment_id: str) -> BuildLogsOutput:
262
286
  elif log.type == "failed":
263
287
  failed = True
264
288
 
265
- return BuildLogsOutput(deployment_id=deployment_id, failed=failed, logs=logs)
289
+ deployment = client.get_deployment(deployment_id)
290
+ return BuildLogsOutput(
291
+ deployment_id=deployment_id,
292
+ failed=failed or deployment.status in BUILD_FAILED_STATUSES,
293
+ logs=logs,
294
+ failure=deployment.failure,
295
+ )
266
296
 
267
297
 
268
298
  def _handle_build_log_error(
@@ -338,22 +368,24 @@ def get_deployment(
338
368
  resolve_app_id_or_fail(toolkit, app_id=app_id)
339
369
 
340
370
  with APIClient() as client:
341
- with toolkit.progress(
342
- title="Fetching deployment",
343
- transient=True,
344
- ) as progress:
345
- with client.handle_http_errors(
371
+ with (
372
+ toolkit.progress(
373
+ title="Fetching deployment",
374
+ transient=True,
375
+ ) as progress,
376
+ client.handle_http_errors(
346
377
  progress,
347
378
  default_message="Error fetching deployment. Please try again later.",
348
379
  not_found_message="Deployment not found.",
349
380
  toolkit=toolkit,
350
- ):
351
- result = _get_deployment(
352
- client,
353
- deployment_id=deployment_id,
354
- )
381
+ ),
382
+ ):
383
+ deployment = client.get_deployment(deployment_id)
355
384
 
356
- toolkit.success(result, render_output=_render_deployment_get_output)
385
+ toolkit.success(
386
+ DeploymentGetOutput(deployment=deployment),
387
+ render_output=_render_deployment_get_output,
388
+ )
357
389
 
358
390
 
359
391
  @deployments_app.command("build-logs", cls=UserCommand)
@@ -407,6 +439,14 @@ def build_logs(
407
439
  return
408
440
  except StreamLogError as e:
409
441
  _handle_build_log_error(toolkit, e)
442
+ except HTTPError as e:
443
+ code = get_http_error_code(e)
444
+ toolkit.fail(
445
+ code,
446
+ handle_http_error(e, not_found_message="Deployment not found."),
447
+ hint=get_http_error_hint(code),
448
+ render_output=_render_build_log_error,
449
+ )
410
450
 
411
451
  except (TooManyRetriesError, TimeoutError):
412
452
  message = "Lost connection to build log stream. Please try again later."
@@ -1,31 +1,10 @@
1
- from pydantic import BaseModel
2
1
  from rich.text import Text
3
2
 
4
- from fastapi_cloud_cli.api import APIClient
3
+ from fastapi_cloud_cli.api import EnvironmentVariable
5
4
 
6
5
  ENV_VAR_VALUE_MAX_LENGTH = 40
7
6
 
8
7
 
9
- class EnvironmentVariable(BaseModel):
10
- name: str
11
- value: str | None = None
12
- is_secret: bool = False
13
- updated_at: str | None = None
14
-
15
-
16
- class EnvironmentVariableResponse(BaseModel):
17
- data: list[EnvironmentVariable]
18
-
19
-
20
- def _get_environment_variables(
21
- client: APIClient, app_id: str
22
- ) -> EnvironmentVariableResponse:
23
- response = client.get(f"/apps/{app_id}/environment-variables/")
24
- response.raise_for_status()
25
-
26
- return EnvironmentVariableResponse.model_validate(response.json())
27
-
28
-
29
8
  def _find_environment_variable(
30
9
  environment_variables: list[EnvironmentVariable], name: str
31
10
  ) -> EnvironmentVariable | None:
@@ -9,7 +9,6 @@ from rich_toolkit.menu import Option
9
9
  from fastapi_cloud_cli.api import APIClient
10
10
  from fastapi_cloud_cli.commands._auth import UserCommand, get_user_command_context
11
11
  from fastapi_cloud_cli.commands.env._app import env_app
12
- from fastapi_cloud_cli.commands.env._shared import _get_environment_variables
13
12
  from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
14
13
  from fastapi_cloud_cli.utils.env import validate_environment_variable_name
15
14
  from fastapi_cloud_cli.utils.execution import JsonOutputOption
@@ -32,17 +31,6 @@ def _render_environment_variable_delete_output(
32
31
  toolkit.print(f"Environment variable [bold]{data.name}[/] deleted.", bullet=False)
33
32
 
34
33
 
35
- def _delete_environment_variable(client: APIClient, app_id: str, name: str) -> bool:
36
- response = client.delete(f"/apps/{app_id}/environment-variables/{name}")
37
-
38
- if response.status_code == 404:
39
- return False
40
-
41
- response.raise_for_status()
42
-
43
- return True
44
-
45
-
46
34
  @env_app.command(cls=UserCommand)
47
35
  def delete(
48
36
  ctx: typer.Context,
@@ -84,10 +72,19 @@ def delete(
84
72
  help="Confirm deletion without prompting.",
85
73
  ),
86
74
  ] = False,
75
+ no_redeploy: Annotated[
76
+ bool,
77
+ typer.Option(
78
+ "--no-redeploy",
79
+ help="Delete the environment variable without redeploying the app.",
80
+ ),
81
+ ] = False,
87
82
  json_output: JsonOutputOption = False,
88
83
  ) -> Any:
89
84
  """
90
- Delete an environment variable from the app.
85
+ Delete an environment variable and redeploy the app by default.
86
+
87
+ Succeeds even if the variable is already absent.
91
88
  """
92
89
 
93
90
  toolkit = get_user_command_context(ctx).toolkit
@@ -109,9 +106,9 @@ def delete(
109
106
  with toolkit.progress(
110
107
  "Fetching environment variables...", transient=True
111
108
  ) as progress:
112
- with client.handle_http_errors(progress):
113
- environment_variables = _get_environment_variables(
114
- client=client, app_id=target_app_id
109
+ with client.handle_http_errors(progress, toolkit=toolkit):
110
+ environment_variables = client.get_environment_variables(
111
+ app_id=target_app_id
115
112
  )
116
113
 
117
114
  toolkit.print_title("environment variables")
@@ -163,23 +160,14 @@ def delete(
163
160
  with toolkit.progress(
164
161
  "Deleting environment variable", transient=True
165
162
  ) as progress:
166
- with client.handle_http_errors(progress):
167
- deleted = _delete_environment_variable(
168
- client=client, app_id=target_app_id, name=name
163
+ with client.handle_http_errors(progress, toolkit=toolkit):
164
+ client.batch_environment_variables(
165
+ app_id=target_app_id,
166
+ upsert={},
167
+ delete=[name],
168
+ redeploy=not no_redeploy,
169
169
  )
170
170
 
171
- if not deleted:
172
- message = (
173
- f"Environment variable {name} not found."
174
- if toolkit.mode == "json"
175
- else "Environment variable not found."
176
- )
177
- toolkit.fail(
178
- "not_found",
179
- message,
180
- hint="Run `fastapi cloud env list` to see available variables.",
181
- )
182
-
183
171
  toolkit.success(
184
172
  EnvironmentVariableDeleteOutput(
185
173
  app_id=target_app_id, name=name, show_tag=name_provided
@@ -7,14 +7,12 @@ from rich.table import Table
7
7
  from rich_toolkit import RichToolkit
8
8
  from rich_toolkit.menu import Option
9
9
 
10
- from fastapi_cloud_cli.api import APIClient
10
+ from fastapi_cloud_cli.api import APIClient, EnvironmentVariable
11
11
  from fastapi_cloud_cli.commands._auth import UserCommand, get_user_command_context
12
12
  from fastapi_cloud_cli.commands.env._app import env_app
13
13
  from fastapi_cloud_cli.commands.env._shared import (
14
- EnvironmentVariable,
15
14
  _find_environment_variable,
16
15
  _format_env_var_value,
17
- _get_environment_variables,
18
16
  )
19
17
  from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
20
18
  from fastapi_cloud_cli.utils.execution import JsonOutputOption
@@ -92,8 +90,8 @@ def get_variable(
92
90
  "Fetching environment variables...", transient=True
93
91
  ) as progress:
94
92
  with client.handle_http_errors(progress):
95
- environment_variables = _get_environment_variables(
96
- client=client, app_id=target_app_id
93
+ environment_variables = client.get_environment_variables(
94
+ app_id=target_app_id
97
95
  )
98
96
 
99
97
  if name is None:
@@ -7,14 +7,12 @@ from rich.table import Table
7
7
  from rich.text import Text
8
8
  from rich_toolkit import RichToolkit
9
9
 
10
- from fastapi_cloud_cli.api import APIClient
10
+ from fastapi_cloud_cli.api import APIClient, EnvironmentVariable
11
11
  from fastapi_cloud_cli.commands._auth import UserCommand, get_user_command_context
12
12
  from fastapi_cloud_cli.commands.env._app import env_app
13
13
  from fastapi_cloud_cli.commands.env._shared import (
14
14
  ENV_VAR_VALUE_MAX_LENGTH,
15
- EnvironmentVariable,
16
15
  _format_env_var_value,
17
- _get_environment_variables,
18
16
  )
19
17
  from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
20
18
  from fastapi_cloud_cli.utils.dates import format_last_updated
@@ -94,8 +92,8 @@ def list_variables(
94
92
  "Fetching environment variables...", transient=True
95
93
  ) as progress:
96
94
  with client.handle_http_errors(progress):
97
- environment_variables = _get_environment_variables(
98
- client=client, app_id=target_app_id
95
+ environment_variables = client.get_environment_variables(
96
+ app_id=target_app_id
99
97
  )
100
98
 
101
99
  toolkit.success(
@@ -6,7 +6,7 @@ import typer
6
6
  from pydantic import BaseModel, Field
7
7
  from rich_toolkit import RichToolkit
8
8
 
9
- from fastapi_cloud_cli.api import APIClient
9
+ from fastapi_cloud_cli.api import APIClient, EnvironmentVariableCreatePayload
10
10
  from fastapi_cloud_cli.commands._auth import UserCommand, get_user_command_context
11
11
  from fastapi_cloud_cli.commands.env._app import env_app
12
12
  from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail
@@ -95,16 +95,6 @@ def _resolve_environment_variable_value(
95
95
  return _input(toolkit, "Enter the value of the environment variable:")
96
96
 
97
97
 
98
- def _set_environment_variable(
99
- client: APIClient, app_id: str, name: str, value: str, is_secret: bool = False
100
- ) -> None:
101
- response = client.post(
102
- f"/apps/{app_id}/environment-variables/",
103
- json={"name": name, "value": value, "is_secret": is_secret},
104
- )
105
- response.raise_for_status()
106
-
107
-
108
98
  @env_app.command(cls=UserCommand)
109
99
  def set(
110
100
  ctx: typer.Context,
@@ -153,13 +143,20 @@ def set(
153
143
  bool,
154
144
  typer.Option(
155
145
  "--secret",
156
- help="Mark the environment variable as secret",
146
+ help="Mark a new environment variable as secret. Existing variables keep their secret status.",
147
+ ),
148
+ ] = False,
149
+ no_redeploy: Annotated[
150
+ bool,
151
+ typer.Option(
152
+ "--no-redeploy",
153
+ help="Save the environment variable without redeploying the app.",
157
154
  ),
158
155
  ] = False,
159
156
  json_output: JsonOutputOption = False,
160
157
  ) -> Any:
161
158
  """
162
- Set an environment variable for the app.
159
+ Create or update an environment variable and redeploy the app by default.
163
160
  """
164
161
 
165
162
  toolkit = get_user_command_context(ctx).toolkit
@@ -190,20 +187,27 @@ def set(
190
187
  with toolkit.progress(
191
188
  "Setting environment variable", transient=True
192
189
  ) as progress:
193
- with client.handle_http_errors(progress):
194
- _set_environment_variable(
195
- client=client,
190
+ with client.handle_http_errors(progress, toolkit=toolkit):
191
+ environment_variables = client.batch_environment_variables(
196
192
  app_id=target_app_id,
197
- name=name,
198
- value=value,
199
- is_secret=secret,
193
+ upsert={
194
+ name: EnvironmentVariableCreatePayload(
195
+ value=value, is_secret=secret
196
+ )
197
+ },
198
+ delete=[],
199
+ redeploy=not no_redeploy,
200
200
  )
201
201
 
202
+ variable = next(
203
+ variable for variable in environment_variables.data if variable.name == name
204
+ )
205
+
202
206
  toolkit.success(
203
207
  EnvironmentVariableSetOutput(
204
208
  app_id=target_app_id,
205
209
  name=name,
206
- is_secret=secret,
210
+ is_secret=variable.is_secret,
207
211
  show_tag=not prompts_user,
208
212
  ),
209
213
  render_output=_render_environment_variable_set_output,
@@ -0,0 +1,14 @@
1
+ from rich.markup import escape
2
+ from rich.text import Text
3
+
4
+ from fastapi_cloud_cli.api import BuildFailure
5
+ from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit
6
+
7
+
8
+ def print_build_error(toolkit: FastAPIRichToolkit, error: BuildFailure) -> None:
9
+ toolkit.print(Text(error.error_title, style="bold red"), emoji="🚨")
10
+ toolkit.print_line()
11
+ toolkit.print(Text(error.error_message))
12
+ if error.error_hint:
13
+ toolkit.print_line()
14
+ toolkit.print_hint(escape(error.error_hint))
@@ -282,7 +282,11 @@ class FastAPIStyle(BaseStyle):
282
282
  prefix = Text(" " * self.content_padding)
283
283
 
284
284
  if emoji:
285
- prefix.append_text(Text.from_markup(emoji))
285
+ bullet = Text.from_markup(emoji)
286
+ bullet.plain = self.symbol(
287
+ bullet.plain, fallback=" x" if emoji == ERROR_BULLET else "*"
288
+ )
289
+ prefix.append_text(bullet)
286
290
 
287
291
  prefix.pad_right(
288
292
  self.content_padding + self.emoji_column_width - prefix.cell_len
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-cloud-cli
3
- Version: 0.25.0
3
+ Version: 0.26.0
4
4
  Summary: Deploy and manage FastAPI Cloud apps from the command line 🚀
5
5
  Author-Email: Patrick Arminio <patrick@fastapilabs.com>
6
6
  License: MIT
@@ -33,7 +33,7 @@ Requires-Dist: typer>=0.26.1
33
33
  Requires-Dist: uvicorn[standard]>=0.17.6
34
34
  Requires-Dist: rignore>=0.5.1
35
35
  Requires-Dist: httpx>=0.27.0
36
- Requires-Dist: rich-toolkit>=0.20.3
36
+ Requires-Dist: rich-toolkit>=0.20.5
37
37
  Requires-Dist: pydantic[email]>=2.7.4; python_version < "3.13"
38
38
  Requires-Dist: pydantic[email]>=2.8.0; python_version == "3.13"
39
39
  Requires-Dist: pydantic[email]>=2.12.0; python_version >= "3.14"
@@ -1,15 +1,15 @@
1
- fastapi_cloud_cli-0.25.0.dist-info/METADATA,sha256=D-nWRnnIHdExKn_WdA44-ZDduiixp_ncxL85rbdQwVM,3108
2
- fastapi_cloud_cli-0.25.0.dist-info/WHEEL,sha256=VP-D4TPS230sME9Z3vb3INXvo1yt0924YRm5AOsk_dE,90
3
- fastapi_cloud_cli-0.25.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
4
- fastapi_cloud_cli-0.25.0.dist-info/licenses/LICENSE,sha256=FqD5B4VbXJnefprQseE0U8llL6FxojC-i8muZy7YmSU,1086
5
- fastapi_cloud_cli/__init__.py,sha256=Mu4JbSLl5nr-J2figk5hmW2mrw4skf_oeIzxbnpcgwY,23
1
+ fastapi_cloud_cli-0.26.0.dist-info/METADATA,sha256=cGX_kGGfi9Wqny026UsXThsuq5nm8Vyoqu08Ar2uXJE,3108
2
+ fastapi_cloud_cli-0.26.0.dist-info/WHEEL,sha256=VP-D4TPS230sME9Z3vb3INXvo1yt0924YRm5AOsk_dE,90
3
+ fastapi_cloud_cli-0.26.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
4
+ fastapi_cloud_cli-0.26.0.dist-info/licenses/LICENSE,sha256=FqD5B4VbXJnefprQseE0U8llL6FxojC-i8muZy7YmSU,1086
5
+ fastapi_cloud_cli/__init__.py,sha256=S1dNBNSmHJkAHkq1vL1Yg-dFpQk0fri_CstNlL1u5qw,23
6
6
  fastapi_cloud_cli/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
7
7
  fastapi_cloud_cli/_app.py,sha256=7RNSWWNLEoIN3WwvTBJ3MSKIPPfbbwoLHFJrSYdJjJc,735
8
- fastapi_cloud_cli/api/__init__.py,sha256=vb3qIJvoxX8Xc9QQ0ZRank5sWGeNGdRpZvkWzF4C91Y,908
8
+ fastapi_cloud_cli/api/__init__.py,sha256=ORZqOHW7yaKjq0jY7M5pK8J5oyFgMOrWNeBzXMC2fCA,1359
9
9
  fastapi_cloud_cli/api/_errors.py,sha256=NJVIfaKsPDZi-7Y5JlZU0qsZc3BBozEC1bb1RKdGs2Q,3711
10
- fastapi_cloud_cli/api/_models.py,sha256=4BX_uYvwsDGp7_2qxh6Zk0U0E3X--q-1jMOf-bRkYGY,4749
10
+ fastapi_cloud_cli/api/_models.py,sha256=9h546GVlEi9CJ7vWt7UNfcpubuvnJ2pUgs33gbKrqVc,5469
11
11
  fastapi_cloud_cli/api/_retry.py,sha256=SsSG2jid2RjwpHpKRU_YfzzjLKAnk17OjgkyQgPLzxo,2706
12
- fastapi_cloud_cli/api/client.py,sha256=4ucVNX2Unaxwyi9Phgahv0JDZHWSecqAR39-oQvK1vU,9624
12
+ fastapi_cloud_cli/api/client.py,sha256=kzrU6nx-L2vWJZ99-KCzTpZIPfhVMwrcRcqaMx9TAo4,10884
13
13
  fastapi_cloud_cli/cli.py,sha256=u-ZYE-mN0Ml5XNy11iMR_W8WCqp8d2J6BZ7YH05-CkM,3989
14
14
  fastapi_cloud_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
15
  fastapi_cloud_cli/commands/_auth.py,sha256=8LKkpQnEoBwJbU_LvIPsDrACuIfc1cyI7xXRpGEPLvw,1199
@@ -34,8 +34,8 @@ fastapi_cloud_cli/commands/deploy/cloud.py,sha256=ISsO-cQpdsw2Ia6R9P-TIOMKx1VxD1
34
34
  fastapi_cloud_cli/commands/deploy/command.py,sha256=7RQCqlVrqusLl2ZtWYOfQ95nEoixszbHdfQiuocrgqQ,13967
35
35
  fastapi_cloud_cli/commands/deploy/configure.py,sha256=2D9MDknTp5yE5ihIE_oHBNgrPY4fcvaItR2dK1SiXVk,4898
36
36
  fastapi_cloud_cli/commands/deploy/upload.py,sha256=TRHKVco06ZKXSLAV80iBy81HswAzSe00GvshOpfkv5s,3185
37
- fastapi_cloud_cli/commands/deploy/wait.py,sha256=nvz7K79HUWCCsHTjibZBapVlhkRb4XRoX-O5zByOGEk,5792
38
- fastapi_cloud_cli/commands/deployments.py,sha256=4B5d6r_UR9Tu68F2SJhG90liffCUpof4U0Lt_bW-q0w,12720
37
+ fastapi_cloud_cli/commands/deploy/wait.py,sha256=f-4xFftueemMtdONqeA65x0UqksS32bc6XOiZatvFpg,6723
38
+ fastapi_cloud_cli/commands/deployments.py,sha256=ZWbfdkkwkmMqtR8ysrnR9bAlnEAsoPAg9DlQbTJuYfU,14319
39
39
  fastapi_cloud_cli/commands/domains/__init__.py,sha256=t0kcyNvJVHITc6xPWvsTMKCUpWd3Y_Ur-F2V66UyGeY,106
40
40
  fastapi_cloud_cli/commands/domains/_app.py,sha256=h-6saPMt--q5vUM3fKxI-kf0c7Ej-5bY9lXYMVsMF9I,120
41
41
  fastapi_cloud_cli/commands/domains/_setup.py,sha256=YiY8uF9QAq3UKf2vTI6sryAlsqGXlfNgYR1qLU6b7g4,6710
@@ -48,11 +48,11 @@ fastapi_cloud_cli/commands/domains/rendering.py,sha256=mqDMDTaZLvOmDbPO-vrqj0IXB
48
48
  fastapi_cloud_cli/commands/domains/restart.py,sha256=Ph3Q4WhtePuAJxdzOYp6YISqUYYGOIVEc-rbsqArbmU,4836
49
49
  fastapi_cloud_cli/commands/env/__init__.py,sha256=X3cOoOwBCn8jgoIPWMPGRiRzPfUomiavIS04q5gWa24,90
50
50
  fastapi_cloud_cli/commands/env/_app.py,sha256=Ip-tTBKpl_xUhMwnYlmvep7tu-VC3gFHo_9IWklBg_k,123
51
- fastapi_cloud_cli/commands/env/_shared.py,sha256=qAC5vTCy1MlS1CtV9DuiodAuCdkkXDtmaiXgHw0Gttg,1380
52
- fastapi_cloud_cli/commands/env/delete.py,sha256=TPxe7Rbu2k3upA7esLFbGzdzPaa3Dcs-f96K0y5YMNI,5919
53
- fastapi_cloud_cli/commands/env/get.py,sha256=4brqnPCx-1My8o-njpb63FgERtoZyL1BlXG0vxSBn40,4023
54
- fastapi_cloud_cli/commands/env/list.py,sha256=aa7QFybYQcdrm2WJoPVoyHI_6ixEwma0rWdkGRBGKUM,3316
55
- fastapi_cloud_cli/commands/env/set.py,sha256=6rehE7DTCRU5AHFzZqHUkDQrmyHuiY6DG7NjI0ZVB4g,5931
51
+ fastapi_cloud_cli/commands/env/_shared.py,sha256=dDtLjcv5AK7v9N_BPD4fZaElc1Mu3xBBpHA78kbeyTI,855
52
+ fastapi_cloud_cli/commands/env/delete.py,sha256=baILA8agCL41h2MNbbdPqj5k600AAG7HYboxveGo2Ag,5601
53
+ fastapi_cloud_cli/commands/env/get.py,sha256=4IzcRM0Np7rvvHijbkt3DTtcbEUdhgiVnGUE65NiSSw,3978
54
+ fastapi_cloud_cli/commands/env/list.py,sha256=oeqjX-eofuidbyoZY31x2bpm0aZAavDhmT3KW3n5jww,3271
55
+ fastapi_cloud_cli/commands/env/set.py,sha256=wGOFeaat-W1OE8VnTeayezlmcPEeeIHznEEcYj4wELk,6247
56
56
  fastapi_cloud_cli/commands/integrations/__init__.py,sha256=0rxfgiemdH6QLzxeA1egGi9T6JVw8EiBV_cOF4cTe-I,424
57
57
  fastapi_cloud_cli/commands/integrations/providers/__init__.py,sha256=XalsF3zAyPvlDsS8a7U9aGtkL2PZz6trlcUFyOdUTAo,136
58
58
  fastapi_cloud_cli/commands/integrations/providers/_app.py,sha256=IkgPmQfkRb0ajimz0D7yqaLRciy8tWOufVxdFNer_rU,124
@@ -84,7 +84,8 @@ fastapi_cloud_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
84
84
  fastapi_cloud_cli/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
85
85
  fastapi_cloud_cli/utils/apps.py,sha256=-qVIxpuXLlRFywDzY9-ZtFcwzFQ0cdFE0bQcQqIN33o,2722
86
86
  fastapi_cloud_cli/utils/auth.py,sha256=O4MzQ1jj16hkO8-NPD1TGUSAabbb-GXx2QLY7vVyyjs,3951
87
- fastapi_cloud_cli/utils/cli.py,sha256=6KjdzsI1secJEzXOqfv8IEa9g3NBAILCDtY7JnizYO8,14475
87
+ fastapi_cloud_cli/utils/build_logs.py,sha256=rVFz1OyedADpuemBwL2mIVSrkjBPEwoD_nAarPkPluY,500
88
+ fastapi_cloud_cli/utils/cli.py,sha256=q11avEJcjBlOpifeTnsDX--BxMGR2aXVbHKgxdPrFhY,14635
88
89
  fastapi_cloud_cli/utils/config.py,sha256=qxPHFmzfJWfm_sfc-DKFPoNTc5jFK7_Bnfy6DVhWigE,700
89
90
  fastapi_cloud_cli/utils/dates.py,sha256=cnBPp1f2cDj8_3vWt1RnOrbGVYh7Nr31n95gN5t2RSI,1081
90
91
  fastapi_cloud_cli/utils/env.py,sha256=a9S6ehNGxhKbQHXxFVJtjqMEkodKwKa0wOvRVh9VBjw,125
@@ -94,4 +95,4 @@ fastapi_cloud_cli/utils/progress_file.py,sha256=GMK-OgS1pr2IgfBpJgwPqkb2k37W0b2C
94
95
  fastapi_cloud_cli/utils/sentry.py,sha256=C_KlTV8JCtB3slrCJLNoTo6beM87rWjxAr92uwmWLR0,599
95
96
  fastapi_cloud_cli/utils/teams.py,sha256=PSCL-hySKcUU_pz45xBEKeB8NCbWLBCGlgx_GFLZ-2w,2007
96
97
  fastapi_cloud_cli/utils/version_check.py,sha256=-iOus8nWNsQn9FnC5tZD0iCftjUdeCj-RFskPLz1KZE,5209
97
- fastapi_cloud_cli-0.25.0.dist-info/RECORD,,
98
+ fastapi_cloud_cli-0.26.0.dist-info/RECORD,,