Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/reflex-base/news/+reserve-stdout.feature.md
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, prompts, spinners and progress bars to stderr for as long as it is set.
35 changes: 28 additions & 7 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 @@ -250,7 +261,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 @@ -264,7 +275,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 @@ -459,8 +470,15 @@ def ask(
Returns:
A string with the user input.
"""
# A prompt is human output like any other, and the one that must not land
# on a reserved stdout: it blocks, so a caller parsing the document reads
# the question as data and never answers it.
return Prompt.ask(
question, choices=choices, default=default, show_choices=show_choices
question,
choices=choices,
default=default,
show_choices=show_choices,
console=_human_console(),
)


Expand Down Expand Up @@ -494,7 +512,7 @@ def print_table(
for row in tabular_data:
table.add_row(*row)

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


def progress():
Expand All @@ -507,7 +525,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 @@ -523,7 +544,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 @@ -78,6 +78,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 @@ -198,7 +201,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()
Comment thread
amsraman marked this conversation as resolved.
)
# 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 @@ -242,7 +247,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 @@ -472,6 +477,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 @@ -633,7 +670,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 @@
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.
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. 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.
26 changes: 20 additions & 6 deletions packages/reflex-hosting-cli/src/reflex_cli/utils/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from typing import overload

from reflex_cli.constants.base import LogLevel
from reflex_cli.utils.log import HAS_REFLEX_BASE, is_json_mode
from reflex_cli.utils.log import HAS_REFLEX_BASE, is_json_mode, is_stdout_reserved
from reflex_cli.utils.log import set_log_level as _set_log_level

if HAS_REFLEX_BASE:
Expand All @@ -28,6 +28,16 @@
from rich.table import Table

_console = Console(highlight=False)
_console_stderr = Console(stderr=True, highlight=False)

def _human_console() -> Console:
"""Resolve the console human-readable output belongs on.

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

def print(msg: str, **kwargs):
"""Print a message.
Expand All @@ -36,7 +46,7 @@ def print(msg: str, **kwargs):
msg: The message to print.
kwargs: Keyword arguments to pass to the print function.
"""
_console.print(msg, **kwargs)
_human_console().print(msg, **kwargs)

def print_table(
tabular_data: list[list[str]],
Expand All @@ -60,7 +70,7 @@ def print_table(
for row in tabular_data:
table.add_row(*row)

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

def rule(title: str, **kwargs):
"""Print a horizontal rule with a title.
Expand All @@ -69,7 +79,7 @@ def rule(title: str, **kwargs):
title: The title of the rule.
kwargs: Keyword arguments to pass to the print function.
"""
_console.rule(title, **kwargs)
_human_console().rule(title, **kwargs)

@overload
def ask(
Expand Down Expand Up @@ -105,7 +115,11 @@ def ask(
A string with the user input.
"""
return Prompt.ask(
question, choices=choices, default=default, show_choices=show_choices
question,
choices=choices,
default=default,
show_choices=show_choices,
console=_human_console(),
)

def progress():
Expand All @@ -130,7 +144,7 @@ def status(*args, **kwargs):
Returns:
A new status.
"""
return _console.status(*args, **kwargs)
return _human_console().status(*args, **kwargs)


def set_log_level(log_level: LogLevel | str | None):
Expand Down
Loading
Loading