fastapi-cloud-cli 0.3.0__tar.gz → 0.4.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.3.0 → fastapi_cloud_cli-0.4.0}/PKG-INFO +2 -1
  2. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/pyproject.toml +2 -1
  3. fastapi_cloud_cli-0.4.0/src/fastapi_cloud_cli/__init__.py +1 -0
  4. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/cli.py +16 -4
  5. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/deploy.py +27 -67
  6. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/env.py +5 -3
  7. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/login.py +22 -2
  8. fastapi_cloud_cli-0.4.0/src/fastapi_cloud_cli/utils/auth.py +122 -0
  9. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/conftest.py +5 -1
  10. fastapi_cloud_cli-0.4.0/tests/test_archive.py +132 -0
  11. fastapi_cloud_cli-0.4.0/tests/test_auth.py +107 -0
  12. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_cli_deploy.py +0 -95
  13. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_cli_login.py +53 -0
  14. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_cli_logout.py +1 -1
  15. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_cli_unlink.py +1 -1
  16. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_cli_whoami.py +1 -1
  17. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_deploy_utils.py +1 -0
  18. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_env_delete.py +1 -1
  19. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_env_list.py +1 -1
  20. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_env_set.py +4 -10
  21. fastapi_cloud_cli-0.4.0/tests/utils.py +42 -0
  22. fastapi_cloud_cli-0.3.0/src/fastapi_cloud_cli/__init__.py +0 -1
  23. fastapi_cloud_cli-0.3.0/src/fastapi_cloud_cli/utils/auth.py +0 -61
  24. fastapi_cloud_cli-0.3.0/tests/test_archive.py +0 -81
  25. fastapi_cloud_cli-0.3.0/tests/utils.py +0 -22
  26. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/LICENSE +0 -0
  27. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/README.md +0 -0
  28. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/requirements-tests.txt +0 -0
  29. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/requirements.txt +0 -0
  30. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/scripts/format.sh +0 -0
  31. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/scripts/lint.sh +0 -0
  32. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/scripts/test-cov-html.sh +0 -0
  33. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/scripts/test.sh +0 -0
  34. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/__main__.py +0 -0
  35. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  36. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  37. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
  38. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
  39. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/config.py +0 -0
  40. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/logging.py +0 -0
  41. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/py.typed +0 -0
  42. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  43. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/api.py +0 -0
  44. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
  45. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/cli.py +0 -0
  46. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/config.py +0 -0
  47. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
  48. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  49. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/__init__.py +0 -0
  50. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/broken_package/mod/__init__.py +0 -0
  51. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/broken_package/mod/app.py +0 -0
  52. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/broken_package/utils.py +0 -0
  53. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_api/api.py +0 -0
  54. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app/api.py +0 -0
  55. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app/app.py +0 -0
  56. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
  57. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_api/app/api.py +0 -0
  58. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
  59. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_app/app/api.py +0 -0
  60. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_app/app/app.py +0 -0
  61. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
  62. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/api.py +0 -0
  63. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/app.py +0 -0
  64. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/main.py +0 -0
  65. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_non_default/app/__init__.py +0 -0
  66. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_non_default/app/nondefault.py +0 -0
  67. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/api.py +0 -0
  68. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/app.py +0 -0
  69. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/main.py +0 -0
  70. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/non_default/nonstandard.py +0 -0
  71. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/__init__.py +0 -0
  72. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/core/__init__.py +0 -0
  73. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/core/utils.py +0 -0
  74. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/__init__.py +0 -0
  75. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/api.py +0 -0
  76. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/app.py +0 -0
  77. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/other.py +0 -0
  78. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/single_file_api.py +0 -0
  79. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/single_file_app.py +0 -0
  80. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/assets/single_file_other.py +0 -0
  81. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_cli.py +0 -0
  82. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.0}/tests/test_config.py +0 -0
  83. {fastapi_cloud_cli-0.3.0 → fastapi_cloud_cli-0.4.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.3.0
3
+ Version: 0.4.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
@@ -39,6 +39,7 @@ Requires-Dist: httpx>=0.27.0
39
39
  Requires-Dist: rich-toolkit>=0.14.5
40
40
  Requires-Dist: pydantic[email]>=1.6.1
41
41
  Requires-Dist: sentry-sdk>=2.20.0
42
+ Requires-Dist: fastar>=0.5.0
42
43
  Provides-Extra: standard
43
44
  Requires-Dist: uvicorn[standard]>=0.15.0; extra == "standard"
44
45
  Description-Content-Type: text/markdown
@@ -39,8 +39,9 @@ dependencies = [
39
39
  "rich-toolkit >= 0.14.5",
40
40
  "pydantic[email] >= 1.6.1",
41
41
  "sentry-sdk >= 2.20.0",
42
+ "fastar >= 0.5.0",
42
43
  ]
43
- version = "0.3.0"
44
+ version = "0.4.0"
44
45
 
45
46
  [project.license]
46
47
  text = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -13,17 +13,29 @@ setup_logging()
13
13
 
14
14
  app = typer.Typer(rich_markup_mode="rich")
15
15
 
16
+ cloud_app = typer.Typer(
17
+ rich_markup_mode="rich",
18
+ help="Manage [bold]FastAPI[/bold] Cloud deployments. 🚀",
19
+ )
16
20
 
17
21
  # TODO: use the app structure
18
22
 
19
23
  # Additional commands
24
+
25
+ # fastapi cloud [command]
26
+ cloud_app.command()(deploy)
27
+ cloud_app.command()(login)
28
+ cloud_app.command()(logout)
29
+ cloud_app.command()(whoami)
30
+ cloud_app.command()(unlink)
31
+
32
+ cloud_app.add_typer(env_app, name="env")
33
+
34
+ # fastapi [command]
20
35
  app.command()(deploy)
21
36
  app.command()(login)
22
- app.command()(logout)
23
- app.command()(whoami)
24
- app.command()(unlink)
25
37
 
26
- app.add_typer(env_app, name="env")
38
+ app.add_typer(cloud_app, name="cloud")
27
39
 
28
40
 
29
41
  def main() -> None:
@@ -2,15 +2,14 @@ import contextlib
2
2
  import json
3
3
  import logging
4
4
  import subprocess
5
- import tarfile
6
5
  import tempfile
7
6
  import time
8
- import uuid
9
7
  from enum import Enum
10
8
  from itertools import cycle
11
9
  from pathlib import Path
12
10
  from typing import Any, Dict, Generator, List, Optional, Union
13
11
 
12
+ import fastar
14
13
  import rignore
15
14
  import typer
16
15
  from httpx import Client
@@ -25,7 +24,6 @@ from fastapi_cloud_cli.utils.api import APIClient
25
24
  from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
26
25
  from fastapi_cloud_cli.utils.auth import is_logged_in
27
26
  from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
28
- from fastapi_cloud_cli.utils.env import validate_environment_variable_name
29
27
 
30
28
  logger = logging.getLogger(__name__)
31
29
 
@@ -36,7 +34,14 @@ def _get_app_name(path: Path) -> str:
36
34
 
37
35
 
38
36
  def _should_exclude_entry(path: Path) -> bool:
39
- parts_to_exclude = [".venv", "__pycache__", ".mypy_cache", ".pytest_cache"]
37
+ parts_to_exclude = [
38
+ ".venv",
39
+ "__pycache__",
40
+ ".mypy_cache",
41
+ ".pytest_cache",
42
+ ".gitignore",
43
+ ".fastapicloudignore",
44
+ ]
40
45
 
41
46
  if any(part in path.parts for part in parts_to_exclude):
42
47
  return True
@@ -47,28 +52,26 @@ def _should_exclude_entry(path: Path) -> bool:
47
52
  return False
48
53
 
49
54
 
50
- def archive(path: Path) -> Path:
55
+ def archive(path: Path, tar_path: Path) -> Path:
51
56
  logger.debug("Starting archive creation for path: %s", path)
52
57
  files = rignore.walk(
53
58
  path,
54
59
  should_exclude_entry=_should_exclude_entry,
55
60
  additional_ignore_paths=[".fastapicloudignore"],
61
+ ignore_hidden=False,
56
62
  )
57
63
 
58
- temp_dir = tempfile.mkdtemp()
59
- logger.debug("Created temp directory: %s", temp_dir)
60
-
61
- name = f"fastapi-cloud-deploy-{uuid.uuid4()}"
62
- tar_path = Path(temp_dir) / f"{name}.tar"
63
64
  logger.debug("Archive will be created at: %s", tar_path)
64
65
 
65
66
  file_count = 0
66
- with tarfile.open(tar_path, "w") as tar:
67
+ with fastar.open(tar_path, "w") as tar:
67
68
  for filename in files:
68
69
  if filename.is_dir():
69
70
  continue
70
71
 
71
- tar.add(filename, arcname=filename.relative_to(path))
72
+ arcname = filename.relative_to(path)
73
+ logger.debug("Adding %s to archive", arcname)
74
+ tar.append(filename, arcname=arcname)
72
75
  file_count += 1
73
76
 
74
77
  logger.debug("Archive created successfully with %s files", file_count)
@@ -120,6 +123,7 @@ class DeploymentStatus(str, Enum):
120
123
  deploying_failed = "deploying_failed"
121
124
  verifying = "verifying"
122
125
  verifying_failed = "verifying_failed"
126
+ verifying_skipped = "verifying_skipped"
123
127
  success = "success"
124
128
  failed = "failed"
125
129
 
@@ -137,6 +141,7 @@ class DeploymentStatus(str, Enum):
137
141
  cls.deploying_failed: "Deploying failed",
138
142
  cls.verifying: "Verifying",
139
143
  cls.verifying_failed: "Verifying failed",
144
+ cls.verifying_skipped: "Verification skipped",
140
145
  cls.success: "Success",
141
146
  cls.failed: "Failed",
142
147
  }[status]
