fastapi-cloud-cli 0.11.0__tar.gz → 0.13.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.11.0 → fastapi_cloud_cli-0.13.0}/PKG-INFO +8 -7
  2. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/pyproject.toml +11 -9
  3. fastapi_cloud_cli-0.13.0/src/fastapi_cloud_cli/__init__.py +1 -0
  4. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/commands/deploy.py +103 -27
  5. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/commands/env.py +8 -8
  6. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/commands/link.py +3 -2
  7. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/commands/login.py +1 -5
  8. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/commands/logs.py +15 -15
  9. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/commands/whoami.py +1 -1
  10. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/logging.py +1 -4
  11. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/utils/api.py +10 -10
  12. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/utils/apps.py +1 -2
  13. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/utils/auth.py +4 -4
  14. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/utils/cli.py +46 -23
  15. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/utils/config.py +6 -0
  16. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/conftest.py +31 -11
  17. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_api_client.py +2 -22
  18. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_auth.py +5 -0
  19. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_cli_deploy.py +389 -50
  20. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_cli_link.py +6 -8
  21. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_cli_login.py +19 -54
  22. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_cli_whoami.py +16 -6
  23. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_config.py +20 -0
  24. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_deploy_utils.py +63 -1
  25. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_env_delete.py +5 -7
  26. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_env_list.py +4 -6
  27. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_env_set.py +5 -7
  28. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_logs.py +27 -14
  29. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/utils.py +1 -0
  30. fastapi_cloud_cli-0.11.0/src/fastapi_cloud_cli/__init__.py +0 -1
  31. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/LICENSE +0 -0
  32. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/README.md +0 -0
  33. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/scripts/format.sh +0 -0
  34. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/scripts/lint.sh +0 -0
  35. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/scripts/test-cov-html.sh +0 -0
  36. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/scripts/test.sh +0 -0
  37. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/__main__.py +0 -0
  38. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/cli.py +0 -0
  39. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  40. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  41. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
  42. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/config.py +0 -0
  43. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/py.typed +0 -0
  44. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  45. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
  46. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  47. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/__init__.py +0 -0
  48. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/broken_package/mod/__init__.py +0 -0
  49. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/broken_package/mod/app.py +0 -0
  50. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/broken_package/utils.py +0 -0
  51. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_api/api.py +0 -0
  52. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_app/api.py +0 -0
  53. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_app/app.py +0 -0
  54. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
  55. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_app_dir_api/app/api.py +0 -0
  56. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
  57. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_app_dir_app/app/api.py +0 -0
  58. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_app_dir_app/app/app.py +0 -0
  59. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
  60. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_app_dir_main/app/api.py +0 -0
  61. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_app_dir_main/app/app.py +0 -0
  62. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_app_dir_main/app/main.py +0 -0
  63. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_app_dir_non_default/app/__init__.py +0 -0
  64. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_app_dir_non_default/app/nondefault.py +0 -0
  65. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_main/api.py +0 -0
  66. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_main/app.py +0 -0
  67. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/default_main/main.py +0 -0
  68. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/default_files/non_default/nonstandard.py +0 -0
  69. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/package/__init__.py +0 -0
  70. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/package/core/__init__.py +0 -0
  71. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/package/core/utils.py +0 -0
  72. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/package/mod/__init__.py +0 -0
  73. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/package/mod/api.py +0 -0
  74. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/package/mod/app.py +0 -0
  75. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/package/mod/other.py +0 -0
  76. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/single_file_api.py +0 -0
  77. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/single_file_app.py +0 -0
  78. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/assets/single_file_other.py +0 -0
  79. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_archive.py +0 -0
  80. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_cli.py +0 -0
  81. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_cli_logout.py +0 -0
  82. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.0}/tests/test_cli_unlink.py +0 -0
  83. {fastapi_cloud_cli-0.11.0 → fastapi_cloud_cli-0.13.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.11.0
3
+ Version: 0.13.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
@@ -19,7 +19,6 @@ Classifier: Framework :: FastAPI
19
19
  Classifier: Intended Audience :: Developers
20
20
  Classifier: License :: OSI Approved :: MIT License
21
21
  Classifier: Programming Language :: Python :: 3 :: Only
22
- Classifier: Programming Language :: Python :: 3.9
23
22
  Classifier: Programming Language :: Python :: 3.10
24
23
  Classifier: Programming Language :: Python :: 3.11
25
24
  Classifier: Programming Language :: Python :: 3.12
@@ -29,13 +28,15 @@ Project-URL: Documentation, https://fastapi.tiangolo.com/fastapi-cloud-cli/
29
28
  Project-URL: Repository, https://github.com/fastapilabs/fastapi-cloud-cli
30
29
  Project-URL: Issues, https://github.com/fastapilabs/fastapi-cloud-cli/issues
31
30
  Project-URL: Changelog, https://github.com/fastapilabs/fastapi-cloud-cli/blob/main/release-notes.md
32
- Requires-Python: >=3.9
33
- Requires-Dist: typer>=0.12.3
34
- Requires-Dist: uvicorn[standard]>=0.15.0
31
+ Requires-Python: >=3.10
32
+ Requires-Dist: typer>=0.16.0
33
+ Requires-Dist: uvicorn[standard]>=0.17.6
35
34
  Requires-Dist: rignore>=0.5.1
36
35
  Requires-Dist: httpx>=0.27.0
37
- Requires-Dist: rich-toolkit>=0.14.5
38
- Requires-Dist: pydantic[email]>=2.0
36
+ Requires-Dist: rich-toolkit>=0.19.4
37
+ Requires-Dist: pydantic[email]>=2.7.4; python_version < "3.13"
38
+ Requires-Dist: pydantic[email]>=2.8.0; python_version == "3.13"
39
+ Requires-Dist: pydantic[email]>=2.12.0; python_version >= "3.14"
39
40
  Requires-Dist: sentry-sdk>=2.20.0
40
41
  Requires-Dist: fastar>=0.8.0
41
42
  Provides-Extra: standard
@@ -5,7 +5,7 @@ description = "Deploy and manage FastAPI Cloud apps from the command line 🚀"
5
5
  authors = [
6
6
  { name = "Patrick Arminio", email = "patrick@fastapilabs.com" },
7
7
  ]
8
- requires-python = ">=3.9"
8
+ requires-python = ">=3.10"
9
9
  readme = "README.md"
10
10
  classifiers = [
11
11
  "Intended Audience :: Information Technology",
@@ -23,23 +23,24 @@ classifiers = [
23
23
  "Intended Audience :: Developers",
24
24
  "License :: OSI Approved :: MIT License",
25
25
  "Programming Language :: Python :: 3 :: Only",
26
- "Programming Language :: Python :: 3.9",
27
26
  "Programming Language :: Python :: 3.10",
28
27
  "Programming Language :: Python :: 3.11",
29
28
  "Programming Language :: Python :: 3.12",
30
29
  "Programming Language :: Python :: 3.13",
31
30
  ]
32
31
  dependencies = [
33
- "typer >= 0.12.3",
34
- "uvicorn[standard] >= 0.15.0",
32
+ "typer >= 0.16.0",
33
+ "uvicorn[standard] >= 0.17.6",
35
34
  "rignore >= 0.5.1",
36
35
  "httpx >= 0.27.0",
37
- "rich-toolkit >= 0.14.5",
38
- "pydantic[email] >= 2.0",
36
+ "rich-toolkit >= 0.19.4",
37
+ "pydantic[email] >= 2.7.4; python_version < '3.13'",
38
+ "pydantic[email] >= 2.8.0; python_version == '3.13'",
39
+ "pydantic[email] >= 2.12.0; python_version >= '3.14'",
39
40
  "sentry-sdk >= 2.20.0",
40
41
  "fastar >= 0.8.0",
41
42
  ]
42
- version = "0.11.0"
43
+ version = "0.13.0"
43
44
 
44
45
  [project.license]
45
46
  text = "MIT"
@@ -59,8 +60,8 @@ Changelog = "https://github.com/fastapilabs/fastapi-cloud-cli/blob/main/release-
59
60
  [dependency-groups]
60
61
  dev = [
61
62
  "prek>=0.2.24,<1.0.0",
62
- "pytest>=4.4.0,<9.0.0",
63
- "coverage[toml]>=6.2,<8.0",
63
+ "pytest>=7.0.0,<9.0.0",
64
+ "coverage[toml]>=7.2,<8.0",
64
65
  "mypy==1.14.1",
65
66
  "ruff==0.13.0",
66
67
  "respx==0.22.0",
@@ -101,6 +102,7 @@ source = [
101
102
  "src",
102
103
  "tests",
103
104
  ]
105
+ relative_files = true
104
106
  context = "${CONTEXT}"
105
107
  dynamic_context = "test_function"
106
108
  omit = [
@@ -0,0 +1 @@
1
+ __version__ = "0.13.0"
@@ -1,19 +1,20 @@
1
1
  import contextlib
2
2
  import logging
3
+ import re
3
4
  import subprocess
4
5
  import tempfile
5
6
  import time
6
7
  from enum import Enum
7
8
  from itertools import cycle
8
- from pathlib import Path
9
+ from pathlib import Path, PurePosixPath
9
10
  from textwrap import dedent
10
- from typing import Annotated, Any, Optional, Union
11
+ from typing import Annotated, Any
11
12
 
12
13
  import fastar
13
14
  import rignore
14
15
  import typer
15
16
  from httpx import Client
16
- from pydantic import BaseModel, EmailStr, TypeAdapter, ValidationError
17
+ from pydantic import AfterValidator, BaseModel, EmailStr, TypeAdapter, ValidationError
17
18
  from rich.text import Text
18
19
  from rich_toolkit import RichToolkit
19
20
  from rich_toolkit.menu import Option
@@ -27,6 +28,39 @@ from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
27
28
  logger = logging.getLogger(__name__)
28
29
 
29
30
 
31
+ def validate_app_directory(v: str | None) -> str | None:
32
+ if v is None:
33
+ return None
34
+
35
+ v = v.strip()
36
+
37
+ if not v:
38
+ return None
39
+
40
+ if v.startswith("~"):
41
+ raise ValueError("cannot start with '~'")
42
+
43
+ path = PurePosixPath(v)
44
+
45
+ if path.is_absolute():
46
+ raise ValueError("must be a relative path, not absolute")
47
+
48
+ if ".." in path.parts:
49
+ raise ValueError("cannot contain '..' path segments")
50
+
51
+ normalized = path.as_posix()
52
+
53
+ if not re.fullmatch(r"[A-Za-z0-9._/ -]+", normalized):
54
+ raise ValueError(
55
+ "contains invalid characters (allowed: letters, numbers, space, / . _ -)"
56
+ )
57
+
58
+ return normalized
59
+
60
+
61
+ AppDirectory = Annotated[str | None, AfterValidator(validate_app_directory)]
62
+
63
+
30
64
  def _cancel_upload(deployment_id: str) -> None:
31
65
  logger.debug("Cancelling upload for deployment: %s", deployment_id)
32
66
 
@@ -113,13 +147,26 @@ def _get_teams() -> list[Team]:
113
147
  class AppResponse(BaseModel):
114
148
  id: str
115
149
  slug: str
150
+ directory: str | None
151
+
152
+
153
+ def _update_app(app_id: str, directory: str | None) -> AppResponse:
154
+ with APIClient() as client:
155
+ response = client.patch(
156
+ f"/apps/{app_id}",
157
+ json={"directory": directory},
158
+ )
159
+
160
+ response.raise_for_status()
161
+
162
+ return AppResponse.model_validate(response.json())
116
163
 
117
164
 
118
- def _create_app(team_id: str, app_name: str) -> AppResponse:
165
+ def _create_app(team_id: str, app_name: str, directory: str | None) -> AppResponse:
119
166
  with APIClient() as client:
120
167
  response = client.post(
121
168
  "/apps/",
122
- json={"name": app_name, "team_id": team_id},
169
+ json={"name": app_name, "team_id": team_id, "directory": directory},
123
170
  )
124
171
 
125
172
  response.raise_for_status()
@@ -226,7 +273,7 @@ def _upload_deployment(deployment_id: str, archive_path: Path) -> None:
226
273
  logger.debug("Upload notification sent successfully")
227
274
 
228
275
 
229
- def _get_app(app_slug: str) -> Optional[AppResponse]:
276
+ def _get_app(app_slug: str) -> AppResponse | None:
230
277
  with APIClient() as client:
231
278
  response = client.get(f"/apps/{app_slug}")
232
279
 
@@ -278,7 +325,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
278
325
 
279
326
  with toolkit.progress("Fetching teams...") as progress:
280
327
  with handle_http_errors(
281
- progress, message="Error fetching teams. Please try again later."
328
+ progress, default_message="Error fetching teams. Please try again later."
282
329
  ):
283
330
  teams = _get_teams()
284
331
 
@@ -298,12 +345,12 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
298
345
 
299
346
  toolkit.print_line()
300
347
 
301
- selected_app: Optional[AppResponse] = None
348
+ selected_app: AppResponse | None = None
302
349
 
303
350
  if not create_new_app:
304
351
  with toolkit.progress("Fetching apps...") as progress:
305
352
  with handle_http_errors(
306
- progress, message="Error fetching apps. Please try again later."
353
+ progress, default_message="Error fetching apps. Please try again later."
307
354
  ):
308
355
  apps = _get_apps(team.id)
309
356
 
@@ -332,10 +379,26 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
332
379
 
333
380
  toolkit.print_line()
334
381
 
382
+ initial_directory = selected_app.directory if selected_app else ""
383
+
384
+ directory_input = toolkit.input(
385
+ title="Path to the directory containing your app (e.g. src, backend):",
386
+ tag="dir",
387
+ value=initial_directory or "",
388
+ placeholder="[italic]Leave empty if it's the current directory[/italic]",
389
+ validator=TypeAdapter(AppDirectory),
390
+ )
391
+
392
+ directory: str | None = directory_input if directory_input else None
393
+
394
+ toolkit.print_line()
395
+
335
396
  toolkit.print("Deployment configuration:", tag="summary")
336
397
  toolkit.print_line()
337
398
  toolkit.print(f"Team: [bold]{team.name}[/bold]")
338
399
  toolkit.print(f"App name: [bold]{app_name}[/bold]")
400
+ toolkit.print(f"Directory: [bold]{directory or '.'}[/bold]")
401
+
339
402
  toolkit.print_line()
340
403
 
341
404
  choice = toolkit.ask(
@@ -352,12 +415,21 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
352
415
  toolkit.print("Deployment cancelled.")
353
416
  raise typer.Exit(0)
354
417
 
355
- if selected_app: # pragma: no cover
356
- app = selected_app
418
+ if selected_app:
419
+ if directory != selected_app.directory:
420
+ with (
421
+ toolkit.progress(title="Updating app directory...") as progress,
422
+ handle_http_errors(progress),
423
+ ):
424
+ app = _update_app(selected_app.id, directory=directory)
425
+
426
+ progress.log(f"App directory updated to '{directory or '.'}'")
427
+ else:
428
+ app = selected_app
357
429
  else:
358
430
  with toolkit.progress(title="Creating app...") as progress:
359
431
  with handle_http_errors(progress):
360
- app = _create_app(team.id, app_name)
432
+ app = _create_app(team.id, app_name, directory=directory)
361
433
 
362
434
  progress.log(f"App created successfully! App slug: {app.slug}")
363
435
 
@@ -446,13 +518,13 @@ def _wait_for_deployment(
446
518
 
447
519
  class SignupToWaitingList(BaseModel):
448
520
  email: EmailStr
449
- name: Optional[str] = None
450
- organization: Optional[str] = None
451
- role: Optional[str] = None
452
- team_size: Optional[str] = None
453
- location: Optional[str] = None
454
- use_case: Optional[str] = None
455
- secret_code: Optional[str] = None
521
+ name: str | None = None
522
+ organization: str | None = None
523
+ role: str | None = None
524
+ team_size: str | None = None
525
+ location: str | None = None
526
+ use_case: str | None = None
527
+ secret_code: str | None = None
456
528
 
457
529
 
458
530
  def _send_waitlist_form(
@@ -552,7 +624,7 @@ def _waitlist_form(toolkit: RichToolkit) -> None:
552
624
 
553
625
  def deploy(
554
626
  path: Annotated[
555
- Union[Path, None],
627
+ Path | None,
556
628
  typer.Argument(
557
629
  help="A path to the folder containing the app you want to deploy"
558
630
  ),
@@ -561,7 +633,7 @@ def deploy(
561
633
  bool, typer.Option("--no-wait", help="Skip waiting for deployment status")
562
634
  ] = False,
563
635
  provided_app_id: Annotated[
564
- Union[str, None],
636
+ str | None,
565
637
  typer.Option(
566
638
  "--app-id",
567
639
  help="Application ID to deploy to",
@@ -586,10 +658,16 @@ def deploy(
586
658
  toolkit.print_title("Welcome to FastAPI Cloud!", tag="FastAPI")
587
659
  toolkit.print_line()
588
660
 
589
- toolkit.print(
590
- "You need to be logged in to deploy to FastAPI Cloud.",
591
- tag="info",
592
- )
661
+ if identity.token and identity.is_expired():
662
+ toolkit.print(
663
+ "Your session has expired. Please log in again.",
664
+ tag="info",
665
+ )
666
+ else:
667
+ toolkit.print(
668
+ "You need to be logged in to deploy to FastAPI Cloud.",
669
+ tag="info",
670
+ )
593
671
  toolkit.print_line()
594
672
 
595
673
  choice = toolkit.ask(
@@ -601,8 +679,6 @@ def deploy(
601
679
  ],
602
680
  )
603
681
 
604
- toolkit.print_line()
605
-
606
682
  if choice == "login":
607
683
  login()
608
684
  else:
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
  from pathlib import Path
3
- from typing import Annotated, Any, Optional, Union
3
+ from typing import Annotated, Any
4
4
 
5
5
  import typer
6
6
  from pydantic import BaseModel
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
16
16
 
17
17
  class EnvironmentVariable(BaseModel):
18
18
  name: str
19
- value: Optional[str] = None
19
+ value: str | None = None
20
20
 
21
21
 
22
22
  class EnvironmentVariableResponse(BaseModel):
@@ -60,7 +60,7 @@ env_app = typer.Typer()
60
60
  @env_app.command()
61
61
  def list(
62
62
  path: Annotated[
63
- Union[Path, None],
63
+ Path | None,
64
64
  typer.Argument(
65
65
  help="A path to the folder containing the app you want to deploy"
66
66
  ),
@@ -110,12 +110,12 @@ def list(
110
110
 
111
111
  @env_app.command()
112
112
  def delete(
113
- name: Union[str, None] = typer.Argument(
113
+ name: str | None = typer.Argument(
114
114
  None,
115
115
  help="The name of the environment variable to delete",
116
116
  ),
117
117
  path: Annotated[
118
- Union[Path, None],
118
+ Path | None,
119
119
  typer.Argument(
120
120
  help="A path to the folder containing the app you want to deploy"
121
121
  ),
@@ -192,16 +192,16 @@ def delete(
192
192
 
193
193
  @env_app.command()
194
194
  def set(
195
- name: Union[str, None] = typer.Argument(
195
+ name: str | None = typer.Argument(
196
196
  None,
197
197
  help="The name of the environment variable to set",
198
198
  ),
199
- value: Union[str, None] = typer.Argument(
199
+ value: str | None = typer.Argument(
200
200
  None,
201
201
  help="The value of the environment variable to set",
202
202
  ),
203
203
  path: Annotated[
204
- Union[Path, None],
204
+ Path | None,
205
205
  typer.Argument(
206
206
  help="A path to the folder containing the app you want to deploy"
207
207
  ),
@@ -49,7 +49,8 @@ def link() -> Any:
49
49
 
50
50
  with toolkit.progress("Fetching teams...") as progress:
51
51
  with handle_http_errors(
52
- progress, message="Error fetching teams. Please try again later."
52
+ progress,
53
+ default_message="Error fetching teams. Please try again later.",
53
54
  ):
54
55
  with APIClient() as client:
55
56
  response = client.get("/teams/")
@@ -77,7 +78,7 @@ def link() -> Any:
77
78
 
78
79
  with toolkit.progress("Fetching apps...") as progress:
79
80
  with handle_http_errors(
80
- progress, message="Error fetching apps. Please try again later."
81
+ progress, default_message="Error fetching apps. Please try again later."
81
82
  ):
82
83
  with APIClient() as client:
83
84
  response = client.get("/apps/", params={"team_id": team["id"]})
@@ -78,17 +78,13 @@ def login() -> Any:
78
78
  """
79
79
  identity = Identity()
80
80
 
81
- if identity.is_expired():
82
- with get_rich_toolkit(minimal=True) as toolkit:
83
- toolkit.print("Your session has expired. Logging in again...")
84
- toolkit.print_line()
85
-
86
81
  if identity.is_logged_in():
87
82
  with get_rich_toolkit(minimal=True) as toolkit:
88
83
  toolkit.print("You are already logged in.")
89
84
  toolkit.print(
90
85
  "Run [bold]fastapi cloud logout[/bold] first if you want to switch accounts."
91
86
  )
87
+
92
88
  return
93
89
 
94
90
  with get_rich_toolkit() as toolkit, APIClient() as client:
@@ -2,9 +2,10 @@ import logging
2
2
  import re
3
3
  from datetime import datetime
4
4
  from pathlib import Path
5
- from typing import Annotated, Optional
5
+ from typing import Annotated
6
6
 
7
7
  import typer
8
+ from httpx import HTTPError
8
9
  from rich.markup import escape
9
10
  from rich_toolkit import RichToolkit
10
11
 
@@ -16,7 +17,7 @@ from fastapi_cloud_cli.utils.api import (
16
17
  )
17
18
  from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config
18
19
  from fastapi_cloud_cli.utils.auth import Identity
19
- from fastapi_cloud_cli.utils.cli import get_rich_toolkit
20
+ from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_error
20
21
 
21
22
  logger = logging.getLogger(__name__)
22
23
 
@@ -85,21 +86,20 @@ def _process_log_stream(
85
86
  return
86
87
  except KeyboardInterrupt: # pragma: no cover
87
88
  toolkit.print_line()
89
+
88
90
  return
89
91
  except StreamLogError as e:
90
- error_msg = str(e)
91
- if "HTTP 401" in error_msg or "HTTP 403" in error_msg:
92
- toolkit.print(
93
- "The specified token is not valid. Use [blue]`fastapi login`[/] to generate a new token.",
94
- )
95
- elif "HTTP 404" in error_msg:
96
- toolkit.print(
97
- "App not found. Make sure to use the correct account.",
98
- )
92
+ if e.status_code == 404:
93
+ message = "App not found. Make sure to use the correct account."
94
+
95
+ elif isinstance(e.__cause__, HTTPError):
96
+ message = handle_http_error(e.__cause__)
97
+
99
98
  else:
100
- toolkit.print(
101
- f"[red]Error:[/] {escape(error_msg)}",
102
- )
99
+ message = f"[red]Error:[/] {escape(str(e))}"
100
+
101
+ toolkit.print(message)
102
+
103
103
  raise typer.Exit(1) from None
104
104
  except (TooManyRetriesError, TimeoutError):
105
105
  toolkit.print(
@@ -110,7 +110,7 @@ def _process_log_stream(
110
110
 
111
111
  def logs(
112
112
  path: Annotated[
113
- Optional[Path],
113
+ Path | None,
114
114
  typer.Argument(
115
115
  help="Path to the folder containing the app (defaults to current directory)"
116
116
  ),
@@ -24,7 +24,7 @@ def whoami() -> Any:
24
24
 
25
25
  with APIClient() as client:
26
26
  with Progress(title="⚡ Fetching profile", transient=True) as progress:
27
- with handle_http_errors(progress, message=""):
27
+ with handle_http_errors(progress, default_message=""):
28
28
  response = client.get("/users/me")
29
29
  response.raise_for_status()
30
30
 
@@ -1,14 +1,11 @@
1
1
  import logging
2
2
  import os
3
- from typing import Union
4
3
 
5
4
  from rich.console import Console
6
5
  from rich.logging import RichHandler
7
6
 
8
7
 
9
- def setup_logging(
10
- terminal_width: Union[int, None] = None, level: Union[int, None] = None
11
- ) -> None:
8
+ def setup_logging(terminal_width: int | None = None, level: int | None = None) -> None:
12
9
  if level is None:
13
10
  level = (
14
11
  logging.DEBUG if os.getenv("FASTAPI_CLOUD_DEBUG") == "1" else logging.INFO
@@ -1,17 +1,14 @@
1
1
  import json
2
2
  import logging
3
3
  import time
4
- from collections.abc import Generator
4
+ from collections.abc import Callable, Generator
5
5
  from contextlib import contextmanager
6
6
  from datetime import timedelta
7
7
  from functools import wraps
8
8
  from typing import (
9
9
  Annotated,
10
- Callable,
11
10
  Literal,
12
- Optional,
13
11
  TypeVar,
14
- Union,
15
12
  )
16
13
 
17
14
  import httpx
@@ -32,7 +29,9 @@ STREAM_LOGS_TIMEOUT = timedelta(minutes=5)
32
29
  class StreamLogError(Exception):
33
30
  """Raised when there's an error streaming logs (build or app logs)."""
34
31
 
35
- pass
32
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
33
+ super().__init__(message)
34
+ self.status_code = status_code
36
35
 
37
36
 
38
37
  class TooManyRetriesError(Exception):
@@ -47,16 +46,16 @@ class AppLogEntry(BaseModel):
47
46
 
48
47
  class BuildLogLineGeneric(BaseModel):
49
48
  type: Literal["complete", "failed", "timeout", "heartbeat"]
50
- id: Optional[str] = None
49
+ id: str | None = None
51
50
 
52
51
 
53
52
  class BuildLogLineMessage(BaseModel):
54
53
  type: Literal["message"] = "message"
55
54
  message: str
56
- id: Optional[str] = None
55
+ id: str | None = None
57
56
 
58
57
 
59
- BuildLogLine = Union[BuildLogLineMessage, BuildLogLineGeneric]
58
+ BuildLogLine = BuildLogLineMessage | BuildLogLineGeneric
60
59
  BuildLogAdapter: TypeAdapter[BuildLogLine] = TypeAdapter(
61
60
  Annotated[BuildLogLine, Field(discriminator="type")]
62
61
  )
@@ -100,7 +99,8 @@ def attempt(attempt_number: int) -> Generator[None, None, None]:
100
99
  except Exception:
101
100
  error_detail = "(response body unavailable)"
102
101
  raise StreamLogError(
103
- f"HTTP {error.response.status_code}: {error_detail}"
102
+ f"HTTP {error.response.status_code}: {error_detail}",
103
+ status_code=error.response.status_code,
104
104
  ) from error
105
105
 
106
106
 
@@ -194,7 +194,7 @@ class APIClient(httpx.Client):
194
194
 
195
195
  time.sleep(0.5)
196
196
 
197
- def _parse_log_line(self, line: str) -> Optional[BuildLogLine]:
197
+ def _parse_log_line(self, line: str) -> BuildLogLine | None:
198
198
  try:
199
199
  return BuildLogAdapter.validate_json(line)
200
200
  except (ValidationError, json.JSONDecodeError) as e:
@@ -1,6 +1,5 @@
1
1
  import logging
2
2
  from pathlib import Path
3
- from typing import Optional
4
3
 
5
4
  from pydantic import BaseModel
6
5
 
@@ -12,7 +11,7 @@ class AppConfig(BaseModel):
12
11
  team_id: str
13
12
 
14
13
 
15
- def get_app_config(path_to_deploy: Path) -> Optional[AppConfig]:
14
+ def get_app_config(path_to_deploy: Path) -> AppConfig | None:
16
15
  config_path = path_to_deploy / ".fastapicloud/cloud.json"
17
16
  logger.debug("Looking for app config at: %s", config_path)
18
17
 
@@ -4,7 +4,7 @@ import json
4
4
  import logging
5
5
  import os
6
6
  import time
7
- from typing import Literal, Optional
7
+ from typing import Literal
8
8
 
9
9
  from pydantic import BaseModel
10
10
 
@@ -36,7 +36,7 @@ def delete_auth_config() -> None:
36
36
  logger.debug("Auth config file doesn't exist, nothing to delete")
37
37
 
38
38
 
39
- def read_auth_config() -> Optional[AuthConfig]:
39
+ def read_auth_config() -> AuthConfig | None:
40
40
  auth_path = get_auth_path()
41
41
  logger.debug("Reading auth config from: %s", auth_path)
42
42
 
@@ -48,7 +48,7 @@ def read_auth_config() -> Optional[AuthConfig]:
48
48
  return AuthConfig.model_validate_json(auth_path.read_text(encoding="utf-8"))
49
49
 
50
50
 
51
- def _get_auth_token() -> Optional[str]:
51
+ def _get_auth_token() -> str | None:
52
52
  logger.debug("Getting auth token")
53
53
  auth_data = read_auth_config()
54
54
 
@@ -120,7 +120,7 @@ class Identity:
120
120
  self.token = env_token
121
121
  self.auth_mode = "token"
122
122
 
123
- def _get_token_from_env(self) -> Optional[str]:
123
+ def _get_token_from_env(self) -> str | None:
124
124
  return os.environ.get("FASTAPI_CLOUD_TOKEN")
125
125
 
126
126
  def is_expired(self) -> bool: