reflex-hosting-cli 0.1.71a2__tar.gz → 0.1.72__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.
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/CHANGELOG.md +12 -3
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/PKG-INFO +1 -1
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/utils/console.py +20 -6
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/utils/hosting.py +71 -15
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/utils/log.py +40 -1
- reflex_hosting_cli-0.1.72/src/reflex_cli/utils/output.py +202 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/v2/apps.py +185 -121
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/v2/auth.py +3 -9
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/v2/cli.py +6 -5
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/v2/deploy.py +2 -6
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/v2/deployments.py +6 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/v2/gcp.py +25 -7
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/v2/project.py +46 -92
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/v2/providers.py +7 -31
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/v2/scan.py +4 -16
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/v2/secrets.py +26 -29
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/v2/vmtypes_regions.py +23 -33
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/.gitignore +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/README.md +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/news/.gitkeep +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/pyproject.toml +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/__init__.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/cli.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/constants/__init__.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/constants/base.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/constants/compiler.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/constants/hosting.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/constants/log_level.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/core/__init__.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/core/config.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/deployments.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/utils/__init__.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/utils/cli_options.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/utils/dependency.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/utils/exceptions.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/v2/__init__.py +0 -0
- {reflex_hosting_cli-0.1.71a2 → reflex_hosting_cli-0.1.72}/src/reflex_cli/v2/utils.py +0 -0
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
## v0.1.
|
|
1
|
+
## v0.1.72 (2026-09-11)
|
|
2
|
+
|
|
3
|
+
### Breaking Changes
|
|
4
|
+
|
|
5
|
+
- Two `reflex cloud` defaults changed. `--interactive` now defaults to whether stdout is a terminal rather than to on, so a pipe, a CI job or an agent is refused with an error instead of waiting at a prompt that nobody answers -- `reflex cloud apps list` in CI with no token now exits 1 with "Token is required for non-interactive mode." Pass `--interactive` to restore the old behavior. And `reflex cloud apps logs --follow` now defaults to off, since following prompts between pages and so never returns on its own; pass `--follow true` for the old behavior. `reflex deploy` takes the same terminal-derived `--interactive`; it keeps `--json` for log records only, since its progress is a stream rather than a result. ([#6917](https://github.com/reflex-dev/reflex/issues/6917))
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- Every `reflex cloud` command now takes `--json`, writing one JSON document to stdout while human-readable messages move to stderr, so the output is parseable without reading a Rich table. Note that a message only lands on stderr if the installed `reflex-base` carries the reservation: against an older one, `--loglevel debug` still writes its records to stdout and they precede the document. ([#6917](https://github.com/reflex-dev/reflex/issues/6917))
|
|
2
10
|
|
|
3
11
|
### Bug Fixes
|
|
4
12
|
|
|
5
|
-
- Fix
|
|
13
|
+
- Fix version comparison bug that broke compatibility with reflex-0.7.6.post1 ([#6963](https://github.com/reflex-dev/reflex/issues/6963))
|
|
6
14
|
|
|
7
15
|
|
|
8
|
-
## v0.1.
|
|
16
|
+
## v0.1.71 (2026-08-28)
|
|
9
17
|
|
|
10
18
|
### Breaking Changes
|
|
11
19
|
|
|
@@ -21,6 +29,7 @@
|
|
|
21
29
|
### Bug Fixes
|
|
22
30
|
|
|
23
31
|
- The hosting config file (`hosting_v1.json`) is now written atomically. `save_token_to_config` and `delete_token_from_config` opened it with mode `"w"`, truncating it before writing, so a failed write — a full disk, an I/O error, an interrupted process — left an empty file and destroyed the stored access token and selected project. Neither helper reports write failures to the caller (`save_token_to_config` logs a warning, `delete_token_from_config` only a debug message), so this was easy to miss. Both now serialize to a temporary file alongside the target and move it into place, leaving the existing credentials untouched when a write fails. A config that exists but cannot be read is no longer treated as empty either, so `delete_token_from_config` leaves a malformed file alone instead of replacing it; `save_token_to_config` still starts fresh from one, so a corrupt config cannot block re-authenticating. This also covers `reflex login` and `reflex logout`, which share these helpers. ([#6918](https://github.com/reflex-dev/reflex/issues/6918))
|
|
32
|
+
- Fix for older reflex versions
|
|
24
33
|
|
|
25
34
|
### Miscellaneous
|
|
26
35
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: reflex-hosting-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.72
|
|
4
4
|
Summary: Reflex Hosting CLI
|
|
5
5
|
Author-email: Nikhil Rao <nikhil@reflex.dev>, Alek Petuskey <alek@reflex.dev>
|
|
6
6
|
Maintainer-email: Simon Young <simon@reflex.dev>, Khaleel Al-Adhami <khaleel@reflex.dev>
|
|
@@ -11,7 +11,7 @@ from collections.abc import Sequence
|
|
|
11
11
|
from typing import overload
|
|
12
12
|
|
|
13
13
|
from reflex_cli.constants.base import LogLevel
|
|
14
|
-
from reflex_cli.utils.log import HAS_REFLEX_BASE, is_json_mode
|
|
14
|
+
from reflex_cli.utils.log import HAS_REFLEX_BASE, is_json_mode, is_stdout_reserved
|
|
15
15
|
from reflex_cli.utils.log import set_log_level as _set_log_level
|
|
16
16
|
|
|
17
17
|
if HAS_REFLEX_BASE:
|
|
@@ -28,6 +28,16 @@ else:
|
|
|
28
28
|
from rich.table import Table
|
|
29
29
|
|
|
30
30
|
_console = Console(highlight=False)
|
|
31
|
+
_console_stderr = Console(stderr=True, highlight=False)
|
|
32
|
+
|
|
33
|
+
def _human_console() -> Console:
|
|
34
|
+
"""Resolve the console human-readable output belongs on.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The stderr console while stdout is carrying a machine-readable
|
|
38
|
+
document, and the stdout console otherwise.
|
|
39
|
+
"""
|
|
40
|
+
return _console_stderr if is_stdout_reserved() else _console
|
|
31
41
|
|
|
32
42
|
def print(msg: str, **kwargs):
|
|
33
43
|
"""Print a message.
|
|
@@ -36,7 +46,7 @@ else:
|
|
|
36
46
|
msg: The message to print.
|
|
37
47
|
kwargs: Keyword arguments to pass to the print function.
|
|
38
48
|
"""
|
|
39
|
-
|
|
49
|
+
_human_console().print(msg, **kwargs)
|
|
40
50
|
|
|
41
51
|
def print_table(
|
|
42
52
|
tabular_data: list[list[str]],
|
|
@@ -60,7 +70,7 @@ else:
|
|
|
60
70
|
for row in tabular_data:
|
|
61
71
|
table.add_row(*row)
|
|
62
72
|
|
|
63
|
-
|
|
73
|
+
_human_console().print(table)
|
|
64
74
|
|
|
65
75
|
def rule(title: str, **kwargs):
|
|
66
76
|
"""Print a horizontal rule with a title.
|
|
@@ -69,7 +79,7 @@ else:
|
|
|
69
79
|
title: The title of the rule.
|
|
70
80
|
kwargs: Keyword arguments to pass to the print function.
|
|
71
81
|
"""
|
|
72
|
-
|
|
82
|
+
_human_console().rule(title, **kwargs)
|
|
73
83
|
|
|
74
84
|
@overload
|
|
75
85
|
def ask(
|
|
@@ -105,7 +115,11 @@ else:
|
|
|
105
115
|
A string with the user input.
|
|
106
116
|
"""
|
|
107
117
|
return Prompt.ask(
|
|
108
|
-
question,
|
|
118
|
+
question,
|
|
119
|
+
choices=choices,
|
|
120
|
+
default=default,
|
|
121
|
+
show_choices=show_choices,
|
|
122
|
+
console=_human_console(),
|
|
109
123
|
)
|
|
110
124
|
|
|
111
125
|
def progress():
|
|
@@ -130,7 +144,7 @@ else:
|
|
|
130
144
|
Returns:
|
|
131
145
|
A new status.
|
|
132
146
|
"""
|
|
133
|
-
return
|
|
147
|
+
return _human_console().status(*args, **kwargs)
|
|
134
148
|
|
|
135
149
|
|
|
136
150
|
def set_log_level(log_level: LogLevel | str | None):
|
|
@@ -2640,20 +2640,24 @@ def get_app_logs(
|
|
|
2640
2640
|
timeout=constants.Hosting.TIMEOUT,
|
|
2641
2641
|
)
|
|
2642
2642
|
response.raise_for_status()
|
|
2643
|
+
# None rather than an empty list on every path that failed to read: a
|
|
2644
|
+
# caller cannot tell "there are no logs" from "we could not fetch them" out
|
|
2645
|
+
# of `[]`, and the two call for opposite next steps -- one is an answer,
|
|
2646
|
+
# the other is worth retrying.
|
|
2643
2647
|
except httpx.RequestError:
|
|
2644
|
-
return
|
|
2648
|
+
return None
|
|
2645
2649
|
except httpx.HTTPStatusError as ex:
|
|
2646
2650
|
try:
|
|
2647
2651
|
ex_details = ex.response.json().get("detail")
|
|
2648
2652
|
except json.JSONDecodeError:
|
|
2649
|
-
return
|
|
2653
|
+
return None
|
|
2650
2654
|
else:
|
|
2651
2655
|
return f"get app logs failed: {ex_details}"
|
|
2652
2656
|
else:
|
|
2653
2657
|
try:
|
|
2654
2658
|
return response.json()
|
|
2655
2659
|
except json.JSONDecodeError:
|
|
2656
|
-
return
|
|
2660
|
+
return None
|
|
2657
2661
|
|
|
2658
2662
|
|
|
2659
2663
|
def list_apps(client: AuthenticatedClient, project: str | None = None) -> list[dict]:
|
|
@@ -3012,6 +3016,54 @@ def _report_deployment_failure(
|
|
|
3012
3016
|
)
|
|
3013
3017
|
|
|
3014
3018
|
|
|
3019
|
+
# The phrases a deployment status is read for. Named here because two callers
|
|
3020
|
+
# classify the same string -- the watch loop below, which decides how to report
|
|
3021
|
+
# a failure, and `reflex cloud apps status`, which states one as a boolean an
|
|
3022
|
+
# agent branches on. A marker spelled twice is two answers waiting to disagree.
|
|
3023
|
+
_STATUS_BUILD_ERROR = "build error"
|
|
3024
|
+
_STATUS_UNKNOWN_ID = "unable to find status for given id"
|
|
3025
|
+
_STATUS_ERROR = "error"
|
|
3026
|
+
_STATUS_BAD_RESPONSE = "bad response"
|
|
3027
|
+
_STATUS_SUCCEEDED = "completed successfully"
|
|
3028
|
+
_STATUS_AWAITING_APPROVAL = "AwaitingApproval"
|
|
3029
|
+
# "failed" is not a phrase the watch loop tests for -- "error" already covers
|
|
3030
|
+
# the statuses it sees -- and is kept because it is what this predicate
|
|
3031
|
+
# replaced. Dropping it could only ever make the answer less strict.
|
|
3032
|
+
_STATUS_FAILED = "failed"
|
|
3033
|
+
|
|
3034
|
+
|
|
3035
|
+
def deployment_status_failed(status: str) -> bool:
|
|
3036
|
+
"""Check whether a deployment status reports a failure.
|
|
3037
|
+
|
|
3038
|
+
Success is asked first, the order `watch_deployment_status` settles it in:
|
|
3039
|
+
a status that has completed is a completed one whatever else it mentions.
|
|
3040
|
+
|
|
3041
|
+
Args:
|
|
3042
|
+
status: The status string the hosting service returned.
|
|
3043
|
+
|
|
3044
|
+
Returns:
|
|
3045
|
+
True if the status says the deployment did not make it.
|
|
3046
|
+
"""
|
|
3047
|
+
if any(
|
|
3048
|
+
marker in status
|
|
3049
|
+
for marker in (
|
|
3050
|
+
_STATUS_SUCCEEDED,
|
|
3051
|
+
_STATUS_AWAITING_APPROVAL,
|
|
3052
|
+
_STATUS_BAD_RESPONSE,
|
|
3053
|
+
)
|
|
3054
|
+
):
|
|
3055
|
+
return False
|
|
3056
|
+
return any(
|
|
3057
|
+
marker in status
|
|
3058
|
+
for marker in (
|
|
3059
|
+
_STATUS_BUILD_ERROR,
|
|
3060
|
+
_STATUS_UNKNOWN_ID,
|
|
3061
|
+
_STATUS_ERROR,
|
|
3062
|
+
_STATUS_FAILED,
|
|
3063
|
+
)
|
|
3064
|
+
)
|
|
3065
|
+
|
|
3066
|
+
|
|
3015
3067
|
def watch_deployment_status(deployment_id: str, client: AuthenticatedClient) -> bool:
|
|
3016
3068
|
"""Continuously watch the status of a specific deployment.
|
|
3017
3069
|
|
|
@@ -3035,31 +3087,31 @@ def watch_deployment_status(deployment_id: str, client: AuthenticatedClient) ->
|
|
|
3035
3087
|
status = _get_deployment_status(
|
|
3036
3088
|
deployment_id=deployment_id, token=client.token
|
|
3037
3089
|
)
|
|
3038
|
-
if
|
|
3090
|
+
if _STATUS_SUCCEEDED in status:
|
|
3039
3091
|
logger.log(log.SUCCESS, status)
|
|
3040
3092
|
break
|
|
3041
|
-
if
|
|
3093
|
+
if _STATUS_AWAITING_APPROVAL in status:
|
|
3042
3094
|
logger.log(
|
|
3043
3095
|
log.SUCCESS,
|
|
3044
3096
|
"build submitted for approval; it will deploy automatically once an approver approves it.",
|
|
3045
3097
|
)
|
|
3046
3098
|
break
|
|
3047
|
-
if
|
|
3099
|
+
if _STATUS_BUILD_ERROR in status:
|
|
3048
3100
|
_report_deployment_failure(
|
|
3049
3101
|
deployment_id, client.token, status, offer_build_logs=True
|
|
3050
3102
|
)
|
|
3051
3103
|
return False
|
|
3052
|
-
if
|
|
3104
|
+
if _STATUS_UNKNOWN_ID in status:
|
|
3053
3105
|
# Not a failed deployment but an id that resolves to nothing,
|
|
3054
3106
|
# so there is no row to report on and nothing to ask for.
|
|
3055
3107
|
logger.error(status)
|
|
3056
3108
|
return False
|
|
3057
|
-
if
|
|
3109
|
+
if _STATUS_ERROR in status:
|
|
3058
3110
|
_report_deployment_failure(
|
|
3059
3111
|
deployment_id, client.token, status, offer_build_logs=False
|
|
3060
3112
|
)
|
|
3061
3113
|
return False
|
|
3062
|
-
if
|
|
3114
|
+
if _STATUS_BAD_RESPONSE in status:
|
|
3063
3115
|
logger.warning(status)
|
|
3064
3116
|
return True
|
|
3065
3117
|
if status != current_status:
|
|
@@ -3285,13 +3337,16 @@ def read_config(
|
|
|
3285
3337
|
return Config.from_yaml_or_toml_or_none()
|
|
3286
3338
|
|
|
3287
3339
|
|
|
3288
|
-
def generate_config(interactive: bool = True, token: str | None = None):
|
|
3340
|
+
def generate_config(interactive: bool = True, token: str | None = None) -> Path | None:
|
|
3289
3341
|
"""Generate the config file with app-based prefilling.
|
|
3290
3342
|
|
|
3291
3343
|
Args:
|
|
3292
3344
|
interactive: Whether to use interactive mode for authentication and app selection.
|
|
3293
3345
|
token: An existing authentication token to use instead of interactive auth.
|
|
3294
3346
|
|
|
3347
|
+
Returns:
|
|
3348
|
+
The path of the config file written, or None if none was.
|
|
3349
|
+
|
|
3295
3350
|
Raises:
|
|
3296
3351
|
click.exceptions.Exit: If authentication fails or user cancels operation.
|
|
3297
3352
|
"""
|
|
@@ -3299,11 +3354,12 @@ def generate_config(interactive: bool = True, token: str | None = None):
|
|
|
3299
3354
|
import yaml
|
|
3300
3355
|
except ImportError:
|
|
3301
3356
|
logger.error("Please install PyYAML to use this command: pip install pyyaml")
|
|
3302
|
-
return
|
|
3357
|
+
return None
|
|
3303
3358
|
|
|
3304
|
-
|
|
3359
|
+
config_path = Path("cloud.yml")
|
|
3360
|
+
if config_path.exists():
|
|
3305
3361
|
logger.error("cloud.yml already exists.")
|
|
3306
|
-
return
|
|
3362
|
+
return None
|
|
3307
3363
|
|
|
3308
3364
|
try:
|
|
3309
3365
|
authenticated_client = get_authenticated_client(
|
|
@@ -3344,13 +3400,13 @@ def generate_config(interactive: bool = True, token: str | None = None):
|
|
|
3344
3400
|
)
|
|
3345
3401
|
default = {"name": current_dir_name}
|
|
3346
3402
|
|
|
3347
|
-
with
|
|
3403
|
+
with config_path.open("w") as config_file:
|
|
3348
3404
|
yaml.dump(default, config_file, default_flow_style=False, sort_keys=False)
|
|
3349
3405
|
logger.log(log.SUCCESS, "cloud.yml created successfully.")
|
|
3350
3406
|
logger.info(
|
|
3351
3407
|
"For more configuration options, see: https://reflex.dev/docs/hosting/config-file/"
|
|
3352
3408
|
)
|
|
3353
|
-
return
|
|
3409
|
+
return config_path
|
|
3354
3410
|
|
|
3355
3411
|
|
|
3356
3412
|
def log_out_on_browser():
|
|
@@ -82,8 +82,12 @@ except ImportError:
|
|
|
82
82
|
"""
|
|
83
83
|
try:
|
|
84
84
|
style, prefix = _style_for_level(record.levelno)
|
|
85
|
+
# Errors always go to stderr; everything else joins them there
|
|
86
|
+
# while stdout is carrying a document.
|
|
85
87
|
console = (
|
|
86
|
-
_console_stderr
|
|
88
|
+
_console_stderr
|
|
89
|
+
if record.levelno >= logging.ERROR or is_stdout_reserved()
|
|
90
|
+
else _console
|
|
87
91
|
)
|
|
88
92
|
# Markup is opt-in per record (``extra={"rich": True}``); plain
|
|
89
93
|
# messages keep their literal brackets.
|
|
@@ -129,3 +133,38 @@ except ImportError:
|
|
|
129
133
|
# no-op when the handler is already attached, so this stays idempotent.
|
|
130
134
|
_CLI_LOGGER.propagate = False
|
|
131
135
|
_CLI_LOGGER.addHandler(_handler)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# Asked separately from the names above, and that separation is the whole point.
|
|
139
|
+
# The reservation is younger than the rest of this shim: every published
|
|
140
|
+
# reflex-base exports SUCCESS, is_json_mode and set_log_level, and none of them
|
|
141
|
+
# exports these two. Importing all five together sent an installation with a
|
|
142
|
+
# perfectly good reflex-base down the fallback path entirely -- losing its
|
|
143
|
+
# console, its log parenting and is_json_mode to acquire a feature it was only
|
|
144
|
+
# ever meant to go without.
|
|
145
|
+
try:
|
|
146
|
+
from reflex_base.utils.log import is_stdout_reserved as is_stdout_reserved
|
|
147
|
+
from reflex_base.utils.log import reserve_stdout as reserve_stdout
|
|
148
|
+
|
|
149
|
+
except ImportError:
|
|
150
|
+
# Tracked here rather than delegated, so `--json` keeps its stdout even
|
|
151
|
+
# against a reflex-base that has never heard of the reservation. Only this
|
|
152
|
+
# shim's own sinks consult it; a reflex-base console cannot be told.
|
|
153
|
+
_stdout_reserved = False
|
|
154
|
+
|
|
155
|
+
def reserve_stdout(reserved: bool = True) -> None:
|
|
156
|
+
"""Reserve stdout for a machine-readable document.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
reserved: Whether stdout carries data rather than human output.
|
|
160
|
+
"""
|
|
161
|
+
global _stdout_reserved
|
|
162
|
+
_stdout_reserved = reserved
|
|
163
|
+
|
|
164
|
+
def is_stdout_reserved() -> bool:
|
|
165
|
+
"""Check whether stdout is reserved for machine-readable output.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
True while a document owns stdout.
|
|
169
|
+
"""
|
|
170
|
+
return _stdout_reserved
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Machine-readable output, and the shared options that turn it on.
|
|
2
|
+
|
|
3
|
+
An agent driving the cloud CLI needs two things a person at a terminal does
|
|
4
|
+
not: output it can parse without regexing a Rich table, and the certainty that
|
|
5
|
+
nothing will stop and wait for a keystroke. ``--json`` answers the first and
|
|
6
|
+
reserves stdout for the document while it does; ``--interactive`` answers the
|
|
7
|
+
second by defaulting to whether stdout is a terminal.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
# Through the CLI's own shim, not reflex_base directly: the hosting CLI has to
|
|
20
|
+
# import against a reflex-base that predates these functions, which is what
|
|
21
|
+
# tests/units/reflex_cli/utils/test_log.py pins.
|
|
22
|
+
from reflex_cli.utils import log
|
|
23
|
+
|
|
24
|
+
# The spellings that ask for JSON on the command line, and the one that
|
|
25
|
+
# refuses it. Read straight off argv so the group callback can reserve stdout
|
|
26
|
+
# before click has parsed the subcommand's options -- anything it says would
|
|
27
|
+
# otherwise land on stdout ahead of the document.
|
|
28
|
+
_JSON_FLAGS = frozenset({"--json", "-j"})
|
|
29
|
+
_JSON_SHORT = "j"
|
|
30
|
+
_NO_JSON_FLAG = "--no-json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _json_flag_state(arg: str) -> bool | None:
|
|
34
|
+
"""Read what one command-line argument says about JSON output.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
arg: A single command-line argument.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
True if it asks for JSON, False if it refuses it, None if it says
|
|
41
|
+
nothing either way.
|
|
42
|
+
"""
|
|
43
|
+
if arg == _NO_JSON_FLAG:
|
|
44
|
+
return False
|
|
45
|
+
if arg in _JSON_FLAGS:
|
|
46
|
+
return True
|
|
47
|
+
# Short flags combine, so `-ij` is `-i -j`. Reading them means this scan
|
|
48
|
+
# can also fire on a `-j` that click would take as some other option's
|
|
49
|
+
# value, which is the direction to be wrong in: a message on stderr costs
|
|
50
|
+
# a little context, one inside the document costs the whole parse.
|
|
51
|
+
return (
|
|
52
|
+
True
|
|
53
|
+
if len(arg) > 1
|
|
54
|
+
and arg.startswith("-")
|
|
55
|
+
and not arg.startswith("--")
|
|
56
|
+
and _JSON_SHORT in arg[1:]
|
|
57
|
+
else None
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stdout_is_tty() -> bool:
|
|
62
|
+
"""Check whether stdout is attached to a terminal.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
True if somebody is plausibly watching, False under a pipe, a CI job
|
|
66
|
+
or an agent.
|
|
67
|
+
"""
|
|
68
|
+
isatty = getattr(sys.stdout, "isatty", None)
|
|
69
|
+
if isatty is None:
|
|
70
|
+
return False
|
|
71
|
+
try:
|
|
72
|
+
return bool(isatty())
|
|
73
|
+
except ValueError:
|
|
74
|
+
# A closed stream. Nobody is answering a prompt on it either way.
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _resolve_interactive(
|
|
79
|
+
ctx: click.Context, param: click.Parameter, value: bool | None
|
|
80
|
+
) -> bool:
|
|
81
|
+
"""Resolve an unset ``--interactive`` against the terminal.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
ctx: The click context.
|
|
85
|
+
param: The click parameter.
|
|
86
|
+
value: The flag's value, or None when neither spelling was passed.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Whether the command may prompt.
|
|
90
|
+
"""
|
|
91
|
+
return stdout_is_tty() if value is None else value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
interactive_option = click.option(
|
|
95
|
+
"--interactive/--no-interactive",
|
|
96
|
+
"-i/",
|
|
97
|
+
"interactive",
|
|
98
|
+
default=None,
|
|
99
|
+
callback=_resolve_interactive,
|
|
100
|
+
help="Whether to prompt for confirmations and choices. Defaults to on when "
|
|
101
|
+
"stdout is a terminal and off otherwise, so a pipe, a CI job or an agent is "
|
|
102
|
+
"never left waiting at a prompt.",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def json_requested(argv: Sequence[str] | None = None) -> bool:
|
|
107
|
+
"""Check whether a command line asks for JSON output.
|
|
108
|
+
|
|
109
|
+
Scanned back to front, so the last flag decides -- the same answer click
|
|
110
|
+
reaches for a boolean flag pair, which a set membership test cannot give:
|
|
111
|
+
``--no-json --json`` enables JSON and ``--json --no-json`` does not.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
argv: The arguments to inspect; defaults to this process's own.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
True if the command line asks for JSON output.
|
|
118
|
+
"""
|
|
119
|
+
args = sys.argv[1:] if argv is None else argv
|
|
120
|
+
for arg in reversed(list(args)):
|
|
121
|
+
if (state := _json_flag_state(arg)) is not None:
|
|
122
|
+
return state
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _hold_reservation(ctx: click.Context, reserved: bool) -> None:
|
|
127
|
+
"""Reserve stdout for this context, releasing it again when it closes.
|
|
128
|
+
|
|
129
|
+
The reservation is process-global, so without an explicit release a
|
|
130
|
+
``--json`` command leaves every later log line in the process writing to
|
|
131
|
+
stderr -- which a CLI process never notices, and an embedding one or a
|
|
132
|
+
second run in the same interpreter does.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
ctx: The click context whose lifetime the reservation follows.
|
|
136
|
+
reserved: Whether stdout carries data rather than human output.
|
|
137
|
+
"""
|
|
138
|
+
previous = log.is_stdout_reserved()
|
|
139
|
+
log.reserve_stdout(reserved)
|
|
140
|
+
ctx.call_on_close(lambda: log.reserve_stdout(previous))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def reserve_stdout_for_argv(
|
|
144
|
+
argv: Sequence[str] | None = None, *, ctx: click.Context | None = None
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Reserve stdout up front when the command line asks for JSON.
|
|
147
|
+
|
|
148
|
+
Always writes the reservation rather than only setting it, so a long-lived
|
|
149
|
+
process (tests, an embedded runner) cannot inherit the previous
|
|
150
|
+
invocation's answer.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
argv: The arguments to inspect; defaults to this process's own.
|
|
154
|
+
ctx: The click context to release the reservation with, if there is one.
|
|
155
|
+
"""
|
|
156
|
+
reserved = json_requested(argv)
|
|
157
|
+
if ctx is None:
|
|
158
|
+
log.reserve_stdout(reserved)
|
|
159
|
+
return
|
|
160
|
+
_hold_reservation(ctx, reserved)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _reserve_stdout(ctx: click.Context, param: click.Parameter, value: bool) -> bool:
|
|
164
|
+
"""Reserve stdout for the document once ``--json`` is parsed.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
ctx: The click context.
|
|
168
|
+
param: The click parameter.
|
|
169
|
+
value: Whether JSON output was asked for.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The flag's value, unchanged.
|
|
173
|
+
"""
|
|
174
|
+
if value:
|
|
175
|
+
_hold_reservation(ctx, True)
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
json_option = click.option(
|
|
180
|
+
"--json/--no-json",
|
|
181
|
+
"-j",
|
|
182
|
+
"as_json",
|
|
183
|
+
is_flag=True,
|
|
184
|
+
is_eager=True,
|
|
185
|
+
callback=_reserve_stdout,
|
|
186
|
+
help="Output the result as a single JSON document on stdout. Human-readable "
|
|
187
|
+
"messages go to stderr instead, so stdout stays parseable.",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def print_json(payload: Any) -> None:
|
|
192
|
+
"""Write one JSON document to stdout.
|
|
193
|
+
|
|
194
|
+
Deliberately not routed through :mod:`reflex_cli.utils.console`: this is the
|
|
195
|
+
output the command was asked for, not a message about it, so it goes to
|
|
196
|
+
stdout even while the console renders to stderr, and is never wrapped in a
|
|
197
|
+
log record.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
payload: The value to serialize.
|
|
201
|
+
"""
|
|
202
|
+
click.echo(json.dumps(payload, default=str))
|