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.
Files changed (81) hide show
  1. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/PKG-INFO +1 -1
  2. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/pyproject.toml +1 -1
  3. fastapi_cloud_cli-0.10.1/src/fastapi_cloud_cli/__init__.py +1 -0
  4. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/cli.py +2 -0
  5. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/deploy.py +3 -3
  6. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/env.py +24 -8
  7. fastapi_cloud_cli-0.10.1/src/fastapi_cloud_cli/commands/logs.py +185 -0
  8. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/api.py +55 -6
  9. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_api_client.py +6 -5
  10. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_cli_deploy.py +2 -2
  11. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_env_list.py +29 -0
  12. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_env_set.py +53 -3
  13. fastapi_cloud_cli-0.10.1/tests/test_logs.py +378 -0
  14. fastapi_cloud_cli-0.9.0/src/fastapi_cloud_cli/__init__.py +0 -1
  15. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/LICENSE +0 -0
  16. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/README.md +0 -0
  17. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/scripts/format.sh +0 -0
  18. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/scripts/lint.sh +0 -0
  19. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/scripts/test-cov-html.sh +0 -0
  20. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/scripts/test.sh +0 -0
  21. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/__main__.py +0 -0
  22. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/__init__.py +0 -0
  23. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/login.py +0 -0
  24. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/logout.py +0 -0
  25. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/unlink.py +0 -0
  26. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/commands/whoami.py +0 -0
  27. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/config.py +0 -0
  28. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/logging.py +0 -0
  29. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/py.typed +0 -0
  30. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/__init__.py +0 -0
  31. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/apps.py +0 -0
  32. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/auth.py +0 -0
  33. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/cli.py +0 -0
  34. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/config.py +0 -0
  35. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/env.py +0 -0
  36. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/src/fastapi_cloud_cli/utils/sentry.py +0 -0
  37. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/__init__.py +0 -0
  38. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/broken_package/mod/__init__.py +0 -0
  39. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/broken_package/mod/app.py +0 -0
  40. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/broken_package/utils.py +0 -0
  41. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_api/api.py +0 -0
  42. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app/api.py +0 -0
  43. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_app/app.py +0 -0
  44. {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
  45. {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
  46. {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
  47. {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
  48. {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
  49. {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
  50. {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
  51. {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
  52. {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
  53. {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
  54. {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
  55. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_main/api.py +0 -0
  56. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_main/app.py +0 -0
  57. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/default_main/main.py +0 -0
  58. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/default_files/non_default/nonstandard.py +0 -0
  59. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/__init__.py +0 -0
  60. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/core/__init__.py +0 -0
  61. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/core/utils.py +0 -0
  62. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/mod/__init__.py +0 -0
  63. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/mod/api.py +0 -0
  64. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/mod/app.py +0 -0
  65. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/package/mod/other.py +0 -0
  66. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/single_file_api.py +0 -0
  67. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/single_file_app.py +0 -0
  68. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/assets/single_file_other.py +0 -0
  69. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/conftest.py +0 -0
  70. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_archive.py +0 -0
  71. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_auth.py +0 -0
  72. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_cli.py +0 -0
  73. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_cli_login.py +0 -0
  74. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_cli_logout.py +0 -0
  75. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_cli_unlink.py +0 -0
  76. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_cli_whoami.py +0 -0
  77. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_config.py +0 -0
  78. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_deploy_utils.py +0 -0
  79. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_env_delete.py +0 -0
  80. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/test_sentry.py +0 -0
  81. {fastapi_cloud_cli-0.9.0 → fastapi_cloud_cli-0.10.1}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-cloud-cli
3
- Version: 0.9.0
3
+ Version: 0.10.1
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,7 +39,7 @@ dependencies = [
39
39
  "sentry-sdk >= 2.20.0",
40
40
  "fastar >= 0.8.0",
41
41
  ]
42
- version = "0.9.0"
42
+ version = "0.10.1"
43
43
 
44
44
  [project.license]
45
45
  text = "MIT"
@@ -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)
@@ -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, BuildLogError, TooManyRetriesError
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 (BuildLogError, TooManyRetriesError, TimeoutError) as e:
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 e
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
- name = toolkit.input("Enter the name of the environment variable to set:")
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
- value = toolkit.input(
240
- "Enter the value of the environment variable to set:", password=True
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
- toolkit.print(f"Environment variable [bold]{name}[/] set.")
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
- BUILD_LOG_MAX_RETRIES = 3
29
- BUILD_LOG_TIMEOUT = timedelta(minutes=5)
28
+ STREAM_LOGS_MAX_RETRIES = 3
29
+ STREAM_LOGS_TIMEOUT = timedelta(minutes=5)
30
30
 
31
31
 
32
- class BuildLogError(Exception):
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 BuildLogError(
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"Build log streaming timed out after {timeout.total_seconds():.0f}s"
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(BUILD_LOG_MAX_RETRIES, BUILD_LOG_TIMEOUT)
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
- BUILD_LOG_MAX_RETRIES,
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(BuildLogError, match="HTTP 404"):
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, match=f"Failed after {BUILD_LOG_MAX_RETRIES} attempts"
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(BUILD_LOG_MAX_RETRIES + 1):
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 BuildLogError, TooManyRetriesError
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
- [BuildLogError, TooManyRetriesError, TimeoutError],
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("/apps/123/environment-variables/").mock(return_value=Response(500))
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("/apps/123/environment-variables/").mock(return_value=Response(200))
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("/apps/123/environment-variables/").mock(return_value=Response(200))
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"