Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`reflex_base.utils.log.reserve_stdout()` reserves stdout for a machine-readable document, rendering log records, tables, rules, spinners and progress bars to stderr for as long as it is set.
26 changes: 20 additions & 6 deletions packages/reflex-base/src/reflex_base/utils/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@
_console = Console(highlight=False)
_console_stderr = Console(stderr=True, highlight=False)


def _human_console() -> Console:
"""Get the console human-readable output renders to.

Returns:
The stderr console while stdout is reserved for a machine-readable
document, the stdout one otherwise.
"""
return _console_stderr if _log.is_stdout_reserved() else _console


# Deprecated features who's warning has been printed.
_EMITTED_DEPRECATION_WARNINGS = set()

Expand Down Expand Up @@ -108,7 +119,7 @@ def print(msg: str, *, dedupe: bool = False, level: str = "info", **kwargs):
if msg in _EMITTED_PRINTS:
return
_EMITTED_PRINTS.add(msg)
_console.print(msg, **kwargs)
_human_console().print(msg, **kwargs)


def _print_stderr(msg: str, *, dedupe: bool = False, level: str = "error", **kwargs):
Expand Down Expand Up @@ -249,7 +260,7 @@ def log(msg: str, *, dedupe: bool = False, **kwargs):
if _log.is_json_mode():
_log.emit_json_print(msg)
else:
_console.log(msg, **kwargs)
_human_console().log(msg, **kwargs)
if should_use_log_file_console():
print_to_log_file(msg, **kwargs)

Expand All @@ -263,7 +274,7 @@ def rule(title: str, **kwargs):
"""
if _log.is_json_mode():
return
_console.rule(title, **kwargs)
_human_console().rule(title, **kwargs)


def warn(msg: str, *, dedupe: bool = False, **kwargs):
Expand Down Expand Up @@ -493,7 +504,7 @@ def print_table(
for row in tabular_data:
table.add_row(*row)

_console.print(table)
_human_console().print(table)


def progress():
Expand All @@ -506,7 +517,10 @@ def progress():
*Progress.get_default_columns()[:-1],
MofNCompleteColumn(),
TimeElapsedColumn(),
disable=_log.is_json_mode(),
# A bar is decoration, and it redraws in place: there is nowhere to
# put it in a machine-readable stream, and nothing to draw it over
# once stdout belongs to a document.
disable=_log.is_json_mode() or _log.is_stdout_reserved(),
)


Expand All @@ -522,7 +536,7 @@ def status(*args, **kwargs):
"""
if _log.is_json_mode():
return _log._quiet_console.status(*args, **kwargs)
return _console.status(*args, **kwargs)
return _human_console().status(*args, **kwargs)


@contextlib.contextmanager
Expand Down
44 changes: 41 additions & 3 deletions packages/reflex-base/src/reflex_base/utils/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@
# Console that renders nowhere, backing interactive rich features in JSON mode.
_quiet_console = Console(quiet=True)

# Whether stdout carries a machine-readable document rather than human output.
_stdout_reserved = False

# The current log level.
_log_level = LogLevel.INFO

Expand Down Expand Up @@ -197,7 +200,9 @@ def emit(self, record: logging.LogRecord):
"""
try:
style, prefix = _style_for(record)
console = _console_stderr if record.levelno >= logging.ERROR else _console
console = (
_console_stderr if record.levelno >= logging.ERROR else human_console()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When stdout is reserved, a record carrying extra={"progress": progress} still replaces the reserved console with progress.console. Route progress-bound records through the reserved stderr console and prevent their progress renderer from writing to stdout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-base/src/reflex_base/utils/log.py, line 204:

<comment>When stdout is reserved, a record carrying `extra={"progress": progress}` still replaces the reserved console with `progress.console`. Route progress-bound records through the reserved stderr console and prevent their progress renderer from writing to stdout.</comment>

<file context>
@@ -197,7 +200,9 @@ def emit(self, record: logging.LogRecord):
             style, prefix = _style_for(record)
-            console = _console_stderr if record.levelno >= logging.ERROR else _console
+            console = (
+                _console_stderr if record.levelno >= logging.ERROR else human_console()
+            )
             # Records may carry a rich Progress to print through, so the
</file context>

)
# Records may carry a rich Progress to print through, so the
# message lands above an active progress bar.
progress = getattr(record, "progress", None)
Expand Down Expand Up @@ -241,7 +246,7 @@ def _write_json(payload: dict, *, stderr: bool):
payload: The record fields.
stderr: Whether the record targets stderr.
"""
stream = sys.stderr if stderr else sys.stdout
stream = sys.stderr if stderr or _stdout_reserved else sys.stdout
stream.write(json.dumps(payload, default=str) + "\n")
stream.flush()

