fastapi-cloud-cli 0.9.0__tar.gz → 0.10.1__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.
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/PKG-INFO +1 -1
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/pyproject.toml +1 -1
- fastapi_cloud_cli-0.10.1/src/fastapi_cloud_cli/__init__.py +1 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/cli.py +2 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/deploy.py +3 -3
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/env.py +24 -8
- fastapi_cloud_cli-0.10.1/src/fastapi_cloud_cli/commands/logs.py +185 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/api.py +55 -6
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_api_client.py +6 -5
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_cli_deploy.py +2 -2
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_env_list.py +29 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_env_set.py +53 -3
- fastapi_cloud_cli-0.10.1/tests/test_logs.py +378 -0
- fastapi_cloud_cli-0.9.0/src/fastapi_cloud_cli/__init__.py +0 -1
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/LICENSE +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/README.md +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/scripts/format.sh +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/scripts/lint.sh +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/scripts/test-cov-html.sh +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/scripts/test.sh +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/__main__.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/login.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/logout.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/config.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/logging.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/py.typed +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/apps.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/auth.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/cli.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/config.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/env.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/__init__.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/broken_package/mod/__init__.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/broken_package/mod/app.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/broken_package/utils.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_api/api.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app/api.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app/app.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app_dir_api/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app_dir_api/app/api.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app_dir_app/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app_dir_app/app/api.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app_dir_app/app/app.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app_dir_main/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app_dir_main/app/api.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app_dir_main/app/app.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app_dir_main/app/main.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app_dir_non_default/app/__init__.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app_dir_non_default/app/nondefault.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_main/api.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_main/app.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_main/main.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/non_default/nonstandard.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/__init__.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/core/__init__.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/core/utils.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/mod/__init__.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/mod/api.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/mod/app.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/mod/other.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/single_file_api.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/single_file_app.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/single_file_other.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/conftest.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_archive.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_auth.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_cli.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_cli_login.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_cli_logout.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_cli_unlink.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_cli_whoami.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_config.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_deploy_utils.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_env_delete.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_sentry.py +0 -0
- {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/utils.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.10.1"
|
|
@@ -4,6 +4,7 @@ from .commands.deploy import deploy
|
|
|
4
4
|
from .commands.env import env_app
|
|
5
5
|
from .commands.login import login
|
|
6
6
|
from .commands.logout import logout
|
|
7
|
+
from .commands.logs import logs
|
|
7
8
|
from .commands.unlink import unlink
|
|
8
9
|
from .commands.whoami import whoami
|
|
9
10
|
from .logging import setup_logging
|
|
@@ -25,6 +26,7 @@ cloud_app = typer.Typer(
|
|
|
25
26
|
# fastapi cloud [command]
|
|
26
27
|
cloud_app.command()(deploy)
|
|
27
28
|
cloud_app.command()(login)
|
|
29
|
+
cloud_app.command()(logs)
|
|
28
30
|
cloud_app.command()(logout)
|
|
29
31
|
cloud_app.command()(whoami)
|
|
30
32
|
cloud_app.command()(unlink)
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/deploy.py
RENAMED
|
@@ -19,7 +19,7 @@ from rich_toolkit import RichToolkit
|
|
|
19
19
|
from rich_toolkit.menu import Option
|
|
20
20
|
|
|
21
21
|
from fastapi_cloud_cli.commands.login import login
|
|
22
|
-
from fastapi_cloud_cli.utils.api import APIClient,
|
|
22
|
+
from fastapi_cloud_cli.utils.api import APIClient, StreamLogError, TooManyRetriesError
|
|
23
23
|
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
|
|
24
24
|
from fastapi_cloud_cli.utils.auth import Identity
|
|
25
25
|
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
|
|
@@ -429,7 +429,7 @@ def _wait_for_deployment(
|
|
|
429
429
|
|
|
430
430
|
last_message_changed_at = time.monotonic()
|
|
431
431
|
|
|
432
|
-
except (
|
|
432
|
+
except (StreamLogError, TooManyRetriesError, TimeoutError) as e:
|
|
433
433
|
progress.set_error(
|
|
434
434
|
dedent(f"""
|
|
435
435
|
[error]Build log streaming failed: {e}[/]
|
|
@@ -438,7 +438,7 @@ def _wait_for_deployment(
|
|
|
438
438
|
""").strip()
|
|
439
439
|
)
|
|
440
440
|
|
|
441
|
-
raise typer.Exit(1) from
|
|
441
|
+
raise typer.Exit(1) from None
|
|
442
442
|
|
|
443
443
|
|
|
444
444
|
class SignupToWaitingList(BaseModel):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from typing import Annotated, Any, Union
|
|
3
|
+
from typing import Annotated, Any, Optional, Union
|
|
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: str
|
|
19
|
+
value: Optional[str] = None
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
class EnvironmentVariableResponse(BaseModel):
|
|
@@ -206,6 +206,13 @@ def set(
|
|
|
206
206
|
help="A path to the folder containing the app you want to deploy"
|
|
207
207
|
),
|
|
208
208
|
] = None,
|
|
209
|
+
secret: Annotated[
|
|
210
|
+
bool,
|
|
211
|
+
typer.Option(
|
|
212
|
+
"--secret",
|
|
213
|
+
help="Mark the environment variable as secret",
|
|
214
|
+
),
|
|
215
|
+
] = False,
|
|
209
216
|
) -> Any:
|
|
210
217
|
"""
|
|
211
218
|
Set an environment variable for the app.
|
|
@@ -233,12 +240,18 @@ def set(
|
|
|
233
240
|
raise typer.Exit(1)
|
|
234
241
|
|
|
235
242
|
if not name:
|
|
236
|
-
|
|
243
|
+
if secret:
|
|
244
|
+
name = toolkit.input("Enter the name of the secret to set:")
|
|
245
|
+
else:
|
|
246
|
+
name = toolkit.input(
|
|
247
|
+
"Enter the name of the environment variable to set:"
|
|
248
|
+
)
|
|
237
249
|
|
|
238
250
|
if not value:
|
|
239
|
-
|
|
240
|
-
"Enter the value
|
|
241
|
-
|
|
251
|
+
if secret:
|
|
252
|
+
value = toolkit.input("Enter the secret value:", password=True)
|
|
253
|
+
else:
|
|
254
|
+
value = toolkit.input("Enter the value of the environment variable:")
|
|
242
255
|
|
|
243
256
|
with toolkit.progress(
|
|
244
257
|
"Setting environment variable", transient=True
|
|
@@ -247,6 +260,9 @@ def set(
|
|
|
247
260
|
assert value is not None
|
|
248
261
|
|
|
249
262
|
with handle_http_errors(progress):
|
|
250
|
-
_set_environment_variable(app_config.app_id, name, value)
|
|
263
|
+
_set_environment_variable(app_config.app_id, name, value, secret)
|
|
251
264
|
|
|
252
|
-
|
|
265
|
+
if secret:
|
|
266
|
+
toolkit.print(f"Secret environment variable [bold]{name}[/] set.")
|
|
267
|
+
else:
|
|
268
|
+
toolkit.print(f"Environment variable [bold]{name}[/] set.")
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
from rich_toolkit import RichToolkit
|
|
10
|
+
|
|
11
|
+
from fastapi_cloud_cli.utils.api import (
|
|
12
|
+
APIClient,
|
|
13
|
+
AppLogEntry,
|
|
14
|
+
StreamLogError,
|
|
15
|
+
TooManyRetriesError,
|
|
16
|
+
)
|
|
17
|
+
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config
|
|
18
|
+
from fastapi_cloud_cli.utils.auth import Identity
|
|
19
|
+
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
LOG_LEVEL_COLORS = {
|
|
25
|
+
"debug": "blue",
|
|
26
|
+
"info": "cyan",
|
|
27
|
+
"warning": "yellow",
|
|
28
|
+
"warn": "yellow",
|
|
29
|
+
"error": "red",
|
|
30
|
+
"critical": "magenta",
|
|
31
|
+
"fatal": "magenta",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
SINCE_PATTERN = re.compile(r"^\d+[smhd]$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _validate_since(value: str) -> str:
|
|
38
|
+
"""Validate the --since parameter format."""
|
|
39
|
+
if not SINCE_PATTERN.match(value):
|
|
40
|
+
raise typer.BadParameter(
|
|
41
|
+
"Invalid format. Use a number followed by s, m, h, or d (e.g., '5m', '1h', '2d')."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _format_log_line(log: AppLogEntry) -> str:
|
|
48
|
+
"""Format a log entry for display with a colored indicator"""
|
|
49
|
+
# Parse the timestamp string to format it consistently
|
|
50
|
+
timestamp = datetime.fromisoformat(log.timestamp.replace("Z", "+00:00"))
|
|
51
|
+
timestamp_str = timestamp.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
52
|
+
color = LOG_LEVEL_COLORS.get(log.level.lower())
|
|
53
|
+
|
|
54
|
+
message = escape(log.message)
|
|
55
|
+
|
|
56
|
+
if color:
|
|
57
|
+
return f"[{color}]┃[/{color}] [dim]{timestamp_str}[/dim] {message}"
|
|
58
|
+
|
|
59
|
+
return f"[dim]┃[/dim] [dim]{timestamp_str}[/dim] {message}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _process_log_stream(
|
|
63
|
+
toolkit: RichToolkit,
|
|
64
|
+
app_config: AppConfig,
|
|
65
|
+
tail: int,
|
|
66
|
+
since: str,
|
|
67
|
+
follow: bool,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Stream app logs and print them to the console."""
|
|
70
|
+
log_count = 0
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
with APIClient() as client:
|
|
74
|
+
for log in client.stream_app_logs(
|
|
75
|
+
app_id=app_config.app_id,
|
|
76
|
+
tail=tail,
|
|
77
|
+
since=since,
|
|
78
|
+
follow=follow,
|
|
79
|
+
):
|
|
80
|
+
toolkit.print(_format_log_line(log))
|
|
81
|
+
log_count += 1
|
|
82
|
+
|
|
83
|
+
if not follow and log_count == 0:
|
|
84
|
+
toolkit.print("No logs found for the specified time range.")
|
|
85
|
+
return
|
|
86
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
87
|
+
toolkit.print_line()
|
|
88
|
+
return
|
|
89
|
+
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
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
toolkit.print(
|
|
101
|
+
f"[red]Error:[/] {escape(error_msg)}",
|
|
102
|
+
)
|
|
103
|
+
raise typer.Exit(1) from None
|
|
104
|
+
except (TooManyRetriesError, TimeoutError):
|
|
105
|
+
toolkit.print(
|
|
106
|
+
"Lost connection to log stream. Please try again later.",
|
|
107
|
+
)
|
|
108
|
+
raise typer.Exit(1) from None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def logs(
|
|
112
|
+
path: Annotated[
|
|
113
|
+
Optional[Path],
|
|
114
|
+
typer.Argument(
|
|
115
|
+
help="Path to the folder containing the app (defaults to current directory)"
|
|
116
|
+
),
|
|
117
|
+
] = None,
|
|
118
|
+
tail: int = typer.Option(
|
|
119
|
+
100,
|
|
120
|
+
"--tail",
|
|
121
|
+
"-t",
|
|
122
|
+
help="Number of log lines to show before streaming.",
|
|
123
|
+
show_default=True,
|
|
124
|
+
),
|
|
125
|
+
since: str = typer.Option(
|
|
126
|
+
"5m",
|
|
127
|
+
"--since",
|
|
128
|
+
"-s",
|
|
129
|
+
help="Show logs since a specific time (e.g., '5m', '1h', '2d').",
|
|
130
|
+
show_default=True,
|
|
131
|
+
callback=_validate_since,
|
|
132
|
+
),
|
|
133
|
+
follow: bool = typer.Option(
|
|
134
|
+
True,
|
|
135
|
+
"--follow/--no-follow",
|
|
136
|
+
"-f",
|
|
137
|
+
help="Stream logs in real-time (use --no-follow to fetch and exit).",
|
|
138
|
+
),
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Stream or fetch logs from your deployed app.
|
|
141
|
+
|
|
142
|
+
Examples:
|
|
143
|
+
fastapi cloud logs # Stream logs in real-time
|
|
144
|
+
fastapi cloud logs --no-follow # Fetch recent logs and exit
|
|
145
|
+
fastapi cloud logs --tail 50 --since 1h # Last 50 logs from the past hour
|
|
146
|
+
"""
|
|
147
|
+
identity = Identity()
|
|
148
|
+
with get_rich_toolkit(minimal=True) as toolkit:
|
|
149
|
+
if not identity.is_logged_in():
|
|
150
|
+
toolkit.print(
|
|
151
|
+
"No credentials found. Use [blue]`fastapi login`[/] to login.",
|
|
152
|
+
tag="auth",
|
|
153
|
+
)
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
|
|
156
|
+
app_path = path or Path.cwd()
|
|
157
|
+
app_config = get_app_config(app_path)
|
|
158
|
+
|
|
159
|
+
if not app_config:
|
|
160
|
+
toolkit.print(
|
|
161
|
+
"No app linked to this directory. Run [blue]`fastapi deploy`[/] first.",
|
|
162
|
+
)
|
|
163
|
+
raise typer.Exit(1)
|
|
164
|
+
|
|
165
|
+
logger.debug("Fetching logs for app ID: %s", app_config.app_id)
|
|
166
|
+
|
|
167
|
+
if follow:
|
|
168
|
+
toolkit.print(
|
|
169
|
+
f"Streaming logs for [bold]{app_config.app_id}[/bold] (Ctrl+C to exit)...",
|
|
170
|
+
tag="logs",
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
toolkit.print(
|
|
174
|
+
f"Fetching logs for [bold]{app_config.app_id}[/bold]...",
|
|
175
|
+
tag="logs",
|
|
176
|
+
)
|
|
177
|
+
toolkit.print_line()
|
|
178
|
+
|
|
179
|
+
_process_log_stream(
|
|
180
|
+
toolkit=toolkit,
|
|
181
|
+
app_config=app_config,
|
|
182
|
+
tail=tail,
|
|
183
|
+
since=since,
|
|
184
|
+
follow=follow,
|
|
185
|
+
)
|
|
@@ -25,11 +25,13 @@ from .auth import Identity
|
|
|
25
25
|
|
|
26
26
|
logger = logging.getLogger(__name__)
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
STREAM_LOGS_MAX_RETRIES = 3
|
|
29
|
+
STREAM_LOGS_TIMEOUT = timedelta(minutes=5)
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
class
|
|
32
|
+
class StreamLogError(Exception):
|
|
33
|
+
"""Raised when there's an error streaming logs (build or app logs)."""
|
|
34
|
+
|
|
33
35
|
pass
|
|
34
36
|
|
|
35
37
|
|
|
@@ -37,6 +39,12 @@ class TooManyRetriesError(Exception):
|
|
|
37
39
|
pass
|
|
38
40
|
|
|
39
41
|
|
|
42
|
+
class AppLogEntry(BaseModel):
|
|
43
|
+
timestamp: str
|
|
44
|
+
message: str
|
|
45
|
+
level: str
|
|
46
|
+
|
|
47
|
+
|
|
40
48
|
class BuildLogLineGeneric(BaseModel):
|
|
41
49
|
type: Literal["complete", "failed", "timeout", "heartbeat"]
|
|
42
50
|
id: Optional[str] = None
|
|
@@ -91,7 +99,7 @@ def attempt(attempt_number: int) -> Generator[None, None, None]:
|
|
|
91
99
|
error_detail = error.response.text
|
|
92
100
|
except Exception:
|
|
93
101
|
error_detail = "(response body unavailable)"
|
|
94
|
-
raise
|
|
102
|
+
raise StreamLogError(
|
|
95
103
|
f"HTTP {error.response.status_code}: {error_detail}"
|
|
96
104
|
) from error
|
|
97
105
|
|
|
@@ -115,7 +123,7 @@ def attempts(
|
|
|
115
123
|
for attempt_number in range(total_attempts):
|
|
116
124
|
if time.monotonic() - start > timeout.total_seconds():
|
|
117
125
|
raise TimeoutError(
|
|
118
|
-
f"
|
|
126
|
+
f"Log streaming timed out after {timeout.total_seconds():.0f}s"
|
|
119
127
|
)
|
|
120
128
|
|
|
121
129
|
with attempt(attempt_number):
|
|
@@ -144,7 +152,7 @@ class APIClient(httpx.Client):
|
|
|
144
152
|
},
|
|
145
153
|
)
|
|
146
154
|
|
|
147
|
-
@attempts(
|
|
155
|
+
@attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT)
|
|
148
156
|
def stream_build_logs(
|
|
149
157
|
self, deployment_id: str
|
|
150
158
|
) -> Generator[BuildLogLine, None, None]:
|
|
@@ -192,3 +200,44 @@ class APIClient(httpx.Client):
|
|
|
192
200
|
except (ValidationError, json.JSONDecodeError) as e:
|
|
193
201
|
logger.debug("Skipping malformed log: %s (error: %s)", line[:100], e)
|
|
194
202
|
return None
|
|
203
|
+
|
|
204
|
+
@attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT)
|
|
205
|
+
def stream_app_logs(
|
|
206
|
+
self,
|
|
207
|
+
app_id: str,
|
|
208
|
+
tail: int,
|
|
209
|
+
since: str,
|
|
210
|
+
follow: bool,
|
|
211
|
+
) -> Generator[AppLogEntry, None, None]:
|
|
212
|
+
timeout = 120 if follow else 30
|
|
213
|
+
with self.stream(
|
|
214
|
+
"GET",
|
|
215
|
+
f"/apps/{app_id}/logs/stream",
|
|
216
|
+
params={
|
|
217
|
+
"tail": tail,
|
|
218
|
+
"since": since,
|
|
219
|
+
"follow": follow,
|
|
220
|
+
},
|
|
221
|
+
timeout=timeout,
|
|
222
|
+
) as response:
|
|
223
|
+
response.raise_for_status()
|
|
224
|
+
for line in response.iter_lines():
|
|
225
|
+
if not line or not line.strip(): # pragma: no cover
|
|
226
|
+
continue
|
|
227
|
+
try:
|
|
228
|
+
data = json.loads(line)
|
|
229
|
+
except json.JSONDecodeError:
|
|
230
|
+
logger.debug("Failed to parse log line: %s", line)
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
if data.get("type") == "heartbeat":
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
if data.get("type") == "error":
|
|
237
|
+
raise StreamLogError(data.get("message", "Unknown error"))
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
yield AppLogEntry.model_validate(data)
|
|
241
|
+
except ValidationError as e: # pragma: no cover
|
|
242
|
+
logger.debug("Failed to parse log entry: %s - %s", data, e)
|
|
243
|
+
continue
|
|
@@ -9,10 +9,10 @@ from time_machine import TimeMachineFixture
|
|
|
9
9
|
|
|
10
10
|
from fastapi_cloud_cli.config import Settings
|
|
11
11
|
from fastapi_cloud_cli.utils.api import (
|
|
12
|
-
|
|
12
|
+
STREAM_LOGS_MAX_RETRIES,
|
|
13
13
|
APIClient,
|
|
14
|
-
BuildLogError,
|
|
15
14
|
BuildLogLineMessage,
|
|
15
|
+
StreamLogError,
|
|
16
16
|
TooManyRetriesError,
|
|
17
17
|
)
|
|
18
18
|
from tests.utils import build_logs_response
|
|
@@ -243,7 +243,7 @@ def test_stream_build_logs_client_error_raises_immediately(
|
|
|
243
243
|
) -> None:
|
|
244
244
|
logs_route.mock(return_value=Response(404, text="Not Found"))
|
|
245
245
|
|
|
246
|
-
with pytest.raises(
|
|
246
|
+
with pytest.raises(StreamLogError, match="HTTP 404"):
|
|
247
247
|
list(client.stream_build_logs(deployment_id))
|
|
248
248
|
|
|
249
249
|
|
|
@@ -255,7 +255,8 @@ def test_stream_build_logs_max_retries_exceeded(
|
|
|
255
255
|
|
|
256
256
|
with patch("time.sleep"):
|
|
257
257
|
with pytest.raises(
|
|
258
|
-
TooManyRetriesError,
|
|
258
|
+
TooManyRetriesError,
|
|
259
|
+
match=f"Failed after {STREAM_LOGS_MAX_RETRIES} attempts",
|
|
259
260
|
):
|
|
260
261
|
list(client.stream_build_logs(deployment_id))
|
|
261
262
|
|
|
@@ -343,7 +344,7 @@ def test_stream_build_logs_connection_closed_without_complete_failed_or_timeout(
|
|
|
343
344
|
logs = client.stream_build_logs(deployment_id)
|
|
344
345
|
|
|
345
346
|
with patch("time.sleep"), pytest.raises(TooManyRetriesError, match="Failed after"):
|
|
346
|
-
for _ in range(
|
|
347
|
+
for _ in range(STREAM_LOGS_MAX_RETRIES + 1):
|
|
347
348
|
next(logs)
|
|
348
349
|
|
|
349
350
|
|
|
@@ -15,7 +15,7 @@ from typer.testing import CliRunner
|
|
|
15
15
|
|
|
16
16
|
from fastapi_cloud_cli.cli import app
|
|
17
17
|
from fastapi_cloud_cli.config import Settings
|
|
18
|
-
from fastapi_cloud_cli.utils.api import
|
|
18
|
+
from fastapi_cloud_cli.utils.api import StreamLogError, TooManyRetriesError
|
|
19
19
|
from tests.conftest import ConfiguredApp
|
|
20
20
|
from tests.utils import Keys, build_logs_response, changing_dir
|
|
21
21
|
|
|
@@ -823,7 +823,7 @@ def test_shows_no_apps_found_message_when_team_has_no_apps(
|
|
|
823
823
|
|
|
824
824
|
@pytest.mark.parametrize(
|
|
825
825
|
"error",
|
|
826
|
-
[
|
|
826
|
+
[StreamLogError, TooManyRetriesError, TimeoutError],
|
|
827
827
|
)
|
|
828
828
|
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
829
829
|
def test_shows_error_message_on_build_exception(
|
|
@@ -85,3 +85,32 @@ def test_shows_environment_variables_names(
|
|
|
85
85
|
assert result.exit_code == 0
|
|
86
86
|
assert "SECRET_KEY" in result.output
|
|
87
87
|
assert "API_KEY" in result.output
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
91
|
+
def test_shows_secret_environment_variables_without_value(
|
|
92
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Test that secret env vars without a value field are handled correctly."""
|
|
95
|
+
respx_mock.get(f"/apps/{configured_app.app_id}/environment-variables/").mock(
|
|
96
|
+
return_value=Response(
|
|
97
|
+
200,
|
|
98
|
+
json={
|
|
99
|
+
"data": [
|
|
100
|
+
{
|
|
101
|
+
"name": "SECRET_KEY",
|
|
102
|
+
"is_secret": True,
|
|
103
|
+
"created_at": "2026-01-13T19:01:07.408378Z",
|
|
104
|
+
"updated_at": "2026-01-13T19:01:07.408389Z",
|
|
105
|
+
"connected_resource": None,
|
|
106
|
+
},
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
with changing_dir(configured_app.path):
|
|
113
|
+
result = runner.invoke(app, ["env", "list"])
|
|
114
|
+
|
|
115
|
+
assert result.exit_code == 0
|
|
116
|
+
assert "SECRET_KEY" in result.output
|
|
@@ -47,7 +47,10 @@ def test_shows_a_message_if_app_is_not_configured(logged_in_cli: None) -> None:
|
|
|
47
47
|
def test_shows_a_message_if_something_is_wrong(
|
|
48
48
|
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path
|
|
49
49
|
) -> None:
|
|
50
|
-
respx_mock.post(
|
|
50
|
+
respx_mock.post(
|
|
51
|
+
"/apps/123/environment-variables/",
|
|
52
|
+
json={"name": "SOME_VAR", "value": "secret", "is_secret": False},
|
|
53
|
+
).mock(return_value=Response(500))
|
|
51
54
|
|
|
52
55
|
with changing_dir(configured_app):
|
|
53
56
|
result = runner.invoke(app, ["env", "set", "SOME_VAR", "secret"])
|
|
@@ -63,7 +66,10 @@ def test_shows_a_message_if_something_is_wrong(
|
|
|
63
66
|
def test_shows_message_when_it_sets(
|
|
64
67
|
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path
|
|
65
68
|
) -> None:
|
|
66
|
-
respx_mock.post(
|
|
69
|
+
respx_mock.post(
|
|
70
|
+
"/apps/123/environment-variables/",
|
|
71
|
+
json={"name": "SOME_VAR", "value": "secret", "is_secret": False},
|
|
72
|
+
).mock(return_value=Response(200))
|
|
67
73
|
|
|
68
74
|
with changing_dir(configured_app):
|
|
69
75
|
result = runner.invoke(app, ["env", "set", "SOME_VAR", "secret"])
|
|
@@ -78,7 +84,10 @@ def test_asks_for_name_and_value(
|
|
|
78
84
|
) -> None:
|
|
79
85
|
steps = [*"SOME_VAR", Keys.ENTER, *"secret", Keys.ENTER]
|
|
80
86
|
|
|
81
|
-
respx_mock.post(
|
|
87
|
+
respx_mock.post(
|
|
88
|
+
"/apps/123/environment-variables/",
|
|
89
|
+
json={"name": "SOME_VAR", "value": "secret", "is_secret": False},
|
|
90
|
+
).mock(return_value=Response(200))
|
|
82
91
|
|
|
83
92
|
with (
|
|
84
93
|
changing_dir(configured_app),
|
|
@@ -93,4 +102,45 @@ def test_asks_for_name_and_value(
|
|
|
93
102
|
|
|
94
103
|
assert "Environment variable SOME_VAR set" in result.output
|
|
95
104
|
|
|
105
|
+
|
|
106
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
107
|
+
def test_asks_for_name_and_value_for_secret(
|
|
108
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path
|
|
109
|
+
) -> None:
|
|
110
|
+
steps = [*"SOME_VAR", Keys.ENTER, *"secret", Keys.ENTER]
|
|
111
|
+
|
|
112
|
+
respx_mock.post(
|
|
113
|
+
"/apps/123/environment-variables/",
|
|
114
|
+
json={"name": "SOME_VAR", "value": "secret", "is_secret": True},
|
|
115
|
+
).mock(return_value=Response(200))
|
|
116
|
+
|
|
117
|
+
with (
|
|
118
|
+
changing_dir(configured_app),
|
|
119
|
+
patch("rich_toolkit.container.getchar", side_effect=steps),
|
|
120
|
+
):
|
|
121
|
+
result = runner.invoke(app, ["env", "set", "--secret"])
|
|
122
|
+
|
|
123
|
+
assert result.exit_code == 0
|
|
124
|
+
|
|
125
|
+
assert "Enter the name of the secret" in result.output
|
|
126
|
+
assert "Enter the secret value" in result.output
|
|
127
|
+
|
|
128
|
+
assert "Secret environment variable SOME_VAR set" in result.output
|
|
129
|
+
|
|
96
130
|
assert "*" * 6 in result.output
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
134
|
+
def test_sets_secret_flag(
|
|
135
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path
|
|
136
|
+
) -> None:
|
|
137
|
+
respx_mock.post(
|
|
138
|
+
"/apps/123/environment-variables/",
|
|
139
|
+
json={"name": "SOME_VAR", "value": "secret", "is_secret": True},
|
|
140
|
+
).mock(return_value=Response(200))
|
|
141
|
+
|
|
142
|
+
with changing_dir(configured_app):
|
|
143
|
+
result = runner.invoke(app, ["env", "set", "SOME_VAR", "secret", "--secret"])
|
|
144
|
+
|
|
145
|
+
assert result.exit_code == 0
|
|
146
|
+
assert "Secret environment variable SOME_VAR set" in result.output
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
import respx
|
|
7
|
+
from typer.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
from fastapi_cloud_cli.cli import cloud_app as app
|
|
10
|
+
from fastapi_cloud_cli.config import Settings
|
|
11
|
+
from fastapi_cloud_cli.utils.api import TooManyRetriesError
|
|
12
|
+
from tests.conftest import ConfiguredApp
|
|
13
|
+
from tests.utils import changing_dir
|
|
14
|
+
|
|
15
|
+
runner = CliRunner()
|
|
16
|
+
settings = Settings.get()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_shows_message_if_not_logged_in(logged_out_cli: None) -> None:
|
|
20
|
+
result = runner.invoke(app, ["logs"])
|
|
21
|
+
|
|
22
|
+
assert result.exit_code == 1
|
|
23
|
+
assert "No credentials found" in result.output
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_shows_message_if_app_not_configured(logged_in_cli: None) -> None:
|
|
27
|
+
result = runner.invoke(app, ["logs"])
|
|
28
|
+
|
|
29
|
+
assert result.exit_code == 1
|
|
30
|
+
assert "No app linked to this directory" in result.output
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
34
|
+
def test_displays_logs(
|
|
35
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
36
|
+
) -> None:
|
|
37
|
+
log_lines = [
|
|
38
|
+
json.dumps(
|
|
39
|
+
{
|
|
40
|
+
"timestamp": "2025-12-05T14:32:01.123000Z",
|
|
41
|
+
"message": "Application startup complete",
|
|
42
|
+
"level": "info",
|
|
43
|
+
}
|
|
44
|
+
),
|
|
45
|
+
json.dumps(
|
|
46
|
+
{
|
|
47
|
+
"timestamp": "2025-12-05T14:32:05.456000Z",
|
|
48
|
+
"message": "GET /health 200",
|
|
49
|
+
"level": "info",
|
|
50
|
+
}
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
response_content = "\n".join(log_lines)
|
|
54
|
+
|
|
55
|
+
respx_mock.get(url__regex=rf"/apps/{configured_app.app_id}/logs/stream.*").mock(
|
|
56
|
+
return_value=httpx.Response(200, content=response_content)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
with changing_dir(configured_app.path):
|
|
60
|
+
result = runner.invoke(app, ["logs", "--no-follow"])
|
|
61
|
+
|
|
62
|
+
assert result.exit_code == 0
|
|
63
|
+
assert "Fetching logs" in result.output
|
|
64
|
+
assert configured_app.app_id in result.output
|
|
65
|
+
assert "Application startup complete" in result.output
|
|
66
|
+
assert "GET /health 200" in result.output
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
70
|
+
def test_passes_default_params(
|
|
71
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
72
|
+
) -> None:
|
|
73
|
+
route = respx_mock.get(
|
|
74
|
+
url__regex=rf"/apps/{configured_app.app_id}/logs/stream.*"
|
|
75
|
+
).mock(return_value=httpx.Response(200, content=""))
|
|
76
|
+
|
|
77
|
+
with changing_dir(configured_app.path):
|
|
78
|
+
result = runner.invoke(app, ["logs"])
|
|
79
|
+
|
|
80
|
+
assert result.exit_code == 0
|
|
81
|
+
url = str(route.calls[0].request.url).lower()
|
|
82
|
+
assert "follow=true" in url
|
|
83
|
+
assert "tail=100" in url
|
|
84
|
+
assert "since=5m" in url
|
|
85
|
+
assert "Streaming logs" in result.output
|
|
86
|
+
assert configured_app.app_id in result.output
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
90
|
+
def test_passes_custom_params(
|
|
91
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
92
|
+
) -> None:
|
|
93
|
+
route = respx_mock.get(
|
|
94
|
+
url__regex=rf"/apps/{configured_app.app_id}/logs/stream.*"
|
|
95
|
+
).mock(return_value=httpx.Response(200, content=""))
|
|
96
|
+
|
|
97
|
+
with changing_dir(configured_app.path):
|
|
98
|
+
result = runner.invoke(
|
|
99
|
+
app, ["logs", "--no-follow", "--tail", "50", "--since", "1h"]
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
assert result.exit_code == 0
|
|
103
|
+
url = str(route.calls[0].request.url).lower()
|
|
104
|
+
assert "tail=50" in url
|
|
105
|
+
assert "since=1h" in url
|
|
106
|
+
assert "follow=false" in url
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
110
|
+
def test_displays_all_log_levels(
|
|
111
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
112
|
+
) -> None:
|
|
113
|
+
log_lines = [
|
|
114
|
+
json.dumps(
|
|
115
|
+
{
|
|
116
|
+
"timestamp": "2025-12-05T14:32:01.123000Z",
|
|
117
|
+
"message": "Debug message",
|
|
118
|
+
"level": "debug",
|
|
119
|
+
}
|
|
120
|
+
),
|
|
121
|
+
json.dumps(
|
|
122
|
+
{
|
|
123
|
+
"timestamp": "2025-12-05T14:32:02.123000Z",
|
|
124
|
+
"message": "Info message",
|
|
125
|
+
"level": "info",
|
|
126
|
+
}
|
|
127
|
+
),
|
|
128
|
+
json.dumps(
|
|
129
|
+
{
|
|
130
|
+
"timestamp": "2025-12-05T14:32:03.123000Z",
|
|
131
|
+
"message": "Warning message",
|
|
132
|
+
"level": "warning",
|
|
133
|
+
}
|
|
134
|
+
),
|
|
135
|
+
json.dumps(
|
|
136
|
+
{
|
|
137
|
+
"timestamp": "2025-12-05T14:32:04.123000Z",
|
|
138
|
+
"message": "Error message",
|
|
139
|
+
"level": "error",
|
|
140
|
+
}
|
|
141
|
+
),
|
|
142
|
+
]
|
|
143
|
+
response_content = "\n".join(log_lines)
|
|
144
|
+
|
|
145
|
+
respx_mock.get(url__regex=rf"/apps/{configured_app.app_id}/logs/stream.*").mock(
|
|
146
|
+
return_value=httpx.Response(200, content=response_content)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
with changing_dir(configured_app.path):
|
|
150
|
+
result = runner.invoke(app, ["logs", "--no-follow"])
|
|
151
|
+
|
|
152
|
+
assert result.exit_code == 0
|
|
153
|
+
assert "Debug message" in result.output
|
|
154
|
+
assert "Info message" in result.output
|
|
155
|
+
assert "Warning message" in result.output
|
|
156
|
+
assert "Error message" in result.output
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
160
|
+
def test_handles_401_unauthorized(
|
|
161
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
162
|
+
) -> None:
|
|
163
|
+
respx_mock.get(url__regex=rf"/apps/{configured_app.app_id}/logs/stream.*").mock(
|
|
164
|
+
return_value=httpx.Response(401)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
with changing_dir(configured_app.path):
|
|
168
|
+
result = runner.invoke(app, ["logs", "--no-follow"])
|
|
169
|
+
|
|
170
|
+
assert result.exit_code == 1
|
|
171
|
+
assert "token is not valid" in result.output
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
175
|
+
def test_handles_404(
|
|
176
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
177
|
+
) -> None:
|
|
178
|
+
respx_mock.get(url__regex=rf"/apps/{configured_app.app_id}/logs/stream.*").mock(
|
|
179
|
+
return_value=httpx.Response(404)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
with changing_dir(configured_app.path):
|
|
183
|
+
result = runner.invoke(app, ["logs", "--no-follow"])
|
|
184
|
+
|
|
185
|
+
assert result.exit_code == 1
|
|
186
|
+
assert "App not found" in result.output
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
190
|
+
def test_shows_message_when_no_logs_found(
|
|
191
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
192
|
+
) -> None:
|
|
193
|
+
respx_mock.get(url__regex=rf"/apps/{configured_app.app_id}/logs/stream.*").mock(
|
|
194
|
+
return_value=httpx.Response(200, content="")
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
with changing_dir(configured_app.path):
|
|
198
|
+
result = runner.invoke(app, ["logs", "--no-follow"])
|
|
199
|
+
|
|
200
|
+
assert result.exit_code == 0
|
|
201
|
+
assert "No logs found" in result.output
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
205
|
+
def test_handles_server_error_message(
|
|
206
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
207
|
+
) -> None:
|
|
208
|
+
log_lines = [
|
|
209
|
+
json.dumps({"type": "error", "message": "Log storage unavailable"}),
|
|
210
|
+
]
|
|
211
|
+
response_content = "\n".join(log_lines)
|
|
212
|
+
|
|
213
|
+
respx_mock.get(url__regex=rf"/apps/{configured_app.app_id}/logs/stream.*").mock(
|
|
214
|
+
return_value=httpx.Response(200, content=response_content)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
with changing_dir(configured_app.path):
|
|
218
|
+
result = runner.invoke(app, ["logs", "--no-follow"])
|
|
219
|
+
|
|
220
|
+
assert result.exit_code == 1
|
|
221
|
+
assert "Log storage unavailable" in result.output
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
225
|
+
def test_handles_unknown_log_level(
|
|
226
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
227
|
+
) -> None:
|
|
228
|
+
log_lines = [
|
|
229
|
+
json.dumps(
|
|
230
|
+
{
|
|
231
|
+
"timestamp": "2025-12-05T14:32:01.123000Z",
|
|
232
|
+
"message": "Unknown level message",
|
|
233
|
+
"level": "custom_level",
|
|
234
|
+
}
|
|
235
|
+
),
|
|
236
|
+
]
|
|
237
|
+
response_content = "\n".join(log_lines)
|
|
238
|
+
|
|
239
|
+
respx_mock.get(url__regex=rf"/apps/{configured_app.app_id}/logs/stream.*").mock(
|
|
240
|
+
return_value=httpx.Response(200, content=response_content)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
with changing_dir(configured_app.path):
|
|
244
|
+
result = runner.invoke(app, ["logs", "--no-follow"])
|
|
245
|
+
|
|
246
|
+
assert result.exit_code == 0
|
|
247
|
+
assert "Unknown level message" in result.output
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
251
|
+
def test_skips_invalid_json_lines(
|
|
252
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
253
|
+
) -> None:
|
|
254
|
+
log_lines = [
|
|
255
|
+
"not valid json",
|
|
256
|
+
json.dumps(
|
|
257
|
+
{
|
|
258
|
+
"timestamp": "2025-12-05T14:32:01.123000Z",
|
|
259
|
+
"message": "Valid log message",
|
|
260
|
+
"level": "info",
|
|
261
|
+
}
|
|
262
|
+
),
|
|
263
|
+
]
|
|
264
|
+
response_content = "\n".join(log_lines)
|
|
265
|
+
|
|
266
|
+
respx_mock.get(url__regex=rf"/apps/{configured_app.app_id}/logs/stream.*").mock(
|
|
267
|
+
return_value=httpx.Response(200, content=response_content)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
with changing_dir(configured_app.path):
|
|
271
|
+
result = runner.invoke(app, ["logs", "--no-follow"])
|
|
272
|
+
|
|
273
|
+
assert result.exit_code == 0
|
|
274
|
+
assert "Valid log message" in result.output
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
278
|
+
def test_skips_heartbeat_messages(
|
|
279
|
+
logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp
|
|
280
|
+
) -> None:
|
|
281
|
+
log_lines = [
|
|
282
|
+
json.dumps({"type": "heartbeat"}),
|
|
283
|
+
json.dumps(
|
|
284
|
+
{
|
|
285
|
+
"timestamp": "2025-12-05T14:32:01.123000Z",
|
|
286
|
+
"message": "Real log message",
|
|
287
|
+
"level": "info",
|
|
288
|
+
}
|
|
289
|
+
),
|
|
290
|
+
]
|
|
291
|
+
response_content = "\n".join(log_lines)
|
|
292
|
+
|
|
293
|
+
respx_mock.get(url__regex=rf"/apps/{configured_app.app_id}/logs/stream.*").mock(
|
|
294
|
+
return_value=httpx.Response(200, content=response_content)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
with changing_dir(configured_app.path):
|
|
298
|
+
result = runner.invoke(app, ["logs", "--no-follow"])
|
|
299
|
+
|
|
300
|
+
assert result.exit_code == 0
|
|
301
|
+
assert "Real log message" in result.output
|
|
302
|
+
assert "heartbeat" not in result.output.lower()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@pytest.mark.parametrize(
|
|
306
|
+
"error",
|
|
307
|
+
[TooManyRetriesError, TimeoutError],
|
|
308
|
+
)
|
|
309
|
+
def test_handles_connection_loss(
|
|
310
|
+
logged_in_cli: None,
|
|
311
|
+
configured_app: ConfiguredApp,
|
|
312
|
+
error: type[Exception],
|
|
313
|
+
) -> None:
|
|
314
|
+
with (
|
|
315
|
+
changing_dir(configured_app.path),
|
|
316
|
+
patch(
|
|
317
|
+
"fastapi_cloud_cli.utils.api.APIClient.stream_app_logs",
|
|
318
|
+
side_effect=error("Connection lost"),
|
|
319
|
+
),
|
|
320
|
+
):
|
|
321
|
+
result = runner.invoke(app, ["logs", "--no-follow"])
|
|
322
|
+
|
|
323
|
+
assert result.exit_code == 1
|
|
324
|
+
assert "Lost connection to log stream" in result.output
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@pytest.mark.parametrize(
|
|
328
|
+
"invalid_since",
|
|
329
|
+
[
|
|
330
|
+
"5", # missing unit
|
|
331
|
+
"m", # missing number
|
|
332
|
+
"5x", # invalid unit
|
|
333
|
+
"5min", # invalid unit (should be 'm')
|
|
334
|
+
"1hour", # invalid unit (should be 'h')
|
|
335
|
+
"5 m", # space not allowed
|
|
336
|
+
"-5m", # negative not allowed
|
|
337
|
+
"", # empty string
|
|
338
|
+
],
|
|
339
|
+
)
|
|
340
|
+
def test_rejects_invalid_since_format(
|
|
341
|
+
logged_in_cli: None,
|
|
342
|
+
configured_app: ConfiguredApp,
|
|
343
|
+
invalid_since: str,
|
|
344
|
+
) -> None:
|
|
345
|
+
with changing_dir(configured_app.path):
|
|
346
|
+
result = runner.invoke(app, ["logs", "--since", invalid_since])
|
|
347
|
+
|
|
348
|
+
assert result.exit_code == 2
|
|
349
|
+
assert "Invalid format" in result.output
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@pytest.mark.respx(base_url=settings.base_api_url)
|
|
353
|
+
@pytest.mark.parametrize(
|
|
354
|
+
"valid_since",
|
|
355
|
+
[
|
|
356
|
+
"30s", # seconds
|
|
357
|
+
"5m", # minutes
|
|
358
|
+
"1h", # hours
|
|
359
|
+
"2d", # days
|
|
360
|
+
"100m", # larger numbers
|
|
361
|
+
],
|
|
362
|
+
)
|
|
363
|
+
def test_accepts_valid_since_format(
|
|
364
|
+
logged_in_cli: None,
|
|
365
|
+
respx_mock: respx.MockRouter,
|
|
366
|
+
configured_app: ConfiguredApp,
|
|
367
|
+
valid_since: str,
|
|
368
|
+
) -> None:
|
|
369
|
+
route = respx_mock.get(
|
|
370
|
+
url__regex=rf"/apps/{configured_app.app_id}/logs/stream.*"
|
|
371
|
+
).mock(return_value=httpx.Response(200, content=""))
|
|
372
|
+
|
|
373
|
+
with changing_dir(configured_app.path):
|
|
374
|
+
result = runner.invoke(app, ["logs", "--no-follow", "--since", valid_since])
|
|
375
|
+
|
|
376
|
+
assert result.exit_code == 0
|
|
377
|
+
url = str(route.calls[0].request.url).lower()
|
|
378
|
+
assert f"since={valid_since}" in url
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.9.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/__init__.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/login.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/logout.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/unlink.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/whoami.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/broken_package/mod/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_api/api.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app/api.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app/app.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_main/api.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_main/app.py
RENAMED
|
File without changes
|
{fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_main/main.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|