fastapi-cloud-cli 0.13.0__tar.gz → 0.14.0__tar.gz

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 (83) hide show
  1. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/PKG-INFO +2 -2
  2. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/pyproject.toml +2 -2
  3. fastapi_cloud_cli-0.14.0/src/fastapi_cloud_cli/__init__.py +1 -0
  4. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/deploy.py +59 -56
  5. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/api.py +82 -0
  6. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/cli.py +9 -3
  7. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_api_client.py +53 -0
  8. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli_deploy.py +265 -18
  9. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_deploy_utils.py +2 -5
  10. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/utils.py +22 -9
  11. fastapi_cloud_cli-0.13.0/src/fastapi_cloud_cli/__init__.py +0 -1
  12. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/LICENSE +0 -0
  13. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/README.md +0 -0
  14. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/scripts/format.sh +0 -0
  15. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/scripts/lint.sh +0 -0
  16. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/scripts/test-cov-html.sh +0 -0
  17. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/scripts/test.sh +0 -0
  18. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/__main__.py +0 -0
  19. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/cli.py +0 -0
  20. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  21. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/env.py +0 -0
  22. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/link.py +0 -0
  23. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/login.py +0 -0
  24. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  25. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/logs.py +0 -0
  26. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
  27. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
  28. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/config.py +0 -0
  29. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/logging.py +0 -0
  30. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/py.typed +0 -0
  31. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  32. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
  33. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/auth.py +0 -0
  34. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/config.py +0 -0
  35. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
  36. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  37. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/__init__.py +0 -0
  38. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/broken_package/mod/__init__.py +0 -0
  39. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/broken_package/mod/app.py +0 -0
  40. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/broken_package/utils.py +0 -0
  41. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_api/api.py +0 -0
  42. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app/api.py +0 -0
  43. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app/app.py +0 -0
  44. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
  45. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_api/app/api.py +0 -0
  46. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
  47. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_app/app/api.py +0 -0
  48. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_app/app/app.py +0 -0
  49. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
  50. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_main/app/api.py +0 -0
  51. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_main/app/app.py +0 -0
  52. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_main/app/main.py +0 -0
  53. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_non_default/app/__init__.py +0 -0
  54. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_app_dir_non_default/app/nondefault.py +0 -0
  55. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_main/api.py +0 -0
  56. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_main/app.py +0 -0
  57. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/default_main/main.py +0 -0
  58. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/default_files/non_default/nonstandard.py +0 -0
  59. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/__init__.py +0 -0
  60. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/core/__init__.py +0 -0
  61. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/core/utils.py +0 -0
  62. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/mod/__init__.py +0 -0
  63. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/mod/api.py +0 -0
  64. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/mod/app.py +0 -0
  65. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/package/mod/other.py +0 -0
  66. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/single_file_api.py +0 -0
  67. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/single_file_app.py +0 -0
  68. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/assets/single_file_other.py +0 -0
  69. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/conftest.py +0 -0
  70. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_archive.py +0 -0
  71. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_auth.py +0 -0
  72. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli.py +0 -0
  73. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli_link.py +0 -0
  74. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli_login.py +0 -0
  75. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli_logout.py +0 -0
  76. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli_unlink.py +0 -0
  77. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_cli_whoami.py +0 -0
  78. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_config.py +0 -0
  79. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_env_delete.py +0 -0
  80. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_env_list.py +0 -0
  81. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_env_set.py +0 -0
  82. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_logs.py +0 -0
  83. {fastapi_cloud_cli-0.13.0 → fastapi_cloud_cli-0.14.0}/tests/test_sentry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-cloud-cli
3
- Version: 0.13.0
3
+ Version: 0.14.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.16.0
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.19.4
36
+ Requires-Dist: rich-toolkit>=0.19.7
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"
@@ -33,14 +33,14 @@ dependencies = [
33
33
  "uvicorn[standard] >= 0.17.6",
34
34
  "rignore >= 0.5.1",
35
35
  "httpx >= 0.27.0",
36
- "rich-toolkit >= 0.19.4",
36
+ "rich-toolkit >= 0.19.7",
37
37
  "pydantic[email] >= 2.7.4; python_version < '3.13'",
38
38
  "pydantic[email] >= 2.8.0; python_version == '3.13'",
39
39
  "pydantic[email] >= 2.12.0; python_version >= '3.14'",
40
40
  "sentry-sdk >= 2.20.0",
41
41
  "fastar >= 0.8.0",
42
42
  ]
43
- version = "0.13.0"
43
+ version = "0.14.0"
44
44
 
45
45
  [project.license]
46
46
  text = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.14.0"
@@ -4,7 +4,6 @@ import re
4
4
  import subprocess
5
5
  import tempfile
6
6
  import time
7
- from enum import Enum
8
7
  from itertools import cycle
9
8
  from pathlib import Path, PurePosixPath