Expand Down Expand Up @@ -402,6 +407,38 @@ def is_json_mode() -> bool:
return environment.REFLEX_LOG_JSON.get()


def reserve_stdout(reserved: bool = True):
"""Reserve stdout for a machine-readable document.

A command that writes structured output (``--json``) owns stdout for the
duration, so every human-readable message -- log records, tables, spinners
-- renders to stderr instead and cannot land in the middle of the document.

Args:
reserved: Whether stdout carries data rather than human output.
"""
global _stdout_reserved
_stdout_reserved = reserved


def is_stdout_reserved() -> bool:
"""Check whether stdout is reserved for a machine-readable document.

Returns:
True if human-readable output has to go to stderr.
"""
return _stdout_reserved


def human_console() -> Console:
"""Get the console human-readable output renders to.

Returns:
The stderr console while stdout is reserved, the stdout one otherwise.
"""
return _console_stderr if _stdout_reserved else _console


def set_json_mode(enabled: bool):
"""Enable or disable machine-readable JSON log output.

Expand Down Expand Up @@ -563,7 +600,8 @@ def ensure_configured():

def _reset():
"""Detach the sinks and restore propagation (test teardown helper)."""
global _configured
global _configured, _stdout_reserved
_stdout_reserved = False
for handler in (_console_handler(), _json_handler(), _active_file_handler):
if handler is not None:
_REFLEX_LOGGER.removeHandler(handler)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
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. `--interactive` defaults to whether stdout is a terminal, so a pipe, a CI job or an agent is never left waiting at a prompt instead of exiting. `reflex cloud apps logs --follow` now defaults to off: following prompts between pages and never returns on its own, so it is opt-in.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This fragment is filed as .feature even though the change is breaking (default interactivity and JSON/output semantics change existing CI/tooling behavior). The monorepo towncrier config defines a breaking type that renders a "Breaking Changes" section; renaming the fragment to +eng-11018-agent-friendly-cli.breaking.md will list it there so changelog readers see the migration impact.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-hosting-cli/news/+eng-11018-agent-friendly-cli.feature.md, line 1:

<comment>This fragment is filed as `.feature` even though the change is breaking (default interactivity and JSON/output semantics change existing CI/tooling behavior). The monorepo towncrier config defines a `breaking` type that renders a "Breaking Changes" section; renaming the fragment to `+eng-11018-agent-friendly-cli.breaking.md` will list it there so changelog readers see the migration impact.</comment>

<file context>
@@ -0,0 +1 @@
+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. `--interactive` defaults to whether stdout is a terminal, so a pipe, a CI job or an agent is never left waiting at a prompt instead of exiting. `reflex cloud apps logs --follow` now defaults to off: following prompts between pages and never returns on its own, so it is opt-in.
</file context>

16 changes: 10 additions & 6 deletions packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py
Original file line number Diff line number Diff line change
Expand Up @@ -2762,25 +2762,29 @@ def read_config(
return Config.from_yaml_or_toml_or_none()


def generate_config(interactive: bool = True, token: str | None = None):
def generate_config(interactive: bool = True, token: str | None = None) -> Path | None:
"""Generate the config file with app-based prefilling.

Args:
interactive: Whether to use interactive mode for authentication and app selection.
token: An existing authentication token to use instead of interactive auth.

Returns:
The path of the config file written, or None if none was.

Raises:
click.exceptions.Exit: If authentication fails or user cancels operation.
"""
try:
import yaml
except ImportError:
logger.error("Please install PyYAML to use this command: pip install pyyaml")
return
return None

if Path("cloud.yml").exists():
config_path = Path("cloud.yml")
if config_path.exists():
logger.error("cloud.yml already exists.")
return
return None

try:
authenticated_client = get_authenticated_client(
Expand Down Expand Up @@ -2821,13 +2825,13 @@ def generate_config(interactive: bool = True, token: str | None = None):
)
default = {"name": current_dir_name}

with Path("cloud.yml").open("w") as config_file:
with config_path.open("w") as config_file:
yaml.dump(default, config_file, default_flow_style=False, sort_keys=False)
logger.log(log.SUCCESS, "cloud.yml created successfully.")
logger.info(
"For more configuration options, see: https://reflex.dev/docs/hosting/config-file/"
)
return
return config_path


def log_out_on_browser():
Expand Down
Loading
Loading