@@ -229,12 +234,6 @@ def _get_apps(team_id: str) -> List[AppResponse]:
229
234
  return [AppResponse.model_validate(app) for app in data]
230
235
 
231
236
 
232
- def _create_environment_variables(app_id: str, env_vars: Dict[str, str]) -> None:
233
- with APIClient() as client:
234
- response = client.patch(f"/apps/{app_id}/environment-variables/", json=env_vars)
235
- response.raise_for_status()
236
-
237
-
238
237
  def _stream_build_logs(deployment_id: str) -> Generator[str, None, None]:
239
238
  with APIClient() as client:
240
239
  with client.stream(
@@ -399,45 +398,6 @@ def _wait_for_deployment(
399
398
  last_message_changed_at = time.monotonic() # pragma: no cover
400
399
 
401
400
 
402
- def _setup_environment_variables(toolkit: RichToolkit, app_id: str) -> None:
403
- if not toolkit.confirm("Do you want to setup environment variables?", tag="env"):
404
- return
405
-
406
- toolkit.print_line()
407
-
408
- env_vars = {}
409
-
410
- while True:
411
- key = toolkit.input(
412
- "Enter the environment variable name: [ENTER to skip]", required=False
413
- )
414
-
415
- if key.strip() == "":
416
- break
417
-
418
- if not validate_environment_variable_name(key):
419
- toolkit.print(
420
- "[error]Invalid environment variable name.",
421
- )
422
-
423
- else:
424
- value = toolkit.input(
425
- "Enter the environment variable value:", password=True
426
- )
427
-
428
- env_vars[key] = value
429
-
430
- toolkit.print_line()
431
-
432
- toolkit.print_line()
433
-
434
- with toolkit.progress("Setting up environment variables...") as progress:
435
- with handle_http_errors(progress):
436
- _create_environment_variables(app_id, env_vars)
437
-
438
- progress.log("Environment variables set up successfully!")
439
-
440
-
441
401
  class SignupToWaitingList(BaseModel):
442
402
  email: EmailStr
443
403
  name: Optional[str] = None
@@ -539,7 +499,7 @@ def _waitlist_form(toolkit: RichToolkit) -> None:
539
499
 
540
500
  with contextlib.suppress(Exception):
541
501
  subprocess.run(
542
- ["open", "raycast://confetti"],
502
+ ["open", "raycast://confetti?emojis=🐔⚡"],
543
503
  stdout=subprocess.DEVNULL,
544
504
  stderr=subprocess.DEVNULL,
545
505
  check=False,
@@ -605,9 +565,6 @@ def deploy(
605
565
  logger.debug("No app config found, configuring new app")
606
566
  app_config = _configure_app(toolkit, path_to_deploy=path_to_deploy)
607
567
  toolkit.print_line()
608
-
609
- _setup_environment_variables(toolkit, app_config.app_id)
610
- toolkit.print_line()
611
568
  else:
612
569
  logger.debug("Existing app config found, proceeding with deployment")
613
570
  toolkit.print("Deploying app...")
@@ -627,16 +584,19 @@ def deploy(
627
584
  if not app:
628
585
  toolkit.print_line()
629
586
  toolkit.print(
630
- "If you deleted this app, you can run [bold]fastapi unlink[/] to unlink the local configuration.",
587
+ "If you deleted this app, you can run [bold]fastapi cloud unlink[/] to unlink the local configuration.",
631
588
  tag="tip",
632
589
  )
633
590
  raise typer.Exit(1)
634
591
 
635
- logger.debug("Creating archive for deployment")
636
- archive_path = archive(path or Path.cwd()) # noqa: F841
592
+ with tempfile.TemporaryDirectory() as temp_dir:
593
+ logger.debug("Creating archive for deployment")
594
+ archive_path = Path(temp_dir) / "archive.tar"
595
+ archive(path or Path.cwd(), archive_path)
637
596
 
638
- with toolkit.progress(title="Creating deployment") as progress:
639
- with handle_http_errors(progress):
597
+ with toolkit.progress(
598
+ title="Creating deployment"
599
+ ) as progress, handle_http_errors(progress):
640
600
  logger.debug("Creating deployment for app: %s", app.id)
641
601
  deployment = _create_deployment(app.id)
642
602
 
@@ -648,7 +608,7 @@ def deploy(
648
608
 
649
609
  _upload_deployment(deployment.id, archive_path)
650
610
 
651
- progress.log("Deployment uploaded successfully!")
611
+ progress.log("Deployment uploaded successfully!")
652
612
 
653
613
  toolkit.print_line()
654
614
 
@@ -44,11 +44,13 @@ def _delete_environment_variable(app_id: str, name: str) -> bool:
44
44
  return True
45
45
 
46
46
 
47
- def _set_environment_variable(app_id: str, name: str, value: str) -> None:
47
+ def _set_environment_variable(
48
+ app_id: str, name: str, value: str, is_secret: bool = False
49
+ ) -> None:
48
50
  with APIClient() as client:
49
- response = client.patch(
51
+ response = client.post(
50
52
  f"/apps/{app_id}/environment-variables/",
51
- json={name: value},
53
+ json={"name": name, "value": value, "is_secret": is_secret},
52
54
  )
53
55
  response.raise_for_status()
54
56
 
@@ -8,7 +8,13 @@ from pydantic import BaseModel
8
8
 
9
9
  from fastapi_cloud_cli.config import Settings
10
10
  from fastapi_cloud_cli.utils.api import APIClient
11
- from fastapi_cloud_cli.utils.auth import AuthConfig, write_auth_config
11
+ from fastapi_cloud_cli.utils.auth import (
12
+ AuthConfig,
13
+ get_auth_token,
14
+ is_logged_in,
15
+ is_token_expired,
16
+ write_auth_config,
17
+ )
12
18
  from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
13
19
 
14
20
  logger = logging.getLogger(__name__)
@@ -76,6 +82,20 @@ def login() -> Any:
76
82
  """
77
83
  Login to FastAPI Cloud. 🚀
78
84
  """
85
+ token = get_auth_token()
86
+ if token is not None and is_token_expired(token):
87
+ with get_rich_toolkit(minimal=True) as toolkit:
88
+ toolkit.print("Your session has expired. Logging in again...")
89
+ toolkit.print_line()
90
+
91
+ if is_logged_in():
92
+ with get_rich_toolkit(minimal=True) as toolkit:
93
+ toolkit.print("You are already logged in.")
94
+ toolkit.print(
95
+ "Run [bold]fastapi logout[/bold] first if you want to switch accounts."
96
+ )
97
+ return
98
+
79
99
  with get_rich_toolkit() as toolkit, APIClient() as client:
80
100
  toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI")
81
101
 
@@ -87,7 +107,7 @@ def login() -> Any:
87
107
 
88
108
  url = authorization_data.verification_uri_complete
89
109
 
90
- progress.log(f"Opening {url}")
110
+ progress.log(f"Opening [link={url}]{url}[/link]")
91
111
 
92
112
  toolkit.print_line()
93
113
 
@@ -0,0 +1,122 @@
1
+ import base64
2
+ import binascii
3
+ import json
4
+ import logging
5
+ import time
6
+ from typing import Optional
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from .config import get_auth_path
11
+
12
+ logger = logging.getLogger("fastapi_cli")
13
+
14
+
15
+ class AuthConfig(BaseModel):
16
+ access_token: str
17
+
18
+
19
+ def write_auth_config(auth_data: AuthConfig) -> None:
20
+ auth_path = get_auth_path()
21
+ logger.debug("Writing auth config to: %s", auth_path)
22
+
23
+ auth_path.write_text(auth_data.model_dump_json(), encoding="utf-8")
24
+ logger.debug("Auth config written successfully")
25
+
26
+
27
+ def delete_auth_config() -> None:
28
+ auth_path = get_auth_path()
29
+ logger.debug("Deleting auth config at: %s", auth_path)
30
+
31
+ if auth_path.exists():
32
+ auth_path.unlink()
33
+ logger.debug("Auth config deleted successfully")
34
+ else:
35
+ logger.debug("Auth config file doesn't exist, nothing to delete")
36
+
37
+
38
+ def read_auth_config() -> Optional[AuthConfig]:
39
+ auth_path = get_auth_path()
40
+ logger.debug("Reading auth config from: %s", auth_path)
41
+
42
+ if not auth_path.exists():
43
+ logger.debug("Auth config file doesn't exist")
44
+ return None
45
+
46
+ logger.debug("Auth config loaded successfully")
47
+ return AuthConfig.model_validate_json(auth_path.read_text(encoding="utf-8"))
48
+
49
+
50
+ def get_auth_token() -> Optional[str]:
51
+ logger.debug("Getting auth token")
52
+ auth_data = read_auth_config()
53
+
54
+ if auth_data is None:
55
+ logger.debug("No auth data found")
56
+ return None
57
+
58
+ logger.debug("Auth token retrieved successfully")
59
+ return auth_data.access_token
60
+
61
+
62
+ def is_token_expired(token: str) -> bool:
63
+ try:
64
+ parts = token.split(".")
65
+
66
+ if len(parts) != 3:
67
+ logger.debug("Invalid JWT format: expected 3 parts, got %d", len(parts))
68
+ return True
69
+
70
+ payload = parts[1]
71
+
72
+ # Add padding if needed (JWT uses base64url encoding without padding)
73
+ if padding := len(payload) % 4:
74
+ payload += "=" * (4 - padding)
75
+
76
+ payload = payload.replace("-", "+").replace("_", "/")
77
+ decoded_bytes = base64.b64decode(payload)
78
+ payload_data = json.loads(decoded_bytes)
79
+
80
+ exp = payload_data.get("exp")
81
+
82
+ if exp is None:
83
+ logger.debug("No 'exp' claim found in token")
84
+
85
+ return False
86
+
87
+ if not isinstance(exp, int): # pragma: no cover
88
+ logger.debug("Invalid 'exp' claim: expected int, got %s", type(exp))
89
+
90
+ return True
91
+
92
+ current_time = time.time()
93
+
94
+ is_expired = current_time >= exp
95
+
96
+ logger.debug(
97
+ "Token expiration check: current=%d, exp=%d, expired=%s",
98
+ current_time,
99
+ exp,
100
+ is_expired,
101
+ )
102
+
103
+ return is_expired
104
+ except (binascii.Error, json.JSONDecodeError) as e:
105
+ logger.debug("Error parsing JWT token: %s", e)
106
+
107
+ return True
108
+
109
+
110
+ def is_logged_in() -> bool:
111
+ token = get_auth_token()
112
+
113
+ if token is None:
114
+ logger.debug("Login status: False (no token)")
115
+ return False
116
+
117
+ if is_token_expired(token):
118
+ logger.debug("Login status: False (token expired)")
119
+ return False
120
+
121
+ logger.debug("Login status: True")
122
+ return True
@@ -7,6 +7,8 @@ from unittest.mock import patch
7
7
  import pytest
8
8
  from typer import rich_utils
9
9
 
10
+ from .utils import create_jwt_token
11
+
10
12
 
11
13
  @pytest.fixture(autouse=True)
12
14
  def reset_syspath() -> Generator[None, None, None]:
@@ -26,7 +28,9 @@ def setup_terminal() -> None:
26
28
 
27
29
  @pytest.fixture
28
30
  def logged_in_cli(temp_auth_config: Path) -> Generator[None, None, None]:
29
- temp_auth_config.write_text('{"access_token": "test_token_12345"}')
31
+ valid_token = create_jwt_token({"sub": "test_user_12345"})
32
+
33
+ temp_auth_config.write_text(f'{{"access_token": "{valid_token}"}}')
30
34
 
31
35
  yield
32
36
 
@@ -0,0 +1,132 @@
1
+ import tarfile
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from fastapi_cloud_cli.commands.deploy import archive
7
+
8
+
9
+ @pytest.fixture
10
+ def src_path(tmp_path: Path) -> Path:
11
+ path = tmp_path / "source"
12
+ path.mkdir()
13
+ return path
14
+
15
+
16
+ @pytest.fixture
17
+ def tar_path(tmp_path: Path) -> Path:
18
+ return tmp_path / "archive.tar"
19
+
20
+
21
+ def test_archive_creates_tar_file(src_path: Path, tar_path: Path) -> None:
22
+ (src_path / "main.py").write_text("print('hello')")
23
+ (src_path / "config.json").write_text('{"key": "value"}')
24
+ (src_path / "subdir").mkdir()
25
+ (src_path / "subdir" / "utils.py").write_text("def helper(): pass")
26
+
27
+ archive(src_path, tar_path)
28
+ assert tar_path.exists()
29
+
30
+
31
+ def test_archive_excludes_venv_and_similar_folders(
32
+ src_path: Path, tar_path: Path
33
+ ) -> None:
34
+ """Should exclude .venv directory from archive."""
35
+ # the only files we want to include
36
+ (src_path / "main.py").write_text("print('hello')")
37
+ (src_path / "static").mkdir()
38
+ (src_path / "static" / "index.html").write_text("<html></html>")
39
+ # virtualenv
40
+ (src_path / ".venv").mkdir()
41
+ (src_path / ".venv" / "lib").mkdir()
42
+ (src_path / ".venv" / "lib" / "package.py").write_text("# package")
43
+ # pycache
44
+ (src_path / "__pycache__").mkdir()
45
+ (src_path / "__pycache__" / "main.cpython-311.pyc").write_text("bytecode")
46
+ # pyc files
47
+ (src_path / "main.pyc").write_text("bytecode")
48
+ # mypy/pytest
49
+ (src_path / ".mypy_cache").mkdir()
50
+ (src_path / ".mypy_cache" / "file.json").write_text("{}")
51
+ (src_path / ".pytest_cache").mkdir()
52
+ (src_path / ".pytest_cache" / "cache.db").write_text("data")
53
+
54
+ archive(src_path, tar_path)
55
+
56
+ with tarfile.open(tar_path, "r") as tar:
57
+ names = tar.getnames()
58
+ assert set(names) == {"main.py", "static/index.html"}
59
+
60
+
61
+ def test_archive_preserves_relative_paths(src_path: Path, tar_path: Path) -> None:
62
+ (src_path / "src").mkdir()
63
+ (src_path / "src" / "app").mkdir()
64
+ (src_path / "src" / "app" / "main.py").write_text("print('hello')")
65
+
66
+ archive(src_path, tar_path)
67
+
68
+ with tarfile.open(tar_path, "r") as tar:
69
+ names = tar.getnames()
70
+ assert names == ["src/app/main.py"]
71
+
72
+
73
+ def test_archive_respects_fastapicloudignore(src_path: Path, tar_path: Path) -> None:
74
+ """Should exclude files specified in .fastapicloudignore."""
75
+ (src_path / "main.py").write_text("print('hello')")
76
+ (src_path / "config.py").write_text("CONFIG = 'value'")
77
+ (src_path / "secrets.env").write_text("SECRET_KEY=xyz")
78
+ (src_path / "data").mkdir()
79
+ (src_path / "data" / "file.txt").write_text("data")
80
+
81
+ (src_path / ".fastapicloudignore").write_text("secrets.env\ndata/\n")
82
+
83
+ archive(src_path, tar_path)
84
+
85
+ with tarfile.open(tar_path, "r") as tar:
86
+ names = tar.getnames()
87
+ assert set(names) == {
88
+ "main.py",
89
+ "config.py",
90
+ }
91
+
92
+
93
+ def test_archive_respects_fastapicloudignore_unignore(
94
+ src_path: Path, tar_path: Path
95
+ ) -> None:
96
+ """Test we can use .fastapicloudignore to unignore files inside .gitignore"""
97
+ (src_path / "main.py").write_text("print('hello')")
98
+
99
+ (src_path / "ignore_me.txt").write_text("You should ignore me")
100
+
101
+ (src_path / "static/build").mkdir(exist_ok=True, parents=True)
102
+ (src_path / "static/build/style.css").write_text("body { background: #bada55 }")
103
+
104
+ # Rignore needs a .git folder to make .gitignore work
105
+ (src_path / ".git").mkdir(exist_ok=True, parents=True)
106
+ (src_path / ".gitignore").write_text("ignore_me.txt\nbuild/")
107
+
108
+ (src_path / ".fastapicloudignore").write_text("!static/build")
109
+
110
+ archive(src_path, tar_path)
111
+
112
+ with tarfile.open(tar_path, "r") as tar:
113
+ names = tar.getnames()
114
+ assert set(names) == {"main.py", "static/build/style.css"}
115
+
116
+
117
+ def test_archive_includes_hidden_files(src_path: Path, tar_path: Path) -> None:
118
+ """Should include hidden files in the archive by default."""
119
+ (src_path / "main.py").write_text("print('hello')")
120
+ (src_path / ".env").write_text("SECRET_KEY=xyz")
121
+ (src_path / ".config").mkdir()
122
+ (src_path / ".config" / "settings.json").write_text('{"setting": "value"}')
123
+
124
+ archive(src_path, tar_path)
125
+
126
+ with tarfile.open(tar_path, "r") as tar:
127
+ names = tar.getnames()
128
+ assert set(names) == {
129
+ "main.py",
130
+ ".env",
131
+ ".config/settings.json",
132
+ }