10
9
  from textwrap import dedent
@@ -20,7 +19,13 @@ from rich_toolkit import RichToolkit
20
19
  from rich_toolkit.menu import Option
21
20
 
22
21
  from fastapi_cloud_cli.commands.login import login
23
- from fastapi_cloud_cli.utils.api import APIClient, StreamLogError, TooManyRetriesError
22
+ from fastapi_cloud_cli.utils.api import (
23
+ SUCCESSFUL_STATUSES,
24
+ APIClient,
25
+ DeploymentStatus,
26
+ StreamLogError,
27
+ TooManyRetriesError,
28
+ )
24
29
  from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
25
30
  from fastapi_cloud_cli.utils.auth import Identity
26
31
  from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
@@ -174,42 +179,6 @@ def _create_app(team_id: str, app_name: str, directory: str | None) -> AppRespon
174
179
  return AppResponse.model_validate(response.json())
175
180
 
176
181
 
177
- class DeploymentStatus(str, Enum):
178
- waiting_upload = "waiting_upload"
179
- ready_for_build = "ready_for_build"
180
- building = "building"
181
- extracting = "extracting"
182
- extracting_failed = "extracting_failed"
183
- building_image = "building_image"
184
- building_image_failed = "building_image_failed"
185
- deploying = "deploying"
186
- deploying_failed = "deploying_failed"
187
- verifying = "verifying"
188
- verifying_failed = "verifying_failed"
189
- verifying_skipped = "verifying_skipped"
190
- success = "success"
191
- failed = "failed"
192
-
193
- @classmethod
194
- def to_human_readable(cls, status: "DeploymentStatus") -> str:
195
- return {
196
- cls.waiting_upload: "Waiting for upload",
197
- cls.ready_for_build: "Ready for build",
198
- cls.building: "Building",
199
- cls.extracting: "Extracting",
200
- cls.extracting_failed: "Extracting failed",
201
- cls.building_image: "Building image",
202
- cls.building_image_failed: "Build failed",
203
- cls.deploying: "Deploying",
204
- cls.deploying_failed: "Deploying failed",
205
- cls.verifying: "Verifying",
206
- cls.verifying_failed: "Verifying failed",
207
- cls.verifying_skipped: "Verification skipped",
208
- cls.success: "Success",
209
- cls.failed: "Failed",
210
- }[status]
211
-
212
-
213
182
  class CreateDeploymentResponse(BaseModel):
214
183
  id: str
215
184
  app_id: str
@@ -440,6 +409,42 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
440
409
  return app_config
441
410
 
442
411
 
412
+ def _verify_deployment(
413
+ toolkit: RichToolkit,
414
+ client: APIClient,
415
+ app_id: str,
416
+ deployment: CreateDeploymentResponse,
417
+ ) -> None:
418
+ with toolkit.progress(
419
+ title="Verifying deployment...",
420
+ inline_logs=True,
421
+ done_emoji="✅",
422
+ ) as progress:
423
+ try:
424
+ final_status = client.poll_deployment_status(app_id, deployment.id)
425
+ except (TimeoutError, TooManyRetriesError, StreamLogError):
426
+ progress.metadata["done_emoji"] = "⚠️"
427
+ progress.current_message = (
428
+ f"Could not confirm deployment status. "
429
+ f"Check the dashboard: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
430
+ )
431
+ return
432
+
433
+ if final_status in SUCCESSFUL_STATUSES:
434
+ progress.current_message = f"Ready the chicken! 🐔 Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
435
+ else:
436
+ progress.metadata["done_emoji"] = "❌"
437
+ progress.current_message = "Deployment failed"
438
+
439
+ human_status = DeploymentStatus.to_human_readable(final_status)
440
+
441
+ progress.log(
442
+ f"😔 Oh no! Deployment failed: {human_status}. "
443
+ f"Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
444
+ )
445
+ raise typer.Exit(1)
446
+
447
+
443
448
  def _wait_for_deployment(
444
449
  toolkit: RichToolkit, app_id: str, deployment: CreateDeploymentResponse
445
450
  ) -> None:
@@ -451,11 +456,6 @@ def _wait_for_deployment(
451
456
  )
452
457
  toolkit.print_line()
453
458
 
454
- toolkit.print(
455
- f"You can also check the status at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]",
456
- )
457
- toolkit.print_line()
458
-
459
459
  time_elapsed = 0.0
460
460
 
461
461
  started_at = time.monotonic()
