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..559fe90f242 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/constants/log_level.py @@ -0,0 +1,115 @@ +"""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] + + 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: + """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 ddea7fb8a75..b3e4e3c21f8 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/console.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/console.py @@ -1,26 +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 import log as _log -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, is_json_mode +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): @@ -38,8 +148,8 @@ def transfer_progress(): """Create a progress bar measured in bytes rather than in steps. Lives here rather than beside ``progress`` in reflex-base because only the - deploy upload wants it: a new name over there would raise this package's - reflex-base floor, and the CLI is released on its own schedule. + deploy upload wants it, and the CLI has to render it whether or not + reflex-base is installed. Returns: A new progress bar, sized and paced for a file transfer. @@ -59,5 +169,5 @@ def transfer_progress(): DownloadColumn(), TransferSpeedColumn(), TimeElapsedColumn(), - disable=_log.is_json_mode(), + disable=is_json_mode(), ) 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 7dfa0cad592..fa5761465a9 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -25,11 +25,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 ( ArchiveUploadError, @@ -2145,7 +2144,7 @@ def upload_archives( f"could not upload the build to storage: HTTP {ex.response.status_code}" ) from ex if attempt < UPLOAD_ATTEMPTS - 1: - console.warn("the upload window expired; reserving another one") + logger.warning("the upload window expired; reserving another one") except httpx.HTTPError as ex: raise ArchiveUploadError(f"could not upload the build: {ex}") from ex else: 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..1cb1b9466cd --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/log.py @@ -0,0 +1,131 @@ +"""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 is_json_mode as is_json_mode + 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 is_json_mode() -> bool: + """Check whether logs should be emitted as JSON records. + + Returns: + False: machine-readable output is a reflex-base feature, driven by + REFLEX_LOG_JSON, and there is no pipeline here to emit it. + """ + return False + + 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/test_min_reflex_support.py b/tests/units/reflex_cli/test_min_reflex_support.py new file mode 100644 index 00000000000..a8a5c3f775b --- /dev/null +++ b/tests/units/reflex_cli/test_min_reflex_support.py @@ -0,0 +1,174 @@ +"""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" + +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") + + +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 _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. + + 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 _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(): + """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 + 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}); framework packages 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." + ) 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..2627b49496c --- /dev/null +++ b/tests/units/reflex_cli/utils/test_log.py @@ -0,0 +1,293 @@ +"""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 + 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( + ("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 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) + 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(monkeypatch): + """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 + + # 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): + """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 + + +def test_fallback_progress_bars(capsys): + """Both progress bars build without reflex-base and render their tasks. + + transfer_progress drives the deploy upload, so it has to work on the reflex + versions that predate reflex-base like everything else here. + """ + with _without_reflex_base() as (_, fallback_log, fallback_console): + # JSON output is a reflex-base pipeline feature; without it there is + # nothing to stay quiet for, so the bars are never disabled. + assert fallback_log.is_json_mode() is False + + with fallback_console.progress() as bar: + bar.add_task("stepping", total=2) + with fallback_console.transfer_progress() as transfer: + task = transfer.add_task("uploading", total=1024) + transfer.update(task, advance=512) + + out = capsys.readouterr().out + assert "stepping" in out + assert "uploading" 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 = [