fastapi-cloud-cli 0.3.1__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.1 → fastapi_cloud_cli-0.4.0}/PKG-INFO +2 -1
  2. {fastapi_cloud_cli-0.3.1 → 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.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/cli.py +16 -4
  5. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/deploy.py +27 -19
  6. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/login.py +21 -1
  7. fastapi_cloud_cli-0.4.0/src/fastapi_cloud_cli/utils/auth.py +122 -0
  8. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/conftest.py +5 -1
  9. fastapi_cloud_cli-0.4.0/tests/test_archive.py +132 -0
  10. fastapi_cloud_cli-0.4.0/tests/test_auth.py +107 -0
  11. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_cli_login.py +53 -0
  12. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_cli_logout.py +1 -1
  13. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_cli_unlink.py +1 -1
  14. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_cli_whoami.py +1 -1
  15. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_deploy_utils.py +1 -0
  16. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_env_delete.py +1 -1
  17. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_env_list.py +1 -1
  18. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_env_set.py +1 -1
  19. fastapi_cloud_cli-0.4.0/tests/utils.py +42 -0
  20. fastapi_cloud_cli-0.3.1/src/fastapi_cloud_cli/__init__.py +0 -1
  21. fastapi_cloud_cli-0.3.1/src/fastapi_cloud_cli/utils/auth.py +0 -61
  22. fastapi_cloud_cli-0.3.1/tests/test_archive.py +0 -103
  23. fastapi_cloud_cli-0.3.1/tests/utils.py +0 -22
  24. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/LICENSE +0 -0
  25. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/README.md +0 -0
  26. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/requirements-tests.txt +0 -0
  27. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/requirements.txt +0 -0
  28. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/scripts/format.sh +0 -0
  29. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/scripts/lint.sh +0 -0
  30. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/scripts/test-cov-html.sh +0 -0
  31. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/scripts/test.sh +0 -0
  32. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/__main__.py +0 -0
  33. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  34. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/env.py +0 -0
  35. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  36. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
  37. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
  38. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/config.py +0 -0
  39. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/logging.py +0 -0
  40. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/py.typed +0 -0
  41. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  42. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/api.py +0 -0
  43. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/apps.py +0 -0
  44. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/cli.py +0 -0
  45. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/config.py +0 -0
  46. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/env.py +0 -0
  47. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  48. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/__init__.py +0 -0
  49. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/broken_package/mod/__init__.py +0 -0
  50. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/broken_package/mod/app.py +0 -0
  51. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/broken_package/utils.py +0 -0
  52. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_api/api.py +0 -0
  53. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app/api.py +0 -0
  54. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app/app.py +0 -0
  55. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
  56. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_api/app/api.py +0 -0
  57. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
  58. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_app/app/api.py +0 -0
  59. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_app/app/app.py +0 -0
  60. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
  61. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/api.py +0 -0
  62. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/app.py +0 -0
  63. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_main/app/main.py +0 -0
  64. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_non_default/app/__init__.py +0 -0
  65. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_app_dir_non_default/app/nondefault.py +0 -0
  66. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/api.py +0 -0
  67. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/app.py +0 -0
  68. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/default_main/main.py +0 -0
  69. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/default_files/non_default/nonstandard.py +0 -0
  70. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/__init__.py +0 -0
  71. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/core/__init__.py +0 -0
  72. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/core/utils.py +0 -0
  73. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/__init__.py +0 -0
  74. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/api.py +0 -0
  75. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/app.py +0 -0
  76. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/package/mod/other.py +0 -0
  77. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/single_file_api.py +0 -0
  78. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/single_file_app.py +0 -0
  79. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/assets/single_file_other.py +0 -0
  80. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_cli.py +0 -0
  81. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_cli_deploy.py +0 -0
  82. {fastapi_cloud_cli-0.3.1 → fastapi_cloud_cli-0.4.0}/tests/test_config.py +0 -0
  83. {fastapi_cloud_cli-0.3.1 → 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.1
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.1"
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
@@ -35,7 +34,14 @@ def _get_app_name(path: Path) -> str:
35
34
 
36
35
 
37
36
  def _should_exclude_entry(path: Path) -> bool:
38
- 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
+ ]
39
45
 
40
46
  if any(part in path.parts for part in parts_to_exclude):
41
47
  return True
@@ -46,29 +52,26 @@ def _should_exclude_entry(path: Path) -> bool:
46
52
  return False
47
53
 
48
54
 
49
- def archive(path: Path) -> Path:
55
+ def archive(path: Path, tar_path: Path) -> Path:
50
56
  logger.debug("Starting archive creation for path: %s", path)
51
57
  files = rignore.walk(
52
58
  path,
53
59
  should_exclude_entry=_should_exclude_entry,
54
60
  additional_ignore_paths=[".fastapicloudignore"],
61
+ ignore_hidden=False,
55
62
  )
56
63
 
57
- temp_dir = tempfile.mkdtemp()
58
- logger.debug("Created temp directory: %s", temp_dir)
59
-
60
- name = f"fastapi-cloud-deploy-{uuid.uuid4()}"
61
- tar_path = Path(temp_dir) / f"{name}.tar"
62
64
  logger.debug("Archive will be created at: %s", tar_path)
63
65
 
64
66
  file_count = 0
65
- with tarfile.open(tar_path, "w") as tar:
67
+ with fastar.open(tar_path, "w") as tar:
66
68
  for filename in files:
67
69
  if filename.is_dir():
68
70
  continue
69
71
 
70
- logger.debug("Adding %s to archive", filename.relative_to(path))
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]
@@ -494,7 +499,7 @@ def _waitlist_form(toolkit: RichToolkit) -> None:
494
499
 