@@ -464,10 +464,15 @@ def _wait_for_deployment(
464
464
 
465
465
  with (
466
466
  toolkit.progress(
467
- next(messages), inline_logs=True, lines_to_show=20
467
+ next(messages),
468
+ inline_logs=True,
469
+ lines_to_show=20,
470
+ done_emoji="🚀",
468
471
  ) as progress,
469
472
  APIClient() as client,
470
473
  ):
474
+ build_complete = False
475
+
471
476
  try:
472
477
  for log in client.stream_build_logs(deployment.id):
473
478
  time_elapsed = time.monotonic() - started_at
@@ -476,17 +481,8 @@ def _wait_for_deployment(
476
481
  progress.log(Text.from_ansi(log.message.rstrip()))
477
482
 
478
483
  if log.type == "complete":
479
- progress.log("")
480
- progress.log(
481
- f"You can also check the app logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
482
- )
483
-
484
- progress.log("")
485
-
486
- progress.log(
487
- f"🐔 Ready the chicken! Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
488
- )
489
-
484
+ build_complete = True
485
+ progress.title = "Build complete!"
490
486
  break
491
487
 
492
488
  if log.type == "failed":
@@ -515,6 +511,11 @@ def _wait_for_deployment(
515
511
 
516
512
  raise typer.Exit(1) from None
517
513
 
514
+ if build_complete:
515
+ toolkit.print_line()
516
+
517
+ _verify_deployment(toolkit, client, app_id, deployment)
518
+
518
519
 
519
520
  class SignupToWaitingList(BaseModel):
520
521
  email: EmailStr
@@ -753,7 +754,9 @@ def deploy(
753
754
  archive(path or Path.cwd(), archive_path)
754
755
 
755
756
  with (
756
- toolkit.progress(title="Creating deployment") as progress,
757
+ toolkit.progress(
758
+ title="Creating deployment", done_emoji="📦"
759
+ ) as progress,
757
760
  handle_http_errors(progress),
758
761
  ):
759
762
  logger.debug("Creating deployment for app: %s", app.id)
@@ -4,6 +4,7 @@ import time
4
4
  from collections.abc import Callable, Generator
5
5
  from contextlib import contextmanager
6
6
  from datetime import timedelta
7
+ from enum import Enum
7
8
  from functools import wraps
8
9
  from typing import (
9
10
  Annotated,
@@ -138,6 +139,57 @@ def attempts(
138
139
  return decorator
139
140
 
140
141
 
142
+ class DeploymentStatus(str, Enum):
143
+ waiting_upload = "waiting_upload"
144
+ ready_for_build = "ready_for_build"
145
+ building = "building"
146
+ extracting = "extracting"
147
+ extracting_failed = "extracting_failed"
148
+ building_image = "building_image"
149
+ building_image_failed = "building_image_failed"
150
+ deploying = "deploying"
151
+ deploying_failed = "deploying_failed"
152
+ verifying = "verifying"
153
+ verifying_failed = "verifying_failed"
154
+ verifying_skipped = "verifying_skipped"
155
+ success = "success"
156
+ failed = "failed"
157
+
158
+ @classmethod
159
+ def to_human_readable(cls, status: "DeploymentStatus") -> str:
160
+ return {
161
+ cls.waiting_upload: "Waiting for upload",
162
+ cls.ready_for_build: "Ready for build",
163
+ cls.building: "Building",
164
+ cls.extracting: "Extracting",
165
+ cls.extracting_failed: "Extracting failed",
166
+ cls.building_image: "Building image",
167
+ cls.building_image_failed: "Build failed",
168
+ cls.deploying: "Deploying",
169
+ cls.deploying_failed: "Deploying failed",
170
+ cls.verifying: "Verifying",
171
+ cls.verifying_failed: "Verifying failed",
172
+ cls.verifying_skipped: "Verification skipped",
173
+ cls.success: "Success",
174
+ cls.failed: "Failed",
175
+ }[status]
176
+
177
+
178
+ SUCCESSFUL_STATUSES = {DeploymentStatus.success, DeploymentStatus.verifying_skipped}
179
+ FAILED_STATUSES = {
180
+ DeploymentStatus.failed,
181
+ DeploymentStatus.verifying_failed,
182
+ DeploymentStatus.deploying_failed,
183
+ DeploymentStatus.building_image_failed,
184
+ DeploymentStatus.extracting_failed,
185
+ }
186
+ TERMINAL_STATUSES = SUCCESSFUL_STATUSES | FAILED_STATUSES
187
+
188
+ POLL_INTERVAL = 2.0
189
+ POLL_TIMEOUT = timedelta(seconds=120)
190
+ POLL_MAX_RETRIES = 5
191
+
192
+
141
193
  class APIClient(httpx.Client):
142
194
  def __init__(self) -> None:
143
195
  settings = Settings.get()
@@ -241,3 +293,33 @@ class APIClient(httpx.Client):
241
293
  except ValidationError as e: # pragma: no cover
242
294
  logger.debug("Failed to parse log entry: %s - %s", data, e)
243
295
  continue
296
+
297
+ def poll_deployment_status(
298
+ self,
299
+ app_id: str,
300
+ deployment_id: str,
301
+ ) -> DeploymentStatus:
302
+ start = time.monotonic()
303
+ error_count = 0
304
+
305
+ while True:
306
+ if time.monotonic() - start > POLL_TIMEOUT.total_seconds():
307
+ raise TimeoutError("Deployment verification timed out")
308
+
309
+ with attempt(error_count):
310
+ response = self.get(f"/apps/{app_id}/deployments/{deployment_id}")
311
+ response.raise_for_status()
312
+ status = DeploymentStatus(response.json()["status"])
313
+ error_count = 0
314
+
315
+ if status in TERMINAL_STATUSES:
316
+ return status
317
+
318
+ time.sleep(POLL_INTERVAL)
319
+ continue
320
+
321
+ error_count += 1
322
+ if error_count >= POLL_MAX_RETRIES:
323
+ raise TooManyRetriesError(
324
+ f"Failed after {POLL_MAX_RETRIES} attempts polling deployment status"
325
+ )
@@ -1,7 +1,7 @@
1
1
  import contextlib
2
2
  import logging
3
3
  from collections.abc import Generator
4
- from typing import Any
4
+ from typing import Any, Literal
5
5
 
6
6
  import typer
7
7
  from httpx import HTTPError, HTTPStatusError, ReadTimeout
@@ -24,9 +24,12 @@ class FastAPIStyle(TaggedStyle):
24
24
  metadata: dict[str, Any],
25
25
  is_animated: bool = False,
26
26
  done: bool = False,
27
+ animation_status: Literal["started", "stopped", "error"] | None = None,
27
28
  ) -> tuple[list[Segment], int]:
28
29
  if not is_animated:
29
- return super()._get_tag_segments(metadata, is_animated, done)
30
+ return super()._get_tag_segments(
31
+ metadata, is_animated, done, animation_status=animation_status
32
+ )
30
33
 
31
34
  emojis = [
32
35
  "🥚",
@@ -40,7 +43,10 @@ class FastAPIStyle(TaggedStyle):
40
43
  tag = emojis[self.animation_counter % len(emojis)]
41
44
 
42
45
  if done:
43
- tag = emojis[-1]
46
+ tag = metadata.get("done_emoji", emojis[-1])
47
+
48
+ if animation_status == "error":
49
+ tag = "🟡"
44
50
 
45
51
  left_padding = self.tag_width - 1
46
52
  left_padding = max(0, left_padding)
@@ -11,6 +11,7 @@ from fastapi_cloud_cli.utils.api import (
11
11
  STREAM_LOGS_MAX_RETRIES,
12
12
  APIClient,
13
13
  BuildLogLineMessage,
14
+ DeploymentStatus,
14
15
  StreamLogError,
15
16
  TooManyRetriesError,
16
17
  )
@@ -351,3 +352,55 @@ def test_stream_build_logs_retry_timeout(
351
352
 
352
353
  with patch("time.sleep"), pytest.raises(TimeoutError, match="timed out"):
353
354
  list(client.stream_build_logs(deployment_id))
355
+
356
+
357
+ @pytest.fixture
358
+ def app_id() -> str:
359
+ return "test-app-456"
360
+
361
+
362
+ @pytest.fixture
363
+ def poll_route(
364
+ respx_mock: respx.MockRouter, app_id: str, deployment_id: str
365
+ ) -> respx.Route:
366
+ return respx_mock.get(f"/apps/{app_id}/deployments/{deployment_id}")
367
+
368
+
369
+ def test_poll_deployment_status_recovers_from_transient_errors(
370
+ poll_route: respx.Route, client: APIClient, app_id: str, deployment_id: str
371
+ ) -> None:
372
+ call_count = 0
373
+
374
+ def handler(request: httpx.Request, route: respx.Route) -> Response:
375
+ nonlocal call_count
376
+ call_count += 1
377
+ if call_count <= 2:
378
+ return Response(500)
379
+ return Response(200, json={"status": "success"})
380
+
381
+ poll_route.mock(side_effect=handler)
382
+
383
+ with patch("time.sleep"):
384
+ status = client.poll_deployment_status(app_id, deployment_id)
385
+
386
+ assert status == DeploymentStatus.success
387
+ assert call_count == 3
388
+
389
+
390
+ def test_poll_deployment_status_raises_after_max_consecutive_errors(
391
+ poll_route: respx.Route, client: APIClient, app_id: str, deployment_id: str
392
+ ) -> None:
393
+ poll_route.mock(return_value=Response(500))
394
+
395
+ with patch("time.sleep"), pytest.raises(TooManyRetriesError):
396
+ client.poll_deployment_status(app_id, deployment_id)
397
+
398
+
399
+ def test_poll_deployment_status_timeout(
400
+ client: APIClient, app_id: str, deployment_id: str
401
+ ) -> None:
402
+ with (
403
+ patch("fastapi_cloud_cli.utils.api.POLL_TIMEOUT", timedelta(seconds=-1)),
404
+ pytest.raises(TimeoutError, match="timed out"),
405
+ ):
406
+ client.poll_deployment_status(app_id, deployment_id)
@@ -2,7 +2,7 @@ import random
2
2
  import string
3
3
  from datetime import timedelta
4
4
  from pathlib import Path
5
- from typing import Optional, TypedDict
5
+ from typing import TypedDict
6
6
  from unittest.mock import patch
7
7
 
8
8
  import httpx
@@ -37,14 +37,14 @@ class RandomApp(TypedDict):
37
37
  slug: str
38
38
  id: str
39
39
  team_id: str
40
- directory: Optional[str]
40
+ directory: str | None
41
41
 
42
42
 
43
43
  def _get_random_app(
44
44
  *,
45
- slug: Optional[str] = None,
46
- team_id: Optional[str] = None,
47
- directory: Optional[str] = None,
45
+ slug: str | None = None,
46
+ team_id: str | None = None,
47
+ directory: str | None = None,
48
48
  ) -> RandomApp:
49
49
  name = "".join(random.choices(string.ascii_lowercase, k=10))
50
50
  slug = slug or "".join(random.choices(string.ascii_lowercase, k=10))
@@ -62,7 +62,7 @@ def _get_random_app(
62
62
 
63
63
  def _get_random_deployment(
64
64
  *,
65
- app_id: Optional[str] = None,
65
+ app_id: str | None = None,
66
66
  status: str = "waiting_upload",
67
67
  ) -> dict[str, str]:
68
68
  id = "".join(random.choices(string.digits, k=10))
@@ -666,6 +666,10 @@ def test_updates_app_directory_via_api_when_changed(
666
666
  )
667
667
  )
668
668
 
669
+ respx_mock.get(f"/apps/{app_data['id']}/deployments/{deployment_data['id']}").mock(
670
+ return_value=Response(200, json={**deployment_data, "status": "success"})
671
+ )
672
+
669
673
  with (
670
674
  changing_dir(tmp_path),
671
675
  patch("rich_toolkit.container.getchar") as mock_getchar,
@@ -733,6 +737,10 @@ def test_does_not_update_app_directory_when_unchanged(
733
737
  )
734
738
  )
735
739
 
740
+ respx_mock.get(f"/apps/{app_data['id']}/deployments/{deployment_data['id']}").mock(
741
+ return_value=Response(200, json={**deployment_data, "status": "success"})
742
+ )
743
+
736
744
  with (
737
745
  changing_dir(tmp_path),
738
746
  patch("rich_toolkit.container.getchar") as mock_getchar,
@@ -807,6 +815,10 @@ def test_exits_successfully_when_deployment_is_done(
807
815
  )
808
816
  )
809
817
 
818
+ respx_mock.get(f"/apps/{app_data['id']}/deployments/{deployment_data['id']}").mock(
819
+ return_value=Response(200, json={**deployment_data, "status": "success"})
820
+ )
821
+
810
822
  with (
811
823
  changing_dir(tmp_path),
812
824
  patch("rich_toolkit.container.getchar") as mock_getchar,
@@ -817,8 +829,6 @@ def test_exits_successfully_when_deployment_is_done(
817
829
 
818
830
  assert result.exit_code == 0
819
831
 
820
- # TODO: show a message when the deployment is done (based on the status)
821
-
822
832
 
823
833
  @pytest.mark.respx
824
834
  def test_exits_successfully_when_deployment_is_done_when_app_is_configured(
@@ -867,6 +877,10 @@ def test_exits_successfully_when_deployment_is_done_when_app_is_configured(
867
877
  f"/deployments/{deployment_data['id']}/upload-complete",
868
878
  ).mock(return_value=Response(200))
869
879
 
880
+ respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
881
+ return_value=Response(200, json={**deployment_data, "status": "success"})
882
+ )
883
+
870
884
  with changing_dir(tmp_path):
871
885
  result = runner.invoke(app, ["deploy"])
872
886
 
@@ -875,10 +889,6 @@ def test_exits_successfully_when_deployment_is_done_when_app_is_configured(
875
889
  # check that logs are shown
876
890
  assert "All good!" in result.output
877
891
 
878
- # check that the dashboard URL is shown
879
- assert "You can also check the app logs at" in result.output
880
- assert deployment_data["dashboard_url"] in result.output
881
-
882
892
  # check that the app URL is shown
883
893
  assert deployment_data["url"] in result.output
884
894
 
@@ -1311,10 +1321,15 @@ def test_short_wait_messages(
1311
1321
  side_effect=build_logs_handler
1312
1322
  )
1313
1323
 
1324
+ respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
1325
+ return_value=Response(200, json={**deployment_data, "status": "success"})
1326
+ )
1327
+
1314
1328
  with changing_dir(tmp_path), patch("time.sleep"):
1315
1329
  result = runner.invoke(app, ["deploy"])
1316
1330
 
1317
- assert "short wait message" in result.output
1331
+ assert result.exit_code == 0
1332
+ assert "Ready the chicken!" in result.output
1318
1333
 
1319
1334
 
1320
1335
  @pytest.mark.respx
@@ -1379,10 +1394,15 @@ def test_long_wait_messages(
1379
1394
  side_effect=build_logs_handler
1380
1395
  )
1381
1396
 
1397
+ respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
1398
+ return_value=Response(200, json={**deployment_data, "status": "success"})
1399
+ )
1400
+
1382
1401
  with changing_dir(tmp_path), patch("time.sleep"):
1383
1402
  result = runner.invoke(app, ["deploy"])
1384
1403
 
1385
- assert "long wait message" in result.output
1404
+ assert result.exit_code == 0
1405
+ assert "Ready the chicken!" in result.output
1386
1406
 
1387
1407
 
1388
1408
  @pytest.mark.respx
@@ -1512,6 +1532,11 @@ def test_deploy_successfully_with_token(
1512
1532
  headers={"Authorization": "Bearer hello"},
1513
1533
  ).mock(return_value=Response(200))
1514
1534
 
1535
+ respx_mock.get(
1536
+ f"/apps/{app_id}/deployments/{deployment_data['id']}",
1537
+ headers={"Authorization": "Bearer hello"},
1538
+ ).mock(return_value=Response(200, json={**deployment_data, "status": "success"}))
1539
+
1515
1540
  with changing_dir(tmp_path):
1516
1541
  result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_TOKEN": "hello"})
1517
1542
 
@@ -1520,10 +1545,6 @@ def test_deploy_successfully_with_token(
1520
1545
  # check that logs are shown
1521
1546
  assert "All good!" in result.output
1522
1547
 
1523
- # check that the dashboard URL is shown
1524
- assert "You can also check the app logs at" in result.output
1525
- assert deployment_data["dashboard_url"] in result.output
1526
-
1527
1548
  # check that the app URL is shown
1528
1549
  assert deployment_data["url"] in result.output
1529
1550
 
@@ -1596,6 +1617,10 @@ def test_deploy_with_app_id_arg(
1596
1617
  )
1597
1618
  )
1598
1619
 
1620
+ respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
1621
+ return_value=Response(200, json={**deployment_data, "status": "success"})
1622
+ )
1623
+
1599
1624
  with changing_dir(tmp_path):
1600
1625
  result = runner.invoke(app, ["deploy", "--app-id", app_id])
1601
1626
 
@@ -1642,6 +1667,10 @@ def test_deploy_with_app_id_from_env_var(
1642
1667
  )
1643
1668
  )
1644
1669
 
1670
+ respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
1671
+ return_value=Response(200, json={**deployment_data, "status": "success"})
1672
+ )
1673
+
1645
1674
  with changing_dir(tmp_path):
1646
1675
  result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_APP_ID": app_id})
1647
1676
 
@@ -1693,6 +1722,10 @@ def test_deploy_with_app_id_matching_local_config(
1693
1722
  )
1694
1723
  )
1695
1724
 
1725
+ respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
1726
+ return_value=Response(200, json={**deployment_data, "status": "success"})
1727
+ )
1728
+
1696
1729
  with changing_dir(tmp_path):
1697
1730
  result = runner.invoke(app, ["deploy", "--app-id", app_id])
1698
1731
 
@@ -1740,3 +1773,217 @@ def test_deploy_with_app_id_arg_app_not_found(
1740
1773
  assert "App not found" in result.output
1741
1774
  # Should NOT show unlink tip when using --app-id
1742
1775
  assert "unlink" not in result.output
1776
+
1777
+
1778
+ def _setup_deployment_mocks(
1779
+ respx_mock: respx.MockRouter,
1780
+ app_id: str,
1781
+ team_id: str,
1782
+ deployment_data: dict[str, str],
1783
+ tmp_path: Path,
1784
+ ) -> None:
1785
+ """Set up common deployment mocks for a configured app."""
1786
+ config_path = tmp_path / ".fastapicloud" / "cloud.json"
1787
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1788
+ config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}')
1789
+
1790
+ app_data = _get_random_app()
1791
+ app_data["id"] = app_id
1792
+
1793
+ respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data))
1794
+ respx_mock.post(f"/apps/{app_id}/deployments/").mock(
1795
+ return_value=Response(201, json=deployment_data)
1796
+ )
1797
+ respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock(
1798
+ return_value=Response(
1799
+ 200, json={"url": "http://test.com", "fields": {"key": "value"}}
1800
+ )
1801
+ )
1802
+ respx_mock.post("http://test.com", data={"key": "value"}).mock(
1803
+ return_value=Response(200)
1804
+ )
1805
+ respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock(
1806
+ return_value=Response(200)
1807
+ )
1808
+ respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock(
1809
+ return_value=Response(
1810
+ 200,
1811
+ content=build_logs_response(
1812
+ {"type": "message", "message": "Building...", "id": "1"},
1813
+ {"type": "complete"},
1814
+ ),
1815
+ )
1816
+ )
1817
+
1818
+
1819
+ @pytest.mark.respx
1820
+ def test_verification_failure_after_build_complete(
1821
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1822
+ ) -> None:
1823
+ app_data = _get_random_app()
1824
+ app_id = app_data["id"]
1825
+ team_id = "some-team-id"
1826
+ deployment_data = _get_random_deployment(app_id=app_id)
1827
+
1828
+ _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
1829
+
1830
+ respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
1831
+ return_value=Response(
1832
+ 200, json={**deployment_data, "status": "verifying_failed"}
1833
+ )
1834
+ )
1835
+
1836
+ with changing_dir(tmp_path):
1837
+ result = runner.invoke(app, ["deploy"])
1838
+
1839
+ assert result.exit_code == 1
1840
+ assert "Deployment failed" in result.output
1841
+ assert "Verifying failed" in result.output
1842
+ assert deployment_data["dashboard_url"] in result.output
1843
+
1844
+
1845
+ @pytest.mark.respx
1846
+ def test_polling_with_intermediate_states(
1847
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1848
+ ) -> None:
1849
+ app_data = _get_random_app()
1850
+ app_id = app_data["id"]
1851
+ team_id = "some-team-id"
1852
+ deployment_data = _get_random_deployment(app_id=app_id)
1853
+
1854
+ _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
1855
+
1856
+ call_count = 0
1857
+
1858
+ def poll_handler(request: httpx.Request, route: respx.Route) -> Response:
1859
+ nonlocal call_count
1860
+ call_count += 1
1861
+ if call_count <= 2:
1862
+ return Response(200, json={**deployment_data, "status": "verifying"})
1863
+ return Response(200, json={**deployment_data, "status": "success"})
1864
+
1865
+ respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
1866
+ side_effect=poll_handler
1867
+ )
1868
+
1869
+ with changing_dir(tmp_path), patch("time.sleep"):
1870
+ result = runner.invoke(app, ["deploy"])
1871
+
1872
+ assert result.exit_code == 0
1873
+ assert deployment_data["url"] in result.output
1874
+
1875
+
1876
+ @pytest.mark.respx
1877
+ def test_polling_timeout_shows_dashboard_link(
1878
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1879
+ ) -> None:
1880
+ app_data = _get_random_app()
1881
+ app_id = app_data["id"]
1882
+ team_id = "some-team-id"
1883
+ deployment_data = _get_random_deployment(app_id=app_id)
1884
+
1885
+ _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
1886
+
1887
+ with (
1888
+ changing_dir(tmp_path),
1889
+ patch(
1890
+ "fastapi_cloud_cli.utils.api.APIClient.poll_deployment_status",
1891
+ side_effect=TimeoutError("Deployment verification timed out"),
1892
+ ),
1893
+ ):
1894
+ result = runner.invoke(app, ["deploy"])
1895
+
1896
+ assert result.exit_code == 0
1897
+ assert "Could not confirm deployment status" in result.output
1898
+ assert deployment_data["dashboard_url"] in result.output
1899
+
1900
+
1901
+ @pytest.mark.respx
1902
+ def test_verifying_skipped_treated_as_success(
1903
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1904
+ ) -> None:
1905
+ app_data = _get_random_app()
1906
+ app_id = app_data["id"]
1907
+ team_id = "some-team-id"
1908
+ deployment_data = _get_random_deployment(app_id=app_id)
1909
+
1910
+ _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
1911
+
1912
+ respx_mock.get(f"/apps/{app_id}/deployments/{deployment_data['id']}").mock(
1913
+ return_value=Response(
1914
+ 200, json={**deployment_data, "status": "verifying_skipped"}
1915
+ )
1916
+ )
1917
+
1918
+ with changing_dir(tmp_path):
1919
+ result = runner.invoke(app, ["deploy"])
1920
+
1921
+ assert result.exit_code == 0
1922
+ assert deployment_data["url"] in result.output
1923
+
1924
+
1925
+ @pytest.mark.respx
1926
+ def test_ctrl_c_during_verification_shows_cancelled(
1927
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1928
+ ) -> None:
1929
+ app_data = _get_random_app()
1930
+ app_id = app_data["id"]
1931
+ team_id = "some-team-id"
1932
+ deployment_data = _get_random_deployment(app_id=app_id)
1933
+
1934
+ _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path)
1935
+
1936
+ with (
1937
+ changing_dir(tmp_path),
1938
+ patch(
1939
+ "fastapi_cloud_cli.utils.api.APIClient.poll_deployment_status",
1940
+ side_effect=KeyboardInterrupt(),
1941
+ ),
1942
+ ):
1943
+ result = runner.invoke(app, ["deploy"])
1944
+
1945
+ assert "🟡" in result.output
1946
+ assert "Cancelled" in result.output
1947
+ assert "✅" not in result.output
1948
+
1949
+
1950
+ @pytest.mark.respx
1951
+ def test_ctrl_c_during_build_streaming_shows_cancelled(
1952
+ logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1953
+ ) -> None:
1954
+ app_data = _get_random_app()
1955
+ app_id = app_data["id"]
1956
+ team_id = "some-team-id"
1957
+ deployment_data = _get_random_deployment(app_id=app_id)
1958
+
1959
+ config_path = tmp_path / ".fastapicloud" / "cloud.json"
1960
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1961
+ config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}')
1962
+
1963
+ respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data))
1964
+ respx_mock.post(f"/apps/{app_id}/deployments/").mock(
1965
+ return_value=Response(201, json=deployment_data)
1966
+ )
1967
+ respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock(
1968
+ return_value=Response(
1969
+ 200, json={"url": "http://test.com", "fields": {"key": "value"}}
1970
+ )
1971
+ )
1972
+ respx_mock.post("http://test.com", data={"key": "value"}).mock(
1973
+ return_value=Response(200)
1974
+ )
1975
+ respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock(
1976
+ return_value=Response(200)
1977
+ )
1978
+
1979
+ with (
1980
+ changing_dir(tmp_path),
1981
+ patch(
1982
+ "fastapi_cloud_cli.utils.api.APIClient.stream_build_logs",
1983
+ side_effect=KeyboardInterrupt(),
1984
+ ),
1985
+ ):
1986
+ result = runner.invoke(app, ["deploy"])
1987
+
1988
+ assert "🟡" in result.output
1989
+ assert "Cancelled." in result.output
@@ -1,13 +1,12 @@
1
1
  from pathlib import Path
