From f3296f33caa766f6a65921c9285906a06cd4dc2a Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 24 Aug 2026 16:15:41 -0700 Subject: [PATCH 1/4] fix(hosting-cli): make reflex-base an optional dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reflex-hosting-cli declared `reflex-base >= 0.9.8.post19.dev0` while advertising support for reflex down to 0.6.6.post1. reflex-base does not exist below 0.9.0, so that floor could not be satisfied on any reflex the CLI claims to support, and on reflex 0.8.9 (which declares no reflex-base dependency) pip would silently install a second, mismatched framework base alongside it with no conflict to detect. The dependency was never needed for the shared logging pipeline: reflex_base.utils.log lists "reflex_cli" in PACKAGE_LOGGER_NAMES, so it parents the CLI's loggers from its own side. Only three things were genuinely imported — LogLevel, log.SUCCESS, and the rich console helpers — and each is now resolved from reflex-base when it is importable and from a local fork otherwise. On reflex 0.9 every name resolves to the identical reflex-base object, so behavior there is unchanged. The forked LogLevel moves to reflex_cli.constants.log_level so it is type-checked on its own, and reflex_cli.utils.log holds the fallback SUCCESS level and rich handler (same styles, stderr routing and level gating as reflex-base). reflex_cli.utils.console drops the PoorProgress, is_debug and debug/info/success/log/warn/error/timing/deprecate re-exports rather than duplicate them into the fallback: the CLI has logged through `logging` since #6866, and reflex-base deprecated the legacy helpers in #6867. --- packages/reflex-hosting-cli/news/6866.misc.md | 2 +- packages/reflex-hosting-cli/pyproject.toml | 4 - .../src/reflex_cli/constants/base.py | 15 +- .../src/reflex_cli/constants/log_level.py | 107 ++++++++ .../src/reflex_cli/utils/console.py | 149 ++++++++++-- .../src/reflex_cli/utils/hosting.py | 3 +- .../src/reflex_cli/utils/log.py | 121 +++++++++ .../src/reflex_cli/v2/apps.py | 3 +- .../src/reflex_cli/v2/cli.py | 3 +- .../src/reflex_cli/v2/gcp.py | 3 +- .../src/reflex_cli/v2/project.py | 3 +- .../src/reflex_cli/v2/providers.py | 3 +- .../src/reflex_cli/v2/scan.py | 3 +- .../src/reflex_cli/v2/secrets.py | 3 +- .../src/reflex_cli/v2/vmtypes_regions.py | 3 +- tests/units/reflex_cli/utils/test_log.py | 229 ++++++++++++++++++ .../reflex_cli/v2/test_vmtypes_regions.py | 8 - uv.lock | 64 +++-- 18 files changed, 642 insertions(+), 84 deletions(-) create mode 100644 packages/reflex-hosting-cli/src/reflex_cli/constants/log_level.py create mode 100644 packages/reflex-hosting-cli/src/reflex_cli/utils/log.py create mode 100644 tests/units/reflex_cli/utils/test_log.py diff --git a/packages/reflex-hosting-cli/news/6866.misc.md b/packages/reflex-hosting-cli/news/6866.misc.md index 4b2ada8700f..0e083f973e6 100644 --- a/packages/reflex-hosting-cli/news/6866.misc.md +++ b/packages/reflex-hosting-cli/news/6866.misc.md @@ -1 +1 @@ -The hosting CLI's forked console module and `LogLevel` enum are now shims over `reflex-base` (new dependency), and its logging goes through standard python `logging`. Debug output renders purple (was blue), errors go to stderr (was stdout), and success messages are hidden at `--loglevel warning`. +The hosting CLI's logging goes through standard python `logging`. On reflex 0.9 and up it shares the `reflex-base` console and `LogLevel`; on earlier reflex, where `reflex-base` is not installed, the CLI renders the same output itself. Debug output renders purple (was blue), errors go to stderr (was stdout), and success messages are hidden at `--loglevel warning`. diff --git a/packages/reflex-hosting-cli/pyproject.toml b/packages/reflex-hosting-cli/pyproject.toml index 86d985e79f1..b354a01472a 100644 --- a/packages/reflex-hosting-cli/pyproject.toml +++ b/packages/reflex-hosting-cli/pyproject.toml @@ -18,13 +18,9 @@ dependencies = [ "httpx >=0.25.1,<1.0", "packaging >=24.2", "platformdirs >=3.10.0,<5.0", - "reflex-base >= 0.9.8.post19.dev0", "rich >=13,<16", ] -[tool.uv.sources] -reflex-base = { workspace = true } - [tool.hatch.version] source = "uv-dynamic-versioning" diff --git a/packages/reflex-hosting-cli/src/reflex_cli/constants/base.py b/packages/reflex-hosting-cli/src/reflex_cli/constants/base.py index 612e8d42589..d6f9f2b760f 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/constants/base.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/constants/base.py @@ -3,9 +3,22 @@ from __future__ import annotations from types import SimpleNamespace +from typing import TYPE_CHECKING from platformdirs import PlatformDirs -from reflex_base.constants.base import LogLevel as LogLevel + +if TYPE_CHECKING: + # The two enums are interchangeable, so the shared one is what gets + # type-checked; reflex_cli.constants.log_level is checked on its own. + from reflex_base.constants.base import LogLevel as LogLevel +else: + try: + # reflex-base only exists from reflex 0.9 on, and the hosting CLI + # supports older reflex too, so its LogLevel is shared when available + # and forked otherwise. + from reflex_base.constants.base import LogLevel as LogLevel + except ImportError: + from reflex_cli.constants.log_level import LogLevel as LogLevel class Reflex(SimpleNamespace): diff --git a/packages/reflex-hosting-cli/src/reflex_cli/constants/log_level.py b/packages/reflex-hosting-cli/src/reflex_cli/constants/log_level.py new file mode 100644 index 00000000000..e17068ad391 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/constants/log_level.py @@ -0,0 +1,107 @@ +"""The hosting CLI's own LogLevel, used when reflex-base is not installed. + +reflex-base only exists from reflex 0.9 on, but the hosting CLI supports older +reflex too. :mod:`reflex_cli.constants.base` prefers the reflex-base enum when +it is importable and falls back to this one otherwise; the two are +interchangeable, with the same members and the same string values. +""" + +from __future__ import annotations + +import logging +from enum import Enum + + +class LogLevel(str, Enum): + """The log levels.""" + + DEBUG = "debug" + DEFAULT = "default" + INFO = "info" + WARNING = "warning" + ERROR = "error" + CRITICAL = "critical" + + @classmethod + def from_string(cls, level: str | None) -> LogLevel | None: + """Convert a string to a log level. + + Args: + level: The log level as a string. + + Returns: + The log level, or None if the string names no level. + """ + if not level: + return None + try: + return cls[level.upper()] + except KeyError: + return None + + def to_logging_level(self) -> int: + """Map this level to a stdlib logging level number. + + DEFAULT acts as a threshold equivalent to INFO. + + Returns: + The stdlib logging level. + """ + return _LOGGING_LEVELS[self] + + # The str mixin supplies alphabetical comparisons, so all four operators + # must be overridden to compare by verbosity rank instead. + def __lt__(self, other: LogLevel) -> bool: + """Compare log levels. + + Args: + other: The other log level. + + Returns: + True if the log level is less verbose than the other log level. + """ + return _LOG_LEVEL_RANK[self] < _LOG_LEVEL_RANK[other] + + def __le__(self, other: LogLevel) -> bool: + """Compare log levels. + + Args: + other: The other log level. + + Returns: + True if the log level is less than or equal to the other log level. + """ + return _LOG_LEVEL_RANK[self] <= _LOG_LEVEL_RANK[other] + + def __gt__(self, other: LogLevel) -> bool: + """Compare log levels. + + Args: + other: The other log level. + + Returns: + True if the log level is more verbose-restrictive than the other. + """ + return _LOG_LEVEL_RANK[self] > _LOG_LEVEL_RANK[other] + + def __ge__(self, other: LogLevel) -> bool: + """Compare log levels. + + Args: + other: The other log level. + + Returns: + True if the log level is greater than or equal to the other. + """ + return _LOG_LEVEL_RANK[self] >= _LOG_LEVEL_RANK[other] + + +_LOG_LEVEL_RANK = {level: rank for rank, level in enumerate(LogLevel)} +_LOGGING_LEVELS = { + LogLevel.DEBUG: logging.DEBUG, + LogLevel.DEFAULT: logging.INFO, + LogLevel.INFO: logging.INFO, + LogLevel.WARNING: logging.WARNING, + LogLevel.ERROR: logging.ERROR, + LogLevel.CRITICAL: logging.CRITICAL, +} diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/console.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/console.py index b56597c991a..172076a02d4 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/console.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/console.py @@ -1,25 +1,136 @@ -"""Functions to communicate to the user via console (shared with reflex-base).""" +"""Interactive console helpers, shared with reflex-base when it is installed. + +Level-gated messages go through :mod:`logging` (see :mod:`reflex_cli.utils.log`); +what remains here are the rich features that are output rather than logging: +prompts, tables, spinners and plain prints. +""" from __future__ import annotations -from reflex_base.constants.base import LogLevel -from reflex_base.utils.console import PoorProgress as PoorProgress -from reflex_base.utils.console import ask as ask -from reflex_base.utils.console import debug as debug -from reflex_base.utils.console import deprecate as deprecate -from reflex_base.utils.console import error as error -from reflex_base.utils.console import info as info -from reflex_base.utils.console import is_debug as is_debug -from reflex_base.utils.console import log as log -from reflex_base.utils.console import print as print -from reflex_base.utils.console import print_table as print_table -from reflex_base.utils.console import progress as progress -from reflex_base.utils.console import rule as rule -from reflex_base.utils.console import set_log_level as _set_log_level -from reflex_base.utils.console import status as status -from reflex_base.utils.console import success as success -from reflex_base.utils.console import timing as timing -from reflex_base.utils.console import warn as warn +from collections.abc import Sequence +from typing import overload + +from reflex_cli.constants.base import LogLevel +from reflex_cli.utils.log import HAS_REFLEX_BASE +from reflex_cli.utils.log import set_log_level as _set_log_level + +if HAS_REFLEX_BASE: + from reflex_base.utils.console import ask as ask + from reflex_base.utils.console import print as print + from reflex_base.utils.console import print_table as print_table + from reflex_base.utils.console import progress as progress + from reflex_base.utils.console import rule as rule + from reflex_base.utils.console import status as status +else: + from rich.console import Console, OverflowMethod + from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn + from rich.prompt import Prompt + from rich.table import Table + + _console = Console(highlight=False) + + def print(msg: str, **kwargs): + """Print a message. + + Args: + msg: The message to print. + kwargs: Keyword arguments to pass to the print function. + """ + _console.print(msg, **kwargs) + + def print_table( + tabular_data: list[list[str]], + headers: Sequence[str] = (), + overflow: OverflowMethod = "ellipsis", + ) -> None: + """Print a table to the console. + + Args: + tabular_data: The data to print in tabular format. + headers: The headers for the table. + overflow: What to do with a cell too wide for its column. The + default cuts it short; pass "fold" for values a user has to + read in full, such as an email or an identifier. + """ + table = Table() + + for column in headers: + table.add_column(column, overflow=overflow) + + for row in tabular_data: + table.add_row(*row) + + _console.print(table) + + def rule(title: str, **kwargs): + """Print a horizontal rule with a title. + + Args: + title: The title of the rule. + kwargs: Keyword arguments to pass to the print function. + """ + _console.rule(title, **kwargs) + + @overload + def ask( + question: str, + choices: list[str] | None = None, + *, + show_choices: bool = True, + ) -> str: ... + + @overload + def ask( + question: str, + choices: list[str] | None = None, + default: str = ..., + show_choices: bool = True, + ) -> str: ... + + def ask( + question: str, + choices: list[str] | None = None, + default: str | None = None, + show_choices: bool = True, + ) -> str | None: + """Ask the user a question, optionally with a list of choices. + + Args: + question: The question to ask the user. + choices: A list of choices to select from. + default: The default option selected. + show_choices: Whether to show the choices. + + Returns: + A string with the user input. + """ + return Prompt.ask( + question, choices=choices, default=default, show_choices=show_choices + ) + + def progress(): + """Create a new progress bar. + + Returns: + A new progress bar. + """ + return Progress( + *Progress.get_default_columns()[:-1], + MofNCompleteColumn(), + TimeElapsedColumn(), + ) + + def status(*args, **kwargs): + """Create a status with a spinner. + + Args: + *args: Args to pass to the status. + **kwargs: Kwargs to pass to the status. + + Returns: + A new status. + """ + return _console.status(*args, **kwargs) def set_log_level(log_level: LogLevel | str): diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py index 49ba13f263c..a132dfdb6bb 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -22,11 +22,10 @@ from urllib.parse import urljoin import click -from reflex_base.utils import log import reflex_cli.constants as constants from reflex_cli.core.config import Config, RegionOption -from reflex_cli.utils import console, dependency +from reflex_cli.utils import console, dependency, log from reflex_cli.utils.dependency import is_valid_url from reflex_cli.utils.exceptions import ( GetAppError, diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/log.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/log.py new file mode 100644 index 00000000000..4a0be795746 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/log.py @@ -0,0 +1,121 @@ +"""Logging pipeline for the hosting CLI, shared with reflex-base when present. + +The CLI logs through plain ``logging.getLogger(__name__)`` loggers either way. +Under reflex 0.9 and up, ``reflex_base.utils.log`` already parents ``reflex_cli`` +under the ``reflex`` logger and owns the sinks, so this module only forwards to +it. Under older reflex there is no reflex-base to forward to, so the fallback +below renders the ``reflex_cli`` logger itself with the same styles. +""" + +from __future__ import annotations + +import logging + +from reflex_cli.constants.base import LogLevel + +try: + from reflex_base.utils.log import SUCCESS as SUCCESS + from reflex_base.utils.log import set_log_level as set_log_level + + HAS_REFLEX_BASE = True + +except ImportError: + from rich.console import Console + + HAS_REFLEX_BASE = False + + # Level between INFO and WARNING for user-facing success messages. + SUCCESS = 25 + logging.addLevelName(SUCCESS, "SUCCESS") + + # (style, prefix) per level, matching the reflex-base console handler. + _LEVEL_STYLES: dict[int, tuple[str, str]] = { + logging.DEBUG: ("purple", "Debug: "), + logging.INFO: ("cyan", "Info: "), + SUCCESS: ("green", "Success: "), + logging.WARNING: ("orange1", "Warning: "), + logging.ERROR: ("red", ""), + logging.CRITICAL: ("red", ""), + } + + _console = Console(highlight=False) + _console_stderr = Console(stderr=True, highlight=False) + + # Formatter kept only for its exception rendering, which is stateless. + _EXC_FORMATTER = logging.Formatter() + + _CLI_LOGGER = logging.getLogger("reflex_cli") + + def _style_for_level(levelno: int) -> tuple[str, str]: + """Resolve the rich style and message prefix for a log level. + + Args: + levelno: The stdlib logging level number. + + Returns: + A (style, prefix) tuple. + """ + levelno = min(logging.CRITICAL, max(logging.DEBUG, levelno)) + # Round down to the nearest known level. + while levelno not in _LEVEL_STYLES: + levelno -= 1 + return _LEVEL_STYLES[levelno] + + class RichConsoleHandler(logging.Handler): + """Render log records with rich, matching the reflex-base look.""" + + def emit(self, record: logging.LogRecord): + """Print a record to the terminal. + + Args: + record: The log record. + """ + try: + style, prefix = _style_for_level(record.levelno) + console = ( + _console_stderr if record.levelno >= logging.ERROR else _console + ) + # Markup is opt-in per record (``extra={"rich": True}``); plain + # messages keep their literal brackets. + markup = bool(getattr(record, "rich", False)) + console.print( + f"{prefix}{record.getMessage()}", + style=style, + end=getattr(record, "end", "\n"), + markup=markup, + ) + if record.exc_info and record.exc_info[0] is not None: + # Tracebacks may contain user data; never parse them as + # markup. Never word-wrap them either: that breaks paths. + console.print( + _EXC_FORMATTER.formatException(record.exc_info), + style=style, + markup=False, + soft_wrap=True, + ) + except Exception: + self.handleError(record) + + _handler = RichConsoleHandler() + + def set_log_level(log_level: LogLevel | None): + """Set the log level and attach the CLI's console sink. + + Args: + log_level: The log level to set, or None to leave it unchanged. + + Raises: + TypeError: If the log level is not a LogLevel enum value. + """ + if log_level is None: + return + if not isinstance(log_level, LogLevel): + msg = f"log_level must be a LogLevel enum value, got {log_level} of type {type(log_level)} instead." + raise TypeError(msg) + _handler.setLevel(log_level.to_logging_level()) + _CLI_LOGGER.setLevel(log_level.to_logging_level()) + # Cut propagation while the sink is attached, so an application-side + # basicConfig cannot double-emit the CLI's records. addHandler is a + # no-op when the handler is already attached, so this stays idempotent. + _CLI_LOGGER.propagate = False + _CLI_LOGGER.addHandler(_handler) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py index 369b9a41ca7..7ca6337d5f3 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py @@ -7,11 +7,10 @@ from typing import Any import click -from reflex_base.utils import log from reflex_cli import constants from reflex_cli.core.config import Config -from reflex_cli.utils import console +from reflex_cli.utils import console, log from reflex_cli.utils.exceptions import ( ConfigInvalidFieldValueError, GetAppError, diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py index 68f9958855d..b4f8e238326 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py @@ -15,10 +15,9 @@ import click from packaging import version -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log from reflex_cli.utils.dependency import extract_domain logger = logging.getLogger(__name__) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py index a0c87ad5025..ed42669f730 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -34,10 +34,9 @@ from urllib.parse import urljoin import click -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log logger = logging.getLogger(__name__) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/project.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/project.py index cebeedba700..ddfb2dfc3e1 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/project.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/project.py @@ -4,10 +4,9 @@ import logging import click -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log from reflex_cli.utils.exceptions import NotAuthenticatedError logger = logging.getLogger(__name__) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/providers.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/providers.py index 5f621a30ac3..8253244d95d 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/providers.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/providers.py @@ -14,10 +14,9 @@ from typing import Any import click -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log from reflex_cli.utils.exceptions import NotAuthenticatedError logger = logging.getLogger(__name__) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/scan.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/scan.py index fa4809da2be..4d848d82561 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/scan.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/scan.py @@ -12,10 +12,9 @@ from typing import Any import click -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log from reflex_cli.utils.exceptions import NotAuthenticatedError logger = logging.getLogger(__name__) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/secrets.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/secrets.py index 79923fd8842..78434e10951 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/secrets.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/secrets.py @@ -5,10 +5,9 @@ import logging import click -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log from reflex_cli.utils.exceptions import NotAuthenticatedError logger = logging.getLogger(__name__) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/vmtypes_regions.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/vmtypes_regions.py index 6e97908ecdc..cc5b1a3b191 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/vmtypes_regions.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/vmtypes_regions.py @@ -4,10 +4,9 @@ import logging import click -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log logger = logging.getLogger(__name__) diff --git a/tests/units/reflex_cli/utils/test_log.py b/tests/units/reflex_cli/utils/test_log.py new file mode 100644 index 00000000000..b2397426bca --- /dev/null +++ b/tests/units/reflex_cli/utils/test_log.py @@ -0,0 +1,229 @@ +"""Tests for the hosting CLI's logging pipeline, with and without reflex-base.""" + +from __future__ import annotations + +import contextlib +import importlib +import logging +import sys +from collections.abc import Iterator +from types import ModuleType + +import pytest +from reflex_cli.utils import console, log + + +class _ReflexBaseBlocker: + """Meta path finder that makes reflex-base look uninstalled.""" + + def find_spec(self, fullname: str, path=None, target=None): + """Refuse to resolve reflex_base, as if it were not installed. + + Args: + fullname: The module being imported. + path: The parent package's search path. + target: The module being reloaded, if any. + + Returns: + None for every other module, deferring to the real finders. + + Raises: + ImportError: If reflex_base (or a submodule) is being imported. + """ + if fullname == "reflex_base" or fullname.startswith("reflex_base."): + msg = f"No module named {fullname!r}" + raise ImportError(msg) + return + + +@contextlib.contextmanager +def _without_reflex_base() -> Iterator[tuple[ModuleType, ModuleType, ModuleType]]: + """Import the CLI's logging modules as if reflex-base were not installed. + + Yields: + The freshly imported (constants.base, utils.log, utils.console) modules. + """ + cli_logger = logging.getLogger("reflex_cli") + saved_state = (cli_logger.handlers[:], cli_logger.level, cli_logger.propagate) + saved_modules = { + name: module + for name, module in sys.modules.items() + if name.startswith(("reflex_cli", "reflex_base")) + } + for name in saved_modules: + del sys.modules[name] + blocker = _ReflexBaseBlocker() + sys.meta_path.insert(0, blocker) + try: + yield ( + importlib.import_module("reflex_cli.constants.base"), + importlib.import_module("reflex_cli.utils.log"), + importlib.import_module("reflex_cli.utils.console"), + ) + finally: + sys.meta_path.remove(blocker) + for name in list(sys.modules): + if name.startswith(("reflex_cli", "reflex_base")): + del sys.modules[name] + sys.modules.update(saved_modules) + cli_logger.handlers, cli_logger.level, cli_logger.propagate = saved_state + + +def test_reflex_base_is_used_when_installed(): + """The workspace has reflex-base, so the shared pipeline is what is used.""" + from reflex_base.utils import log as base_log + + assert log.HAS_REFLEX_BASE + assert log.SUCCESS is base_log.SUCCESS + assert log.set_log_level is base_log.set_log_level + + +def test_reflex_base_adopts_the_cli_logger_without_being_imported(): + """reflex-base parents reflex_cli itself, so the CLI never has to import it.""" + from reflex_base.utils import log as base_log + + assert "reflex_cli" in base_log.PACKAGE_LOGGER_NAMES + + +@pytest.mark.parametrize( + "module", + [ + "reflex_cli.utils.hosting", + "reflex_cli.v2.apps", + "reflex_cli.v2.cli", + "reflex_cli.v2.deployments", + "reflex_cli.v2.gcp", + "reflex_cli.v2.project", + "reflex_cli.v2.providers", + "reflex_cli.v2.scan", + "reflex_cli.v2.secrets", + "reflex_cli.v2.vmtypes_regions", + ], +) +def test_cli_imports_without_reflex_base(module: str): + """Every CLI module imports on reflex versions that predate reflex-base.""" + with _without_reflex_base(): + importlib.import_module(module) + + +def test_fallback_success_level(): + """Without reflex-base the CLI defines the same SUCCESS level itself.""" + with _without_reflex_base() as (_, fallback_log, _console): + assert not fallback_log.HAS_REFLEX_BASE + assert fallback_log.SUCCESS == log.SUCCESS == 25 + assert logging.getLevelName(fallback_log.SUCCESS) == "SUCCESS" + + +def test_fallback_log_level_enum_matches_reflex_base(): + """The forked LogLevel is interchangeable with the reflex-base one.""" + from reflex_base.constants.base import LogLevel as BaseLogLevel + + with _without_reflex_base() as (constants_base, _, _console): + forked = constants_base.LogLevel + assert forked is not BaseLogLevel + assert [level.value for level in forked] == [ + level.value for level in BaseLogLevel + ] + for level in forked: + assert ( + level.to_logging_level() == BaseLogLevel(level.value).to_logging_level() + ) + assert forked.from_string("warning") is forked.WARNING + assert forked.from_string("nonsense") is None + assert forked.from_string(None) is None + assert forked.DEBUG < forked.INFO + assert forked.DEBUG <= forked.DEBUG + assert forked.ERROR > forked.INFO + assert forked.ERROR >= forked.ERROR + + +@pytest.mark.parametrize( + ("level", "message", "expected"), + [ + (logging.DEBUG, "a debug line", "Debug: a debug line"), + (logging.INFO, "an info line", "Info: an info line"), + (25, "a success line", "Success: a success line"), + (logging.WARNING, "a warning line", "Warning: a warning line"), + ], +) +def test_fallback_renders_records_to_stdout(capsys, level, message, expected): + """The fallback sink renders records with the same prefixes as reflex-base.""" + with _without_reflex_base() as (constants_base, fallback_log, _console): + fallback_log.set_log_level(constants_base.LogLevel.DEBUG) + logging.getLogger("reflex_cli.test").log(level, message) + + assert expected in capsys.readouterr().out + + +def test_fallback_renders_errors_to_stderr(capsys): + """Errors go to stderr, matching the reflex-base handler.""" + with _without_reflex_base() as (constants_base, fallback_log, _console): + fallback_log.set_log_level(constants_base.LogLevel.INFO) + logging.getLogger("reflex_cli.test").error("a failure") + + captured = capsys.readouterr() + assert "a failure" in captured.err + assert "a failure" not in captured.out + + +def test_fallback_gates_on_log_level(capsys): + """Records below the configured level are not rendered.""" + with _without_reflex_base() as (constants_base, fallback_log, _console): + fallback_log.set_log_level(constants_base.LogLevel.WARNING) + cli_logger = logging.getLogger("reflex_cli.test") + cli_logger.info("quiet info") + cli_logger.warning("loud warning") + + captured = capsys.readouterr() + assert "quiet info" not in captured.out + assert "loud warning" in captured.out + + +def test_fallback_does_not_stack_handlers(): + """Repeated set_log_level calls re-use the one sink instead of stacking.""" + with _without_reflex_base() as (constants_base, fallback_log, _console): + cli_logger = logging.getLogger("reflex_cli") + fallback_log.set_log_level(constants_base.LogLevel.INFO) + fallback_log.set_log_level(constants_base.LogLevel.DEBUG) + + assert cli_logger.handlers == [fallback_log._handler] + # Propagation is cut so an application's own root config cannot + # double-emit the CLI's records. + assert not cli_logger.propagate + assert cli_logger.level == logging.DEBUG + + +def test_fallback_rejects_non_log_level(): + """A non-LogLevel value is a programming error, not a silent no-op.""" + with _without_reflex_base() as (_, fallback_log, _console): + with pytest.raises(TypeError): + fallback_log.set_log_level("debug") + # None means "leave it alone", matching reflex-base. + fallback_log.set_log_level(None) + + +def test_set_log_level_accepts_strings(): + """console.set_log_level keeps taking legacy string values.""" + with _without_reflex_base() as (_, _log, fallback_console): + fallback_console.set_log_level("warning") + assert logging.getLogger("reflex_cli").level == logging.WARNING + # An unknown level falls back to INFO rather than raising. + fallback_console.set_log_level("nonsense") + assert logging.getLogger("reflex_cli").level == logging.INFO + + console.set_log_level("warning") + console.set_log_level("info") + + +def test_fallback_console_helpers(capsys): + """The forked console renders the rich helpers the CLI actually uses.""" + with _without_reflex_base() as (_, _log, fallback_console): + fallback_console.print("a plain line") + fallback_console.print_table([["a@b.com", "1"]], headers=["email", "id"]) + fallback_console.rule("a rule") + + out = capsys.readouterr().out + assert "a plain line" in out + assert "a@b.com" in out + assert "email" in out + assert "a rule" in out diff --git a/tests/units/reflex_cli/v2/test_vmtypes_regions.py b/tests/units/reflex_cli/v2/test_vmtypes_regions.py index 56134d3e31c..239d727d18d 100644 --- a/tests/units/reflex_cli/v2/test_vmtypes_regions.py +++ b/tests/units/reflex_cli/v2/test_vmtypes_regions.py @@ -17,14 +17,6 @@ runner = CliRunner() -@pytest.fixture -def mock_console(mocker: MockFixture): - """Fixture to mock console.print and console.error.""" - mock_print = mocker.patch("reflex_cli.utils.console.print") - mock_error = mocker.patch("reflex_cli.utils.console.error") - return mock_print, mock_error - - def test_get_vm_types_success(mocker: MockFixture): """Test successful retrieval of VM types.""" mock_get_vm_types = mocker.patch( diff --git a/uv.lock b/uv.lock index ab4dfd64387..75a88ad310d 100644 --- a/uv.lock +++ b/uv.lock @@ -585,7 +585,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -663,7 +663,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } @@ -1003,7 +1003,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1879,15 +1879,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "cycler", marker = "python_full_version < '3.11'" }, - { name = "fonttools", marker = "python_full_version < '3.11'" }, - { name = "kiwisolver", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pillow", marker = "python_full_version < '3.11'" }, - { name = "pyparsing", marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" } }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ @@ -1963,16 +1963,16 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "cycler", marker = "python_full_version >= '3.11'" }, - { name = "fonttools", marker = "python_full_version >= '3.11'" }, - { name = "kiwisolver", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" } }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pillow", marker = "python_full_version >= '3.11'" }, - { name = "pyparsing", marker = "python_full_version >= '3.11'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } wheels = [ @@ -2534,10 +2534,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, - { name = "pytz", marker = "python_full_version < '3.11'" }, - { name = "tzdata", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -2606,10 +2606,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } wheels = [ @@ -4133,7 +4133,6 @@ dependencies = [ { name = "httpx" }, { name = "packaging" }, { name = "platformdirs" }, - { name = "reflex-base" }, { name = "rich" }, ] @@ -4143,7 +4142,6 @@ requires-dist = [ { name = "httpx", specifier = ">=0.25.1,<1.0" }, { name = "packaging", specifier = ">=24.2" }, { name = "platformdirs", specifier = ">=3.10.0,<5.0" }, - { name = "reflex-base", editable = "packages/reflex-base" }, { name = "rich", specifier = ">=13,<16" }, ] @@ -4306,7 +4304,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -4367,7 +4365,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -4446,7 +4444,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ From ee7706fc204d6fdd7b60229385cfd68812c66fe9 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 24 Aug 2026 16:26:02 -0700 Subject: [PATCH 2/4] test(hosting-cli): guard the advertised minimum reflex version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime half was already covered: test_cli_imports_without_reflex_base fails if a CLI module imports a workspace package older reflex does not ship. Nothing covered the packaging half — re-adding `reflex-base` to the declared dependencies without importing it at module scope broke no test. Three interlocking guards, derived from the checkout rather than hard-coded so a package added under packages/ later is covered automatically: - no declared dependency on a workspace package while MINIMUM_REFLEX_VERSION predates the workspace split (reflex 0.9.0, where reflex-base first shipped) - no [tool.uv.sources] workspace entry, which resolves locally and so hides an unsatisfiable dependency inside the monorepo - MINIMUM_REFLEX_VERSION <= RECOMMENDED_REFLEX_VERSION, so the CLI cannot gate on a version it then tells the user to upgrade away from The first guard skips itself if the floor is raised past the split, at which point a workspace dependency is legitimate — and the third then catches the half-migration of raising the minimum without the recommendation. --- .../reflex_cli/test_min_reflex_support.py | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/units/reflex_cli/test_min_reflex_support.py diff --git a/tests/units/reflex_cli/test_min_reflex_support.py b/tests/units/reflex_cli/test_min_reflex_support.py new file mode 100644 index 00000000000..d8ee0364020 --- /dev/null +++ b/tests/units/reflex_cli/test_min_reflex_support.py @@ -0,0 +1,138 @@ +"""Guards on the reflex versions reflex-hosting-cli claims to support. + +The hosting CLI advertises support down to +``ReflexHostingCli.MINIMUM_REFLEX_VERSION``, which predates the reflex release +that split the framework into workspace packages. Depending on any of those +packages is therefore unsatisfiable on the oldest reflex the CLI claims to +support -- and it fails quietly rather than loudly: reflex 0.8.x declares no +reflex-base dependency of its own, so pip has no conflict to report and simply +installs a second, mismatched framework base alongside reflex. + +The companion runtime guard is ``test_cli_imports_without_reflex_base`` in +``tests/units/reflex_cli/utils/test_log.py``, which covers the other half -- +importing a workspace package that older reflex does not ship. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name +from packaging.version import Version +from reflex_cli.constants.hosting import ReflexHostingCli + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +REPO_ROOT = Path(__file__).resolve().parents[3] +CLI_PYPROJECT = REPO_ROOT / "packages" / "reflex-hosting-cli" / "pyproject.toml" + +# The reflex release that first shipped the workspace packages: reflex-base was +# carved out in #6281 and its earliest tag is reflex-base-v0.9.0. No package +# under packages/ can be depended on from a reflex older than this. +REFLEX_WORKSPACE_SPLIT_VERSION = Version("0.9.0") + + +def _load(pyproject: Path) -> dict: + """Parse a pyproject.toml file. + + Args: + pyproject: The file to parse. + + Returns: + The parsed document. + """ + with pyproject.open("rb") as f: + return tomllib.load(f) + + +def _workspace_package_names() -> set[str]: + """Collect the distribution name of every package in the workspace. + + Derived from the checkout rather than hard-coded, so a package added under + ``packages/`` later is covered without touching this test. + + Returns: + The canonicalized distribution names. + """ + names = set() + for pyproject in sorted((REPO_ROOT / "packages").glob("*/pyproject.toml")): + if name := _load(pyproject).get("project", {}).get("name"): + names.add(canonicalize_name(name)) + return names + + +def test_workspace_packages_are_discoverable(): + """The workspace scan finds real packages, so the guards below are not vacuous.""" + names = _workspace_package_names() + assert canonicalize_name("reflex-base") in names + assert canonicalize_name("reflex-hosting-cli") in names + + +def test_no_dependency_that_the_minimum_reflex_cannot_satisfy(): + """The CLI must not require a package older reflex does not ship. + + Adding one (as #6866 did with reflex-base) makes the advertised floor a + lie, so this fails until either the dependency goes or + MINIMUM_REFLEX_VERSION is raised past the workspace split. + """ + minimum = ReflexHostingCli.MINIMUM_REFLEX_VERSION + declared = { + canonicalize_name(Requirement(dep).name) + for dep in _load(CLI_PYPROJECT)["project"]["dependencies"] + } + offenders = sorted(declared & _workspace_package_names()) + + if minimum >= REFLEX_WORKSPACE_SPLIT_VERSION: + pytest.skip( + f"MINIMUM_REFLEX_VERSION is {minimum}, at or past the workspace " + f"split ({REFLEX_WORKSPACE_SPLIT_VERSION}); workspace dependencies " + "are satisfiable and this guard no longer applies." + ) + + assert not offenders, ( + f"reflex-hosting-cli advertises reflex >= {minimum} " + f"(ReflexHostingCli.MINIMUM_REFLEX_VERSION) but declares {offenders}, " + f"which reflex only ships from {REFLEX_WORKSPACE_SPLIT_VERSION} on. " + "Reach for it through an optional import instead (see " + "reflex_cli.utils.log), or raise MINIMUM_REFLEX_VERSION and " + "RECOMMENDED_REFLEX_VERSION to match what is actually supported." + ) + + +def test_no_workspace_dependency_sources(): + """No ``[tool.uv.sources]`` workspace entry may smuggle a sibling package in. + + A workspace source resolves locally, so a dependency added this way can look + fine in the monorepo while being unsatisfiable for an installed user. + """ + sources = _load(CLI_PYPROJECT).get("tool", {}).get("uv", {}).get("sources", {}) + workspace_sources = sorted( + name for name, spec in sources.items() if spec.get("workspace") + ) + assert not workspace_sources, ( + f"reflex-hosting-cli declares workspace sources for {workspace_sources}. " + "The published package cannot resolve them; drop the source and the " + "matching dependency." + ) + + +def test_recommended_version_is_reachable_from_the_minimum(): + """The upgrade the CLI recommends must be a real step up from the floor. + + ``v2/deployments.py`` tells users below the recommended version to upgrade + to it, so it has to be a version that both exists and satisfies the CLI's + own dependencies. + """ + minimum = ReflexHostingCli.MINIMUM_REFLEX_VERSION + recommended = ReflexHostingCli.RECOMMENDED_REFLEX_VERSION + assert minimum <= recommended, ( + f"MINIMUM_REFLEX_VERSION ({minimum}) is above " + f"RECOMMENDED_REFLEX_VERSION ({recommended}), so the CLI gates on a " + "version it then tells the user is too old." + ) From 3c30ee82bd4944c49d97a6f0e4e675d7e5b2dd01 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 24 Aug 2026 16:35:32 -0700 Subject: [PATCH 3/4] fix(hosting-cli): complete the forked LogLevel API and stop test state leaking Review feedback on #6939. - The forked LogLevel was missing subprocess_level(), which the shared enum defines. Nothing calls it on the CLI's enum today (only reflex calls it, on its own config.loglevel), but parity is the fork's whole contract: a method present on reflex 0.9 and absent on older reflex is precisely the bug class this package guards against. Added it, and added a test asserting the fork covers reflex-base's entire public LogLevel API so the next gap fails loudly rather than waiting to be noticed. - test_set_log_level_accepts_strings exercised the reflex-base path outside the isolation context. That path is process-wide -- it sets REFLEX_LOGLEVEL so subprocesses inherit the level, and moves a module global -- so the test left REFLEX_LOGLEVEL=info behind for the rest of the session. Sandboxed the env var with monkeypatch and restore the previous level in a finally. - codespell: "re-use" -> "reuse". --- .../src/reflex_cli/constants/log_level.py | 8 +++ tests/units/reflex_cli/utils/test_log.py | 50 +++++++++++++++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/constants/log_level.py b/packages/reflex-hosting-cli/src/reflex_cli/constants/log_level.py index e17068ad391..559fe90f242 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/constants/log_level.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/constants/log_level.py @@ -49,6 +49,14 @@ def to_logging_level(self) -> int: """ return _LOGGING_LEVELS[self] + def subprocess_level(self) -> LogLevel: + """Return the log level to hand to a subprocess. + + Returns: + This level, or WARNING when it is DEFAULT. + """ + return self if self != LogLevel.DEFAULT else LogLevel.WARNING + # The str mixin supplies alphabetical comparisons, so all four operators # must be overridden to compare by verbosity rank instead. def __lt__(self, other: LogLevel) -> bool: diff --git a/tests/units/reflex_cli/utils/test_log.py b/tests/units/reflex_cli/utils/test_log.py index b2397426bca..884d5e63506 100644 --- a/tests/units/reflex_cli/utils/test_log.py +++ b/tests/units/reflex_cli/utils/test_log.py @@ -135,6 +135,34 @@ def test_fallback_log_level_enum_matches_reflex_base(): assert forked.DEBUG <= forked.DEBUG assert forked.ERROR > forked.INFO assert forked.ERROR >= forked.ERROR + for level in forked: + assert ( + level.subprocess_level().value + == BaseLogLevel(level.value).subprocess_level().value + ) + + +def test_fallback_log_level_covers_the_whole_reflex_base_api(): + """The fork must expose everything the shared enum does. + + The fork is a drop-in for reflex-base's LogLevel, so CLI code written + against the shared enum has to keep working when reflex-base is absent. + A method added there and missed here would work on reflex 0.9 and break on + older reflex -- the exact class of bug this package guards against. + """ + from reflex_base.constants.base import LogLevel as BaseLogLevel + + with _without_reflex_base() as (constants_base, _, _console): + forked = constants_base.LogLevel + missing = { + name + for name in dir(BaseLogLevel) + if not name.startswith("_") and not hasattr(forked, name) + } + assert not missing, ( + f"reflex_cli.constants.log_level.LogLevel is missing {sorted(missing)}, " + "which reflex_base.constants.base.LogLevel defines." + ) @pytest.mark.parametrize( @@ -180,7 +208,7 @@ def test_fallback_gates_on_log_level(capsys): def test_fallback_does_not_stack_handlers(): - """Repeated set_log_level calls re-use the one sink instead of stacking.""" + """Repeated set_log_level calls reuse the one sink instead of stacking.""" with _without_reflex_base() as (constants_base, fallback_log, _console): cli_logger = logging.getLogger("reflex_cli") fallback_log.set_log_level(constants_base.LogLevel.INFO) @@ -202,7 +230,7 @@ def test_fallback_rejects_non_log_level(): fallback_log.set_log_level(None) -def test_set_log_level_accepts_strings(): +def test_set_log_level_accepts_strings(monkeypatch): """console.set_log_level keeps taking legacy string values.""" with _without_reflex_base() as (_, _log, fallback_console): fallback_console.set_log_level("warning") @@ -211,8 +239,22 @@ def test_set_log_level_accepts_strings(): fallback_console.set_log_level("nonsense") assert logging.getLogger("reflex_cli").level == logging.INFO - console.set_log_level("warning") - console.set_log_level("info") + # The reflex-base path is process-wide: it sets REFLEX_LOGLEVEL so + # subprocesses inherit the level, and moves a module global. Sandbox the + # environment and put the level back, or the rest of the session (and any + # subprocess it spawns) inherits whatever this test left behind. + from reflex_base.constants.base import LogLevel as BaseLogLevel + from reflex_base.utils import log as base_log + + previous = base_log.get_log_level() + monkeypatch.setenv("REFLEX_LOGLEVEL", previous.value) + try: + console.set_log_level("warning") + assert base_log.get_log_level() is BaseLogLevel.WARNING + console.set_log_level("nonsense") + assert base_log.get_log_level() is BaseLogLevel.INFO + finally: + base_log.set_log_level(previous) def test_fallback_console_helpers(capsys): From c765d9fb289d0a71964451a1228a5b257afd91f8 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 24 Aug 2026 16:39:03 -0700 Subject: [PATCH 4/4] test(hosting-cli): ban only the framework packages, not every workspace member Review feedback on #6939. The guard treated every distribution under packages/ as unavailable at the advertised reflex floor, which is too broad: reflex-release and reflex-docgen are ordinary PyPI distributions that install fine against any reflex, so depending on one would have failed the test for no reason. The set that actually matters is the framework runtime -- the workspace packages reflex itself depends on. Their presence and version are decided by whichever reflex the user installed, which is what makes a CLI dependency on one either unsatisfiable or a silent second copy. That set is now derived by intersecting the root package's dependencies with the workspace members (minus the hosting CLI itself), so it keeps tracking the checkout. The discoverability test now pins both directions: reflex-base and reflex-components-core are in the banned set, reflex-release and reflex-docgen are not. --- .../reflex_cli/test_min_reflex_support.py | 72 ++++++++++++++----- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/tests/units/reflex_cli/test_min_reflex_support.py b/tests/units/reflex_cli/test_min_reflex_support.py index d8ee0364020..a8a5c3f775b 100644 --- a/tests/units/reflex_cli/test_min_reflex_support.py +++ b/tests/units/reflex_cli/test_min_reflex_support.py @@ -32,9 +32,11 @@ REPO_ROOT = Path(__file__).resolve().parents[3] CLI_PYPROJECT = REPO_ROOT / "packages" / "reflex-hosting-cli" / "pyproject.toml" -# The reflex release that first shipped the workspace packages: reflex-base was -# carved out in #6281 and its earliest tag is reflex-base-v0.9.0. No package -# under packages/ can be depended on from a reflex older than this. +ROOT_PYPROJECT = REPO_ROOT / "pyproject.toml" + +# The reflex release that first shipped the framework runtime packages: +# reflex-base was carved out in #6281 and its earliest tag is reflex-base-v0.9.0. +# None of them can be depended on from a reflex older than this. REFLEX_WORKSPACE_SPLIT_VERSION = Version("0.9.0") @@ -51,12 +53,24 @@ def _load(pyproject: Path) -> dict: return tomllib.load(f) +def _dependency_names(pyproject: Path) -> set[str]: + """Collect the canonicalized names a package declares as dependencies. + + Args: + pyproject: The pyproject.toml to read. + + Returns: + The canonicalized distribution names. + """ + return { + canonicalize_name(Requirement(dep).name) + for dep in _load(pyproject)["project"]["dependencies"] + } + + def _workspace_package_names() -> set[str]: """Collect the distribution name of every package in the workspace. - Derived from the checkout rather than hard-coded, so a package added under - ``packages/`` later is covered without touching this test. - Returns: The canonicalized distribution names. """ @@ -67,11 +81,37 @@ def _workspace_package_names() -> set[str]: return names -def test_workspace_packages_are_discoverable(): - """The workspace scan finds real packages, so the guards below are not vacuous.""" - names = _workspace_package_names() - assert canonicalize_name("reflex-base") in names - assert canonicalize_name("reflex-hosting-cli") in names +def _framework_runtime_packages() -> set[str]: + """Collect the workspace packages that reflex itself pulls in. + + These are the framework runtime: their presence and version are decided by + whichever reflex the user installed, so the hosting CLI depending on one is + either unsatisfiable on old reflex or silently resolves to a second, + mismatched copy alongside it. Workspace *utilities* reflex does not depend + on (reflex-release, reflex-docgen) are ordinary PyPI distributions and are + perfectly fine to depend on -- they are excluded here. + + Derived from the checkout rather than hard-coded, so a framework package + added later is covered without touching this test. + + Returns: + The canonicalized distribution names, excluding the hosting CLI itself. + """ + return (_dependency_names(ROOT_PYPROJECT) & _workspace_package_names()) - { + canonicalize_name("reflex-hosting-cli") + } + + +def test_framework_runtime_packages_are_discoverable(): + """The scan finds the right packages, so the guards below are not vacuous.""" + framework = _framework_runtime_packages() + # Shipped by reflex, so gated on the user's reflex version. + assert canonicalize_name("reflex-base") in framework + assert canonicalize_name("reflex-components-core") in framework + # Independent distributions, and the CLI itself: not gated, not banned. + assert canonicalize_name("reflex-release") not in framework + assert canonicalize_name("reflex-docgen") not in framework + assert canonicalize_name("reflex-hosting-cli") not in framework def test_no_dependency_that_the_minimum_reflex_cannot_satisfy(): @@ -82,17 +122,13 @@ def test_no_dependency_that_the_minimum_reflex_cannot_satisfy(): MINIMUM_REFLEX_VERSION is raised past the workspace split. """ minimum = ReflexHostingCli.MINIMUM_REFLEX_VERSION - declared = { - canonicalize_name(Requirement(dep).name) - for dep in _load(CLI_PYPROJECT)["project"]["dependencies"] - } - offenders = sorted(declared & _workspace_package_names()) + offenders = sorted(_dependency_names(CLI_PYPROJECT) & _framework_runtime_packages()) if minimum >= REFLEX_WORKSPACE_SPLIT_VERSION: pytest.skip( f"MINIMUM_REFLEX_VERSION is {minimum}, at or past the workspace " - f"split ({REFLEX_WORKSPACE_SPLIT_VERSION}); workspace dependencies " - "are satisfiable and this guard no longer applies." + f"split ({REFLEX_WORKSPACE_SPLIT_VERSION}); framework packages are " + "satisfiable and this guard no longer applies." ) assert not offenders, (