495
500
  with contextlib.suppress(Exception):
496
501
  subprocess.run(
497
- ["open", "raycast://confetti"],
502
+ ["open", "raycast://confetti?emojis=🐔⚡"],
498
503
  stdout=subprocess.DEVNULL,
499
504
  stderr=subprocess.DEVNULL,
500
505
  check=False,
@@ -579,16 +584,19 @@ def deploy(
579
584
  if not app:
580
585
  toolkit.print_line()
581
586
  toolkit.print(
582
- "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.",
583
588
  tag="tip",
584
589
  )
585
590
  raise typer.Exit(1)
586
591
 
587
- logger.debug("Creating archive for deployment")
588
- 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)
589
596
 
590
- with toolkit.progress(title="Creating deployment") as progress:
591
- with handle_http_errors(progress):
597
+ with toolkit.progress(
598
+ title="Creating deployment"
599
+ ) as progress, handle_http_errors(progress):
592
600
  logger.debug("Creating deployment for app: %s", app.id)
593
601
  deployment = _create_deployment(app.id)
594
602
 
@@ -600,7 +608,7 @@ def deploy(
600
608
 
601
609
  _upload_deployment(deployment.id, archive_path)
602
610
 
603
- progress.log("Deployment uploaded successfully!")
611
+ progress.log("Deployment uploaded successfully!")
604
612
 
605
613
  toolkit.print_line()
606
614
 
@@ -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
 
@@ -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
+ }
@@ -0,0 +1,107 @@
1
+ import base64
2
+ import time
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from fastapi_cloud_cli.utils.auth import (
8
+ AuthConfig,
9
+ is_logged_in,
10
+ is_token_expired,
11
+ write_auth_config,
12
+ )
13
+
14
+ from .utils import create_jwt_token
15
+
16
+
17
+ def test_is_token_expired_with_valid_token() -> None:
18
+ future_exp = int(time.time()) + 3600
19
+
20
+ token = create_jwt_token({"exp": future_exp, "sub": "test_user"})
21
+
22
+ assert not is_token_expired(token)
23
+
24
+
25
+ def test_is_token_expired_with_expired_token() -> None:
26
+ past_exp = int(time.time()) - 3600
27
+ token = create_jwt_token({"exp": past_exp, "sub": "test_user"})
28
+
29
+ assert is_token_expired(token)
30
+
31
+
32
+ def test_is_token_expired_with_no_exp_claim() -> None:
33
+ token = create_jwt_token({"sub": "test_user"})
34
+
35
+ # Tokens without exp claim should be considered valid
36
+ assert not is_token_expired(token)
37
+
38
+
39
+ @pytest.mark.parametrize(
40
+ "token",
41
+ [
42
+ "not.a.valid.jwt.token",
43
+ "only.two",
44
+ "invalid",
45
+ "",
46
+ "...",
47
+ ],
48
+ )
49
+ def test_is_token_expired_with_malformed_token(token: str) -> None:
50
+ assert is_token_expired(token)
51
+
52
+
53
+ def test_is_token_expired_with_invalid_base64() -> None:
54
+ token = "header.!!!invalid_signature!!!.signature"
55
+ assert is_token_expired(token)
56
+
57
+
58
+ def test_is_token_expired_with_invalid_json() -> None:
59
+ header_encoded = base64.urlsafe_b64encode(b'{"alg":"HS256"}').decode().rstrip("=")
60
+ payload_encoded = base64.urlsafe_b64encode(b"{invalid json}").decode().rstrip("=")
61
+ signature = base64.urlsafe_b64encode(b"signature").decode().rstrip("=")
62
+ token = f"{header_encoded}.{payload_encoded}.{signature}"
63
+
64
+ assert is_token_expired(token)
65
+
66
+
67
+ def test_is_logged_in_with_no_token(temp_auth_config: Path) -> None:
68
+ assert not temp_auth_config.exists()
69
+ assert not is_logged_in()
70
+
71
+
72
+ def test_is_logged_in_with_valid_token(temp_auth_config: Path) -> None:
73
+ future_exp = int(time.time()) + 3600
74
+ token = create_jwt_token({"exp": future_exp, "sub": "test_user"})
75
+
76
+ write_auth_config(AuthConfig(access_token=token))
77
+
78
+ assert is_logged_in()
79
+
80
+
81
+ def test_is_logged_in_with_expired_token(temp_auth_config: Path) -> None:
82
+ past_exp = int(time.time()) - 3600
83
+ token = create_jwt_token({"exp": past_exp, "sub": "test_user"})
84
+
85
+ write_auth_config(AuthConfig(access_token=token))
86
+
87
+ assert not is_logged_in()
88
+
89
+
90
+ def test_is_logged_in_with_malformed_token(temp_auth_config: Path) -> None:
91
+ write_auth_config(AuthConfig(access_token="not.a.valid.token"))
92
+
93
+ assert not is_logged_in()
94
+
95
+
96
+ def test_is_token_expired_edge_case_exact_expiration() -> None:
97
+ current_time = int(time.time())
98
+ token = create_jwt_token({"exp": current_time, "sub": "test_user"})
99
+
100
+ assert is_token_expired(token)
101
+
102
+
103
+ def test_is_token_expired_edge_case_one_second_before() -> None:
104
+ current_time = int(time.time())
105
+ token = create_jwt_token({"exp": current_time + 1, "sub": "test_user"})
106
+
107
+ assert not is_token_expired(token)
@@ -1,3 +1,4 @@
1
+ import time
1
2
  from pathlib import Path
2
3
  from unittest.mock import patch
3
4
 
@@ -9,6 +10,7 @@ from typer.testing import CliRunner
9
10
 
10
11
  from fastapi_cloud_cli.cli import app
11
12
  from fastapi_cloud_cli.config import Settings
13
+ from tests.utils import create_jwt_token
12
14
 
13
15
  runner = CliRunner()
14
16
  settings = Settings.get()
@@ -162,3 +164,54 @@ def test_fetch_access_token_handles_500_error(respx_mock: respx.MockRouter) -> N
162
164
  with APIClient() as client:
163
165
  with pytest.raises(httpx.HTTPStatusError):
164
166
  _fetch_access_token(client, "test_device_code", 5)
167
+
168
+
169
+ @pytest.mark.respx(base_url=settings.base_api_url)
170
+ def test_notify_already_logged_in_user(
171
+ respx_mock: respx.MockRouter, logged_in_cli: None
172
+ ) -> None:
173
+ result = runner.invoke(app, ["login"])
174
+
175
+ assert result.exit_code == 0
176
+ assert "You are already logged in." in result.output
177
+ assert "Run fastapi logout first if you want to switch accounts." in result.output
178
+
179
+
180
+ @pytest.mark.respx(base_url=settings.base_api_url)
181
+ def test_notify_expired_token_user(
182
+ respx_mock: respx.MockRouter, temp_auth_config: Path
183
+ ) -> None:
184
+ past_exp = int(time.time()) - 3600
185
+ expired_token = create_jwt_token({"sub": "test_user_12345", "exp": past_exp})
186
+
187
+ temp_auth_config.write_text(f'{{"access_token": "{expired_token}"}}')
188
+
189
+ with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open:
190
+ respx_mock.post(
191
+ "/login/device/authorization", data={"client_id": settings.client_id}
192
+ ).mock(
193
+ return_value=Response(
194
+ 200,
195
+ json={
196
+ "verification_uri_complete": "http://test.com",
197
+ "verification_uri": "http://test.com",
198
+ "user_code": "1234",
199
+ "device_code": "5678",
200
+ },
201
+ )
202
+ )
203
+ respx_mock.post(
204
+ "/login/device/token",
205
+ data={
206
+ "device_code": "5678",
207
+ "client_id": settings.client_id,
208
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
209
+ },
210
+ ).mock(return_value=Response(200, json={"access_token": "new_token_1234"}))
211
+
212
+ result = runner.invoke(app, ["login"])
213
+
214
+ assert result.exit_code == 0
215
+ assert "Your session has expired. Logging in again..." in result.output
216
+ assert "Now you are logged in!" in result.output
217
+ assert mock_open.called
@@ -2,7 +2,7 @@ from pathlib import Path
2
2
 
3
3
  from typer.testing import CliRunner
4
4
 
5
- from fastapi_cloud_cli.cli import app
5
+ from fastapi_cloud_cli.cli import cloud_app as app
6
6
 
7
7
  runner = CliRunner()
8
8
 
@@ -3,7 +3,7 @@ from unittest.mock import patch
3
3
 
4
4
  from typer.testing import CliRunner
5
5
 
6
- from fastapi_cloud_cli.cli import app
6
+ from fastapi_cloud_cli.cli import cloud_app as app
7
7
 
8
8
  runner = CliRunner()
9
9
 
@@ -5,7 +5,7 @@ import respx
5
5
  from httpx import ReadTimeout, Response
6
6
  from typer.testing import CliRunner
7
7
 
8
- from fastapi_cloud_cli.cli import app
8
+ from fastapi_cloud_cli.cli import cloud_app as app
9
9
  from fastapi_cloud_cli.config import Settings
10
10
 
11
11
  runner = CliRunner()
@@ -58,6 +58,7 @@ def test_includes_paths(path: Path) -> None:
58
58
  (DeploymentStatus.deploying_failed, "Deploying failed"),
59
59
  (DeploymentStatus.verifying, "Verifying"),
60
60
  (DeploymentStatus.verifying_failed, "Verifying failed"),
61
+ (DeploymentStatus.verifying_skipped, "Verification skipped"),
61
62
  (DeploymentStatus.success, "Success"),
62
63
  (DeploymentStatus.failed, "Failed"),
63
64
  ],