2
- from typing import Optional
3
2
 
4
3
  import pytest
5
4
 
6
5
  from fastapi_cloud_cli.commands.deploy import (
7
- DeploymentStatus,
8
6
  _should_exclude_entry,
9
7
  validate_app_directory,
10
8
  )
9
+ from fastapi_cloud_cli.utils.api import DeploymentStatus
11
10
 
12
11
 
13
12
  @pytest.mark.parametrize(
@@ -98,9 +97,7 @@ def test_deployment_status_to_human_readable(
98
97
  ("a/b/c", "a/b/c"),
99
98
  ],
100
99
  )
101
- def test_validate_app_directory_valid(
102
- value: Optional[str], expected: Optional[str]
103
- ) -> None:
100
+ def test_validate_app_directory_valid(value: str | None, expected: str | None) -> None:
104
101
  """Should accept valid directory values and normalize them."""
105
102
  assert validate_app_directory(value) == expected
106
103
 
@@ -1,14 +1,15 @@
1
1
  import base64
2
2
  import json
3
3
  import os
4
+ import sys
4
5
  from collections.abc import Generator
5
6
  from contextlib import contextmanager
6
7
  from pathlib import Path
7
- from typing import Any, Union
8
+ from typing import Any
8
9
 
9
10
 
10
11
  @contextmanager
