reflex-hosting-cli 0.1.13__py3-none-any.whl

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.
@@ -0,0 +1,354 @@
1
+ """The Hosting CLI deployments sub-commands."""
2
+
3
+ import asyncio
4
+ import json
5
+ from datetime import datetime
6
+ from typing import Optional, Tuple
7
+
8
+ import typer
9
+ from tabulate import tabulate
10
+
11
+ from reflex_cli import constants
12
+ from reflex_cli.utils import console
13
+
14
+ deployments_cli = typer.Typer()
15
+
16
+ TIME_FORMAT_HELP = "Accepts ISO 8601 format, unix epoch or time relative to now. For time relative to now, use the format: <d><unit>. Valid units are d (day), h (hour), m (minute), s (second). For example, 1d for 1 day ago from now."
17
+ MIN_LOGS_LIMIT = 50
18
+ MAX_LOGS_LIMIT = 1000
19
+
20
+
21
+ @deployments_cli.command(name="list")
22
+ def list_deployments(
23
+ loglevel: constants.LogLevel = typer.Option(
24
+ constants.LogLevel.INFO, help="The log level to use."
25
+ ),
26
+ as_json: bool = typer.Option(
27
+ False, "-j", "--json", help="Whether to output the result in json format."
28
+ ),
29
+ ):
30
+ """List all the hosted deployments of the authenticated user.
31
+
32
+ Args:
33
+ loglevel: The log level to use.
34
+ as_json: Whether to output the result in json format.
35
+
36
+ Raises:
37
+ Exit: If the command fails.
38
+ """
39
+ from reflex_cli.utils import hosting
40
+
41
+ console.set_log_level(loglevel)
42
+ try:
43
+ deployments = hosting.list_deployments()
44
+ except Exception as ex:
45
+ console.error(f"Unable to list deployments")
46
+ raise typer.Exit(1) from ex
47
+
48
+ if as_json:
49
+ console.print(json.dumps(deployments))
50
+ return
51
+ if deployments:
52
+ headers = list(deployments[0].keys())
53
+ table = [list(deployment.values()) for deployment in deployments]
54
+ console.print(tabulate(table, headers=headers))
55
+ else:
56
+ # If returned empty list, print the empty
57
+ console.print(str(deployments))
58
+
59
+
60
+ @deployments_cli.command(name="delete")
61
+ def delete_deployment(
62
+ key: str = typer.Argument(..., help="The name of the deployment."),
63
+ loglevel: constants.LogLevel = typer.Option(
64
+ constants.LogLevel.INFO, help="The log level to use."
65
+ ),
66
+ ):
67
+ """Delete a hosted instance.
68
+
69
+ Args:
70
+ key: The name of the deployment.
71
+ loglevel: The log level to use.
72
+
73
+ Raises:
74
+ Exit: If the command fails.
75
+ """
76
+ from reflex_cli.utils import hosting
77
+
78
+ console.set_log_level(loglevel)
79
+
80
+ try:
81
+ hosting.delete_deployment(key)
82
+ except Exception as ex:
83
+ console.error(f"Unable to delete deployment")
84
+ raise typer.Exit(1) from ex
85
+ console.print(f"Successfully deleted [ {key} ].")
86
+
87
+
88
+ @deployments_cli.command(name="status")
89
+ def get_deployment_status(
90
+ key: str = typer.Argument(..., help="The name of the deployment."),
91
+ loglevel: constants.LogLevel = typer.Option(
92
+ constants.LogLevel.INFO, help="The log level to use."
93
+ ),
94
+ ):
95
+ """Check the status of a deployment.
96
+
97
+ Args:
98
+ key: The name of the deployment.
99
+ loglevel: The log level to use.
100
+
101
+ Raises:
102
+ Exit: If the command fails.
103
+ """
104
+ from reflex_cli.utils import hosting
105
+
106
+ console.set_log_level(loglevel)
107
+
108
+ try:
109
+ console.print(f"Getting status for [ {key} ] ...\n")
110
+ status = hosting.get_deployment_status(key)
111
+
112
+ # TODO: refactor all these tabulate calls
113
+ status.backend.updated_at = hosting.convert_to_local_time_str(
114
+ status.backend.updated_at or "N/A"
115
+ )
116
+ backend_status = status.backend.dict(exclude_none=True)
117
+ headers = list(backend_status.keys())
118
+ table = list(backend_status.values())
119
+ console.print(tabulate([table], headers=headers))
120
+ # Add a new line in console
121
+ console.print("\n")
122
+ status.frontend.updated_at = hosting.convert_to_local_time_str(
123
+ status.frontend.updated_at or "N/A"
124
+ )
125
+ frontend_status = status.frontend.dict(exclude_none=True)
126
+ headers = list(frontend_status.keys())
127
+ table = list(frontend_status.values())
128
+ console.print(tabulate([table], headers=headers))
129
+ except Exception as ex:
130
+ console.error(f"Unable to get deployment status")
131
+ raise typer.Exit(1) from ex
132
+
133
+
134
+ def _process_command_options_timestamps_limit(
135
+ from_timestamp, to_timestamp, limit
136
+ ) -> Tuple[Optional[str], Optional[str], Optional[int]]:
137
+ """Helper function to process/sanity check the command options for timestamps and limit.
138
+
139
+ Args:
140
+ from_timestamp: The start time of the logs.
141
+ to_timestamp: The end time of the logs.
142
+ limit: The maximum number of logs to return.
143
+
144
+ Raises:
145
+ Exit: If the command options format/value is invalid.
146
+
147
+ Returns:
148
+ The processed timestamps and limit.
149
+ """
150
+ from reflex_cli.utils import hosting
151
+
152
+ command_timestamp = datetime.now().astimezone()
153
+ if (
154
+ from_timestamp is not None
155
+ and (
156
+ from_timestamp := hosting.process_user_entered_timestamp(
157
+ from_timestamp, command_timestamp
158
+ )
159
+ )
160
+ is None
161
+ ):
162
+ console.error("Unable to process --from timestamp.")
163
+ raise typer.Exit(1)
164
+
165
+ if (
166
+ to_timestamp is not None
167
+ and (
168
+ to_timestamp := hosting.process_user_entered_timestamp(
169
+ to_timestamp, command_timestamp
170
+ )
171
+ )
172
+ is None
173
+ ):
174
+ console.error("Unable to process --to timestamp.")
175
+ raise typer.Exit(1)
176
+
177
+ if limit is not None and not MIN_LOGS_LIMIT <= limit <= MAX_LOGS_LIMIT:
178
+ console.error(f"Limit must be between {MIN_LOGS_LIMIT} and {MAX_LOGS_LIMIT}.")
179
+ raise typer.Exit(1)
180
+
181
+ return from_timestamp, to_timestamp, limit
182
+
183
+
184
+ @deployments_cli.command(name="logs")
185
+ def get_deployment_logs(
186
+ key: str = typer.Argument(..., help="The name of the deployment."),
187
+ from_timestamp: Optional[str] = typer.Option(
188
+ None,
189
+ "--from",
190
+ help=f"The start time of the logs. {TIME_FORMAT_HELP}",
191
+ ),
192
+ to_timestamp: Optional[str] = typer.Option(
193
+ None, "--to", help=f"The end time of the logs. {TIME_FORMAT_HELP}"
194
+ ),
195
+ limit: Optional[int] = typer.Option(
196
+ None,
197
+ "--limit",
198
+ help=f"The number of logs to return. The acceptable range is {MIN_LOGS_LIMIT}-{MAX_LOGS_LIMIT}.",
199
+ ),
200
+ loglevel: constants.LogLevel = typer.Option(
201
+ constants.LogLevel.INFO, help="The log level to use."
202
+ ),
203
+ ):
204
+ """Get the logs for a deployment.
205
+
206
+ Args:
207
+ key: The name of the deployment.
208
+ from_timestamp: The start time of the logs.
209
+ to_timestamp: The end time of the logs.
210
+ limit: The maximum number of logs to return.
211
+ loglevel: The log level to use.
212
+
213
+ Raises:
214
+ Exit: If the command fails.
215
+ """
216
+ from reflex_cli.utils import hosting
217
+
218
+ console.set_log_level(loglevel)
219
+
220
+ from_timestamp, to_timestamp, limit = _process_command_options_timestamps_limit(
221
+ from_timestamp, to_timestamp, limit
222
+ )
223
+
224
+ console.print("Note: there is a few seconds delay for logs to be available.")
225
+ # This is a case where it is not streaming logs
226
+ if to_timestamp is not None or limit is not None:
227
+ hosting.get_logs(
228
+ key=key,
229
+ log_type=hosting.LogType.APP_LOG,
230
+ from_iso_timestamp=from_timestamp,
231
+ to_iso_timestamp=to_timestamp,
232
+ limit=limit,
233
+ )
234
+ else:
235
+ try:
236
+ asyncio.get_event_loop().run_until_complete(
237
+ hosting.stream_logs(key, from_iso_timestamp=from_timestamp)
238
+ )
239
+ except Exception as ex:
240
+ console.error(f"Unable to get deployment logs")
241
+ raise typer.Exit(1) from ex
242
+
243
+
244
+ @deployments_cli.command(name="build-logs")
245
+ def get_deployment_build_logs(
246
+ key: str = typer.Argument(..., help="The name of the deployment."),
247
+ from_timestamp: Optional[str] = typer.Option(
248
+ None,
249
+ "--from",
250
+ help=f"The start time of the logs. {TIME_FORMAT_HELP}",
251
+ ),
252
+ to_timestamp: Optional[str] = typer.Option(
253
+ None, "--to", help=f"The end time of the logs. {TIME_FORMAT_HELP}"
254
+ ),
255
+ limit: Optional[int] = typer.Option(
256
+ None,
257
+ "--limit",
258
+ help=f"The number of logs to return. The acceptable range is {MIN_LOGS_LIMIT}-{MAX_LOGS_LIMIT}.",
259
+ ),
260
+ loglevel: constants.LogLevel = typer.Option(
261
+ constants.LogLevel.INFO, help="The log level to use."
262
+ ),
263
+ ):
264
+ """Get the build logs for a deployment.
265
+
266
+ Args:
267
+ key: The name of the deployment.
268
+ from_timestamp: The start time of the logs.
269
+ to_timestamp: The end time of the logs.
270
+ limit: The maximum number of logs to return.
271
+ loglevel: The log level to use.
272
+
273
+ Raises:
274
+ Exit: If the command fails.
275
+ """
276
+ from reflex_cli.utils import hosting
277
+
278
+ console.set_log_level(loglevel)
279
+
280
+ from_timestamp, to_timestamp, limit = _process_command_options_timestamps_limit(
281
+ from_timestamp, to_timestamp, limit
282
+ )
283
+
284
+ console.print("Note: there is a few seconds delay for logs to be available.")
285
+ # This is a case where it is not streaming logs
286
+ if to_timestamp is not None or limit is not None:
287
+ hosting.get_logs(
288
+ key=key,
289
+ log_type=hosting.LogType.BUILD_LOG,
290
+ from_iso_timestamp=from_timestamp,
291
+ to_iso_timestamp=to_timestamp,
292
+ limit=limit,
293
+ )
294
+ else:
295
+ try:
296
+ # TODO: we need to find a way not to fetch logs
297
+ # that match the deployed app name but not previously of a different owner
298
+ # This should not happen often
299
+ asyncio.run(hosting.stream_logs(key, log_type=hosting.LogType.BUILD_LOG))
300
+ except Exception as ex:
301
+ console.error(f"Unable to get deployment logs")
302
+ raise typer.Exit(1) from ex
303
+
304
+
305
+ @deployments_cli.command(name="regions")
306
+ def get_deployment_regions(
307
+ loglevel: constants.LogLevel = typer.Option(
308
+ constants.LogLevel.INFO, help="The log level to use."
309
+ ),
310
+ as_json: bool = typer.Option(
311
+ False, "-j", "--json", help="Whether to output the result in json format."
312
+ ),
313
+ ):
314
+ """List all the regions of the hosting service.
315
+
316
+ Args:
317
+ loglevel: The log level to use.
318
+ as_json: Whether to output the result in json format.
319
+ """
320
+ from reflex_cli.utils import hosting
321
+
322
+ console.set_log_level(loglevel)
323
+
324
+ list_regions_info = hosting.get_regions()
325
+ if as_json:
326
+ console.print(json.dumps(list_regions_info))
327
+ return
328
+ if list_regions_info:
329
+ headers = list(list_regions_info[0].keys())
330
+ table = [list(deployment.values()) for deployment in list_regions_info]
331
+ console.print(tabulate(table, headers=headers))
332
+
333
+
334
+ @deployments_cli.command(name="share")
335
+ def share_deployment(
336
+ url: Optional[str] = typer.Option(
337
+ None,
338
+ help="The URL of the deployed app to share. If not provided, the latest deployment is shared.",
339
+ ),
340
+ loglevel: constants.LogLevel = typer.Option(
341
+ constants.LogLevel.INFO, help="The log level to use."
342
+ ),
343
+ ):
344
+ """Share a deployment.
345
+
346
+ Args:
347
+ url: The URL of the deployed app to share.
348
+ loglevel: The log level to use.
349
+ """
350
+ from reflex_cli.utils import hosting
351
+
352
+ console.set_log_level(loglevel)
353
+
354
+ hosting.collect_deployment_info_interactive(demo_url=url)
@@ -0,0 +1 @@
1
+ """Reflex utilities."""
@@ -0,0 +1,155 @@
1
+ """Functions to communicate to the user via console."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn
7
+ from rich.prompt import Prompt
8
+
9
+ from reflex_cli.constants import LogLevel
10
+
11
+ # Console for pretty printing.
12
+ _console = Console()
13
+
14
+ # The current log level.
15
+ _LOG_LEVEL = LogLevel.INFO
16
+
17
+
18
+ def set_log_level(log_level: LogLevel):
19
+ """Set the log level.
20
+
21
+ Args:
22
+ log_level: The log level to set.
23
+ """
24
+ global _LOG_LEVEL
25
+ _LOG_LEVEL = log_level
26
+
27
+
28
+ def print(msg: str, **kwargs):
29
+ """Print a message.
30
+
31
+ Args:
32
+ msg: The message to print.
33
+ kwargs: Keyword arguments to pass to the print function.
34
+ """
35
+ _console.print(msg, **kwargs)
36
+
37
+
38
+ def debug(msg: str, **kwargs):
39
+ """Print a debug message.
40
+
41
+ Args:
42
+ msg: The debug message.
43
+ kwargs: Keyword arguments to pass to the print function.
44
+ """
45
+ if _LOG_LEVEL <= LogLevel.DEBUG:
46
+ print(f"[blue]Debug: {msg}[/blue]", **kwargs)
47
+
48
+
49
+ def info(msg: str, **kwargs):
50
+ """Print an info message.
51
+
52
+ Args:
53
+ msg: The info message.
54
+ kwargs: Keyword arguments to pass to the print function.
55
+ """
56
+ if _LOG_LEVEL <= LogLevel.INFO:
57
+ print(f"[cyan]Info: {msg}[/cyan]", **kwargs)
58
+
59
+
60
+ def success(msg: str, **kwargs):
61
+ """Print a success message.
62
+
63
+ Args:
64
+ msg: The success message.
65
+ kwargs: Keyword arguments to pass to the print function.
66
+ """
67
+ if _LOG_LEVEL <= LogLevel.INFO:
68
+ print(f"[green]Success: {msg}[/green]", **kwargs)
69
+
70
+
71
+ def log(msg: str, **kwargs):
72
+ """Takes a string and logs it to the console.
73
+
74
+ Args:
75
+ msg: The message to log.
76
+ kwargs: Keyword arguments to pass to the print function.
77
+ """
78
+ if _LOG_LEVEL <= LogLevel.INFO:
79
+ _console.log(msg, **kwargs)
80
+
81
+
82
+ def rule(title: str, **kwargs):
83
+ """Prints a horizontal rule with a title.
84
+
85
+ Args:
86
+ title: The title of the rule.
87
+ kwargs: Keyword arguments to pass to the print function.
88
+ """
89
+ _console.rule(title, **kwargs)
90
+
91
+
92
+ def warn(msg: str, **kwargs):
93
+ """Print a warning message.
94
+
95
+ Args:
96
+ msg: The warning message.
97
+ kwargs: Keyword arguments to pass to the print function.
98
+ """
99
+ if _LOG_LEVEL <= LogLevel.WARNING:
100
+ print(f"[orange1]Warning: {msg}[/orange1]", **kwargs)
101
+
102
+
103
+ def error(msg: str, **kwargs):
104
+ """Print an error message.
105
+
106
+ Args:
107
+ msg: The error message.
108
+ kwargs: Keyword arguments to pass to the print function.
109
+ """
110
+ if _LOG_LEVEL <= LogLevel.ERROR:
111
+ print(f"[red]{msg}[/red]", **kwargs)
112
+
113
+
114
+ def ask(
115
+ question: str, choices: list[str] | None = None, default: str | None = None
116
+ ) -> str:
117
+ """Takes a prompt question and optionally a list of choices
118
+ and returns the user input.
119
+
120
+ Args:
121
+ question: The question to ask the user.
122
+ choices: A list of choices to select from.
123
+ default: The default option selected.
124
+
125
+ Returns:
126
+ A string with the user input.
127
+ """
128
+ return Prompt.ask(question, choices=choices, default=default) # type: ignore
129
+
130
+
131
+ def progress():
132
+ """Create a new progress bar.
133
+
134
+
135
+ Returns:
136
+ A new progress bar.
137
+ """
138
+ return Progress(
139
+ *Progress.get_default_columns()[:-1],
140
+ MofNCompleteColumn(),
141
+ TimeElapsedColumn(),
142
+ )
143
+
144
+
145
+ def status(*args, **kwargs):
146
+ """Create a status with a spinner.
147
+
148
+ Args:
149
+ *args: Args to pass to the status.
150
+ **kwargs: Kwargs to pass to the status.
151
+
152
+ Returns:
153
+ A new status.
154
+ """
155
+ return _console.status(*args, **kwargs)
@@ -0,0 +1,132 @@
1
+ """Building the app and initializing all prerequisites."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import sys
10
+
11
+ import charset_normalizer
12
+
13
+ from reflex_cli import constants
14
+ from reflex_cli.utils import console
15
+
16
+
17
+ def detect_encoding(filename: str) -> str | None:
18
+ """Detect the encoding of the given file.
19
+
20
+ Args:
21
+ filename: The file to detect encoding for.
22
+
23
+ Raises:
24
+ FileNotFoundError: If the file `filename` does not exist.
25
+
26
+ Returns:
27
+ The encoding of the file if file exits and encoding is detected, otherwise None.
28
+ """
29
+ if not os.path.exists(filename):
30
+ raise FileNotFoundError
31
+ # Detect the encoding of the original file
32
+ charset_matches = charset_normalizer.from_path(filename)
33
+ maybe_charset_match = charset_matches.best()
34
+ if maybe_charset_match is None:
35
+ console.warn(
36
+ f"Unable to detect encoding of {constants.RequirementsTxt.FILE} to check requirements. Please manually update it if required.."
37
+ )
38
+ return None
39
+ encoding = maybe_charset_match.encoding
40
+ console.debug(
41
+ f"Detected encoding for {constants.RequirementsTxt.FILE} as {encoding}."
42
+ )
43
+ return encoding
44
+
45
+
46
+ def check_requirements():
47
+ """Check if the requirements.txt needs update based on current environment.
48
+ Throw warnings if too many installed or unused (based on imports) packages in
49
+ the local environment.
50
+ """
51
+ # First check the encoding of requirements.txt if applicable. If unable to determine encoding
52
+ # will not proceed to check for requirement updates.
53
+ encoding = "utf-8"
54
+ if (
55
+ os.path.exists(constants.RequirementsTxt.FILE)
56
+ and (encoding := detect_encoding(constants.RequirementsTxt.FILE)) is None
57
+ ):
58
+ return
59
+
60
+ # Run the pipdeptree command and get the output
61
+ try:
62
+ result = subprocess.run(
63
+ [sys.executable, "-m", "pipdeptree", "--warn", "silence"],
64
+ capture_output=True,
65
+ text=True,
66
+ check=True,
67
+ )
68
+ except subprocess.CalledProcessError as cpe:
69
+ console.debug(f"Unable to run pipdeptree util in subprocess: {cpe}")
70
+ console.warn(
71
+ "Unable to detect installed packages in your environment. Please make sure your requirements.txt is up to date."
72
+ )
73
+ return
74
+
75
+ # Filter the output lines using a regular expression
76
+ lines = result.stdout.split("\n")
77
+ new_requirements_lines = []
78
+ for line in lines:
79
+ if re.match(r"^\w+", line):
80
+ # Special handling of psycopg2, force the binary version instead
81
+ if line.startswith("psycopg2=="):
82
+ line = line.replace("psycopg2==", "psycopg2-binary==")
83
+ new_requirements_lines.append(f"{line}\n")
84
+
85
+ current_requirements_lines = ""
86
+ if os.path.exists(constants.RequirementsTxt.FILE):
87
+ with open(constants.RequirementsTxt.FILE, "r", encoding=encoding) as f:
88
+ current_requirements_lines = list(f)
89
+ console.debug("Current requirements.txt:")
90
+ console.debug("".join(current_requirements_lines))
91
+
92
+ # Show the differences of current and the newly generated requirements.txt
93
+ diff_content = "".join(
94
+ difflib.unified_diff(
95
+ current_requirements_lines,
96
+ new_requirements_lines,
97
+ fromfile="requirements.txt",
98
+ tofile="new_requirements.txt",
99
+ )
100
+ )
101
+
102
+ if not diff_content:
103
+ console.info("No updates required for the requirements.txt.")
104
+ return
105
+
106
+ if not current_requirements_lines:
107
+ console.info("It seems like there's no requirements.txt in your project.")
108
+ else:
109
+ console.info("The requirements.txt may need to be updated.")
110
+
111
+ console.print(diff_content)
112
+
113
+ user_choice = console.ask(
114
+ "Would you like to update requirements.txt based on the changes above?",
115
+ choices=["y", "n"],
116
+ )
117
+ if user_choice != "y":
118
+ console.warn("Please update requirements.txt manually if needed.")
119
+ # Not exit here since the newly generated requirements.txt is necessarily correct
120
+ # i.e., user enters `n` to override and proceed to deploy
121
+ return
122
+
123
+ # Write the filtered lines to requirements.txt
124
+ try:
125
+ with open(constants.RequirementsTxt.FILE, "w", encoding=encoding) as f:
126
+ f.writelines(new_requirements_lines)
127
+ console.info("requirements.txt updated.")
128
+ except OSError:
129
+ console.error(
130
+ f"Unable to write to {constants.RequirementsTxt.FILE}. Please manually update it."
131
+ )
132
+ sys.exit(1)