@@ -6,7 +6,7 @@ import respx
6
6
  from httpx import Response
7
7
  from typer.testing import CliRunner
8
8
 
9
- from fastapi_cloud_cli.cli import app
9
+ from fastapi_cloud_cli.cli import cloud_app as app
10
10
  from fastapi_cloud_cli.config import Settings
11
11
  from tests.utils import Keys, changing_dir
12
12
 
@@ -5,7 +5,7 @@ import respx
5
5
  from httpx import Response
6
6
  from typer.testing import CliRunner
7
7
 
8
- from fastapi_cloud_cli.cli import app
8
+ from fastapi_cloud_cli.cli import cloud_app as app
9
9
  from fastapi_cloud_cli.config import Settings
10
10
  from tests.conftest import ConfiguredApp
11
11
  from tests.utils import changing_dir
@@ -6,7 +6,7 @@ import respx
6
6
  from httpx import Response
7
7
  from typer.testing import CliRunner
8
8
 
9
- from fastapi_cloud_cli.cli import app
9
+ from fastapi_cloud_cli.cli import cloud_app as app
10
10
  from fastapi_cloud_cli.config import Settings
11
11
  from tests.utils import Keys, changing_dir
12
12
 
@@ -0,0 +1,42 @@
1
+ import base64
2
+ import json
3
+ import os
4
+ from contextlib import contextmanager
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Generator, Union
7
+
8
+
9
+ @contextmanager
10
+ def changing_dir(directory: Union[str, Path]) -> Generator[None, None, None]:
11
+ initial_dir = os.getcwd()
12
+ os.chdir(directory)
13
+ try:
14
+ yield
15
+ finally:
16
+ os.chdir(initial_dir)
17
+
18
+
19
+ class Keys:
20
+ RIGHT_ARROW = "\x1b[C"
21
+ DOWN_ARROW = "\x1b[B"
22
+ ENTER = "\r"
23
+ CTRL_C = "\x03"
24
+ TAB = "\t"
25
+
26
+
27
+ def create_jwt_token(payload: Dict[str, Any]) -> str:
28
+ # Note: This creates a JWT with an invalid signature, but that's OK for our tests
29
+ # since we only parse the payload, not verify the signature.
30
+
31
+ header = {"alg": "HS256", "typ": "JWT"}
32
+ header_encoded = (
33
+ base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip("=")
34
+ )
35
+
36
+ payload_encoded = (
37
+ base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
38
+ )
39
+
40
+ signature = base64.urlsafe_b64encode(b"signature").decode().rstrip("=")
41
+
42
+ return f"{header_encoded}.{payload_encoded}.{signature}"
@@ -1 +0,0 @@
1
- __version__ = "0.3.1"
@@ -1,61 +0,0 @@
1
- import logging
2
- from typing import Optional
3
-
4
- from pydantic import BaseModel
5
-
6
- from .config import get_auth_path
7
-
8
- logger = logging.getLogger("fastapi_cli")
9
-
10
-
11
- class AuthConfig(BaseModel):
12
- access_token: str
13
-
14
-
15
- def write_auth_config(auth_data: AuthConfig) -> None:
16
- auth_path = get_auth_path()
17
- logger.debug("Writing auth config to: %s", auth_path)
18
-
19
- auth_path.write_text(auth_data.model_dump_json(), encoding="utf-8")
20
- logger.debug("Auth config written successfully")
21
-
22
-
23
- def delete_auth_config() -> None:
24
- auth_path = get_auth_path()
25
- logger.debug("Deleting auth config at: %s", auth_path)
26
-
27
- if auth_path.exists():
28
- auth_path.unlink()
29
- logger.debug("Auth config deleted successfully")
30
- else:
31
- logger.debug("Auth config file doesn't exist, nothing to delete")
32
-
33
-
34
- def read_auth_config() -> Optional[AuthConfig]:
35
- auth_path = get_auth_path()
36
- logger.debug("Reading auth config from: %s", auth_path)
37
-
38
- if not auth_path.exists():
39
- logger.debug("Auth config file doesn't exist")
40
- return None
41
-
42
- logger.debug("Auth config loaded successfully")
43
- return AuthConfig.model_validate_json(auth_path.read_text(encoding="utf-8"))
44
-
45
-
46
- def get_auth_token() -> Optional[str]:
47
- logger.debug("Getting auth token")
48
- auth_data = read_auth_config()
49
-
50
- if auth_data is None:
51
- logger.debug("No auth data found")
52
- return None
53
-
54
- logger.debug("Auth token retrieved successfully")
55
- return auth_data.access_token
56
-
57
-
58
- def is_logged_in() -> bool:
59
- result = get_auth_token() is not None
60
- logger.debug("Login status: %s", result)
61
- return result
@@ -1,103 +0,0 @@
1
- import tarfile
2
- from pathlib import Path
3
-
4
- from fastapi_cloud_cli.commands.deploy import archive
5
-
6
-
7
- def test_archive_creates_tar_file(tmp_path: Path) -> None:
8
- (tmp_path / "main.py").write_text("print('hello')")
9
- (tmp_path / "config.json").write_text('{"key": "value"}')
10
- (tmp_path / "subdir").mkdir()
11
- (tmp_path / "subdir" / "utils.py").write_text("def helper(): pass")
12
-
13
- tar_path = archive(tmp_path)
14
-
15
- assert tar_path.exists()
16
- assert tar_path.suffix == ".tar"
17
- assert tar_path.name.startswith("fastapi-cloud-deploy-")
18
-
19
-
20
- def test_archive_excludes_venv_and_similar_folders(tmp_path: Path) -> None:
21
- """Should exclude .venv directory from archive."""
22
- # the only files we want to include
23
- (tmp_path / "main.py").write_text("print('hello')")
24
- (tmp_path / "static").mkdir()
25
- (tmp_path / "static" / "index.html").write_text("<html></html>")
26
- # virtualenv
27
- (tmp_path / ".venv").mkdir()
28
- (tmp_path / ".venv" / "lib").mkdir()
29
- (tmp_path / ".venv" / "lib" / "package.py").write_text("# package")
30
- # pycache
31
- (tmp_path / "__pycache__").mkdir()
32
- (tmp_path / "__pycache__" / "main.cpython-311.pyc").write_text("bytecode")
33
- # pyc files
34
- (tmp_path / "main.pyc").write_text("bytecode")
35
- # mypy/pytest
36
- (tmp_path / ".mypy_cache").mkdir()
37
- (tmp_path / ".mypy_cache" / "file.json").write_text("{}")
38
- (tmp_path / ".pytest_cache").mkdir()
39
- (tmp_path / ".pytest_cache" / "cache.db").write_text("data")
40
-
41
- tar_path = archive(tmp_path)
42
-
43
- with tarfile.open(tar_path, "r") as tar:
44
- names = tar.getnames()
45
- assert set(names) == {"main.py", "static/index.html"}
46
-
47
-
48
- def test_archive_preserves_relative_paths(tmp_path: Path) -> None:
49
- (tmp_path / "src").mkdir()
50
- (tmp_path / "src" / "app").mkdir()
51
- (tmp_path / "src" / "app" / "main.py").write_text("print('hello')")
52
-
53
- tar_path = archive(tmp_path)
54
-
55
- with tarfile.open(tar_path, "r") as tar:
56
- names = tar.getnames()
57
- assert names == ["src/app/main.py"]
58
-
59
-
60
- def test_archive_respects_fastapicloudignore(tmp_path: Path) -> None:
61
- """Should exclude files specified in .fastapicloudignore."""
62
- # Create test files
63
- (tmp_path / "main.py").write_text("print('hello')")
64
- (tmp_path / "config.py").write_text("CONFIG = 'value'")
65
- (tmp_path / "secrets.env").write_text("SECRET_KEY=xyz")
66
- (tmp_path / "data").mkdir()
67
- (tmp_path / "data" / "file.txt").write_text("data")
68
-
69
- # Create .fastapicloudignore file
70
- (tmp_path / ".fastapicloudignore").write_text("secrets.env\ndata/\n")
71
-
72
- # Create archive
73
- tar_path = archive(tmp_path)
74
-
75
- # Verify ignored files are excluded
76
- with tarfile.open(tar_path, "r") as tar:
77
- names = tar.getnames()
78
- assert set(names) == {
79
- "main.py",
80
- "config.py",
81
- }
82
-
83
-
84
- def test_archive_respects_fastapicloudignore_unignore(tmp_path: Path) -> None:
85
- """Test we can use .fastapicloudignore to unignore files inside .gitignore"""
86
- # Create test files
87
- (tmp_path / "main.py").write_text("print('hello')")
88
- (tmp_path / "static/build").mkdir(exist_ok=True, parents=True)
89
- (tmp_path / "static/build/style.css").write_text("body { background: #bada55 }")
90
- # Rignore needs a .git folder to make .gitignore work
91
- (tmp_path / ".git").mkdir(exist_ok=True, parents=True)
92
- (tmp_path / ".gitignore").write_text("build/")
93
-
94
- # Create .fastapicloudignore file
95
- (tmp_path / ".fastapicloudignore").write_text("!static/build")
96
-
97
- # Create archive
98
- tar_path = archive(tmp_path)
99
-
100
- # Verify ignored files are excluded
101
- with tarfile.open(tar_path, "r") as tar:
102
- names = tar.getnames()
103
- assert set(names) == {"main.py", "static/build/style.css"}
@@ -1,22 +0,0 @@
1
- import os
2
- from contextlib import contextmanager
3
- from pathlib import Path
4
- from typing import Generator, Union
5
-
6
-
7
- @contextmanager
8
- def changing_dir(directory: Union[str, Path]) -> Generator[None, None, None]:
9
- initial_dir = os.getcwd()
10
- os.chdir(directory)
11
- try:
12
- yield
13
- finally:
14
- os.chdir(initial_dir)
15
-
16
-
17
- class Keys:
18
- RIGHT_ARROW = "\x1b[C"
19
- DOWN_ARROW = "\x1b[B"
20
- ENTER = "\r"
21
- CTRL_C = "\x03"
22
- TAB = "\t"