11
- def changing_dir(directory: Union[str, Path]) -> Generator[None, None, None]:
12
+ def changing_dir(directory: str | Path) -> Generator[None, None, None]:
12
13
  initial_dir = os.getcwd()
13
14
  os.chdir(directory)
14
15
  try:
@@ -22,13 +23,25 @@ def build_logs_response(*logs: dict[str, Any]) -> str:
22
23
  return "\n".join(json.dumps(log) for log in logs)
23
24
 
24
25
 
25
- class Keys:
26
- RIGHT_ARROW = "\x1b[C"
27
- DOWN_ARROW = "\x1b[B"
28
- ENTER = "\r"
29
- CTRL_C = "\x03"
30
- TAB = "\t"
31
- BACKSPACE = "\x7f"
26
+ if sys.platform == "win32":
27
+
28
+ class Keys:
29
+ RIGHT_ARROW = "\xe0M"
30
+ DOWN_ARROW = "\xe0P"
31
+ ENTER = "\r"
32
+ CTRL_C = "\x03"
33
+ TAB = "\t"
34
+ BACKSPACE = "\x08"
35
+
36
+ else:
37
+
38
+ class Keys:
39
+ RIGHT_ARROW = "\x1b[C"
40
+ DOWN_ARROW = "\x1b[B"
41
+ ENTER = "\r"
42
+ CTRL_C = "\x03"
43
+ TAB = "\t"
44
+ BACKSPACE = "\x7f"
32
45
 
33
46
 
34
47
  def create_jwt_token(payload: dict[str, Any]) -> str:
@@ -1 +0,0 @@
1
- __version__ = "0.13.0"