diff --git a/packages/hatch-reflex-pyi/pyproject.toml b/packages/hatch-reflex-pyi/pyproject.toml index a2754ad8a0c..49dbb448edc 100644 --- a/packages/hatch-reflex-pyi/pyproject.toml +++ b/packages/hatch-reflex-pyi/pyproject.toml @@ -2,6 +2,7 @@ name = "hatch-reflex-pyi" dynamic = ["version"] description = "Hatch build hook that generates .pyi stubs for Reflex component packages." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/integrations-docs/pyproject.toml b/packages/integrations-docs/pyproject.toml index 8c1f21066f1..4224611b1fd 100644 --- a/packages/integrations-docs/pyproject.toml +++ b/packages/integrations-docs/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-integrations-docs" dynamic = ["version"] description = "Reflex Integrations Docs." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-base/pyproject.toml b/packages/reflex-base/pyproject.toml index dac1ac47f52..1c267d80d3c 100644 --- a/packages/reflex-base/pyproject.toml +++ b/packages/reflex-base/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-base" dynamic = ["version"] description = "Core types for the Reflex framework." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-components-code/pyproject.toml b/packages/reflex-components-code/pyproject.toml index 763f4b94712..ef6439bd439 100644 --- a/packages/reflex-components-code/pyproject.toml +++ b/packages/reflex-components-code/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-components-code" dynamic = ["version"] description = "Reflex code display components." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-components-core/pyproject.toml b/packages/reflex-components-core/pyproject.toml index 0fb3e2dccca..4620ece472a 100644 --- a/packages/reflex-components-core/pyproject.toml +++ b/packages/reflex-components-core/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-components-core" dynamic = ["version"] description = "UI components for Reflex." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-components-dataeditor/pyproject.toml b/packages/reflex-components-dataeditor/pyproject.toml index 40ad5717189..4e9d6f61b14 100644 --- a/packages/reflex-components-dataeditor/pyproject.toml +++ b/packages/reflex-components-dataeditor/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-components-dataeditor" dynamic = ["version"] description = "Reflex dataeditor components." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-components-gridjs/pyproject.toml b/packages/reflex-components-gridjs/pyproject.toml index 1f6dc004ce9..6af602a3e89 100644 --- a/packages/reflex-components-gridjs/pyproject.toml +++ b/packages/reflex-components-gridjs/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-components-gridjs" dynamic = ["version"] description = "Reflex gridjs components." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-components-internal/pyproject.toml b/packages/reflex-components-internal/pyproject.toml index 7a07ae2881c..c35de3244e2 100644 --- a/packages/reflex-components-internal/pyproject.toml +++ b/packages/reflex-components-internal/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-components-internal" dynamic = ["version"] description = "Reflex internal components." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] @@ -36,6 +37,7 @@ dependencies = [ "reflex-components-react-player", "reflex-components-recharts", "reflex-components-sonner", + "reflex-hosting-cli", ] [build-system] diff --git a/packages/reflex-components-lucide/pyproject.toml b/packages/reflex-components-lucide/pyproject.toml index b13287b210a..f61f75482c7 100644 --- a/packages/reflex-components-lucide/pyproject.toml +++ b/packages/reflex-components-lucide/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-components-lucide" dynamic = ["version"] description = "Reflex lucide components." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-components-markdown/pyproject.toml b/packages/reflex-components-markdown/pyproject.toml index 322d2600316..95e347e5e30 100644 --- a/packages/reflex-components-markdown/pyproject.toml +++ b/packages/reflex-components-markdown/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-components-markdown" dynamic = ["version"] description = "Reflex markdown components." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-components-moment/pyproject.toml b/packages/reflex-components-moment/pyproject.toml index 9e11d129ba1..8dc4dc89e76 100644 --- a/packages/reflex-components-moment/pyproject.toml +++ b/packages/reflex-components-moment/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-components-moment" dynamic = ["version"] description = "Reflex moment components." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-components-plotly/pyproject.toml b/packages/reflex-components-plotly/pyproject.toml index 4e1a6866153..1df3dbbf3f9 100644 --- a/packages/reflex-components-plotly/pyproject.toml +++ b/packages/reflex-components-plotly/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-components-plotly" dynamic = ["version"] description = "Reflex plotly components." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-components-radix/pyproject.toml b/packages/reflex-components-radix/pyproject.toml index 5e5aa750549..52288918209 100644 --- a/packages/reflex-components-radix/pyproject.toml +++ b/packages/reflex-components-radix/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-components-radix" dynamic = ["version"] description = "Reflex radix components." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-components-react-player/pyproject.toml b/packages/reflex-components-react-player/pyproject.toml index b198bee33fc..96ea69612be 100644 --- a/packages/reflex-components-react-player/pyproject.toml +++ b/packages/reflex-components-react-player/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-components-react-player" dynamic = ["version"] description = "Reflex react-player components." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-components-recharts/pyproject.toml b/packages/reflex-components-recharts/pyproject.toml index 8ed8f072bec..46ca97c1ab2 100644 --- a/packages/reflex-components-recharts/pyproject.toml +++ b/packages/reflex-components-recharts/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-components-recharts" dynamic = ["version"] description = "Reflex recharts components." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-components-sonner/pyproject.toml b/packages/reflex-components-sonner/pyproject.toml index 837431fe861..e378463d2d6 100644 --- a/packages/reflex-components-sonner/pyproject.toml +++ b/packages/reflex-components-sonner/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-components-sonner" dynamic = ["version"] description = "Reflex sonner components." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-docgen/pyproject.toml b/packages/reflex-docgen/pyproject.toml index fcd2e920d7e..fa180d5fb40 100644 --- a/packages/reflex-docgen/pyproject.toml +++ b/packages/reflex-docgen/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-docgen" dynamic = ["version"] description = "Generate documentation for Reflex components." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] diff --git a/packages/reflex-hosting-cli/README.md b/packages/reflex-hosting-cli/README.md new file mode 100644 index 00000000000..d02cd615bf6 --- /dev/null +++ b/packages/reflex-hosting-cli/README.md @@ -0,0 +1,2 @@ +# reflex-hosting-cli +Hosting CLI for Reflex. diff --git a/packages/reflex-hosting-cli/pyproject.toml b/packages/reflex-hosting-cli/pyproject.toml new file mode 100644 index 00000000000..c3dfca357db --- /dev/null +++ b/packages/reflex-hosting-cli/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "reflex-hosting-cli" +dynamic = ["version"] +description = "Reflex Hosting CLI" +license.text = "Apache-2.0" +readme = "README.md" +authors = [ + { name = "Nikhil Rao", email = "nikhil@reflex.dev" }, + { name = "Alek Petuskey", email = "alek@reflex.dev" }, +] +maintainers = [ + { name = "Simon Young", email = "simon@reflex.dev" }, + { name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }, +] +requires-python = ">=3.10" +dependencies = [ + "click >=8.2", + "httpx >=0.25.1,<1.0", + "packaging >=24.2", + "platformdirs >=3.10.0,<5.0", + "rich >=13,<15", +] + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +pattern-prefix = "reflex-hosting-cli-" +fallback-version = "0.0.0dev0" + +[tool.hatch.build.targets.wheel] +packages = ["src/reflex_cli"] + +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" diff --git a/packages/reflex-hosting-cli/src/reflex_cli/__init__.py b/packages/reflex-hosting-cli/src/reflex_cli/__init__.py new file mode 100644 index 00000000000..6c091d1f261 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/__init__.py @@ -0,0 +1 @@ +"""CLI library for the hosting service.""" diff --git a/packages/reflex-hosting-cli/src/reflex_cli/cli.py b/packages/reflex-hosting-cli/src/reflex_cli/cli.py new file mode 100644 index 00000000000..1ec835b8979 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/cli.py @@ -0,0 +1,9 @@ +"""Disabled V1 CLI for the hosting service.""" + +from __future__ import annotations + +from reflex_cli.utils import disabled_v1_hosting + +login = disabled_v1_hosting +logout = disabled_v1_hosting +deploy = disabled_v1_hosting diff --git a/packages/reflex-hosting-cli/src/reflex_cli/constants/__init__.py b/packages/reflex-hosting-cli/src/reflex_cli/constants/__init__.py new file mode 100644 index 00000000000..006935f8611 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/constants/__init__.py @@ -0,0 +1,15 @@ +"""The constants package.""" + +from .base import Dirs, LogLevel, Reflex +from .compiler import ComponentName +from .hosting import Hosting, ReflexHostingCli, RequirementsTxt + +__ALL__ = [ + Hosting, + LogLevel, + Reflex, + ComponentName, + ReflexHostingCli, + RequirementsTxt, + Dirs, +] diff --git a/packages/reflex-hosting-cli/src/reflex_cli/constants/base.py b/packages/reflex-hosting-cli/src/reflex_cli/constants/base.py new file mode 100644 index 00000000000..d9c1996c6e9 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/constants/base.py @@ -0,0 +1,58 @@ +"""Base file for constants that don't fit any other categories.""" + +from __future__ import annotations + +from enum import Enum +from types import SimpleNamespace + +from platformdirs import PlatformDirs + + +class Reflex(SimpleNamespace): + """Base constants concerning Reflex. This is duplicate of the same class in reflex main.""" + + # The name of the Reflex package. + MODULE_NAME = "reflex" + + # Files and directories used to init a new project. + # The directory to store reflex dependencies. + DIR = ( + # on windows, we use C:/Users//AppData/Local/reflex. + # on macOS, we use ~/Library/Application Support/reflex. + # on linux, we use ~/.local/share/reflex. + PlatformDirs(MODULE_NAME, False).user_data_dir + ) + + +# Log levels +class LogLevel(str, Enum): + """The log levels.""" + + DEBUG = "debug" + DEFAULT = "default" + INFO = "info" + WARNING = "warning" + ERROR = "error" + CRITICAL = "critical" + + def __le__(self, other: LogLevel | str) -> 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. + + """ + if isinstance(other, str): + other = LogLevel(other.lower().strip()) + levels = list(LogLevel) + return levels.index(self) <= levels.index(other) + + +class Dirs(SimpleNamespace): + """Various directories/paths used by the CLI.""" + + # The cloud.yaml file. + CLOUD_YAML = "cloud.yml" diff --git a/packages/reflex-hosting-cli/src/reflex_cli/constants/compiler.py b/packages/reflex-hosting-cli/src/reflex_cli/constants/compiler.py new file mode 100644 index 00000000000..8518cd0756b --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/constants/compiler.py @@ -0,0 +1,33 @@ +"""Compiler variables.""" + +from enum import Enum +from types import SimpleNamespace + + +class Ext(SimpleNamespace): + """Extension used in Reflex.""" + + # The extension for JS files. + JS = ".js" + # The extension for python files. + PY = ".py" + # The extension for css files. + CSS = ".css" + # The extension for zip files. + ZIP = ".zip" + + +class ComponentName(Enum): + """Component names.""" + + BACKEND = "Backend" + FRONTEND = "Frontend" + + def zip(self): + """Give the zip filename for the component. + + Returns: + The lower-case filename with zip extension. + + """ + return self.value.lower() + Ext.ZIP diff --git a/packages/reflex-hosting-cli/src/reflex_cli/constants/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/constants/hosting.py new file mode 100644 index 00000000000..3371acfbbda --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/constants/hosting.py @@ -0,0 +1,56 @@ +"""Constants related to hosting.""" + +import os +from pathlib import Path +from types import SimpleNamespace + +from packaging import version + +from reflex_cli.constants.base import Reflex + + +class ReflexHostingCli(SimpleNamespace): + """Constants related to reflex-hosting-cli.""" + + MODULE_NAME = "reflex-hosting-cli" + + MINIMUM_REFLEX_VERSION = version.parse("0.6.6.post1") + + RECOMMENDED_REFLEX_VERSION = version.parse("0.7.6") + + +class Hosting(SimpleNamespace): + """Constants related to hosting.""" + + # The hosting config json file + HOSTING_JSON = Path(Reflex.DIR) / "hosting_v1.json" + HOSTING_JSON_V0 = Path(Reflex.DIR) / "hosting_v0.json" + # The hosting service backend URL + HOSTING_SERVICE = os.environ.get( + "REFLEX_CLOUD_BACKEND_URL", + os.environ.get("CP_BACKEND_URL", "https://build.reflex.dev"), + ) + # The hosting service webpage URL + HOSTING_SERVICE_UI = os.environ.get( + "REFLEX_CLOUD_URL", os.environ.get("CP_WEB_URL", "https://build.reflex.dev") + ) + # The time to wait for HTTP requests to the backend + TIMEOUT = 10 + # The number of times to retry authentication + AUTH_RETRY_LIMIT = 5 + # How long to wait between retry attempts + AUTH_RETRY_SLEEP_DURATION = 5 + + # Aliases for compatibility with previous versions of Reflex + CP_BACKEND_URL = HOSTING_SERVICE + CP_WEB_URL = HOSTING_SERVICE_UI + + +class RequirementsTxt(SimpleNamespace): + """Requirements.txt constants.""" + + # The requirements.txt file. + FILE = "requirements.txt" + + # The pyproject.toml file. + PYPROJECT = "pyproject.toml" diff --git a/packages/reflex-hosting-cli/src/reflex_cli/core/__init__.py b/packages/reflex-hosting-cli/src/reflex_cli/core/__init__.py new file mode 100644 index 00000000000..7eabc703f0d --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/core/__init__.py @@ -0,0 +1 @@ +"""core module for reflex-cli.""" diff --git a/packages/reflex-hosting-cli/src/reflex_cli/core/config.py b/packages/reflex-hosting-cli/src/reflex_cli/core/config.py new file mode 100644 index 00000000000..f1cbb31b43c --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/core/config.py @@ -0,0 +1,321 @@ +"""Module for the Config class.""" + +from __future__ import annotations + +import dataclasses +import typing +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, get_origin + +from reflex_cli import constants +from reflex_cli.utils.exceptions import ConfigError, ConfigInvalidFieldValueError + +RegionOption = Literal[ + "ams", + "arn", + "bog", + "cdg", + "dfw", + "ewr", + "fra", + "gru", + "iad", + "jnb", + "lax", + "lhr", + "nrt", + "ord", + "sjc", + "sin", + "syd", + "yyz", +] + +VmType = Literal[ + "c1m.5", + "c1m1", + "c1m2", + "c2m2", + "c2m4", + "c4m4", + "c4m8", + "pc1m2", + "pc2m4", + "pc2m8", + "pc4m8", +] + + +def _validate_type(value: Any, _type: type, key: str = ""): + if not isinstance(value, _type): + raise ( + ConfigInvalidFieldValueError( + f"Invalid value for {key}. Expected a {_type}, got {value} of type {type(value).__name__}." + ) + if key + else ConfigInvalidFieldValueError( + f"Invalid value. Expected a {_type}, got {value} of type {type(value).__name__}." + ) + ) + + +def _validate_literal(value: Any, key: str = "", valid_values: Sequence[str] = ()): + _validate_type(value, str, key) + if value not in valid_values: + raise ( + ConfigInvalidFieldValueError( + f"Invalid value for {key}. Expected one of {valid_values}, got {value}." + ) + if key + else ConfigInvalidFieldValueError( + f"Invalid value. Expected one of {valid_values}, got {value}." + ) + ) + + +def _validate_list(value: Any, item_type: type, key: str = ""): + _validate_type(value, list, key) + for item in value: + _validate_dispatch(item, item_type, key=f"{key} item" if key else "") + + +def _validate_dict(value: Any, key_type: type, value_type: type, key: str = ""): + _validate_type(value, dict, key) + for key, val in value.items(): + _validate_dispatch(key, key_type, key=f"{key} key" if key else "") + _validate_dispatch(val, value_type, key=f"{key} value" if key else "") + + +def _validate_optional(value: Any, non_optional_type: type, key: str = ""): + if value is not None: + _validate_dispatch(value, non_optional_type, key) + + +def _validate_dispatch( + value: Any, + _type: Any, + key: str = "", +): + if _type in [str, int, float, bool]: + _validate_type(value, _type, key) + origin = get_origin(_type) + if origin is typing.Union: + args = typing.get_args(_type) + if len(args) == 2 and type(None) in args: + non_optional_type = next(arg for arg in args if arg is not type(None)) + _validate_optional(value, non_optional_type, key) + return + if origin is list: + item_type = typing.get_args(_type)[0] + _validate_list(value, item_type, key) + if origin is typing.Literal: + _validate_literal(value, key, typing.get_args(_type)) + if origin is dict: + key_type, value_type = typing.get_args(_type) + _validate_dict(value, key_type, value_type, key) + + +@dataclass +class Config: + """Configuration class for the CLI.""" + + name: str | None = dataclasses.field(default=None) + description: str | None = dataclasses.field(default=None) + vmtype: VmType | None = dataclasses.field(default=None) + regions: dict[RegionOption, int] | None = dataclasses.field(default=None) + hostname: str | None = dataclasses.field(default=None) + envfile: str | None = dataclasses.field(default=None) + project: str | None = dataclasses.field(default=None) + packages: list[str] = dataclasses.field(default_factory=list) + appid: str | None = dataclasses.field(default=None) + strategy: str | None = dataclasses.field(default=None) + include_db: bool = dataclasses.field(default=False) + + _cloud_config_path: Path | None = dataclasses.field(default=None) + + def __post_init__(self): + """Post-initialization validation for the Config class. + + Raises: + ConfigInvalidFieldValueError: If any field value is invalid. + + # noqa: DAR401 + + """ + evaluated_type = typing.get_type_hints(Config) + for field in dataclasses.fields(self): + if field.name.startswith("_"): + continue + field_type = evaluated_type.get(field.name) + if field_type is None: + raise ConfigInvalidFieldValueError(f"Invalid field: {field}") + try: + _validate_dispatch( + getattr(self, field.name), field_type, key=field.name + ) + except ValueError as e: + if self._cloud_config_path: + raise ConfigInvalidFieldValueError( + f"Invalid {self._cloud_config_path.name}. " + str(e) + ).with_traceback(e.__traceback__) from None + + @classmethod + def _filter_dict( + cls, + data: dict[str, Any], + ) -> dict[str, Any]: + """Filters a dictionary to only include fields defined in the Config class. + + Args: + data: The dictionary to filter. + + Returns: + dict[str, Any]: A filtered dictionary containing only valid Config fields. + + """ + fields_keys = {field.name for field in dataclasses.fields(cls)} + return { + key: value + for key, value in data.items() + if key in fields_keys and value is not None + } + + @classmethod + def from_yaml( + cls, + yaml_path: Path = Path.cwd() / constants.Dirs.CLOUD_YAML, + env: str | None = None, + ) -> Config: + """Creates a Config instance from a YAML file. + + Args: + yaml_path: The path to the YAML file. Defaults to "cloud.yml" in the current directory. + env: The environment to load the config for. + + Returns: + Config: A Config instance with the values from the YAML file. + + Raises: + ConfigError: If the YAML file is not found. + + """ + if not yaml_path.exists(): + raise ConfigError(f"Config file not found at {yaml_path}.") + + try: + import yaml + except ImportError as e: + raise ConfigError( + "YAML support is not available. Please install PyYAML to use this feature." + ) from e + + with yaml_path.open() as file: + data = yaml.safe_load(file) + if env: + data = data.get("env", {}).get(env, {}) + data = cls._filter_dict(data) + return cls(_cloud_config_path=yaml_path, **data) + + @classmethod + def from_toml( + cls, + pyproject_path: Path = Path.cwd() / "pyproject.toml", + env: str | None = None, + ) -> Config: + """Creates a Config instance from a TOML file. + + Args: + pyproject_path: The path to the TOML file. Defaults to "pyproject.toml" in the current directory. + env: The environment to load the config for. + + Returns: + Config: A Config instance with the values from the TOML file. + + Raises: + ConfigError: If the TOML file is not found. + + """ + if not pyproject_path.exists(): + raise ConfigError(f"Config file not found at {pyproject_path}.") + + try: + import tomllib + except ImportError as e: + raise ConfigError( + "TOML support is not available. Please use Python 3.11 or later." + ) from e + + with pyproject_path.open("rb") as file: + pyproject_data = tomllib.load(file) + if not isinstance(pyproject_data, dict): + raise ConfigError( + f"Invalid TOML file format at {pyproject_path}. Expected a dictionary." + ) + tools = pyproject_data.get("tool", {}) + if not isinstance(tools, dict): + raise ConfigError( + f"Invalid TOML file format at {pyproject_path}. Expected 'tool' to be a dictionary." + ) + if "reflex-cloud" not in tools: + raise ConfigError( + f"Invalid TOML file format at {pyproject_path}. Expected 'tool.reflex-cloud' to be present." + ) + data = tools["reflex-cloud"] + if env: + data = data.get("env", {}).get(env, {}) + data = cls._filter_dict(data) + return cls(_cloud_config_path=pyproject_path, **data) + + @classmethod + def from_yaml_or_toml_or_default(cls) -> Config: + """Creates a Config instance from either a YAML or TOML file, or returns a default instance. + + Returns: + Config: A Config instance with values from the YAML or TOML file, or a default instance if neither file exists. + + """ + return cls.from_yaml_or_toml_or_none() or cls() + + @classmethod + def from_yaml_or_toml_or_none(cls, env: str | None = None) -> Config | None: + """Attempts to create a Config instance from either a YAML or TOML file. + + Args: + env: The environment to load the config for. If provided, it will look for environment + + Returns: + Config | None: A Config instance with values from the YAML or TOML file, or None if neither file exists or is valid. + + """ + try: + return cls.from_yaml(env=env) + + except ConfigError: + try: + return cls.from_toml(env=env) + except ConfigError: + return None + + def with_overrides(self, **kwargs: Any) -> Config: + """Creates a new Config instance with overrides. + + Args: + **kwargs: Key-value pairs of fields to override. The values take + precedence over the existing instance values. + + Returns: + Config: A new Config instance with updated values. + + """ + return dataclasses.replace(self, **kwargs) + + def exists(self) -> bool: + """Check if the config file exists. + + Returns: + bool: True if the config file exists, False otherwise. + + """ + return bool(self._cloud_config_path) and self._cloud_config_path.exists() diff --git a/packages/reflex-hosting-cli/src/reflex_cli/deployments.py b/packages/reflex-hosting-cli/src/reflex_cli/deployments.py new file mode 100644 index 00000000000..665d417592e --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/deployments.py @@ -0,0 +1,242 @@ +"""Disabled Hosting CLI deployments sub-commands.""" + +from __future__ import annotations + +from importlib.util import find_spec +from typing import TYPE_CHECKING + +import click + +from reflex_cli import constants +from reflex_cli.utils import disabled_v1_hosting + +if TYPE_CHECKING: + import typer + + +TIME_FORMAT_HELP = "Accepts ISO 8601 format, unix epoch or time relative to now. For time relative to now, use the format: . Valid units are d (day), h (hour), m (minute), s (second). For example, 1d for 1 day ago from now." +MIN_LOGS_LIMIT = 50 +MAX_LOGS_LIMIT = 1000 + + +@click.group() +def deployments_cli(): + """Commands for managing deployments.""" + disabled_v1_hosting() + + +@deployments_cli.command(name="list") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in json format.", +) +def list_deployments(loglevel: str, as_json: bool): + """List all the hosted deployments of the authenticated user. + + Args: + loglevel: The log level to use. + as_json: Whether to output the result in json format. + + """ + + +@deployments_cli.command(name="delete") +@click.argument("key", required=True) +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +def delete_deployment(key: str, loglevel: str): + """Delete a hosted instance. + + Args: + key: The name of the deployment. + loglevel: The log level to use. + + """ + + +@deployments_cli.command(name="status") +@click.argument("key", required=True) +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +def get_deployment_status(key: str, loglevel: str): + """Check the status of a deployment. + + Args: + key: The name of the deployment. + loglevel: The log level to use. + + """ + + +@deployments_cli.command(name="logs") +@click.argument("key", required=True) +@click.option( + "--from", "from_timestamp", help=f"The start time of the logs. {TIME_FORMAT_HELP}" +) +@click.option( + "--to", "to_timestamp", help=f"The end time of the logs. {TIME_FORMAT_HELP}" +) +@click.option( + "--limit", + type=int, + help=f"The number of logs to return. The acceptable range is {MIN_LOGS_LIMIT}-{MAX_LOGS_LIMIT}.", +) +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +def get_deployment_logs( + key: str, + from_timestamp: str | None, + to_timestamp: str | None, + limit: int | None, + loglevel: str, +): + """Get the logs for a deployment. + + Args: + key: The name of the deployment. + from_timestamp: The start time of the logs. + to_timestamp: The end time of the logs. + limit: The maximum number of logs to return. + loglevel: The log level to use. + + """ + + +@deployments_cli.command(name="build-logs") +@click.argument("key", required=True) +@click.option( + "--from", "from_timestamp", help=f"The start time of the logs. {TIME_FORMAT_HELP}" +) +@click.option( + "--to", "to_timestamp", help=f"The end time of the logs. {TIME_FORMAT_HELP}" +) +@click.option( + "--limit", + type=int, + help=f"The number of logs to return. The acceptable range is {MIN_LOGS_LIMIT}-{MAX_LOGS_LIMIT}.", +) +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +def get_deployment_build_logs( + key: str, + from_timestamp: str | None, + to_timestamp: str | None, + limit: int | None, + loglevel: str, +): + """Get the build logs for a deployment. + + Args: + key: The name of the deployment. + from_timestamp: The start time of the logs. + to_timestamp: The end time of the logs. + limit: The maximum number of logs to return. + loglevel: The log level to use. + + """ + + +@deployments_cli.command(name="regions") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in json format.", +) +def get_deployment_regions(loglevel: str, as_json: bool): + """List all the regions of the hosting service. + + Args: + loglevel: The log level to use. + as_json: Whether to output the result in json format. + + """ + + +@deployments_cli.command(name="share") +@click.option( + "--url", + help="The URL of the deployed app to share. If not provided, the latest deployment is shared.", +) +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +def share_deployment(url: str | None, loglevel: str): + """Share a deployment. + + Args: + url: The URL of the deployed app to share. + loglevel: The log level to use. + + """ + + +def _patch_typer(click_instance: click.Command) -> typer.Typer: + import functools + + import typer + from typer.models import TyperInfo + + fake_typer_app = typer.Typer(add_completion=False) + + fake_typer_app.callback()(lambda: None) + + original_get_group_from_info = typer.main.get_group_from_info + + def get_group_from_info(group_info: TyperInfo, *args, **kwargs): + if group_info.typer_instance is fake_typer_app: + click_instance.name = group_info.name + return click_instance + return original_get_group_from_info(group_info, *args, **kwargs) + + functools.update_wrapper( + get_group_from_info, + original_get_group_from_info, + ) + + typer.main.get_group_from_info = get_group_from_info + + return fake_typer_app + + +if ( + find_spec("typer") is not None + and find_spec("typer.core") is not None + and find_spec("typer.models") is not None +): + deployments_cli = _patch_typer(deployments_cli) # pyright: ignore[reportAssignmentType] diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/__init__.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/__init__.py new file mode 100644 index 00000000000..02b3f38df09 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/__init__.py @@ -0,0 +1,25 @@ +"""Reflex utilities.""" + +import click + +from reflex_cli.constants.hosting import ReflexHostingCli + +from . import console + + +def disabled_v1_hosting(*args, **kwargs): + """Print error and exit when using v1 hosting commands. + + Args: + *args: ignored. + **kwargs: ignored. + + Raises: + Exit: Always. + + """ + console.error( + "The alpha hosting service has been decommissioned as of Dec 5, 2024. " + f"Please upgrade to reflex>={ReflexHostingCli.MINIMUM_REFLEX_VERSION} to use Reflex Cloud hosting." + ) + raise click.exceptions.Exit(1) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/console.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/console.py new file mode 100644 index 00000000000..b0c3d0c4d9e --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/console.py @@ -0,0 +1,226 @@ +"""Functions to communicate to the user via console.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import overload + +from rich.console import Console + +from reflex_cli.constants import LogLevel + +# Console for pretty printing. +_console = Console() + +# The current log level. +_LOG_LEVEL = LogLevel.INFO + + +def set_log_level(log_level: LogLevel | str): + """Set the log level. + + Args: + log_level: The log level to set. + + """ + if isinstance(log_level, str): + try: + log_level = LogLevel(log_level) + except ValueError: + log_level = LogLevel.INFO + global _LOG_LEVEL + _LOG_LEVEL = log_level + + +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] = (), +) -> None: + """Print a table to the console. + + Args: + tabular_data: The data to print in tabular format. + headers: The headers for the table. + """ + from rich.table import Table + + table = Table() + + for column in headers: + table.add_column(column) + + for row in tabular_data: + table.add_row(*row) + + _console.print(table) + + +def debug(msg: str, **kwargs): + """Print a debug message. + + Args: + msg: The debug message. + kwargs: Keyword arguments to pass to the print function. + + """ + if _LOG_LEVEL <= LogLevel.DEBUG: + print(f"[blue]Debug: {msg}[/blue]", **kwargs) + + +def info(msg: str, **kwargs): + """Print an info message. + + Args: + msg: The info message. + kwargs: Keyword arguments to pass to the print function. + + """ + if _LOG_LEVEL <= LogLevel.INFO: + print(f"[cyan]Info: {msg}[/cyan]", **kwargs) + + +def success(msg: str, **kwargs): + """Print a success message. + + Args: + msg: The success message. + kwargs: Keyword arguments to pass to the print function. + + """ + if _LOG_LEVEL <= LogLevel.WARNING: + print(f"[green]Success: {msg}[/green]", **kwargs) + + +def log(msg: str, **kwargs): + """Takes a string and logs it to the console. + + Args: + msg: The message to log. + kwargs: Keyword arguments to pass to the print function. + + """ + if _LOG_LEVEL <= LogLevel.INFO: + _console.log(msg, **kwargs) + + +def rule(title: str, **kwargs): + """Prints 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) + + +def warn(msg: str, **kwargs): + """Print a warning message. + + Args: + msg: The warning message. + kwargs: Keyword arguments to pass to the print function. + + """ + if _LOG_LEVEL <= LogLevel.WARNING: + print(f"[orange1]Warning: {msg}[/orange1]", **kwargs) + + +def error(msg: str, **kwargs): + """Print an error message. + + Args: + msg: The error message. + kwargs: Keyword arguments to pass to the print function. + + """ + if _LOG_LEVEL <= LogLevel.ERROR: + print(f"[red]{msg}[/red]", **kwargs) + + +@overload +def ask( + question: str, + *, + choices: list[str] | None = None, + show_choices: bool = True, +) -> str: ... + + +@overload +def ask( + question: str, + *, + default: str, + choices: list[str] | None = None, + show_choices: bool = True, +) -> str: ... + + +def ask( + question: str, + *, + choices: list[str] | None = None, + show_choices: bool = True, + default: str | None = None, +) -> str | None: + """Takes a prompt question and optionally a list of choices + and returns the user input. + + Args: + question: The question to ask the user. + choices: A list of choices to select from. + show_choices: Whether to show the choices. + default: The default option selected. + + Returns: + A string with the user input. + + """ + from rich.prompt import Prompt + + return Prompt.ask( + question, choices=choices, default=default, show_choices=show_choices + ) + + +def progress(): + """Create a new progress bar. + + + Returns: + A new progress bar. + + """ + from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn + + 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) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/dependency.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/dependency.py new file mode 100644 index 00000000000..0709d123a4d --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/dependency.py @@ -0,0 +1,170 @@ +"""Building the app and initializing all prerequisites.""" + +from __future__ import annotations + +import importlib.metadata +import io +import re +import subprocess +import sys +from pathlib import Path +from urllib.parse import urlparse + +from reflex_cli import constants +from reflex_cli.utils import console + + +def detect_encoding(filename: Path) -> str | None: + """Detect the encoding of the given file. + + Args: + filename: The file to detect encoding for. + + Raises: + FileNotFoundError: If the file `filename` does not exist. + + Returns: + The encoding of the file if file exits and encoding is detected, otherwise None. + + """ + if not filename.exists(): + raise FileNotFoundError + + for encoding in [ + None if sys.version_info < (3, 10) else io.text_encoding(None), + "utf-8", + ]: + try: + filename.read_text(encoding) + except UnicodeDecodeError: # noqa: PERF203 + continue + except Exception: + return None + else: + return encoding + else: + return None + + +def does_requirements_file_or_pyproject_exist() -> bool: + """Check if requirements.txt or pyproject.toml exists. + + Returns: + True if either file exists, False otherwise. + """ + return ( + Path(constants.RequirementsTxt.FILE).exists() + or Path(constants.RequirementsTxt.PYPROJECT).exists() + ) + + +def check_requirements(): + """Check if the requirements.txt needs update based on current environment. + Throw warnings if too many installed or unused (based on imports) packages in + the local environment. + + Returns: + None + + Raises: + SystemExit: If no requirements.txt is found. + """ + if not does_requirements_file_or_pyproject_exist(): + console.warn("No requirements.txt or pyproject.toml found.") + return + + if not Path(constants.RequirementsTxt.FILE).exists(): + return + + # First check the encoding of requirements.txt if applicable. If unable to determine encoding + # will not proceed to check for requirement updates. + encoding = "utf-8" + if ( + Path(constants.RequirementsTxt.FILE).exists() + and (encoding := detect_encoding(Path(constants.RequirementsTxt.FILE))) is None + ): + return + + # Run the pipdeptree command and get the output + try: + result = subprocess.run( + [sys.executable, "-m", "pip", "freeze"], + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as cpe: + console.debug(f"Unable to run pip freeze in subprocess: {cpe}") + console.warn( + "Unable to detect installed packages in your environment using pip freeze." + " Please make sure your requirements.txt is up to date." + ) + return + + # Filter the output lines using a regular expression + lines = result.stdout.split("\n") + new_requirements_lines: set[str] = set() + for line in lines: + if re.match(r"^\w+", line): + new_requirements_lines.add(f"{line}\n") + + current_requirements_lines: set[str] = set() + if Path(constants.RequirementsTxt.FILE).exists(): + with Path(constants.RequirementsTxt.FILE).open(encoding=encoding) as f: + current_requirements_lines = set(f) + console.debug("Current requirements.txt:") + console.debug("".join(current_requirements_lines)) + + diff = list(new_requirements_lines - current_requirements_lines) + + if not diff: + return + + if not current_requirements_lines: + console.warn("It seems like there's no requirements.txt in your project.") + raise SystemExit("No requirements.txt found.") + + console.warn("Detected difference in requirements.txt and python env.") + console.warn("The requirements.txt may need to be updated.") + console.ask("Do you wish to proceed? (ctl+c to cancel)") + return + + +def get_reflex_version() -> str: + """Get the version of the reflex package. + + Returns: + The version of the reflex package. + """ + return importlib.metadata.version(constants.Reflex.MODULE_NAME) + + +def is_valid_url(url: str) -> bool: + """Check if the given URL is valid. + + Args: + url: The URL to check. + + Returns: + True if the URL is valid, otherwise False. + + """ + try: + result = urlparse(url) + return all([result.scheme, result.netloc]) + except ValueError: + return False + + +def extract_domain(url: str) -> str: + """Extract the domain from the given URL. + + Args: + url: The URL to extract the domain from. + + Returns: + The domain part of the url. + + """ + parsed_url = urlparse(url) + return parsed_url.netloc diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/exceptions.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/exceptions.py new file mode 100644 index 00000000000..f40a3d493c7 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/exceptions.py @@ -0,0 +1,37 @@ +"""Custom Exceptions.""" + + +class ReflexHostingCliError(Exception): + """Base exception for all Reflex Hosting CLI exceptions.""" + + +class NotAuthenticatedError(ReflexHostingCliError): + """Raised when the user is not authenticated.""" + + +class GetAppError(ReflexHostingCliError): + """Raised when retrieving an app fails.""" + + +class ScaleAppError(ReflexHostingCliError): + """Raised when scaling an app fails.""" + + +class ResponseError(ReflexHostingCliError): + """Raised when a response is not as expected.""" + + +class ConfigError(ReflexHostingCliError): + """Raised when there is an error with the config.""" + + +class ConfigInvalidFieldValueError(ReflexHostingCliError): + """Raised when a field in the config has an invalid value.""" + + +class ScaleTypeError(ReflexHostingCliError): + """Raised when the scale type is invalid.""" + + +class ScaleParamError(ReflexHostingCliError): + """Raised when the scale parameter is invalid.""" diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py new file mode 100644 index 00000000000..010aa1b2823 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -0,0 +1,2227 @@ +"""Hosting service related utilities.""" + +from __future__ import annotations + +import contextlib +import dataclasses +import importlib.metadata +import json +import platform +import re +import subprocess +import sys +import time +import uuid +import webbrowser +from collections.abc import Mapping +from enum import Enum +from http import HTTPStatus +from pathlib import Path +from typing import Any, TypedDict +from urllib.parse import urljoin + +import click + +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.dependency import is_valid_url +from reflex_cli.utils.exceptions import ( + GetAppError, + NotAuthenticatedError, + ResponseError, + ScaleAppError, + ScaleParamError, +) + + +class ScaleType(str, Enum): + """The scale type for an application.""" + + SIZE = "size" + REGION = "region" + + +@dataclasses.dataclass +class ScaleAppCliArgs: + """CLI arguments for scaling an application.""" + + type: ScaleType | None = None + regions: dict[str, int] | None = None + vm_type: str | None = None + + @classmethod + def create( + cls, + regions: list[str] | dict[str, int] | None = None, + vm_type: str | None = None, + scale_type: ScaleType | str | None = None, + ) -> ScaleAppCliArgs: + """Create a ScaleAppCliArgs object. + + Args: + regions: The regions to scale to. + vm_type: The VM size to scale to. + scale_type: The scale type. + + Returns: + An instance of ScaleAppCliArgs. + + Raises: + ScaleAppError: If both regions and vm_type are provided. + + """ + if isinstance(regions, list): + regions = dict.fromkeys(regions, 1) + + if vm_type is not None and regions: + raise ScaleAppError("Only one of --vmtype or --regions should be provided.") + return cls(ScaleType(scale_type) if scale_type else None, regions, vm_type) + + @property + def is_valid(self) -> bool: + """Check if the CLI arguments are valid. + + Returns: + bool: True if either vmtype or regions is set. + + """ + return bool(self.regions or self.vm_type) + + +class Region(TypedDict): + """Region for scaling an application.""" + + name: RegionOption + number_of_machines: int + + +@dataclasses.dataclass +class ScaleParams: + """Parameters for scaling an application.""" + + type: ScaleType | None = None + vm_type: str | None = None + regions: tuple[Region, ...] = () + + @classmethod + def create( + cls, + scale_type: ScaleType | None = None, + vm_type: str | None = None, + regions: list[RegionOption] | Mapping[RegionOption, int] | None = None, + ): + """Create a ScaleParams object. + + Args: + scale_type: The scale type. + vm_type: The VM type to scale to. + regions: The regions to scale to. + + Returns: + ScaleParams: The created ScaleParams object. + + """ + if isinstance(regions, list): + regions = dict.fromkeys(regions, 1) + return cls( + scale_type, + vm_type, + tuple( + Region(name=name, number_of_machines=number) + for name, number in regions.items() + ) + if regions + else (), + ) + + @classmethod + def from_config(cls, config: Config) -> ScaleParams: + """Create a ScaleParams object from a Config object. + + Args: + config: The Config object. + + Returns: + The created ScaleParams object. + + """ + return cls.create( + vm_type=config.vmtype, + regions={**config.regions} if config.regions else None, + ) + + def set_type(self, scale_type: ScaleType | str | None) -> ScaleParams: + """Set the scale type. + + Args: + scale_type: The scale type. + + Returns: + The ScaleParams object with the scale type set. + + """ + return ScaleParams( + ScaleType(scale_type) if scale_type else None, self.vm_type, self.regions + ) + + def set_type_from_cli_args(self, cli_args: ScaleAppCliArgs) -> ScaleParams: + """Set the scale type from CLI arguments. + + Args: + cli_args: The CLI arguments. + + Returns: + The ScaleParams object with the scale type set. + + Raises: + ScaleParamError: If the scale type is not provided when using cloud.yml or pyproject.toml. + + """ + scale_type = cli_args.type + + if scale_type is None and not cli_args.is_valid: + raise ScaleParamError( + "specify the type of scaling using --scale-type when using cloud.yml or pyproject.toml" + ) + + if scale_type is not None and cli_args.is_valid: + console.warn( + "using --scale-type with --regions or --vmtype will have no effect" + ) + + if not cli_args.is_valid: + if scale_type == ScaleType.SIZE and not cli_args.vm_type: + raise ScaleParamError( + f"'vmtype' should be provided in the {constants.Dirs.CLOUD_YAML} for size scaling" + ) + + if scale_type == ScaleType.REGION and not cli_args.regions: + raise ScaleParamError( + f"'regions' should be provided in the {constants.Dirs.CLOUD_YAML} for region scaling" + ) + + if cli_args.is_valid: + return self.set_type( + ScaleType(ScaleType.REGION) + if cli_args.regions + else ScaleType(ScaleType.SIZE) + ) + return self.set_type(ScaleType(scale_type) if scale_type else None) + + def as_json(self) -> dict[str, Any]: + """Convert the object to a dictionary. + + Returns: + dict: The object as a dictionary. + + """ + if self.type is None: + self.type = ScaleType.REGION + return ( + { + "type": str(self.type.value), + "size": self.vm_type, + } + if self.type == ScaleType.SIZE + else { + "type": str(self.type.value), + "regions": { + region["name"]: region["number_of_machines"] + for region in self.regions + }, + } + ) + + +@dataclasses.dataclass +class UnAuthenticatedClient: + """A client that is not authenticated.""" + + @staticmethod + def authenticate() -> AuthenticatedClient: + """Authenticate the client. + + Returns: + An authenticated client. + + """ + access_token, validated_info = authenticate_on_browser() + return AuthenticatedClient(access_token, validated_info) + + +@dataclasses.dataclass +class AuthenticatedClient: + """A client that is authenticated.""" + + token: str + validated_data: dict[str, Any] + + +def get_authentication_client( + token: str | None = None, +) -> AuthenticatedClient | UnAuthenticatedClient: + """Get an authentication client. + + Args: + token: The authentication token. + + Returns: + An authenticated client if the token is valid, otherwise an unauthenticated client. + + """ + access_token = token or get_existing_access_token() + if access_token: + validated_info = validate_token_with_retries(access_token) + if validated_info: + return AuthenticatedClient(access_token, validated_info) + return UnAuthenticatedClient() + + +def get_authenticated_client( + token: str | None = None, interactive: bool = True +) -> AuthenticatedClient: + """Get an authenticated client. + + Args: + token: The authentication token. + interactive: If running in interactive mode. + + Returns: + An authenticated client. + + Raises: + Exit: If no token is provided in non-interactive mode. + + """ + env_token = get_existing_access_token() if not token else "" + if not token and not env_token and not interactive: + console.error("Token is required for non-interactive mode.") + raise click.exceptions.Exit(1) + + client = get_authentication_client(token) + if isinstance(client, UnAuthenticatedClient): + return client.authenticate() + return client + + +class SilentBackgroundBrowser(webbrowser.BackgroundBrowser): + """A webbrowser.BackgroundBrowser that does not raise exceptions when it fails to open a browser.""" + + def open(self, url: str, new: int = 0, autoraise: bool = True): + """Open url in a new browser window. + + Args: + url: The URL to open. + new: Whether to open in a new window (2), tab (1), or the same tab (0). + autoraise: Whether to raise the window. + + Returns: + bool: True if the URL was opened successfully, False otherwise. + + """ + cmdline = [self.name] + [arg.replace("%s", url) for arg in self.args] + sys.audit("webbrowser.open", url) + try: + if sys.platform[:3] == "win": + p = subprocess.Popen( + cmdline, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + else: + p = subprocess.Popen( + cmdline, + close_fds=True, + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return p.poll() is None + except OSError: + return False + + +webbrowser.BackgroundBrowser = SilentBackgroundBrowser + + +def get_existing_access_token() -> str: + """Fetch the access token from the existing config if applicable. + + Returns: + The access token. + If not found, return empty string for it instead. + + """ + import os + + console.debug("Fetching token from existing config...") + access_token = "" + try: + with constants.Hosting.HOSTING_JSON.open() as config_file: + hosting_config = json.load(config_file) + access_token = hosting_config.get("access_token", "") + except Exception as ex: + console.debug( + f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}" + ) + + if not access_token: + access_token = os.environ.get("REFLEX_ACCESS_TOKEN", "") + if access_token: + console.debug("Using REFLEX_ACCESS_TOKEN from environment") + + return access_token + + +def is_reflex_enterprise_installed() -> bool: + """Check if reflex-enterprise is installed. + + Returns: + True if reflex-enterprise is installed, False otherwise. + """ + import importlib.metadata + + try: + importlib.metadata.version("reflex-enterprise") + except importlib.metadata.PackageNotFoundError: + return False + except Exception: + return False + else: + return True + + +def validate_token(token: str) -> dict[str, Any]: + """Validate the token with the control plane. + + Args: + token: The access token to validate. + + Returns: + Information about the user associated with the token. + + Raises: + ValueError: if access denied. + Exception: if runs into timeout, failed requests, unexpected errors. These should be tried again. + + """ + import httpx + + try: + # Add reflex-enterprise detection flag as query parameter + params = { + "source": "reflex-enterprise" + if is_reflex_enterprise_installed() + else "reflex" + } + + response = httpx.post( + urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/authenticate/me"), + headers=authorization_header(token), + params=params, + timeout=constants.Hosting.TIMEOUT, + ) + response.raise_for_status() + return response.json() + except httpx.RequestError as re: + console.debug(f"Request to auth server failed due to {re}") + raise Exception(str(re)) from re + except httpx.HTTPError as ex: + console.debug(f"Unable to validate the token due to: {ex}") + raise Exception("server error") from ex + except ValueError as ve: + console.debug("Access denied") + raise ValueError("access denied") from ve + except Exception as ex: + console.debug(f"Unexpected error: {ex}") + raise Exception("internal errors") from ex + + +def delete_token_from_config(): + """Delete the invalid token from the config file if applicable.""" + if constants.Hosting.HOSTING_JSON.exists(): + try: + with constants.Hosting.HOSTING_JSON.open("r") as config_file: + hosting_config = json.load(config_file) + hosting_config.pop("access_token", None) + with constants.Hosting.HOSTING_JSON.open("w") as config_file: + json.dump(hosting_config, config_file) + except Exception as ex: + # Best efforts removing invalid token is OK + console.debug( + f"Unable to delete the invalid token from config file, err: {ex}" + ) + # Delete the previous hosting service data if present. + if constants.Hosting.HOSTING_JSON_V0.exists(): + constants.Hosting.HOSTING_JSON_V0.unlink() + + +def save_token_to_config(token: str): + """Best efforts cache the token to the config file. + + Args: + token: The access token to save. + + """ + try: + if not Path(constants.Reflex.DIR).exists(): + Path(constants.Reflex.DIR).mkdir(parents=True, exist_ok=True) + hosting_config: dict[str, str] = {} + if constants.Hosting.HOSTING_JSON.exists(): + try: + with constants.Hosting.HOSTING_JSON.open("r") as config_file: + hosting_config = json.load(config_file) + except (OSError, ValueError): + hosting_config = {} + hosting_config["access_token"] = token + with constants.Hosting.HOSTING_JSON.open("w") as config_file: + json.dump(hosting_config, config_file) + except Exception as ex: + console.warn( + f"Unable to save token to {constants.Hosting.HOSTING_JSON} due to: {ex}" + ) + + +def create_token( + name: str, + expiration: int, + client: AuthenticatedClient, +) -> str: + """Create a new access token. + + Args: + name: The name of the token. + expiration: The expiration time in seconds. If None, the token does not expire. + client: The authenticated client + + Returns: + The created access token. + + Raises: + NotAuthenticatedError: If the client is not authenticated. + Exception: If the token creation fails. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + try: + response = httpx.post( + urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/user/token"), + json={"name": name, "expiration": expiration}, + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + response.raise_for_status() + except httpx.HTTPStatusError as ex: + raise Exception(f"Failed to create token: {ex.response.text}") from ex + + return response.text + + +def requires_access_token() -> str: + """Fetch the access token from the existing config if applicable. + + Returns: + The access token. If not found, return empty string for it instead. + + """ + # Check if the user is authenticated + + access_token = get_existing_access_token() + if not access_token: + console.debug("No access token found from the existing config.") + + return access_token + + +def authenticated_token() -> tuple[str, dict[str, Any]]: + """Fetch the access token from the existing config if applicable and validate it. + + Returns: + The access token and validated user info. + If not found, return empty string and dict for it instead. + + """ + # Check if the user is authenticated + + validated_info = {} + access_token = get_existing_access_token() + if access_token and not ( + validated_info := validate_token_with_retries(access_token) + ): + access_token = "" + + return access_token, validated_info + + +def authorization_header(token: str) -> dict[str, str]: + """Construct an authorization header with the specified token. + + Args: + token: The access token to use. + + Returns: + The authorization header in dict format. + + """ + return {"X-API-TOKEN": token} + + +def requires_authenticated() -> str: + """Check if the user is authenticated. + + Returns: + The validated access token or empty string if not authenticated. + + """ + access_token, _ = authenticated_token() + if access_token: + return access_token + access_token, _ = authenticate_on_browser() + return access_token + + +def interactive_resolve_project_or_app_name_conflicts( + items: list[dict], + rows: list[list[str]], + headers: list[str], + conflict_warn_msg: str, + conflict_ask_msg: str, +) -> dict: + """Interactively resolve conflicts when multiple projects or apps are found. + + Args: + items: The list of items to choose from. + rows: The rows to display in the table. + headers: The headers of the table. + conflict_warn_msg: The warning message to display. + conflict_ask_msg: The question to ask the user. + + Returns: + The selected item as a dictionary + + """ + console.warn(conflict_warn_msg) + console.print_table(rows, headers=list(headers)) + option = console.ask( + conflict_ask_msg, + choices=[str(i) for i in range(len(rows))], + ) + return items[int(option)] + + +def search_app( + app_name: str, + client: AuthenticatedClient, + project_id: str | None, + interactive: bool = False, +) -> dict | None: + """Search for an application by name within a specific project. + + Args: + app_name: The name of the application to search for. + project_id: The ID of the project to search within. If None, searches across all projects. + client: The authenticated client + interactive: Whether to interactively resolve conflicts. + + Returns: + list[dict]: The search results as a list of dicts. + + Raises: + NotAuthenticatedError: If the token is not valid. + Exception: If the search request fails. + Exit: If multiple apps are found and interactive is False. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + params: dict[str, str] = {"app_name": app_name} + if project_id: + params["project_id"] = project_id + response = httpx.get( + urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/apps/search"), + params=params, + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + if response.status_code == HTTPStatus.NOT_FOUND: + return None + ex_details = ex.response.json().get("detail") + raise Exception(ex_details) from ex + + apps = response.json() + + if len(apps) > 1 and not interactive: + console.error( + f"Multiple apps with the name {app_name!r} found. Please provide a unique name." + ) + raise click.exceptions.Exit(1) + + if len(apps) > 1 and interactive: + return interactive_resolve_project_or_app_name_conflicts( + apps, + rows=[ + [f"({i})", x["id"], x["name"], x["project"]["name"], x["project_id"]] + for i, x in enumerate(apps) + ], + headers=["", "App ID", "Name", "Project name", "Project ID"], + conflict_warn_msg="Found multiple apps with the same name. Select one to continue", + conflict_ask_msg="Which app would you like to use?", + ) + if len(apps) == 1: + return apps[0] + return None + + +def search_project( + project_name: str, client: AuthenticatedClient, interactive: bool = False +) -> dict | None: + """Search for a project by name. + + Args: + project_name: The name of the application to search for. + client: The authenticated client + interactive: Whether to interactively resolve conflicts. + + Returns: + list[dict]: The search results as a list of dict. + + Raises: + NotAuthenticatedError: If the token is not valid. + Exception: If the search request fails. + Exit: If multiple projects are found and interactive is False. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + + response = httpx.get( + urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/project/search"), + params={"project_name": project_name}, + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + if response.status_code == HTTPStatus.NOT_FOUND: + return None + ex_details = ex.response.json().get("detail") + raise Exception(f"project search failed: {ex_details}") from ex + + projects = response.json() + + if len(projects) > 1 and not interactive: + console.error( + f"Multiple projects with the name {project_name!r} found. Please provide a unique name." + ) + raise click.exceptions.Exit(1) + + if len(projects) > 1 and interactive: + return interactive_resolve_project_or_app_name_conflicts( + projects, + rows=[[f"({i})", x["id"], x["name"]] for i, x in enumerate(projects)], + headers=["", "Project ID", "Project name"], + conflict_warn_msg="Found multiple projects with the same name. Select one to continue", + conflict_ask_msg="Which project would you like to use?", + ) + if len(projects) == 1: + return projects[0] + return None + + +def get_app(app_id: str, client: AuthenticatedClient) -> dict: + """Retrieve details of a specific application by its ID. + + Args: + app_id: The ID of the application to retrieve. + client: The authenticated client + + Returns: + dict: The application details as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + GetAppError: If the request to get the app fails. + ValueError: If the app_id is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + if not isinstance(app_id, str) or not app_id: + raise ValueError("app_id should be a string") + response = httpx.get( + urljoin(constants.Hosting.HOSTING_SERVICE, f"/api/v1/apps/{app_id}"), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + try: + raise GetAppError(ex.response.json().get("detail")) from ex + except json.JSONDecodeError: + raise GetAppError(ex.response.text) from ex + return response.json() + + +def create_app( + app_name: str, + client: AuthenticatedClient, + description: str, + project_id: str | None, +): + """Create a new application. + + Args: + app_name: The name of the application. + description: The description of the application. + project_id: The ID of the project to associate the application with. + client: The authenticated client + + Returns: + dict: The created application details as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + ValueError: If forbidden. + + """ + import httpx + + if not isinstance(app_name, str) or not app_name: + raise ValueError("app_name should be a string") + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.post( + urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/apps/"), + json={"name": app_name, "description": description, "project": project_id}, + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + if response.status_code == HTTPStatus.FORBIDDEN: + console.debug(f"Server responded with 403: {response.text}") + raise ValueError(f"{response.text}") + response.raise_for_status() + response_json = response.json() + return response_json + + +def get_hostname( + app_id: str, app_name: str, client: AuthenticatedClient, hostname: str | None +) -> dict: + """Retrieve or reserve a hostname for a specific application. + + Args: + app_id: The ID of the application. + app_name: The name of the application. + hostname: The desired hostname. If None, a hostname will be generated. + client: The authenticated client + + Returns: + dict: The hostname details as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + Exception: If deployment fails or the hostname is invalid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + + data = {"app_id": app_id, "app_name": app_name} + if hostname: + clean_hostname = extract_subdomain(hostname) + if clean_hostname is None: + raise Exception("bad hostname provided") + data["hostname"] = clean_hostname + response = httpx.post( + urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/apps/reserve"), + headers=authorization_header(client.token), + json=data, + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + if ex.response.status_code == 413: + raise Exception( + "deployment failed: the deployment payload is too large (over 100MB). " + "Please reduce the size of your project by removing large files or " + "adding them to your .gitignore file." + ) from ex + try: + ex_details = ex.response.json().get("detail") + if ex_details == "hostname taken": + return {"error": "hostname taken"} + raise Exception(f"deployment failed: {ex_details}") from ex + except (ValueError, AttributeError): + # Response is not valid JSON or missing detail field + raise Exception( + f"deployment failed: HTTP {ex.response.status_code} - {ex.response.text}" + ) from ex + response_json = response.json() + return response_json + + +def extract_subdomain(url: str): + """Extract the subdomain from a given URL. + + Args: + url: The URL to extract the subdomain from. + + Returns: + str | None: The extracted subdomain, or None if extraction fails. + + """ + from urllib.parse import urlparse + + if not url.startswith(("http://", "https://")): + url = "http://" + url + + parsed_url = urlparse(url) + netloc = parsed_url.netloc + + netloc = netloc.removeprefix("www.") + + parts = netloc.split(".") + + if len(parts) >= 2 or len(parts) == 1: + return parts[0] + + return None + + +def get_secrets(app_id: str, client: AuthenticatedClient) -> str: + """Retrieve secrets for a given application. + + Args: + app_id: The ID of the application. + client: The authenticated client + + Returns: + The secrets as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.get( + urljoin(constants.Hosting.HOSTING_SERVICE, f"/api/v1/apps/{app_id}/secrets"), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + try: + return ex.response.json().get("detail") + except json.JSONDecodeError: + return ex.response.text + return response.json() + + +def update_secrets( + app_id: str, + secrets: dict, + client: AuthenticatedClient, + reboot: bool = False, +): + """Update secrets for a given application. + + Args: + app_id: The ID of the application. + secrets: The secrets to update. + reboot: Whether to reboot the application with the new secrets. + client: The authenticated client + + Returns: + The updated secrets as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.post( + urljoin( + constants.Hosting.HOSTING_SERVICE, + f"/api/v1/apps/{app_id}/secrets?reboot={reboot}", + ), + headers=authorization_header(client.token), + json={"secrets": secrets}, + timeout=constants.Hosting.TIMEOUT, + ) + response.raise_for_status() + response_json = response.json() + return response_json + + +def delete_secret( + app_id: str, key: str, client: AuthenticatedClient, reboot: bool = False +) -> str: + """Delete a secret for a given application. + + Args: + app_id: The ID of the application. + key: The key of the secret to delete. + reboot: Whether to reboot the application with the updated secrets. + client: The authenticated client + + Returns: + The response from the delete operation as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.delete( + urljoin( + constants.Hosting.HOSTING_SERVICE, + f"/api/v1/apps/{app_id}/secrets/{key}?reboot={reboot}", + ), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + try: + return ex.response.json().get("detail") + except json.JSONDecodeError: + return ex.response.text + return response.json() + + +def create_project(name: str, client: AuthenticatedClient) -> dict: + """Create a new project. + + Args: + name: The name of the project. + client: The authenticated client + + Returns: + dict: The created project details as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + ValueError: If the request to create the project fails. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.post( + urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/project/create"), + json={"name": name}, + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + response_json = response.json() + if response.status_code == HTTPStatus.BAD_REQUEST: + console.debug(f"Server responded with 400: {response_json.get('detail')}") + raise ValueError(f"{response_json.get('detail', 'bad request')}") + if response.status_code == HTTPStatus.CONFLICT: + console.debug(f"Duplicate project name: {response_json.get('detail')}") + raise ValueError( + f"A project named '{name}' already exists. Please use a different name." + ) + response.raise_for_status() + return response_json + + +def select_project(project: str, token: str | None = None) -> str: + """Select a project by its ID. + + Args: + project: The ID of the project to select. + token: The authentication token. If None, attempts to authenticate. + + Returns: + None + + """ + try: + with constants.Hosting.HOSTING_JSON.open() as config_file: + hosting_config = json.load(config_file) + with constants.Hosting.HOSTING_JSON.open("w") as config_file: + hosting_config["project"] = project + json.dump(hosting_config, config_file) + except Exception as ex: + return ( + f"failed to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}" + ) + return f"{project} is now selected." + + +def get_selected_project() -> str | None: + """Retrieve the currently selected project ID. + + Returns: + str | None: The ID of the selected project, or None if no project is selected. + + """ + try: + with constants.Hosting.HOSTING_JSON.open() as config_file: + hosting_config = json.load(config_file) + return hosting_config.get("project") + except Exception as ex: + console.debug( + f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}" + ) + return None + + +def get_projects(client: AuthenticatedClient) -> list[dict]: + """Retrieve a list of projects. + + Args: + client: The authenticated client. + + Returns: + The list of projects as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.get( + urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/project/"), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + response.raise_for_status() + response_json = response.json() + return response_json + + +def get_project(project_id: str, client: AuthenticatedClient): + """Retrieve a single project given the project ID. + + Args: + project_id: The ID of the project. + client: The authenticated client + + Returns: + The project details as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.get( + urljoin(constants.Hosting.HOSTING_SERVICE, f"/api/v1/project/{project_id}"), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + response.raise_for_status() + response_json = response.json() + return response_json + + +def get_project_roles(project_id: str, client: AuthenticatedClient): + """Retrieve the roles for a project. + + Args: + project_id: The ID of the project. + client: The authenticated client + + Returns: + The roles as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.get( + urljoin( + constants.Hosting.HOSTING_SERVICE, f"/api/v1/project/{project_id}/roles" + ), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + response.raise_for_status() + response_json = response.json() + return response_json + + +def get_project_role_permissions( + project_id: str, role_id: str, client: AuthenticatedClient +): + """Retrieve the permissions for a specific role in a project. + + Args: + project_id: The ID of the project. + role_id: The ID of the role. + client: The authenticated client + + Returns: + The role permissions as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.get( + urljoin( + constants.Hosting.HOSTING_SERVICE, + f"/api/v1/project/{project_id}/role/{role_id}", + ), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + response.raise_for_status() + response_json = response.json() + return response_json + + +def get_project_role_users(project_id: str, client: AuthenticatedClient): + """Retrieve the users for a project. + + Args: + project_id: The ID of the project. + client: The authenticated client + + Returns: + The users as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.get( + urljoin( + constants.Hosting.HOSTING_SERVICE, f"/api/v1/project/{project_id}/users" + ), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + response.raise_for_status() + response_json = response.json() + return response_json + + +def invite_user_to_project( + role_id: str, user_id: str, client: AuthenticatedClient +) -> str: + """Invite a user to a project with a specific role. + + Args: + role_id: The ID of the role to assign to the user. + user_id: The ID of the user to invite. + client: The authenticated client + + Returns: + The response from the invite operation as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.post( + urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/project/users/invite"), + headers=authorization_header(client.token), + json={"user_id": user_id, "role_id": role_id}, + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + try: + return ex.response.json().get("detail") + except json.JSONDecodeError: + return ex.response.text + return response.json() + + +def validate_deployment_args( + app_name: str, + app_id: str | None, + project_id: str | None, + regions: list[str] | None, + vmtype: str | None, + hostname: str | None, + client: AuthenticatedClient, +) -> str: + """Validate the deployment arguments. + + Args: + app_name: The name of the application. + app_id: The ID of the application. + project_id: The ID of the project to associate the deployment with. + regions: The list of regions for the deployment. + vmtype: The VM type for the deployment. + hostname: The hostname for the deployment. + client: The authenticated client. + + Returns: + The validation result as a string -- "success" if all checks pass. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + return "not authenticated" + + param_data = { + "app_name": app_name or "", + "app_id": app_id or "", + "project_id": project_id or "", + "regions": json.dumps(regions or []), + "vmtype": vmtype or "", + "hostname": hostname or "", + } + response = httpx.get( + urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/deployments/validate_cli"), + headers=authorization_header(client.token), + params=param_data, + timeout=15, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + try: + ex_details = ex.response.json().get("detail") + except (httpx.RequestError, ValueError, KeyError): + return "deployment failed: internal server error" + else: + return f"deployment failed: {ex_details}" + + return "success" + + +def create_deployment( + zip_dir: Path, + client: AuthenticatedClient, + app_name: str | None, + project_id: str | None, + regions: list | None, + hostname: str | None, + vmtype: str | None, + secrets: dict | None, + packages: list | None, + strategy: str | None, + app_id: str | None, +) -> str: + """Create a new deployment for an application. + + Args: + app_name: The name of the application. + project_id: The ID of the project to associate the deployment with. + regions: The list of regions for the deployment. + zip_dir: The directory containing the zip files for the deployment. + hostname: The hostname for the deployment. + vmtype: The VM type for the deployment. + secrets: The secrets to use for the deployment. + client: The authenticated client + packages: The list of packages to install on the VM. + strategy: The deployment strategy to use. + app_id: The ID of the application. + + Returns: + The deployment id.git c + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + cli_version = importlib.metadata.version("reflex-hosting-cli") + zips = [ + ( + "files", + ( + "backend.zip", + (zip_dir / "backend.zip").open("rb"), + ), + ), + ( + "files", + ( + "frontend.zip", + (zip_dir / "frontend.zip").open("rb"), + ), + ), + ] + payload: dict[str, Any] = { + "app_id": app_id, + "app_name": app_name, + "reflex_hosting_cli_version": cli_version, + "reflex_version": dependency.get_reflex_version(), + "python_version": platform.python_version(), + } + if project_id: + payload["project_id"] = project_id + if regions: + regions = regions or [] + payload["regions"] = json.dumps(regions) + if hostname: + payload["hostname"] = hostname + if vmtype: + payload["vm_type"] = vmtype + if secrets: + payload["secrets"] = json.dumps(secrets) + if packages: + payload["packages"] = json.dumps(packages) + if strategy: + payload["deployment_strategy"] = strategy + + response = httpx.post( + urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/deployments"), + data=payload, + files=zips, + headers=authorization_header(client.token), + timeout=55, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + if ex.response.status_code == 413: + return ( + "deployment failed: the deployment payload is too large (over 100MB). " + "Please reduce the size of your project by removing large files or " + "adding them to your .gitignore file." + ) + try: + ex_details = ex.response.json().get("detail") + except (httpx.RequestError, ValueError, KeyError): + return "deployment failed: internal server error" + else: + return f"deployment failed: {ex_details}" + return response.json() + + +def stop_app(app_id: str, client: AuthenticatedClient): + """Stop a running application. + + Args: + app_id: The ID of the application. + client: The authenticated client + + Returns: + The response from the stop operation as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.post( + urljoin(constants.Hosting.HOSTING_SERVICE, f"/api/v1/apps/{app_id}/stop"), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + ex_details = ex.response.json().get("detail") + return f"stop app failed: {ex_details}" + return response.json() + + +def start_app(app_id: str, client: AuthenticatedClient): + """Start a stopped application. + + Args: + app_id: The ID of the application. + client: The authenticated client + + Returns: + The response from the start operation as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.post( + urljoin(constants.Hosting.HOSTING_SERVICE, f"/api/v1/apps/{app_id}/start"), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + ex_details = ex.response.json().get("detail") + return f"start app failed: {ex_details}" + return response.json() + + +def delete_app(app_id: str, client: AuthenticatedClient): + """Delete an application. + + Args: + app_id: The ID of the application. + client: The authenticated client + + Returns: + The response from the delete operation as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + app = get_app(app_id=app_id, client=client) + if not app: + console.warn("no app with given id found") + return None + response = httpx.delete( + urljoin(constants.Hosting.HOSTING_SERVICE, f"/api/v1/apps/{app['id']}/delete"), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + ex_details = ex.response.json().get("detail") + return f"delete app failed: {ex_details}" + return response.json() + + +def get_app_logs( + app_id: str, + offset: int | None, + start: int | None, + end: int | None, + client: AuthenticatedClient, + cursor: str | None = None, +): + """Retrieve logs for a given application. + + Args: + app_id: The ID of the application. + offset: The offset in seconds from the current time. + start: The start time in Unix epoch format. + end: The end time in Unix epoch format. + client: The authenticated client + cursor: The cursor for pagination. + + Returns: + The logs as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + try: + app = get_app(app_id=app_id, client=client) + except GetAppError: + console.warn(f"No application found with ID '{app_id}'") + return None + if not app: + console.warn("no app with given id found") + return None + params: dict[str, str | int | None] = ( + {"offset": offset} if offset else {"start": start, "end": end} + ) + if cursor: + params["cursor"] = cursor + try: + with console.status("Fetching application logs..."): + response = httpx.get( + urljoin( + constants.Hosting.HOSTING_SERVICE, + f"/api/v1/apps/{app['id']}/logsv2", + ), + params=params, + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + response.raise_for_status() + except httpx.RequestError: + return [] + except httpx.HTTPStatusError as ex: + try: + ex_details = ex.response.json().get("detail") + except json.JSONDecodeError: + return [] + else: + return f"get app logs failed: {ex_details}" + else: + try: + return response.json() + except json.JSONDecodeError: + return [] + + +def list_apps(client: AuthenticatedClient, project: str | None = None) -> list[dict]: + """List all the hosted deployments of the authenticated user. + + Args: + project: The project ID to filter deployments. + client: The authenticated client + + Returns: + List[dict]: A list of deployments as dictionaries. + + Raises: + NotAuthenticatedError: If the token is not valid. + Exception: when listing apps fails. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + + url = urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/apps") + params = {"project": project} if project else None + + response = httpx.get( + url, + params=params, + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + ex_details = ex.response.json().get("detail") + raise Exception(f"list app failed: {ex_details}") from ex + return response.json() + + +def get_app_history(app_id: str, client: AuthenticatedClient) -> list: + """Retrieve the deployment history for a given application. + + Args: + app_id: The ID of the application. + client: The authenticated client + + Returns: + list: A list of deployment history entries as dictionaries. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.get( + urljoin(constants.Hosting.HOSTING_SERVICE, f"/api/v1/apps/{app_id}/history"), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + + response.raise_for_status() + response_json = response.json() + result = [ + { + "id": deployment["id"], + "status": deployment["status"], + "hostname": deployment["hostname"], + "python version": deployment["python_version"], + "reflex version": deployment["reflex_version"], + "vm type": deployment["vm_type"], + "timestamp": deployment["timestamp"], + } + for deployment in response_json + ] + return result + + +def get_app_status(app_id: str, client: AuthenticatedClient) -> str: + """Retrieve the status of a specific app. + + Args: + app_id: The ID of the app. + client: The authenticated client + + Returns: + str: The status of the app. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + try: + response = httpx.get( + urljoin( + constants.Hosting.HOSTING_SERVICE, + f"/api/v1/deployments/{app_id}/status", + ), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + except httpx.RequestError as e: + return "lost connection: trying again" + f"({e.__class__.__name__}: {e})" + + try: + response.raise_for_status() + except httpx.HTTPStatusError: + return f"error: bad response: {response.status_code}. received a bad response from cloud service." + return response.json() + + +def scale_app(app_id: str, scale_params: ScaleParams, client: AuthenticatedClient): + """Scale an application. + + Args: + app_id: The ID of the application. + scale_params: The scaling parameters. + client: The authenticated client + + Returns: + The response from the scale operation as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + ResponseError: If the request to scale the app fails. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.post( + urljoin(constants.Hosting.HOSTING_SERVICE, f"/api/v1/apps/{app_id}/scale"), + headers=authorization_header(client.token), + json=scale_params.as_json(), + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + ex_details = ex.response.json().get("detail") + raise ResponseError(f"scale app failed: {ex_details}") from ex + return response.json() + + +def get_deployment_status(deployment_id: str, client: AuthenticatedClient) -> str: + """Retrieve the status of a specific deployment. + + Args: + deployment_id: The ID of the deployment. + client: The authenticated client + + Returns: + str: The status of the deployment. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.get( + urljoin( + constants.Hosting.HOSTING_SERVICE, + f"/api/v1/deployments/{deployment_id}/status", + ), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + ex_details = ex.response.json().get("detail") + return f"get status failed: {ex_details}" + return response.json() + + +def _get_deployment_status(deployment_id: str, token: str) -> str: + """Retrieve the status of a specific deployment with error handling. + + Args: + deployment_id: The ID of the deployment. + token: The authentication token. + + Returns: + str: The status of the deployment, or an error message if the request fails. + + """ + import httpx + + try: + response = httpx.get( + urljoin( + constants.Hosting.HOSTING_SERVICE, + f"/api/v1/deployments/{deployment_id}/status", + ), + headers=authorization_header(token), + timeout=constants.Hosting.TIMEOUT, + ) + except httpx.RequestError as e: + return "lost connection: trying again" + f"({e.__class__.__name__}: {e})" + + try: + response.raise_for_status() + except httpx.HTTPStatusError: + return "bad response. received a bad response from cloud service." + return response.json() + + +def watch_deployment_status(deployment_id: str, client: AuthenticatedClient) -> bool: + """Continuously watch the status of a specific deployment. + + Args: + deployment_id: The ID of the deployment. + client: The authenticated client + + Returns: + True when the watching ends. + False when watching ends in fail. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + with console.status("listening to status updates!"): + current_status = "" + while True: + status = _get_deployment_status( + deployment_id=deployment_id, token=client.token + ) + if "completed successfully" in status: + console.success(status) + break + if "build error" in status: + console.warn(status) + console.warn( + f"to see the build logs:\n reflex cloud apps build-logs {deployment_id}" + ) + return False + if "unable to find status for given id" in status: + console.error(status) + return False + if "error" in status: + console.warn(status) + return False + if "bad response" in status: + console.warn(status) + return True + if status == current_status: + continue + current_status = status + console.info(status) + time.sleep(0.5) + return True + + +def get_deployment_build_logs(deployment_id: str, client: AuthenticatedClient): + """Retrieve the build logs for a specific deployment. + + Args: + deployment_id: The ID of the deployment. + client: The authenticated client + + Returns: + dict: The build logs as a dictionary. + + Raises: + NotAuthenticatedError: If the token is not valid. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + response = httpx.get( + urljoin( + constants.Hosting.HOSTING_SERVICE, + f"/api/v1/deployments/{deployment_id}/build/logs", + ), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + + response.raise_for_status() + return response.json() + + +def list_projects(): + """List all projects. + + This function is currently a placeholder and does not perform any operations. + + Returns: + None + + """ + return + + +def fetch_token(request_id: str) -> str: + """Fetch the access token for the request_id from Control Plane. + + Args: + request_id: The request ID used when the user opens the browser for authentication. + + Returns: + The access token if it exists, empty strings otherwise. + + """ + import httpx + + token = "" + try: + resp = httpx.get( + urljoin( + constants.Hosting.HOSTING_SERVICE, + f"/api/v1/cli/token?request_id={request_id}", + ), + timeout=constants.Hosting.TIMEOUT, + ) + resp.raise_for_status() + token = (resp_json := resp.json()).get("token_id", "") + project_id = resp_json.get("user_id", "") + select_project(project=project_id) + except httpx.RequestError as re: + console.debug(f"Unable to fetch token due to request error: {re}") + except httpx.HTTPError as he: + console.debug(f"Unable to fetch token due to {he}") + except json.JSONDecodeError as jde: + console.debug(f"Server did not respond with valid json: {jde}") + except KeyError as ke: + console.debug(f"Server response format unexpected: {ke}") + except Exception as ex: + console.debug(f"Unexpected errors: {ex}") + + return token + + +def authenticate_on_browser() -> tuple[str, dict[str, Any]]: + """Open the browser to authenticate the user. + + Returns: + The access token if valid and user information dict otherwise ("", {}). + + Raises: + Exit: when the hosting service URL is invalid. + + """ + request_id = uuid.uuid4().hex + auth_url = urljoin( + constants.Hosting.HOSTING_SERVICE_UI, f"/cli/login?request_id={request_id}" + ) + + console.print(f"Opening {auth_url} ...") + + if not is_valid_url(constants.Hosting.HOSTING_SERVICE_UI): + console.error( + f"Invalid hosting URL: {constants.Hosting.HOSTING_SERVICE_UI}. Ensure the URL is in the correct format and includes a valid scheme" + ) + raise click.exceptions.Exit(1) + + if not webbrowser.open(auth_url): + console.warn( + f"Unable to automatically open the browser. Please go to {auth_url} to authenticate." + ) + validated_info = {} + access_token = "" + console.ask("please hit 'Enter' or 'Return' after login on website complete") + with console.status("Waiting for access token ..."): + for _ in range(constants.Hosting.AUTH_RETRY_LIMIT): + access_token = fetch_token(request_id) + if access_token: + break + time.sleep(1) + + if access_token and (validated_info := validate_token_with_retries(access_token)): + save_token_to_config(access_token) + else: + access_token = "" + return access_token, validated_info + + +def get_default_project(authenticated_client: AuthenticatedClient) -> str | None: + """Get the default project ID for the authenticated user. + + Args: + authenticated_client: The authenticated client. + + Returns: + The default project ID if available, None otherwise. + """ + return authenticated_client.validated_data.get("user_id") + + +def validate_token_with_retries(access_token: str) -> dict[str, Any]: + """Validate the access token without retries. + + Args: + access_token: The access token to validate. + + Returns: + validated user info dict. + + """ + with console.status("Validating access token ..."): + try: + return validate_token(access_token) + except ValueError: + console.error("Access denied") + delete_token_from_config() + except Exception as ex: + console.debug(f"Unable to validate token due to: {ex}") + return {} + + +def process_envs(envs: list[str]) -> dict[str, str]: + """Process the environment variables. + + Args: + envs: The environment variables expected in key=value format. + + Raises: + SystemExit: If the envs are not in valid format. + + Returns: + dict[str, str]: The processed environment variables in a dictionary. + + Raises: + SystemExit: If invalid format. + + """ + processed_envs = {} + for env in envs: + kv = env.split("=", maxsplit=1) + if len(kv) != 2: + raise SystemExit("Invalid env format: should be =.") + + if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", kv[0]): + raise SystemExit( + "Invalid env name: should start with a letter or underscore, followed by letters, digits, or underscores." + ) + processed_envs[kv[0]] = kv[1] + return processed_envs + + +def read_config( + config_path: str | None = None, env: str | None = None +) -> Config | None: + """Read the config file. + + Args: + config_path: The path to the config file. If None, defaults to 'cloud.yml'. + env: The environment to read the config for. If None, reads the default config. + + Returns: + Config | None: The config file as a Config instance, or None if not found or invalid. + + """ + if config_path: + return Config.from_yaml(Path(config_path)) + return Config.from_yaml_or_toml_or_none() + + +def generate_config(interactive: bool = True, token: str | None = None): + """Generate the config file with app-based prefilling. + + Args: + interactive: Whether to use interactive mode for authentication and app selection. + token: An existing authentication token to use instead of interactive auth. + + Raises: + click.exceptions.Exit: If authentication fails or user cancels operation. + """ + try: + import yaml + except ImportError: + console.error("Please install PyYAML to use this command: pip install pyyaml") + return + + if Path("cloud.yml").exists(): + console.error("cloud.yml already exists.") + return + + try: + authenticated_client = get_authenticated_client( + token=token, interactive=interactive + ) + except click.exceptions.Exit: + console.error("Authentication required to generate prefilled config.") + raise + + current_dir_name = Path.cwd().name + + try: + app = search_app( + app_name=current_dir_name, + project_id=None, + client=authenticated_client, + interactive=interactive, + ) + except click.exceptions.Exit: + raise + except Exception as ex: + console.warn(f"Could not search for apps: {ex}") + app = None + + if app: + console.info(f"Found app '{app['name']}' - prefilling config with app data.") + default = {"name": app["name"]} + + if app.get("id"): + default["appid"] = app["id"] + if app.get("description"): + default["description"] = app["description"] + if app.get("project_id"): + default["project"] = app["project_id"] + else: + console.info( + f"No app found with name '{current_dir_name}' - creating config with minimal defaults." + ) + default = {"name": current_dir_name} + + with Path("cloud.yml").open("w") as config_file: + yaml.dump(default, config_file, default_flow_style=False, sort_keys=False) + console.success("cloud.yml created successfully.") + console.info( + "For more configuration options, see: https://reflex.dev/docs/hosting/config-file/" + ) + return + + +def log_out_on_browser(): + """Open the browser to log out the user.""" + with contextlib.suppress(Exception): + delete_token_from_config() + console.print(f"Opening {constants.Hosting.HOSTING_SERVICE_UI} ...") + if not webbrowser.open(constants.Hosting.HOSTING_SERVICE_UI): + console.warn( + f"Unable to open the browser automatically. Please go to {constants.Hosting.HOSTING_SERVICE_UI} to log out." + ) + + +def get_vm_types() -> list[dict]: + """Retrieve the available VM types. + + Returns: + list[dict]: A list of VM types as dictionaries. + + """ + import httpx + + try: + response = httpx.get( + urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/deployments/vm_types"), + timeout=10, + ) + response.raise_for_status() + response_json = response.json() + if response_json is None or not isinstance(response_json, list): + console.error("Expect server to return a list ") + return [] + if ( + response_json + and response_json[0] is not None + and not isinstance(response_json[0], dict) + ): + console.error("Expect return values are dict's") + return [] + except Exception as ex: + console.error(f"Unable to get vmtypes due to {ex}.") + return [] + else: + return response_json + + +def get_regions() -> list[dict]: + """Get the supported regions from the hosting server. + + Returns: + list[dict]: A list of dict representation of the region information. + + """ + import httpx + + try: + response = httpx.get( + urljoin(constants.Hosting.HOSTING_SERVICE, "/api/v1/deployments/regions"), + timeout=10, + ) + response.raise_for_status() + response_json = response.json() + if response_json is None or not isinstance(response_json, list): + console.error("Expect server to return a list ") + return [] + if ( + response_json + and response_json[0] is not None + and not isinstance(response_json[0], dict) + ): + console.error("Expect return values are dict's") + return [] + return [ + {"name": region["name"], "code": region["code"]} for region in response_json + ] + except Exception as ex: + console.error(f"Unable to get regions due to {ex}.") + return [] diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/__init__.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/__init__.py new file mode 100644 index 00000000000..6c091d1f261 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/__init__.py @@ -0,0 +1 @@ +"""CLI library for the hosting service.""" diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py new file mode 100644 index 00000000000..a8745353885 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py @@ -0,0 +1,813 @@ +"""App commands for the Reflex Cloud CLI.""" + +from __future__ import annotations + +import json + +import click + +from reflex_cli import constants +from reflex_cli.core.config import Config +from reflex_cli.utils import console +from reflex_cli.utils.exceptions import ( + ConfigInvalidFieldValueError, + GetAppError, + NotAuthenticatedError, + ResponseError, + ScaleAppError, + ScaleParamError, + ScaleTypeError, +) + + +@click.group() +def apps_cli(): + """Commands for managing apps.""" + + +@apps_cli.command(name="history") +@click.argument("app_id", required=False) +@click.option("--app-name", help="The name of the application.") +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in json format.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def app_history( + app_id: str | None, + app_name: str | None, + token: str | None, + loglevel: str, + as_json: bool, + interactive: bool, +): + """Retrieve the deployment history for a given application.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + if not app_id: + config = hosting.read_config() + if config: + app_id = config.appid + if not isinstance(app_id, (str, type(None))): + console.error( + "app_id must be a string or None. Please check your config file." + ) + raise click.exceptions.Exit(1) + + if app_name is not None and app_id is None: + result = hosting.search_app( + app_name=app_name, + project_id=None, + client=authenticated_client, + interactive=interactive, + ) + app_id = result.get("id") if result else None + + if not app_id: + console.error("No valid app_id or app_name provided.") + raise click.exceptions.Exit(1) + + history = hosting.get_app_history(app_id=app_id, client=authenticated_client) + + if as_json: + console.print(json.dumps(history)) + return + if history: + headers = list(history[0].keys()) + table = [ + [str(value) for value in deployment.values()] for deployment in history + ] + console.print_table(table, headers=headers) + else: + console.print(str(history)) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + + +@apps_cli.command("build-logs") +@click.argument("deployment_id", required=True) +@click.option("--token", help="The authentication token.") +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def deployment_build_logs( + deployment_id: str, + token: str | None, + interactive: bool, +): + """Retrieve the build logs for a specific deployment.""" + from reflex_cli.utils import hosting + + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + logs = hosting.get_deployment_build_logs( + deployment_id=deployment_id, client=authenticated_client + ) + console.print(logs) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + + +@apps_cli.command(name="status") +@click.argument("deployment_id", required=True) +@click.option( + "--watch/--no-watch", is_flag=True, help="Whether to continuously watch the status." +) +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def deployment_status( + deployment_id: str, + watch: bool, + token: str | None, + loglevel: str, + interactive: bool, +): + """Retrieve the status of a specific deployment.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + if watch: + status = hosting.watch_deployment_status( + deployment_id=deployment_id, client=authenticated_client + ) + if status is False: + raise click.exceptions.Exit(1) + else: + status = hosting.get_deployment_status( + deployment_id=deployment_id, client=authenticated_client + ) + console.error(status) if "failed" in status else console.print(status) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + + +@apps_cli.command(name="stop") +@click.argument("app_id", required=False) +@click.option("--app-name", help="The name of the application.") +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def stop_app( + app_id: str | None, + app_name: str | None, + token: str | None, + loglevel: str, + interactive: bool, +): + """Stop a running application.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + if not app_id: + config = hosting.read_config() + if config: + app_id = config.appid + if not isinstance(app_id, (str, type(None))): + console.error( + "app_id must be a string or None. Please check your config file." + ) + raise click.exceptions.Exit(1) + + if app_name is not None and app_id is None: + app_result = hosting.search_app( + app_name=app_name, + project_id=None, + client=authenticated_client, + interactive=interactive, + ) + app_id = app_result.get("id") if app_result else None + + if not app_id: + console.error("No valid app_id or app_name provided.") + raise click.exceptions.Exit(1) + + result = hosting.stop_app(app_id=app_id, client=authenticated_client) + if result: + console.error(result) if "failed" in result else console.success(result) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + + +@apps_cli.command(name="start") +@click.argument("app_id", required=False) +@click.option("--app-name", help="The name of the application.") +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def start_app( + app_id: str | None, + app_name: str | None, + token: str | None, + loglevel: str, + interactive: bool, +): + """Start a stopped application.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + if not app_id: + config = hosting.read_config() + if config: + app_id = config.appid + if not isinstance(app_id, (str, type(None))): + console.error( + "app_id must be a string or None. Please check your config file." + ) + raise click.exceptions.Exit(1) + + if app_name is not None and app_id is None: + app_result = hosting.search_app( + app_name=app_name, + project_id=None, + client=authenticated_client, + interactive=interactive, + ) + app_id = app_result.get("id") if app_result else None + + if not app_id: + console.error("No valid app_id or app_name provided.") + raise click.exceptions.Exit(1) + + result = hosting.start_app(app_id=app_id, client=authenticated_client) + if result: + console.error(result) if "failed" in result else console.success(result) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + + +@apps_cli.command(name="delete") +@click.argument("app_id", required=False) +@click.option("--app-name", help="The name of the application.") +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def delete_app( + app_id: str | None, + app_name: str | None, + token: str | None, + loglevel: str, + interactive: bool, +): + """Delete an application.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + if not app_id: + config = hosting.read_config() + if config: + app_id = config.appid + if not isinstance(app_id, (str, type(None))): + console.error( + "app_id must be a string or None. Please check your config file." + ) + raise click.exceptions.Exit(1) + + app_name_from_search = None + if app_name is not None and app_id is None: + app_result = hosting.search_app( + app_name=app_name, + project_id=None, + client=authenticated_client, + interactive=interactive, + ) + if not app_result: + console.warn(f"App '{app_name}' not found.") + raise click.exceptions.Exit(1) + app_id = app_result.get("id") if app_result else None + app_name_from_search = app_result.get("name") if app_result else app_name + + if app_name_from_search is None and app_id: + try: + app_result = hosting.get_app( + client=authenticated_client, + app_id=app_id, + ) + except GetAppError: + console.warn(f"No application found with ID '{app_id}'") + return + if not app_result: + console.warn(f"App with ID '{app_id}' not found.") + raise click.exceptions.Exit(0) + + if not app_id: + console.error("No valid app_id or app_name provided.") + raise click.exceptions.Exit(1) + + if interactive: + app_name_display = "Unknown" + + if app_name_from_search is not None: + app_name_display = app_name_from_search + elif app_name is not None: + app_name_display = app_name + else: + try: + app_details = hosting.get_app( + app_id=app_id, client=authenticated_client + ) + app_name_display = app_details.get("name", "Unknown") + except Exception: + app_name_display = "Unknown" + + app_id_display = app_id + + if ( + console.ask( + f"Are you sure you want to delete app '{app_name_display}' (ID: {app_id_display})?", + choices=["y", "n"], + default="n", + ) + != "y" + ): + console.info("Deletion cancelled.") + return + + result = hosting.delete_app(app_id=app_id, client=authenticated_client) + if result: + console.warn(result) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + + +@apps_cli.command(name="logs") +@click.argument("app_id", required=False) +@click.option("--app-name", help="The name of the application.") +@click.option("--token", help="The authentication token.") +@click.option("--offset", type=int, help="The offset in seconds from the current time.") +@click.option("--start", type=int, help="The start time in Unix epoch format.") +@click.option("--end", type=int, help="The end time in Unix epoch format.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +@click.option("--cursor", type=str, help="The cursor for pagination.") +@click.option("--pretty", type=bool, help="Use pretty printing for logs.") +@click.option( + "--follow", type=bool, default=True, help="Asks to continue to query logs." +) +def app_logs( + app_id: str | None, + app_name: str | None, + token: str | None, + offset: int | None, + start: int | None, + end: int | None, + loglevel: str, + interactive: bool, + cursor: str | None = None, + pretty: bool = False, + follow: bool = True, +): + """Retrieve logs for a given application.""" + import pprint + + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + if not app_id: + config = hosting.read_config() + if config: + app_id = config.appid + if not isinstance(app_id, (str, type(None))): + console.error( + "app_id must be a string or None. Please check your config file." + ) + raise click.exceptions.Exit(1) + + if app_name is not None and app_id is None: + app_result = hosting.search_app( + app_name=app_name, + project_id=None, + client=authenticated_client, + interactive=interactive, + ) + app_id = app_result.get("id") if app_result else None + + if not app_id: + console.error("No valid app_id or app_name provided.") + raise click.exceptions.Exit(1) + + if offset is None and start is None and end is None: + offset = 3600 + if not offset and not (start and end): + console.error("must provide both start and end") + raise click.exceptions.Exit(1) + + while True: + console.debug(f"fetching logs with cursor: {cursor}") + result = hosting.get_app_logs( + app_id=app_id, + offset=offset, + start=start, + end=end, + client=authenticated_client, + cursor=cursor, + ) + if not isinstance(result, list): + console.warn("Unable to retrieve logs.") + return + if len(result) == 2 and isinstance(result[1], str): + cursor = result[1] + result = result[0] + else: + cursor = None + if not result: + console.warn("No logs found for the specified criteria.") + return + result.reverse() + for log in result: + if pretty: + log = pprint.pformat(log, indent=2) + console.info(log) + if not (interactive and follow): + return + from rich.prompt import Prompt + + prompt = Prompt.ask( + "Press Enter to fetch next 100 logs or type 'exit' to quit", + default="", + show_default=False, + ) + if prompt.lower() == "exit": + console.info("Exiting log retrieval.") + return + except ResponseError as err: + console.error(f"Error retrieving logs: {err}") + raise click.exceptions.Exit(1) from err + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + + +@apps_cli.command(name="list") +@click.option("--project", "project_id", help="The project ID to filter deployments.") +@click.option("--project-name", help="The name of the project.") +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in JSON format.", +) +@click.option( + "--interactive/--no-interactive", + is_flag=True, + default=True, + help="Whether to list configuration options and ask for confirmation.", +) +def list_apps( + project_id: str | None, + project_name: str | None, + token: str | None, + loglevel: str, + as_json: bool, + interactive: bool, +): + """List all the hosted deployments of the authenticated user. Will exit if unable to list deployments.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + if project_name and not project_id: + result = hosting.search_project( + project_name, client=authenticated_client, interactive=interactive + ) + project_id = result.get("id") if result else None + + if project_id is None: + project_id = hosting.get_selected_project() + + if project_id is not None and not as_json: + try: + project = hosting.get_project(project_id, client=authenticated_client) + console.info( + f"Listing apps for project '{project['name']}' ({project_id})" + ) + except Exception: + pass + + deployments = hosting.list_apps(project=project_id, client=authenticated_client) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + except Exception as ex: + console.error("Unable to list deployments") + raise click.exceptions.Exit(1) from ex + + if as_json: + console.print(json.dumps(deployments)) + return + if deployments: + headers = list(deployments[0].keys()) + table = [ + [str(value) for value in deployment.values()] for deployment in deployments + ] + console.print_table(table, headers=headers) + else: + console.print(str(deployments)) + + +@apps_cli.command(name="scale") +@click.argument("app_id", required=False) +@click.option("--app-name", help="The name of the app.") +@click.option("--vmtype", help="The virtual machine type to scale to.") +@click.option("--regions", "-r", multiple=True, help="Region to scale the app to.") +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option("--scale-type", help="The type of scaling.") +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def scale_app( + app_id: str | None, + app_name: str | None, + vmtype: str | None, + regions: tuple[str, ...], + token: str | None, + loglevel: str, + scale_type: str | None, + interactive: bool, +): + """Scale an application by changing the VM type or adding/removing regions.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + if not app_id: + config = hosting.read_config() + if config: + app_id = config.appid + if not isinstance(app_id, (str, type(None))): + console.error( + "app_id must be a string or None. Please check your config file." + ) + raise click.exceptions.Exit(1) + + cli_args = hosting.ScaleAppCliArgs.create( + regions=list(regions), vm_type=vmtype, scale_type=scale_type + ) + config = Config.from_yaml_or_toml_or_default().with_overrides( + vmtype=cli_args.vm_type, + regions=cli_args.regions, + ) + + if not config.exists() and not cli_args.is_valid: + console.error( + "specify either --vmtype or --regions or add them to the cloud.yml or pyproject.toml file" + ) + raise click.exceptions.Exit(1) + + if config.exists() and cli_args.is_valid: + console.warn( + "CLI arguments will override the values in the cloud.yml or pyproject.toml file." + ) + scale_params = hosting.ScaleParams.from_config(config).set_type_from_cli_args( + cli_args + ) + + # If app_name is provided, find the app_id + if app_name is not None and app_id is None: + app_result = hosting.search_app( + app_name=app_name, + project_id=None, + client=authenticated_client, + interactive=interactive, + ) + app_id = app_result.get("id") if app_result else None + + if not app_id: + console.error("No valid app_id or app_name provided.") + raise click.exceptions.Exit(1) + + hosting.scale_app( + app_id=app_id, scale_params=scale_params, client=authenticated_client + ) + console.success("Successfully scaled the app.") + + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + except ( + ScaleAppError, + ResponseError, + ConfigInvalidFieldValueError, + ScaleTypeError, + ScaleParamError, + ) as err: + console.error(err.args[0]) + raise click.exceptions.Exit(1) from err + + +@apps_cli.command(name="inspect") +@click.argument("app_id", required=False) +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in JSON format.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def inspect_app( + app_id: str | None, + token: str | None, + loglevel: str, + as_json: bool, + interactive: bool, +): + """Retrieve detailed information about a specific application.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + if not app_id: + config = hosting.read_config() + if config: + app_id = config.appid + if not isinstance(app_id, (str, type(None))): + console.error( + "app_id must be a string or None. Please check your config file." + ) + raise click.exceptions.Exit(1) + + if not app_id: + console.error( + "No valid app_id provided or found in cloud.yml or pyproject.toml." + ) + raise click.exceptions.Exit(1) + + app_info = hosting.get_app(app_id=app_id, client=authenticated_client) + + if as_json: + console.print(json.dumps(app_info)) + return + + if app_info: + if isinstance(app_info, dict): + headers = list(app_info.keys()) + values = [[str(value) for value in app_info.values()]] + console.print_table(values, headers=headers) + else: + console.print(str(app_info)) + else: + console.print("No app information found.") + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py new file mode 100644 index 00000000000..106dcedf475 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py @@ -0,0 +1,479 @@ +"""CLI for the hosting service.""" + +from __future__ import annotations + +import dataclasses +import json +import os +import shutil +import tempfile +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import click +from packaging import version + +from reflex_cli import constants +from reflex_cli.utils import console +from reflex_cli.utils.dependency import extract_domain + + +def login( + loglevel: constants.LogLevel = constants.LogLevel.INFO, +) -> dict[str, Any]: + """Authenticate with Reflex hosting service. + + Args: + loglevel: The log level to use. + + Returns: + Information about the logged in user. + + Raises: + SystemExit: If the command fails. + + """ + from reflex_cli.utils import hosting + + # Set the log level. + console.set_log_level(loglevel) + + access_token, validated_info = hosting.authenticated_token() + if access_token: + console.print("You already logged in.") + return validated_info + + # If not already logged in, open a browser window/tab to the login page. + access_token, validated_info = hosting.authenticate_on_browser() + + if not access_token: + console.error("Unable to authenticate. Please try again or contact support.") + raise SystemExit(1) + + console.print("Successfully logged in.") + return validated_info + + +def logout( + loglevel: constants.LogLevel = constants.LogLevel.INFO, +): + """Log out of access to Reflex hosting service. + + Args: + loglevel: The log level to use. + + """ + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + console.debug("Deleting access token from config locally") + hosting.delete_token_from_config() + console.success("Successfully logged out.") + + +def deploy( + export_fn: Callable[[str, str, str, bool, bool, bool, bool], None] + | Callable[[str, str, str, bool, bool, bool], None], + app_name: str | None = None, + description: str | None = None, + regions: list[str] | None = None, + project: str | None = None, + envs: list[str] | None = None, + vmtype: str | None = None, + hostname: str | None = None, + interactive: bool = True, + envfile: str | None = None, + loglevel: constants.LogLevel = constants.LogLevel.INFO, + token: str | None = None, + config_path: str | None = None, + env: str | None = None, + project_name: str | None = None, + app_id: str | None = None, + **kwargs, +): + """Deploy the app to the Reflex hosting service. + + Args: + app_name: The name of the app. + export_fn: The function from the Reflex main framework to export the app. + description: The app's description. + regions: The regions to deploy to. + project: The project to deploy to. + envs: The environment variables to set. + vmtype: The VM type to allocate. + hostname: The hostname to use for the frontend. + interactive: Whether to use interactive mode. + envfile: The path to an env file to use. Will override any envs set manually. + loglevel: The log level to use. + token: The authentication token. + config_path: The path to the config file. + env: The environment to use for deployment. + project_name: The name of the project. + app_id: The ID of the app. + **kwargs: Additional keyword arguments. + + Raises: + Exit: If the command fails. + + """ + import httpx + + from reflex_cli.utils import hosting + + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + # Set the log level. + console.set_log_level(loglevel) + project_id = project + config = {} + config_from_file = hosting.read_config(config_path, env=env) + if config_from_file: + config = dataclasses.asdict(config_from_file) + + packages = None + strategy = None + include_db = False + # If a config file is provided, use values from the file that are not provided as arguments. + if config: + if not regions: + regions = config.get("regions", None) + if not vmtype: + vmtype = config.get("vmtype", None) + if not hostname: + hostname = config.get("hostname", None) + if not envfile: + envfile = config.get("envfile", None) + if not project_id: + project_id = config.get("project", None) + if not project_name: + project_name = config.get("projectname", None) + if not app_id: + app_id = config.get("appid", None) + if not isinstance(app_id, (str, type(None))): + console.error( + "app_id must be a string or None. Please check your config file." + ) + raise SystemExit(1) + if not packages: + packages = config.get("packages", None) + if not include_db: + include_db = config.get("include_db", False) + if not strategy: + strategy = config.get("strategy", None) + app_name = config.get("name", app_name) + if not isinstance(app_name, (str, type(None))): + console.error( + "app_name must be a string or None. Please check your config file." + ) + raise SystemExit(1) + if app_name == "default": + # not sure if this is the best check? + console.error( + "Please set real config values in cloud.yml or pyproject.toml" + ) + raise SystemExit(1) + if not description: + description = config.get("description", None) + + # resolve the project id from the project name. + if project_name and not project_id: + result = hosting.search_project( + project_name, client=authenticated_client, interactive=interactive + ) + project_id = result.get("id") if result else None + + try: + # check if provided project exists. + if project_id: + hosting.get_project(project_id, client=authenticated_client) + else: + project_id = hosting.get_selected_project() + except httpx.HTTPStatusError as ex: + try: + console.error(ex.response.json().get("detail")) + except json.JSONDecodeError: + console.error(ex.response.text) + raise click.exceptions.Exit(1) from ex + + envs = envs or [] + + if not app_name and not app_id: + console.error( + "Please provide a valid app name or ID for the deployed instance." + ) + raise click.exceptions.Exit(1) + try: + if app_name and not app_id: + search_project_id = project_id + if not interactive and not project and not search_project_id: + search_project_id = hosting.get_selected_project() + elif interactive and not project: + search_project_id = None + + app = hosting.search_app( + app_name=app_name, + project_id=search_project_id, + client=authenticated_client, + interactive=interactive, + ) + else: + app = hosting.get_app(app_id or "", client=authenticated_client) + app_name = app.get("name") + except click.exceptions.Exit: + raise + except Exception as ex: + console.error(f"Deployment failed: {ex}") + raise click.exceptions.Exit(1) from ex + + if app and interactive and not project and not app_id: + default_project_id = hosting.get_selected_project() + app_project_id = app.get("project_id") + + if app_project_id and ( + not default_project_id or app_project_id != default_project_id + ): + app_project = hosting.get_project( + app_project_id, client=authenticated_client + ) + app_project_name = app_project.get("name", "Unknown") + if ( + console.ask( + f"Deploy to app '{app['name']}' in project '{app_project_name}'?", + choices=["y", "n"], + default="y", + ) + != "y" + ): + console.info("Deployment cancelled.") + raise click.exceptions.Exit(0) + + project_id = app_project_id + + if not app and interactive: + if ( + console.ask( + f"No app with {app_name or app_id} found. Do you want to create a new app to deploy?", + choices=["y", "n"], + default="y", + ) + == "y" + ): + # Check if we need confirmation for deploying to non-default project + if not project: + default_project_id = hosting.get_selected_project() + if not default_project_id: + try: + if project_id: + target_project = hosting.get_project( + project_id, client=authenticated_client + ) + project_name = target_project.get("name", "Unknown") + else: + token = hosting.get_existing_access_token() + default_project_id = hosting.get_default_project( + authenticated_client + ) + if default_project_id: + default_project = hosting.get_project( + default_project_id, client=authenticated_client + ) + project_name = default_project.get( + "name", "Default Project" + ) + else: + project_name = "Default Project" + except Exception: + project_name = "Unknown" + + if ( + console.ask( + f"Create and deploy app '{app_name}' in project '{project_name}'?", + choices=["y", "n"], + default="y", + ) + != "y" + ): + console.info("Deployment cancelled.") + raise click.exceptions.Exit(0) + elif project_id and project_id != default_project_id: + try: + target_project = hosting.get_project( + project_id, client=authenticated_client + ) + project_name = target_project.get("name", "Unknown") + except Exception: + project_name = "Unknown" + + if ( + console.ask( + f"Create and deploy app '{app_name}' in project '{project_name}'?", + choices=["y", "n"], + default="y", + ) + != "y" + ): + console.info("Deployment cancelled.") + raise click.exceptions.Exit(0) + + if description is None: + description = console.ask( + "App Description (Enter to skip)", + ) + app = hosting.create_app( + app_name=app_name or "", + description=description, + project_id=project_id, + client=authenticated_client, + ) + console.info(f"created app. \nName: {app['name']} \nId: {app['id']}") + else: + console.error("Please create an app to deploy.") + raise click.exceptions.Exit(1) + elif not app: + app = hosting.create_app( + app_name=app_name or "", + description=description or "", + project_id=project_id, + client=authenticated_client, + ) + console.info(f"created app. \nName: {app['name']} \nId: {app['id']}") + + urls = hosting.get_hostname( + app_id=app["id"], + app_name=app["name"], + hostname=hostname, + client=authenticated_client, + ) + if "error" in urls: + console.error(urls["error"]) + raise click.exceptions.Exit(1) + server_url = os.getenv("REFLEX_OVERRIDE_BACKEND_URL") or urls["server"] # backend + host_url = os.getenv("REFLEX_OVERRIDE_FRONTEND_URL") or urls["hostname"] # frontend + processed_envs = hosting.process_envs(envs) if envs else None + + if not app_name: + console.error("Please set an app name.") + raise click.exceptions.Exit(1) + + # at this point, if project_id is None, the App should have the correct project_id and + # we should use that going forward to pass validation checks. + project_id = project_id or app.get("project_id") + + validation_message = hosting.validate_deployment_args( + app_name=app_name, + app_id=app.get("id"), + project_id=project_id, + regions=regions, + vmtype=vmtype, + hostname=hostname, + client=authenticated_client, + ) + + if validation_message != "success": + console.error(validation_message) + raise click.exceptions.Exit(1) + + if envfile: + try: + from dotenv import dotenv_values # pyright: ignore[reportMissingImports] + + processed_envs = dotenv_values(envfile) + except ImportError: + console.error( + """The `python-dotenv` package is required to load environment variables from a file. Run `pip install "python-dotenv>=1.0.1"`.""" + ) + + # Compile the app in production mode: backend first then frontend. + temporary_dir = tempfile.TemporaryDirectory() + temporary_dir_path = Path(temporary_dir.name) + + import importlib.metadata + + rx_version = version.parse(importlib.metadata.version("reflex")) + breaking_version = version.parse("0.7.6") + # Try zipping backend first + try: + # Check if the reflex version is >= 0.7.6 + if rx_version <= breaking_version: + export_fn( + str(temporary_dir_path), + server_url, + host_url, + False, + True, + True, + ) # pyright: ignore[reportCallIssue] + else: + export_fn( + str(temporary_dir_path), + server_url, + host_url, + False, + True, + include_db, + True, # pyright: ignore[reportCallIssue] + ) + except Exception as ex: + console.error(f"Unable to export due to: {ex}") + if temporary_dir_path.exists(): + shutil.rmtree(temporary_dir_path) + raise click.exceptions.Exit(1) from ex + + # Zip frontend + try: + # Check if the reflex version is >= 0.7.6 + if rx_version <= breaking_version: + export_fn(str(temporary_dir_path), server_url, host_url, True, False, True) # pyright: ignore[reportCallIssue] + else: + export_fn( + str(temporary_dir_path), + server_url, + host_url, + True, + False, + include_db, + True, # pyright: ignore[reportCallIssue] + ) + except ImportError as ie: + console.error( + f"Encountered ImportError, did you install all the dependencies? {ie}" + ) + if temporary_dir_path.exists(): + shutil.rmtree(temporary_dir_path) + raise click.exceptions.Exit(1) from ie + except Exception as ex: + console.error(f"Unable to export due to: {ex}") + if temporary_dir_path.exists(): + shutil.rmtree(temporary_dir_path) + raise click.exceptions.Exit(1) from ex + + result = hosting.create_deployment( + app_id=app.get("id"), + app_name=app_name, + project_id=project_id, + regions=regions, + zip_dir=Path(temporary_dir_path), + hostname=extract_domain(host_url) if hostname else None, + vmtype=vmtype, + secrets=processed_envs, + client=authenticated_client, + packages=packages, + strategy=strategy, + ) + if "failed" in result: + console.error(result) + raise click.exceptions.Exit(1) + hosting_ui_url = f"{constants.Hosting.HOSTING_SERVICE_UI}/project/{app['project_id']}/app/{app['id']}/" + console.print( + f"deployment progress can now be viewed on the website: {hosting_ui_url}" + ) + console.print( + f"you are now safe to exit this command.\nfollow along with the deployment with the following command: \n reflex cloud apps status {result} --watch" + ) + status = hosting.watch_deployment_status(result, client=authenticated_client) + if status is False: + raise click.exceptions.Exit(1) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py new file mode 100644 index 00000000000..8042bf8f6c5 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py @@ -0,0 +1,144 @@ +"""The Hosting CLI deployments sub-commands.""" + +from __future__ import annotations + +import importlib.metadata +from importlib.util import find_spec +from typing import TYPE_CHECKING + +import click +from packaging import version + +from reflex_cli import constants +from reflex_cli.utils import console +from reflex_cli.v2.apps import apps_cli +from reflex_cli.v2.project import project_cli +from reflex_cli.v2.secrets import secrets_cli +from reflex_cli.v2.vmtypes_regions import vm_types_regions_cli + +if TYPE_CHECKING: + import typer + + +@click.group +@click.pass_context +def hosting_cli(ctx: click.Context) -> None: + """The Hosting CLI. + + This CLI is used to manage the Reflex cloud hosting service. + It provides commands for managing apps, projects, secrets, and VM types/regions. + + """ + if _reflex_version is None: + ctx.fail("Reflex is not installed. Install it with `pip install reflex`.") + if _reflex_version < constants.ReflexHostingCli.MINIMUM_REFLEX_VERSION: + ctx.fail( + f"Reflex version {_reflex_version} is not compatible with reflex-hosting-cli. " + f"Please upgrade Reflex to at least version {constants.ReflexHostingCli.MINIMUM_REFLEX_VERSION}." + ) + if _reflex_version < constants.ReflexHostingCli.RECOMMENDED_REFLEX_VERSION: + console.warn( + f"Support for Reflex version {_reflex_version} in reflex-hosting-cli is deprecated. " + f"Please upgrade Reflex to at least version {constants.ReflexHostingCli.RECOMMENDED_REFLEX_VERSION}." + ) + check_version() + + +try: + _reflex_version: version.Version | None = version.parse( + importlib.metadata.version("reflex") + ) +except importlib.metadata.PackageNotFoundError: + _reflex_version = None + + +hosting_cli.add_command( + apps_cli, + name="apps", +) +hosting_cli.add_command( + project_cli, + name="project", +) +hosting_cli.add_command( + secrets_cli, + name="secrets", +) +for name, command in vm_types_regions_cli.commands.items(): + # Add the command to the hosting CLI + hosting_cli.add_command(command, name=name) + + +def _patch_typer(click_instance: click.Command) -> typer.Typer: + import functools + + import typer + from typer.models import TyperInfo + + fake_typer_app = typer.Typer(add_completion=False) + + fake_typer_app.callback()(lambda: None) + + original_get_group_from_info = typer.main.get_group_from_info + + def get_group_from_info(group_info: TyperInfo, *args, **kwargs): + if group_info.typer_instance is fake_typer_app: + click_instance.name = group_info.name + return click_instance + return original_get_group_from_info(group_info, *args, **kwargs) + + functools.update_wrapper( + get_group_from_info, + original_get_group_from_info, + ) + + typer.main.get_group_from_info = get_group_from_info + + return fake_typer_app + + +if ( + find_spec("typer") is not None + and find_spec("typer.core") is not None + and find_spec("typer.models") is not None +): + hosting_cli = _patch_typer(hosting_cli) # pyright: ignore[reportAssignmentType] + +TIME_FORMAT_HELP = "Accepts ISO 8601 format, unix epoch or time relative to now. For time relative to now, use the format: . Valid units are d (day), h (hour), m (minute), s (second). For example, 1d for 1 day ago from now." +MIN_LOGS_LIMIT = 50 +MAX_LOGS_LIMIT = 1000 + + +def check_version(): + """Callback to be invoked for all hosting CLI commands. + + Checks if the installed version of the package is up-to-date with the latest version available on PyPI. + If a newer version is available, it prints a warning message and exits. + + Raises: + Exit: If a newer version is available, prompting the user to upgrade. + + """ + import httpx + + package_name = constants.ReflexHostingCli.MODULE_NAME + try: + installed_version = importlib.metadata.version(package_name) + response = httpx.get(f"https://pypi.org/pypi/{package_name}/json") + response.raise_for_status() + latest_version = response.json()["info"]["version"] + + if version.parse(installed_version) < version.parse(latest_version): + console.error( + f"Warning: You are using {package_name} version {installed_version}. " + f"A newer version {latest_version} is available. " + f"Upgrade using: pip install --upgrade {package_name}" + ) + raise click.exceptions.Exit(1) + except ( + importlib.metadata.PackageNotFoundError, + httpx.RequestError, + httpx.HTTPStatusError, + ): + # Silently pass if we can't check the version + pass diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/project.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/project.py new file mode 100644 index 00000000000..271dbe77884 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/project.py @@ -0,0 +1,535 @@ +"""Project commands for the Reflex Cloud CLI.""" + +import json + +import click + +from reflex_cli import constants +from reflex_cli.utils import console +from reflex_cli.utils.exceptions import NotAuthenticatedError + + +@click.group() +def project_cli(): + """Commands for managing projects.""" + + +@project_cli.command(name="create") +@click.argument("name", required=True) +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in json format.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def create_project( + name: str, + token: str | None, + loglevel: str, + as_json: bool, + interactive: bool, +): + """Create a new project.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + project = hosting.create_project(name=name, client=authenticated_client) + except ValueError as err: + console.error(str(err)) + raise click.exceptions.Exit(1) from err + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + + if as_json: + console.print(json.dumps(project)) + return + if project: + project = [project] + headers = list(project[0].keys()) + table = [ + [str(value) if value is not None else "" for value in p.values()] + for p in project + ] + console.print_table(table, headers=headers) + else: + console.print(str(project)) + + +@project_cli.command(name="invite") +@click.argument("role", required=True) +@click.argument("user", required=True) +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def invite_user_to_project( + role: str, + user: str, + token: str | None, + loglevel: str, + interactive: bool, +): + """Invite a user to a project.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + result = hosting.invite_user_to_project( + role_id=role, user_id=user, client=authenticated_client + ) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + + if "failed" in result: + console.error(f"Unable to invite user to project: {result}") + raise click.exceptions.Exit(1) + console.success("Successfully invited user to project.") + + +@project_cli.command(name="select") +@click.argument("project_id", required=False) +@click.option("--project-name", help="The name of the project. ") +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--interactive/--no-interactive", + is_flag=True, + default=True, + help="Whether to list configuration options and ask for confirmation.", +) +def select_project( + project_id: str | None, + project_name: str | None, + token: str | None, + loglevel: str, + interactive: bool, +): + """Select a project.""" + import httpx + + from reflex_cli.utils import hosting + + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + # check if provided project exists. + if project_id: + hosting.get_project(project_id, client=authenticated_client) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + except httpx.HTTPStatusError as ex: + try: + console.error(ex.response.json().get("detail")) + except json.JSONDecodeError: + console.error(ex.response.text) + raise click.exceptions.Exit(1) from ex + + if project_name and not project_id: + result = hosting.search_project( + project_name, interactive=interactive, client=authenticated_client + ) + project_id = result.get("id") if result else None + + if not project_id: + console.error("No project selected. Please provide a valid project ID or name.") + raise click.exceptions.Exit(1) + + console.set_log_level(loglevel) + result = hosting.select_project(project=project_id, token=token) + if "failed" in result: + console.error(result) + raise click.exceptions.Exit(1) + console.success(result) + + +@project_cli.command(name="selected") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option("--token", help="The authentication token.") +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def get_select_project( + loglevel: str, + token: str | None, + interactive: bool, +): + """Get the currently selected project.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + project = hosting.get_selected_project() + if project: + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + project_details = hosting.get_project( + project_id=project, client=authenticated_client + ) + console.print_table( + [[project, project_details["name"]]], + headers=["Selected Project ID", "Project Name"], + ) + except NotAuthenticatedError: + console.error( + "You are not authenticated. Run `reflex login` to authenticate." + ) + raise click.exceptions.Exit(1) from None + except Exception as e: + console.error(f"Unable to get the currently selected project: {e}") + else: + console.warn( + "no selected project. run `reflex cloud project select` to set one." + ) + + +@project_cli.command(name="list") +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in json format.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def get_projects( + token: str | None, + loglevel: str, + as_json: bool, + interactive: bool, +): + """Retrieve a list of projects.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + projects = hosting.get_projects(client=authenticated_client) + if as_json: + console.print(json.dumps(projects)) + return + if projects: + headers = list(projects[0].keys()) + table = [] + for project in projects: + row = [] + for value in project.values(): + if isinstance(value, (dict, list)): + row.append(json.dumps(value)) + else: + row.append(str(value)) + table.append(row) + console.print_table(table, headers=headers) + else: + # If returned empty list, print the empty + console.print(str(projects)) + except NotAuthenticatedError: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from None + except Exception as e: + console.error(f"Unable to get projects: {e}") + raise click.exceptions.Exit(1) from e + + +@project_cli.command(name="roles") +@click.option( + "--project-id", + help="The ID of the project. If not provided, the selected project will be used. If no project_id is provided or selected throws an error.", +) +@click.option("--project-name", help="The name of the project. ") +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in json format.", +) +@click.option( + "--interactive/--no-interactive", + is_flag=True, + default=True, + help="Whether to list configuration options and ask for confirmation.", +) +def get_project_roles( + project_id: str | None, + project_name: str | None, + token: str | None, + loglevel: str, + as_json: bool, + interactive: bool, +): + """Retrieve the roles for a project.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + if project_name and not project_id: + result = hosting.search_project( + project_name, client=authenticated_client, interactive=interactive + ) + project_id = result.get("id") if result else None + if project_id is None: + project_id = hosting.get_selected_project() + if project_id is None: + console.error( + "no project_id provided or selected. Set it with `reflex cloud project roles --project-id \\[project_id]`" + ) + raise click.exceptions.Exit(1) + + roles = hosting.get_project_roles( + project_id=project_id, client=authenticated_client + ) + + if as_json: + console.print(json.dumps(roles)) + return + if roles: + headers = list(roles[0].keys()) + table = [ + [str(value) if value is not None else "" for value in role.values()] + for role in roles + ] + console.print_table(table, headers=headers) + else: + # If returned empty list, print the empty + console.print(str(roles)) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + + +@project_cli.command(name="role-permissions") +@click.argument("role_id", required=True) +@click.option( + "--project-id", + help="The ID of the project. If not provided, the selected project will be used. If no project is selected, it throws an error.", +) +@click.option("--project-name", help="The name of the project. ") +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in json format.", +) +@click.option( + "--interactive/--no-interactive", + is_flag=True, + default=True, + help="Whether to list configuration options and ask for confirmation.", +) +def get_project_role_permissions( + role_id: str, + project_id: str | None, + project_name: str | None, + token: str | None, + loglevel: str, + as_json: bool, + interactive: bool, +): + """Retrieve the permissions for a specific role in a project.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + if project_name and not project_id: + result = hosting.search_project( + project_name, client=authenticated_client, interactive=interactive + ) + project_id = result.get("id") if result else None + if project_id is None: + project_id = hosting.get_selected_project() + if project_id is None: + console.error( + "no project_id provided or selected. Set it with `reflex cloud project role-permissions --project-id \\[project_id]`." + ) + raise click.exceptions.Exit(1) + + permissions = hosting.get_project_role_permissions( + project_id=project_id, role_id=role_id, client=authenticated_client + ) + + if as_json: + console.print(json.dumps(permissions)) + return + if permissions: + headers = list(permissions[0].keys()) + table = [ + [ + str(value) if value is not None else "" + for value in permission.values() + ] + for permission in permissions + ] + console.print_table(table, headers=headers) + else: + # If returned empty list, print the empty + console.print(str(permissions)) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + + +@project_cli.command(name="users") +@click.option( + "--project-id", + help="The ID of the project. If not provided, the selected project will be used. If no project is selected, it throws an error.", +) +@click.option("--project-name", help="The name of the project. ") +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in json format.", +) +@click.option( + "--interactive/--no-interactive", + is_flag=True, + default=True, + help="Whether to list configuration options and ask for confirmation.", +) +def get_project_role_users( + project_id: str | None, + project_name: str | None, + token: str | None, + loglevel: str, + as_json: bool, + interactive: bool, +): + """Retrieve the users for a project.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + if project_name and not project_id: + result = hosting.search_project( + project_name, client=authenticated_client, interactive=interactive + ) + project_id = result.get("id") if result else None + if project_id is None: + project_id = hosting.get_selected_project() + if project_id is None: + console.error( + "no project_id provided or selected. Set it with `reflex cloud project users --project-id \\[project_id]`" + ) + raise click.exceptions.Exit(1) + + users = hosting.get_project_role_users( + project_id=project_id, client=authenticated_client + ) + + if as_json: + console.print(json.dumps(users)) + return + if users: + headers = list(users[0].keys()) + table = [ + [str(value) if value is not None else "" for value in user.values()] + for user in users + ] + console.print_table(table, headers=headers) + else: + # If returned empty list, print the empty + console.print(str(users)) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/secrets.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/secrets.py new file mode 100644 index 00000000000..3f1b28f66a6 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/secrets.py @@ -0,0 +1,242 @@ +"""Secrets commands for the Reflex Cloud CLI.""" + +from __future__ import annotations + +import click + +from reflex_cli import constants +from reflex_cli.utils import console +from reflex_cli.utils.exceptions import NotAuthenticatedError + + +@click.group() +def secrets_cli(): + """Commands for managing secrets.""" + + +@secrets_cli.command(name="list") +@click.argument("app_id", required=False) +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in JSON format.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def get_secrets( + app_id: str | None, + token: str | None, + loglevel: str, + as_json: bool, + interactive: bool, +): + """Retrieve secrets for a given application.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + if not app_id: + config = hosting.read_config() + if config: + app_id = config.appid + if not isinstance(app_id, (str, type(None))): + console.error( + "app_id must be a string or None. Please check your config file." + ) + raise click.exceptions.Exit(1) + + if not app_id: + console.error("No valid app_id provided.") + raise click.exceptions.Exit(1) + + secrets = hosting.get_secrets(app_id=app_id, client=authenticated_client) + if "failed" in secrets: + console.error(secrets) + raise click.exceptions.Exit(1) + if as_json: + console.print(secrets) + return + if secrets: + headers = ["Keys"] + table = [[key] for key in secrets] + console.print_table(table, headers=headers) + else: + console.print(str(secrets)) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + + +@secrets_cli.command(name="update") +@click.argument("app_id", required=False) +@click.option( + "--envfile", + help="The path to an env file to use. Will override any envs set manually.", +) +@click.option( + "--env", + "envs", + multiple=True, + help="The environment variables to set: =. Required if envfile is not specified. For multiple envs, repeat this option, e.g. --env k1=v2 --env k2=v2.", +) +@click.option( + "--reboot/--no-reboot", + is_flag=True, + help="Automatically reboot your site with the new secrets", +) +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def update_secrets( + app_id: str | None, + envfile: str | None, + envs: tuple[str, ...], + reboot: bool, + token: str | None, + loglevel: str, + interactive: bool, +): + """Update secrets for a given application.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + if not app_id: + config = hosting.read_config() + if config: + app_id = config.appid + if not isinstance(app_id, (str, type(None))): + console.error( + "app_id must be a string or None. Please check your config file." + ) + raise click.exceptions.Exit(1) + + if not app_id: + console.error("No valid app_id provided.") + raise click.exceptions.Exit(1) + + if envfile is None and not envs: + console.error("--envfile or --env must be provided") + raise click.exceptions.Exit(1) + + if envfile and envs: + console.warn("--envfile is set; ignoring --env") + + if envfile: + try: + from dotenv import ( # pyright: ignore[reportMissingImports] + dotenv_values, + ) + except ImportError: + console.error( + """The `python-dotenv` package is required to load environment variables from a file. Run `pip install "python-dotenv>=1.0.1"`.""" + ) + raise click.exceptions.Exit(1) from None + secrets = dotenv_values(envfile) + else: + secrets = hosting.process_envs(list(envs)) + hosting.update_secrets( + app_id=app_id, secrets=secrets, reboot=reboot, client=authenticated_client + ) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + + +@secrets_cli.command(name="delete") +@click.argument("app_id", required=False) +@click.argument("key", required=True) +@click.option("--token", help="The authentication token.") +@click.option( + "--reboot/--no-reboot", + is_flag=True, + help="Automatically reboot your site with the new secrets", +) +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def delete_secret( + app_id: str | None, + key: str, + token: str | None, + reboot: bool, + loglevel: str, + interactive: bool, +): + """Delete a secret for a given application.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + if not app_id: + config = hosting.read_config() + if config: + app_id = config.appid + if not isinstance(app_id, (str, type(None))): + console.error( + "app_id must be a string or None. Please check your config file." + ) + raise click.exceptions.Exit(1) + + if not app_id: + console.error("No valid app_id provided.") + raise click.exceptions.Exit(1) + + result = hosting.delete_secret( + app_id=app_id, key=key, reboot=reboot, client=authenticated_client + ) + if "failed" in result: + console.error(result) + raise click.exceptions.Exit(1) + console.success("Successfully deleted secret.") + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/utils.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/utils.py new file mode 100644 index 00000000000..6f7aabf665c --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/utils.py @@ -0,0 +1,6 @@ +"""Compat module for reflex 0.6.5 -> 0.6.6.""" + +from reflex_cli.utils import dependency as dependency +from reflex_cli.utils import hosting as hosting + +# Do not add more stuff to this module, put it in reflex_cli.utils instead. 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 new file mode 100644 index 00000000000..d8049ffd4cc --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/vmtypes_regions.py @@ -0,0 +1,198 @@ +"""VMTypes and Regions commands for the Reflex Cloud CLI.""" + +import json + +import click + +from reflex_cli import constants +from reflex_cli.utils import console + + +@click.group() +def vm_types_regions_cli(): + """Commands for VM types and regions.""" + + +@vm_types_regions_cli.command("create-token") +@click.argument("name", required=True) +@click.option( + "--duration", + type=click.IntRange(min=1, max=90), + default=90, + help="Duration in days for the token to be valid. Default is 90 days.", +) +@click.option("--token", help="An existing authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def create_token( + name: str, + token: str | None, + interactive: bool, + duration: int, + loglevel: constants.LogLevel = constants.LogLevel.INFO, +): + """Create a new authentication token for the hosting service.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + if duration is None: + duration = 90 # Default duration is 90 days + console.info("No duration specified. Using default duration of 90 days.") + + token = hosting.create_token( + name=name, expiration=duration, client=authenticated_client + ) + console.success(f"Token: {token}") + + +@vm_types_regions_cli.command("vmtypes") +@click.option("--token", help="The authentication token.") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in json format.", +) +def get_vm_types( + token: str | None, + loglevel: str, + as_json: bool, +): + """Retrieve the available VM types.""" + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + vmtypes = hosting.get_vm_types() + if as_json: + console.print(json.dumps(vmtypes)) + return + if vmtypes: + ordered_vmtpes: list[list[str | float]] = [ + [ + value + for key in ["id", "name", "cpu", "ram"] + if (value := vmtype.get(key)) is not None + ] + for vmtype in vmtypes + ] + headers = ["id", "name", "cpu (cores)", "ram (gb)"] + table = [list(map(str, vmtype)) for vmtype in ordered_vmtpes] + console.print_table(table, headers=headers) + else: + console.print(str(vmtypes)) + + +@vm_types_regions_cli.command(name="regions") +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in json format.", +) +def get_deployment_regions( + loglevel: str, + as_json: bool, +): + """List all the regions of the hosting service. + Areas available for deployment are: + ams Amsterdam, Netherlands + arn Stockholm, Sweden + atl Atlanta, Georgia (US) + bog Bogotá, Colombia + bom Mumbai, India + bos Boston, Massachusetts (US) + cdg Paris, France + den Denver, Colorado (US) + dfw Dallas, Texas (US) + ewr Secaucus, NJ (US) + eze Ezeiza, Argentina + fra Frankfurt, Germany + gdl Guadalajara, Mexico + gig Rio de Janeiro, Brazil + gru Sao Paulo, Brazil + hkg Hong Kong, Hong Kong + iad Ashburn, Virginia (US) + jnb Johannesburg, South Africa + lax Los Angeles, California (US) + lhr London, United Kingdom + mad Madrid, Spain + mia Miami, Florida (US) + nrt Tokyo, Japan + ord Chicago, Illinois (US) + otp Bucharest, Romania + phx Phoenix, Arizona (US) + qro Querétaro, Mexico + scl Santiago, Chile + sea Seattle, Washington (US) + sin Singapore, Singapore + sjc San Jose, California (US) + syd Sydney, Australia + waw Warsaw, Poland + yul Montreal, Canada + yyz Toronto, Canada. + """ + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + list_regions_info = hosting.get_regions() + if as_json: + console.print(json.dumps(list_regions_info)) + return + if list_regions_info: + headers = list(list_regions_info[0].keys()) + table = [ + [str(value) if value is not None else "" for value in deployment.values()] + for deployment in list_regions_info + ] + console.print_table(table, headers=headers) + + +@vm_types_regions_cli.command(name="config") +@click.option("--token", help="An existing authentication token.") +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def generate_cloud_config( + token: str | None = None, + interactive: bool = True, +): + """Generate a configuration file for the cloud deployment.""" + from reflex_cli.utils import hosting + + hosting.generate_config(interactive=interactive, token=token) + console.print("Configuration file generated.") diff --git a/packages/reflex-site-shared/pyproject.toml b/packages/reflex-site-shared/pyproject.toml index 4fdadce6044..3f859ebf1ea 100644 --- a/packages/reflex-site-shared/pyproject.toml +++ b/packages/reflex-site-shared/pyproject.toml @@ -2,6 +2,7 @@ name = "reflex-site-shared" dynamic = ["version"] description = "Reflex Site Shared." +license.text = "Apache-2.0" readme = "README.md" authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] @@ -46,6 +47,7 @@ dependencies = [ "reflex-components-react-player", "reflex-components-recharts", "reflex-components-sonner", + "reflex-hosting-cli", "reflex", "ruff-format", "ruff", diff --git a/pyproject.toml b/pyproject.toml index a97ce9d3d8e..79f7bc40acd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "python-multipart >=0.0.20,<1.0", "python-socketio >=5.12.0,<6.0", "redis >=5.2.1,<8.0", - "reflex-hosting-cli >=0.1.61", + "reflex-hosting-cli", "rich >=13,<15", "starlette >=0.47.0", "typing_extensions >=4.13.0", @@ -110,6 +110,7 @@ dev = [ "sqlmodel", "starlette-admin", "toml", + "typer", "uvicorn", ] @@ -231,6 +232,14 @@ lint.flake8-bugbear.extend-immutable-calls = [ "*.pyi" = ["D301", "D415", "D417", "D418", "E742", "N", "PGH"] "pyi_generator.py" = ["N802"] "packages/reflex-base/src/reflex_base/constants/*.py" = ["N"] +"packages/reflex-hosting-cli/src/reflex_cli/**/*.py" = [ + "B008", + "D420", + "EM101", + "EM102", + "RET504", +] +"tests/units/reflex_cli/**/*.py" = ["DOC201", "PT006", "RUF052"] "*/.templates/apps/blank/code/*" = ["INP001"] "*/blank.py" = ["I001"] @@ -320,6 +329,7 @@ reflex-components-react-player.workspace = true reflex-components-recharts.workspace = true reflex-components-sonner.workspace = true reflex-docgen.workspace = true +reflex-hosting-cli.workspace = true reflex-components-internal.workspace = true reflex-site-shared.workspace = true reflex-integrations-docs.workspace = true diff --git a/tests/units/reflex_cli/__init__.py b/tests/units/reflex_cli/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/units/reflex_cli/conftest.py b/tests/units/reflex_cli/conftest.py new file mode 100644 index 00000000000..f01f3f74a1f --- /dev/null +++ b/tests/units/reflex_cli/conftest.py @@ -0,0 +1,14 @@ +"""Shared fixtures for reflex_cli tests.""" + +import pytest +from pytest_mock import MockFixture + + +@pytest.fixture(autouse=True) +def mock_check_version(mocker: MockFixture) -> None: + """Bypass the hosting-cli PyPI version check during tests. + + The workspace build reports a dev version older than the published one, + causing `check_version` to emit a warning and exit(1). + """ + mocker.patch("reflex_cli.v2.deployments.check_version") diff --git a/tests/units/reflex_cli/utils/__init__.py b/tests/units/reflex_cli/utils/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/units/reflex_cli/utils/test_dependency.py b/tests/units/reflex_cli/utils/test_dependency.py new file mode 100644 index 00000000000..285007582d3 --- /dev/null +++ b/tests/units/reflex_cli/utils/test_dependency.py @@ -0,0 +1,36 @@ +from pathlib import Path + +import pytest +from pytest_mock import MockFixture +from reflex_cli.utils.dependency import detect_encoding, is_valid_url + + +def test_detect_encoding_file_not_found(mocker: MockFixture): + filename = "non_existent_file.txt" + + mocker.patch("pathlib.Path.exists", return_value=False) + + with pytest.raises(FileNotFoundError): + detect_encoding(Path(filename)) + + +@pytest.mark.parametrize( + "url,expected", + [ + ("https://www.example.com", True), + ("http://example.com", True), + ("https://subdomain.example.com", True), + ("http://example.com:8080", True), + ("https://example.com/path?query=1&lang=en", True), + ("https://example.com/#fragment", True), + ("invalid-url", False), + ("www.example.com", False), + ("http://", False), + ("", False), + (None, False), + ("https://", False), + ("ftp://", False), + ], +) +def test_is_valid_url(url: str, expected: bool): + assert is_valid_url(url) == expected diff --git a/tests/units/reflex_cli/utils/test_hosting.py b/tests/units/reflex_cli/utils/test_hosting.py new file mode 100644 index 00000000000..312a12e3f09 --- /dev/null +++ b/tests/units/reflex_cli/utils/test_hosting.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import json +from unittest.mock import mock_open + +import click +import pytest +from pytest_mock import MockerFixture, MockFixture +from reflex_cli.utils.hosting import ( + authenticated_token, + delete_token_from_config, + get_authenticated_client, + get_existing_access_token, + save_token_to_config, +) + + +@pytest.mark.parametrize( + "config_content, expected_token", + [ + ('{"access_token": "valid_token"}', "valid_token"), + ("{}", ""), + (None, ""), + ], +) +def test_get_existing_access_token( + mocker: MockerFixture, config_content: str | None, expected_token: str +): + mocker.patch("os.environ.get", return_value="") + mocker.patch("pathlib.Path.open", mock_open(read_data=config_content)) + assert get_existing_access_token() == expected_token + + mocker.patch("pathlib.Path.open", side_effect=FileNotFoundError("Test exception")) + assert get_existing_access_token() == "" + + +@pytest.mark.parametrize( + "file_exists, config_content", + [ + (True, '{"access_token": "valid_token"}'), + (True, '{"another_key": "value"}'), + (False, ""), + ], +) +def test_delete_token_from_config( + mocker: MockerFixture, + file_exists: bool, + config_content: str, +): + mocker.patch("pathlib.Path.exists", return_value=file_exists) + mock_os_remove = mocker.patch("pathlib.Path.unlink") + + mocked_open = mock_open(read_data=config_content) + mocker.patch("pathlib.Path.open", mocked_open) + mock_json_load = mocker.patch( + "json.load", return_value=json.loads(config_content or "{}") + ) + mock_json_dump = mocker.patch("json.dump") + + delete_token_from_config() + + if file_exists: + assert mocked_open.call_count == 2 + mock_json_load.assert_called_once() + mock_json_dump.assert_called_once() + assert "access_token" not in mock_json_dump.call_args.args[0] + mock_os_remove.assert_called_once() + else: + mocked_open.assert_not_called() + mock_os_remove.assert_not_called() + + +def test_save_token_to_config(mocker: MockFixture): + mocker.patch("pathlib.Path.exists", return_value=False) + mock_makedirs = mocker.patch("pathlib.Path.mkdir") + save_token_to_config("test_token") + mock_makedirs.assert_called_once() + + mocker.patch("pathlib.Path.exists", return_value=True) + mock_json_dump = mocker.patch("json.dump") + mocker.patch("pathlib.Path.open", mock_open()) + save_token_to_config("test_token") + mock_json_dump.assert_called_once() + + +def test_authenticated_token_found_and_valid(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token", + return_value="valid_token", + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value={"user_info": True} + ) + + token = authenticated_token() + + assert token == ("valid_token", {"user_info": True}) + + +def test_authenticated_token_not_found(mocker: MockFixture): + mocker.patch("reflex_cli.utils.hosting.get_existing_access_token", return_value="") + + token = authenticated_token() + assert token == ("", {}) + + +def test_authenticated_token_found_but_invalid(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token", + return_value="invalid_token", + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_token", + side_effect=ValueError("access denied"), + ) + mocker.patch( + "reflex_cli.constants.hosting.Hosting.AUTH_RETRY_LIMIT", return_value=1 + ) + + token = authenticated_token() + assert token == ("", {}) + + +def test_authenticated_token_found_but_validation_fails(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token", + return_value="invalid_token", + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_token", + side_effect=ValueError("server error"), + ) + mocker.patch( + "reflex_cli.utils.hosting.authenticate_on_browser", + return_value="new_valid_token", + ) + mock_delete_token = mocker.patch( + "reflex_cli.utils.hosting.delete_token_from_config" + ) + + token = authenticated_token() + + assert token == ("", {}) + mock_delete_token.assert_called_once() + + +def test_authenticate_without_token_in_non_interactive_mode(mocker: MockerFixture): + mocker.patch("reflex_cli.utils.hosting.get_existing_access_token", return_value="") + with pytest.raises(click.exceptions.Exit): + get_authenticated_client(token=None, interactive=False) + + +def test_authenticate_with_env_token_in_non_interactive_mode(mocker: MockerFixture): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token", return_value="env_token" + ) + mock_get_auth_client = mocker.patch( + "reflex_cli.utils.hosting.get_authentication_client" + ) + mock_authenticated_client = mocker.MagicMock() + mock_get_auth_client.return_value = mock_authenticated_client + + result = get_authenticated_client(token=None, interactive=False) + + assert result == mock_authenticated_client + mock_get_auth_client.assert_called_once_with(None) diff --git a/tests/units/reflex_cli/v2/__init__.py b/tests/units/reflex_cli/v2/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/units/reflex_cli/v2/test_apps.py b/tests/units/reflex_cli/v2/test_apps.py new file mode 100644 index 00000000000..218068dc8f0 --- /dev/null +++ b/tests/units/reflex_cli/v2/test_apps.py @@ -0,0 +1,1520 @@ +from __future__ import annotations + +import json +from unittest import mock + +import httpx +import pytest +from click.testing import CliRunner +from pytest_mock import MockerFixture, MockFixture +from reflex_cli.core.config import Config +from reflex_cli.utils import hosting +from reflex_cli.utils.exceptions import GetAppError +from reflex_cli.v2.deployments import hosting_cli +from typer.main import Typer, get_command + +hosting_cli = ( + get_command(hosting_cli) if isinstance(hosting_cli, Typer) else hosting_cli +) + +runner = CliRunner() + + +def test_app_history_success(mocker: MockFixture): + """Test retrieving deployment history successfully.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_app_history = mocker.patch( + "reflex_cli.utils.hosting.get_app_history", + return_value=[ + { + "id": "deployment1", + "status": "success", + "hostname": "example.com", + "python version": "3.10", + "reflex version": "1.2.3", + "vm type": "small", + "timestamp": "2024-11-29T12:00:00Z", + }, + { + "id": "deployment2", + "status": "failure", + "hostname": "example.org", + "python version": "3.11", + "reflex version": "1.1.0", + "vm type": "medium", + "timestamp": "2024-11-28T10:00:00Z", + }, + ], + ) + mock_console_print_table = mocker.patch("reflex_cli.utils.console.print_table") + + result = runner.invoke(hosting_cli, ["apps", "history", "test_app_id"]) + + assert result.exit_code == 0, result.output + mock_get_app_history.assert_called_once_with( + app_id="test_app_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print_table.assert_called_once() + + +def test_app_history_as_json(mocker: MockFixture): + """Test retrieving deployment history with JSON output.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_app_history = mocker.patch( + "reflex_cli.utils.hosting.get_app_history", + return_value=[ + { + "id": "deployment1", + "status": "success", + "hostname": "example.com", + "python version": "3.10", + "reflex version": "1.2.3", + "vm type": "small", + "timestamp": "2024-11-29T12:00:00Z", + } + ], + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke( + hosting_cli, + ["apps", "history", "test_app_id", "--json"], + ) + + assert result.exit_code == 0, result.output + mock_get_app_history.assert_called_once_with( + app_id="test_app_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print.assert_called_once_with( + json.dumps([ + { + "id": "deployment1", + "status": "success", + "hostname": "example.com", + "python version": "3.10", + "reflex version": "1.2.3", + "vm type": "small", + "timestamp": "2024-11-29T12:00:00Z", + } + ]) + ) + + +def test_app_history_no_deployments(mocker: MockFixture): + """Test retrieving deployment history when there are no deployments.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_app_history = mocker.patch( + "reflex_cli.utils.hosting.get_app_history", + return_value=[], + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke(hosting_cli, ["apps", "history", "test_app_id"]) + + assert result.exit_code == 0, result.output + mock_get_app_history.assert_called_once_with( + app_id="test_app_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print.assert_called_once_with("[]") + + +def test_app_history_http_error(mocker: MockFixture): + """Test retrieving deployment history when an HTTP error occurs.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_app_history = mocker.patch( + "reflex_cli.utils.hosting.get_app_history", + side_effect=Exception("HTTP request failed"), + ) + mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["apps", "history", "test_app_id"]) + + assert result.exit_code == 1 + mock_get_app_history.assert_called_once_with( + app_id="test_app_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + + +def test_deployment_build_logs_success(mocker: MockFixture): + """Test successful retrieval of build logs.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_deployment_build_logs = mocker.patch( + "reflex_cli.utils.hosting.get_deployment_build_logs", + return_value={"log": "Build completed successfully."}, + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke(hosting_cli, ["apps", "build-logs", "test_deployment_id"]) + + assert result.exit_code == 0, result.output + mock_get_deployment_build_logs.assert_called_once_with( + deployment_id="test_deployment_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print.assert_called_once_with({"log": "Build completed successfully."}) + + +def test_deployment_build_logs_with_token(mocker: MockFixture): + """Test retrieval of build logs with a provided token.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_deployment_build_logs = mocker.patch( + "reflex_cli.utils.hosting.get_deployment_build_logs", + return_value={"log": "Build completed successfully."}, + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke( + hosting_cli, + ["apps", "build-logs", "test_deployment_id", "--token", "fake-token"], + ) + + assert result.exit_code == 0, result.output + mock_get_deployment_build_logs.assert_called_once_with( + deployment_id="test_deployment_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print.assert_called_once_with({"log": "Build completed successfully."}) + + +def test_deployment_build_logs_not_authenticated(mocker: MockFixture): + """Test retrieval of build logs when not authenticated.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_deployment_build_logs = mocker.patch( + "reflex_cli.utils.hosting.get_deployment_build_logs", + side_effect=Exception("not authenticated"), + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke(hosting_cli, ["apps", "build-logs", "test_deployment_id"]) + + assert result.exit_code == 1 # Command should fail due to exception + mock_get_deployment_build_logs.assert_called_once_with( + deployment_id="test_deployment_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print.assert_not_called() + + +def test_deployment_build_logs_http_error(mocker: MockFixture): + """Test retrieval of build logs when an HTTP error occurs.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_deployment_build_logs = mocker.patch( + "reflex_cli.utils.hosting.get_deployment_build_logs", + side_effect=Exception("HTTP error: bad response from server"), + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke(hosting_cli, ["apps", "build-logs", "test_deployment_id"]) + + assert result.exit_code == 1 + mock_get_deployment_build_logs.assert_called_once_with( + deployment_id="test_deployment_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print.assert_not_called() + + +def test_deployment_status_success(mocker: MockFixture): + """Test successful retrieval of a deployment's status.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_status = mocker.patch( + "reflex_cli.utils.hosting.get_deployment_status", + return_value="Deployment is running smoothly.", + ) + mock_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke(hosting_cli, ["apps", "status", "12345"]) + + assert result.exit_code == 0, result.output + mock_get_status.assert_called_once_with( + deployment_id="12345", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_print.assert_called_once_with("Deployment is running smoothly.") + + +def test_deployment_status_watch_success(mocker: MockFixture): + """Test continuous status watching for a deployment.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_watch_status = mocker.patch( + "reflex_cli.utils.hosting.watch_deployment_status", + return_value=None, + ) + + result = runner.invoke(hosting_cli, ["apps", "status", "12345", "--watch"]) + + assert result.exit_code == 0, result.output + mock_watch_status.assert_called_once_with( + deployment_id="12345", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + + +def test_deployment_status_http_error(mocker: MockFixture): + """Test HTTP error during status retrieval.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get = mocker.patch("httpx.get") + mock_response = mocker.Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "HTTP Error", + request=mocker.Mock(), + response=mocker.Mock(json=lambda: {"detail": "Invalid token"}), + ) + mock_get.return_value = mock_response + mock_error = mocker.patch("reflex_cli.utils.console.error") + mocker.patch( + "reflex_cli.utils.hosting.requires_authenticated", return_value="fake_token" + ) + mocker.patch("reflex_cli.utils.hosting.get_app", return_value={"id": "fake_app_id"}) + mocker.patch( + "reflex_cli.utils.hosting.authorization_header", + return_value={"X-API-TOKEN": "fake_token"}, + ) + + result = runner.invoke(hosting_cli, ["apps", "status", "12345"]) + + assert result.exit_code == 0, result.output + mock_error.assert_called_once_with("get status failed: Invalid token") + + +def test_stop_app_success(mocker: MockFixture): + """Test successful stopping of an app.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_stop_app = mocker.patch( + "reflex_cli.utils.hosting.stop_app", + return_value="App stopped successfully", + ) + mock_success = mocker.patch("reflex_cli.utils.console.success") + + result = runner.invoke(hosting_cli, ["apps", "stop", "app123"]) + + assert result.exit_code == 0, result.output + mock_stop_app.assert_called_once_with( + app_id="app123", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_success.assert_called_once_with("App stopped successfully") + + +def test_stop_app_failure(mocker: MockFixture): + """Test failure during app stop operation.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_stop_app = mocker.patch( + "reflex_cli.utils.hosting.stop_app", + return_value="stop app failed: Unable to stop app due to server error", + ) + mock_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["apps", "stop", "app123"]) + + assert result.exit_code == 0, result.output + mock_stop_app.assert_called_once_with( + app_id="app123", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_error.assert_called_once_with( + "stop app failed: Unable to stop app due to server error" + ) + + +def test_stop_app_http_error(mocker: MockFixture): + """Test HTTP error during app stop operation.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_post = mocker.patch("httpx.post") + mock_response = mocker.Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "HTTP Error", + request=mocker.Mock(), + response=mocker.Mock(json=lambda: {"detail": "Invalid token"}), + ) + mock_post.return_value = mock_response + mock_error = mocker.patch("reflex_cli.utils.console.error") + mocker.patch( + "reflex_cli.utils.hosting.requires_authenticated", return_value="fake_token" + ) + mocker.patch("reflex_cli.utils.hosting.get_app", return_value={"id": "fake_app_id"}) + mocker.patch( + "reflex_cli.utils.hosting.authorization_header", + return_value={"X-API-TOKEN": "fake_token"}, + ) + + result = runner.invoke(hosting_cli, ["apps", "stop", "app123"]) + + assert result.exit_code == 0, result.output + mock_error.assert_called_once_with("stop app failed: Invalid token") + + +def test_start_app_success(mocker: MockFixture): + """Test successful start of an app.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_start_app = mocker.patch( + "reflex_cli.utils.hosting.start_app", + return_value={"status": "success", "message": "App started successfully"}, + ) + mock_success = mocker.patch("reflex_cli.utils.console.success") + + result = runner.invoke(hosting_cli, ["apps", "start", "app123"]) + + assert result.exit_code == 0, result.output + mock_start_app.assert_called_once_with( + app_id="app123", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_success.assert_called_once_with({ + "status": "success", + "message": "App started successfully", + }) + + +def test_start_app_failure(mocker: MockFixture): + """Test failure during app start operation.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_start_app = mocker.patch( + "reflex_cli.utils.hosting.start_app", + return_value="start app failed: Unable to start app due to server error", + ) + mock_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["apps", "start", "app123"]) + + assert result.exit_code == 0, result.output + mock_start_app.assert_called_once_with( + app_id="app123", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_error.assert_called_once_with( + "start app failed: Unable to start app due to server error" + ) + + +def test_start_app_http_error(mocker: MockFixture): + """Test HTTP error during app start operation.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_post = mocker.patch("httpx.post") + mock_response = mocker.Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "HTTP Error", + request=mocker.Mock(), + response=mocker.Mock(json=lambda: {"detail": "Invalid token"}), + ) + mock_post.return_value = mock_response + mock_error = mocker.patch("reflex_cli.utils.console.error") + mocker.patch( + "reflex_cli.utils.hosting.requires_authenticated", return_value="fake_token" + ) + mocker.patch("reflex_cli.utils.hosting.get_app", return_value={"id": "fake_app_id"}) + mocker.patch( + "reflex_cli.utils.hosting.authorization_header", + return_value={"X-API-TOKEN": "fake_token"}, + ) + + result = runner.invoke(hosting_cli, ["apps", "start", "app123"]) + + assert result.exit_code == 0, result.output + mock_error.assert_called_once_with("start app failed: Invalid token") + + +def test_delete_app_success(mocker: MockFixture): + """Test successful deletion of an app with confirmation.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_delete_app = mocker.patch( + "reflex_cli.utils.hosting.delete_app", + return_value={"status": "success", "message": "App deleted successfully"}, + ) + mock_get_app = mocker.patch( + "reflex_cli.utils.hosting.get_app", + return_value={"id": "app123", "name": "test-app"}, + ) + mock_warn = mocker.patch("reflex_cli.utils.console.warn") + mock_ask = mocker.patch("reflex_cli.utils.console.ask", return_value="y") + + result = runner.invoke(hosting_cli, ["apps", "delete", "app123"]) + + assert result.exit_code == 0, result.output + assert mock_get_app.call_count == 2 + mock_get_app.assert_has_calls( + [ + mock.call( + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + app_id="app123", + ), + mock.call( + app_id="app123", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ), + ], + any_order=True, + ) + mock_ask.assert_called_once_with( + "Are you sure you want to delete app 'test-app' (ID: app123)?", + choices=["y", "n"], + default="n", + ) + mock_delete_app.assert_called_once_with( + app_id="app123", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_warn.assert_called_once_with({ + "status": "success", + "message": "App deleted successfully", + }) + + +def test_delete_app_failure(mocker: MockFixture): + """Test failure during app deletion.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_delete_app = mocker.patch( + "reflex_cli.utils.hosting.delete_app", + return_value="delete app failed: Unable to delete app due to server error", + ) + mock_get_app = mocker.patch( + "reflex_cli.utils.hosting.get_app", + return_value={"id": "app123", "name": "test-app"}, + ) + mock_warn = mocker.patch("reflex_cli.utils.console.warn") + mock_ask = mocker.patch("reflex_cli.utils.console.ask", return_value="y") + + result = runner.invoke(hosting_cli, ["apps", "delete", "app123"]) + + assert result.exit_code == 0, result.output + assert mock_get_app.call_count == 2 + mock_get_app.assert_has_calls( + [ + mock.call( + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + app_id="app123", + ), + mock.call( + app_id="app123", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ), + ], + any_order=True, + ) + mock_ask.assert_called_once_with( + "Are you sure you want to delete app 'test-app' (ID: app123)?", + choices=["y", "n"], + default="n", + ) + mock_delete_app.assert_called_once_with( + app_id="app123", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_warn.assert_called_once_with( + "delete app failed: Unable to delete app due to server error" + ) + + +def test_delete_app_no_app_id(mocker: MockFixture): + """Test case when no app_id is provided.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_error = mocker.patch("reflex_cli.utils.console.error") + result = runner.invoke(hosting_cli, ["apps", "delete", ""]) + + assert result.exit_code == 1 + mock_error.assert_called_once_with("No valid app_id or app_name provided.") + + +def test_delete_app_http_error(mocker: MockFixture): + """Test HTTP error during app deletion.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_delete = mocker.patch("httpx.delete") + mock_response = mocker.Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "HTTP Error", + request=mocker.Mock(), + response=mocker.Mock(json=lambda: {"detail": "Invalid token"}), + ) + mock_delete.return_value = mock_response + + mock_get_app = mocker.patch( + "reflex_cli.utils.hosting.get_app", + return_value={"id": "app123", "name": "test-app"}, + ) + mock_warn = mocker.patch("reflex_cli.utils.console.warn") + mock_ask = mocker.patch("reflex_cli.utils.console.ask", return_value="y") + mocker.patch( + "reflex_cli.utils.hosting.requires_authenticated", return_value="fake_token" + ) + mocker.patch( + "reflex_cli.utils.hosting.authorization_header", + return_value={"X-API-TOKEN": "fake_token"}, + ) + + result = runner.invoke(hosting_cli, ["apps", "delete", "app123"]) + + assert result.exit_code == 0, result.output + assert mock_get_app.call_count >= 1 + mock_ask.assert_called_once_with( + "Are you sure you want to delete app 'test-app' (ID: app123)?", + choices=["y", "n"], + default="n", + ) + mock_warn.assert_called_once_with("delete app failed: Invalid token") + + +def test_delete_app_confirmation_cancelled(mocker: MockFixture): + """Test deletion cancelled when user responds 'n' to confirmation.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_delete_app = mocker.patch("reflex_cli.utils.hosting.delete_app") + mock_get_app = mocker.patch( + "reflex_cli.utils.hosting.get_app", + return_value={"id": "app123", "name": "test-app"}, + ) + mock_ask = mocker.patch("reflex_cli.utils.console.ask", return_value="n") + mock_info = mocker.patch("reflex_cli.utils.console.info") + + result = runner.invoke(hosting_cli, ["apps", "delete", "app123"]) + + assert result.exit_code == 0, result.output + assert mock_get_app.call_count == 2 + mock_get_app.assert_has_calls( + [ + mock.call( + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + app_id="app123", + ), + mock.call( + app_id="app123", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ), + ], + any_order=True, + ) + mock_ask.assert_called_once_with( + "Are you sure you want to delete app 'test-app' (ID: app123)?", + choices=["y", "n"], + default="n", + ) + mock_info.assert_called_once_with("Deletion cancelled.") + mock_delete_app.assert_not_called() + + +def test_delete_app_non_interactive_skips_confirmation(mocker: MockFixture): + """Test deletion proceeds without confirmation when --no-interactive flag is used.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_delete_app = mocker.patch( + "reflex_cli.utils.hosting.delete_app", + return_value={"status": "success", "message": "App deleted successfully"}, + ) + mock_get_app = mocker.patch("reflex_cli.utils.hosting.get_app") + mock_warn = mocker.patch("reflex_cli.utils.console.warn") + mock_ask = mocker.patch("reflex_cli.utils.console.ask") + + result = runner.invoke( + hosting_cli, ["apps", "delete", "app123", "--no-interactive"] + ) + + assert result.exit_code == 0, result.output + mock_ask.assert_not_called() + assert mock_get_app.call_count == 1 + mock_delete_app.assert_called_once_with( + app_id="app123", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_warn.assert_called_once_with({ + "status": "success", + "message": "App deleted successfully", + }) + + +def test_delete_app_get_app_fails_fallback_to_unknown(mocker: MockFixture): + """Test deletion shows 'Unknown' when get_app fails.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_delete_app = mocker.patch( + "reflex_cli.utils.hosting.delete_app", + return_value={"status": "success", "message": "App deleted successfully"}, + ) + mock_get_app = mocker.patch( + "reflex_cli.utils.hosting.get_app", + side_effect=[ + GetAppError("Failed to fetch app"), + {"id": "app123", "name": "Unknown"}, + ], + ) + mock_warn = mocker.patch("reflex_cli.utils.console.warn") + mock_ask = mocker.patch("reflex_cli.utils.console.ask", return_value="y") + + result = runner.invoke(hosting_cli, ["apps", "delete", "app123"]) + + assert result.exit_code == 0, result.output + assert mock_get_app.call_count == 1 + mock_ask.assert_not_called() + mock_delete_app.assert_not_called() + mock_warn.assert_called_once_with("No application found with ID 'app123'") + + +def test_delete_app_with_app_name_confirmation(mocker: MockFixture): + """Test deletion with app name shows proper app name in confirmation.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_search_app = mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value={"id": "app123", "name": "my-test-app"}, + ) + mock_delete_app = mocker.patch( + "reflex_cli.utils.hosting.delete_app", + return_value={"status": "success", "message": "App deleted successfully"}, + ) + mock_warn = mocker.patch("reflex_cli.utils.console.warn") + mock_ask = mocker.patch("reflex_cli.utils.console.ask", return_value="y") + + result = runner.invoke(hosting_cli, ["apps", "delete", "--app-name", "my-test-app"]) + + assert result.exit_code == 0, result.output + mock_search_app.assert_called_once() + mock_ask.assert_called_once_with( + "Are you sure you want to delete app 'my-test-app' (ID: app123)?", + choices=["y", "n"], + default="n", + ) + mock_delete_app.assert_called_once_with( + app_id="app123", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_warn.assert_called_once_with({ + "status": "success", + "message": "App deleted successfully", + }) + + +def test_delete_app_not_found_early_exit(mocker: MockFixture): + """Test early exit with warning when app is not found during search.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_search_app = mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value=None, + ) + mock_warn = mocker.patch("reflex_cli.utils.console.warn") + mock_delete_app = mocker.patch("reflex_cli.utils.hosting.delete_app") + mock_ask = mocker.patch("reflex_cli.utils.console.ask") + + result = runner.invoke( + hosting_cli, ["apps", "delete", "--app-name", "nonexistent-app"] + ) + + assert result.exit_code == 1, result.output + mock_search_app.assert_called_once() + mock_warn.assert_called_once_with("App 'nonexistent-app' not found.") + mock_ask.assert_not_called() + mock_delete_app.assert_not_called() + + +def test_app_logs_no_app_id(mocker: MockFixture): + """Test case when no app_id is provided.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_error = mocker.patch("reflex_cli.utils.console.error") + result = runner.invoke(hosting_cli, ["apps", "logs", ""]) + + assert result.exit_code == 1 + mock_error.assert_called_once_with("No valid app_id or app_name provided.") + + +def test_app_logs_invalid_time_range(mocker: MockFixture): + """Test case when offset is provided without start and end.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_error = mocker.patch("reflex_cli.utils.console.error") + result = runner.invoke( + hosting_cli, + [ + "apps", + "logs", + "app123", + "--start", + "423453423", + ], + ) + + assert result.exit_code == 1 + mock_error.assert_called_once_with("must provide both start and end") + + +def test_app_logs_success(mocker: MockFixture): + """Test case for successful log retrieval.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_app_logs = mocker.patch( + "reflex_cli.utils.hosting.get_app_logs", + return_value=["log1", "log2", "log3"], + ) + mock_info = mocker.patch("reflex_cli.utils.console.info") + + result = runner.invoke(hosting_cli, ["apps", "logs", "app123", "--follow", "false"]) + + assert result.exit_code == 0, result.output + mock_get_app_logs.assert_called_once_with( + app_id="app123", + offset=3600, + start=None, + end=None, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + cursor=None, + ) + mock_info.assert_any_call("log3") + mock_info.assert_any_call("log2") + mock_info.assert_any_call("log1") + + +def test_app_logs_failure(mocker: MockFixture): + """Test case when log retrieval fails.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_app_logs = mocker.patch( + "reflex_cli.utils.hosting.get_app_logs", + return_value="get app logs failed: Unable to retrieve logs", + ) + mock_warn = mocker.patch("reflex_cli.utils.console.warn") + + result = runner.invoke(hosting_cli, ["apps", "logs", "app123", "--follow", "false"]) + + assert result.exit_code == 0, result.output + mock_get_app_logs.assert_called_once_with( + app_id="app123", + offset=3600, + start=None, + end=None, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + cursor=None, + ) + mock_warn.assert_called_once_with("Unable to retrieve logs.") + + +def test_app_logs_http_error(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get = mocker.patch("httpx.get") + mock_response = mocker.Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "HTTP Error", + request=mocker.Mock(), + response=mocker.Mock(json=lambda: {"detail": "Invalid token"}), + ) + mock_get.return_value = mock_response + mock_warn = mocker.patch("reflex_cli.v2.deployments.console.warn") + mocker.patch( + "reflex_cli.utils.hosting.requires_authenticated", return_value="fake_token" + ) + mocker.patch("reflex_cli.utils.hosting.get_app", return_value={"id": "fake_app_id"}) + mocker.patch( + "reflex_cli.utils.hosting.authorization_header", + return_value={"X-API-TOKEN": "fake_token"}, + ) + + result = runner.invoke( + hosting_cli, + ["apps", "logs", "fake_app_id", "--token", "fake_token", "--follow", "false"], + ) + + assert result.exit_code == 0, result.output + mock_warn.assert_called_once_with("Unable to retrieve logs.") + + +def test_list_apps_no_project(mocker: MockFixture): + """Test case when no project is provided.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_selected_project = mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", + return_value="default_project", + ) + mock_list_apps = mocker.patch( + "reflex_cli.utils.hosting.list_apps", + return_value=[{"id": "1", "name": "App1"}, {"id": "2", "name": "App2"}], + ) + mock_print_table = mocker.patch("reflex_cli.utils.console.print_table") + + result = runner.invoke(hosting_cli, ["apps", "list"]) + + assert result.exit_code == 0, result.output + mock_get_selected_project.assert_called_once() + mock_list_apps.assert_called_once_with( + project="default_project", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_print_table.assert_called_once_with( + [["1", "App1"], ["2", "App2"]], + headers=["id", "name"], + ) + + +def test_list_apps_with_project(mocker: MockFixture): + """Test case when a project is provided.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_list_apps = mocker.patch( + "reflex_cli.utils.hosting.list_apps", + return_value=[{"id": "1", "name": "App1"}], + ) + mock_print_table = mocker.patch("reflex_cli.utils.console.print_table") + + result = runner.invoke(hosting_cli, ["apps", "list", "--project", "project123"]) + + assert result.exit_code == 0, result.output + mock_list_apps.assert_called_once_with( + project="project123", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_print_table.assert_called_once_with( + [["1", "App1"]], + headers=["id", "name"], + ) + + +def test_list_apps_json_output(mocker: MockFixture): + """Test case for JSON output.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_list_apps = mocker.patch( + "reflex_cli.utils.hosting.list_apps", + return_value=[{"id": "1", "name": "App1"}], + ) + mock_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke(hosting_cli, ["apps", "list", "--json"]) + + assert result.exit_code == 0, result.output + mock_list_apps.assert_called_once_with( + project=None, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_print.assert_called_once_with(json.dumps([{"id": "1", "name": "App1"}])) + + +def test_list_apps_error(mocker: MockFixture): + """Test case when an error occurs while listing deployments.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_list_apps = mocker.patch( + "reflex_cli.utils.hosting.list_apps", + side_effect=Exception("Unable to list deployments"), + ) + mock_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["apps", "list"]) + + assert result.exit_code == 1 + mock_list_apps.assert_called_once_with( + project=None, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_error.assert_called_once_with("Unable to list deployments") + + +def test_list_apps_empty_response(mocker: MockFixture): + """Test case when no deployments are found.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_list_apps = mocker.patch("reflex_cli.utils.hosting.list_apps", return_value=[]) + mock_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke(hosting_cli, ["apps", "list"]) + + assert result.exit_code == 0, result.output + mock_list_apps.assert_called_once_with( + project=None, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_print.assert_called_once_with("[]") + + +def test_scale_no_args_or_config(mocker: MockFixture): + """Test error when neither args nor config file exists.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.core.config.Config.from_yaml_or_toml_or_default", + return_value=Config(), + ) + mocker.patch("reflex_cli.core.config.Config.exists", return_value=False) + mock_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["apps", "scale", "--app-name", "random"]) + + assert result.exit_code == 1 + mock_error.assert_called_with( + "specify either --vmtype or --regions or add them to the cloud.yml or pyproject.toml file" + ) + + +def test_scale_both_vmtype_and_regions(mocker: MockFixture): + """Test error when both --vmtype and --regions are provided.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke( + hosting_cli, ["apps", "scale", "--vmtype", "c1m1", "--regions", "sjc"] + ) + + assert result.exit_code == 1 + mock_error.assert_called_with( + "Only one of --vmtype or --regions should be provided." + ) + + +def test_scale_args_override_config(mocker: MockFixture): + """Test warning when both args and config are provided.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "fake-project", + }, + ) + mocker.patch( + "reflex_cli.utils.hosting.scale_app", + ) + mocker.patch( + "reflex_cli.utils.hosting.ScaleParams.from_config", + return_value=hosting.ScaleParams( + type=hosting.ScaleType(hosting.ScaleType.SIZE), vm_type="c1m1" + ), + ) + mocker.patch( + "reflex_cli.core.config.Config.from_yaml_or_toml_or_default", + return_value=Config(regions={"ams": 1}, vmtype="c1m2"), + ) + mocker.patch("reflex_cli.core.config.Config.exists", return_value=True) + mock_warn = mocker.patch("reflex_cli.v2.deployments.console.warn") + + result = runner.invoke( + hosting_cli, ["apps", "scale", "--app-name", "random", "--vmtype", "c1m1"] + ) + + assert result.exit_code == 0, result.output + mock_warn.assert_called_with( + "CLI arguments will override the values in the cloud.yml or pyproject.toml file." + ) + + +def test_scale_warn_cli_args_with_scale_type(mocker: MockFixture): + """Test error when scaletype is set to size but vmtype is missing from config.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", + validated_data={"foo": "bar"}, + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.scale_app", + ) + mocker.patch( + "reflex_cli.utils.hosting.ScaleParams.from_config", + return_value=hosting.ScaleParams( + type=hosting.ScaleType(hosting.ScaleType.SIZE), vm_type="c1m1" + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "fake-project", + }, + ) + + mocker.patch("reflex_cli.core.config.Config.exists", return_value=True) + mocker.patch( + "reflex_cli.core.config.Config.from_yaml_or_toml_or_default", + return_value=Config(regions={"ams": 1}, vmtype=None), + ) + mock_warn = mocker.patch("reflex_cli.utils.console.warn") + + result = runner.invoke( + hosting_cli, + [ + "apps", + "scale", + "--app-name", + "random", + "--regions", + "ams", + "--scale-type", + "size", + ], + ) + + assert result.exit_code == 0, result.output + mock_warn.assert_called_with( + "using --scale-type with --regions or --vmtype will have no effect" + ) + + +def test_scale_regions_via_config_no_scaletype(mocker: MockFixture): + """Test error when scaletype is set to regions but regions is missing from config.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", + validated_data={"foo": "bar"}, + ), + ) + + mocker.patch("reflex_cli.core.config.Config.exists", return_value=True) + mocker.patch( + "reflex_cli.core.config.Config.from_yaml_or_toml_or_default", + return_value=Config(regions=None, vmtype="c1m2"), + ) + mock_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["apps", "scale", "--app-name", "random"]) + + assert result.exit_code == 1 + mock_error.assert_called_with( + "specify the type of scaling using --scale-type when using cloud.yml or pyproject.toml" + ) + + +def test_scale_regions_via_config_without_regions(mocker: MockFixture): + """Test error when scaletype is set to regions but regions is missing from config.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", + validated_data={"foo": "bar"}, + ), + ) + + mocker.patch("reflex_cli.core.config.Config.exists", return_value=True) + mocker.patch( + "reflex_cli.core.config.Config.from_yaml_or_toml_or_default", + return_value=Config(regions=None, vmtype="c1m2"), + ) + mock_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke( + hosting_cli, ["apps", "scale", "--app-name", "random", "--scale-type", "region"] + ) + + assert result.exit_code == 1 + mock_error.assert_called_with( + "'regions' should be provided in the cloud.yml for region scaling" + ) + + +def test_scale_size_via_config_without_vmtype(mocker: MockFixture): + """Test error when scaletype is set to size but vmtype is missing from config.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", + validated_data={"foo": "bar"}, + ), + ) + + mocker.patch("reflex_cli.core.config.Config.exists", return_value=True) + mocker.patch( + "reflex_cli.core.config.Config.from_yaml_or_toml_or_default", + return_value=Config(regions=None, vmtype=None), + ) + mock_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke( + hosting_cli, ["apps", "scale", "--app-name", "random", "--scale-type", "size"] + ) + + assert result.exit_code == 1 + mock_error.assert_called_with( + "'vmtype' should be provided in the cloud.yml for size scaling" + ) + + +@pytest.mark.parametrize( + ("config", "scale_params", "command_args"), + [ + ( + Config(vmtype="c1m1"), + hosting.ScaleParams( + type=hosting.ScaleType(hosting.ScaleType.SIZE), + vm_type="c1m1", + ), + ["--vmtype", "c1m1"], + ), + ( + Config(vmtype=None, regions={"ams": 1}), + hosting.ScaleParams( + type=hosting.ScaleType.REGION, + vm_type=None, + regions=(hosting.Region(name="ams", number_of_machines=1),), + ), + ["--regions", "ams"], + ), + ( + Config(vmtype=None, regions={"ams": 1, "sjc": 1}), + hosting.ScaleParams( + type=hosting.ScaleType.REGION, + vm_type=None, + regions=( + hosting.Region(name="ams", number_of_machines=1), + hosting.Region(name="sjc", number_of_machines=1), + ), + ), + ["--regions", "ams", "--regions", "sjc"], + ), + ], +) +def test_scale_correct_post_request_cli_args( + mocker: MockerFixture, + config: Config, + scale_params: hosting.ScaleParams, + command_args: list[str], +): + """Test the correct POST request is made with appropriate parameters.""" + mocker.patch("reflex_cli.core.config.Config.exists", return_value=False) + mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "fake-project", + }, + ) + mock_authenticated_client = mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=mock_authenticated_client, + ) + mocker.patch( + "reflex_cli.core.config.Config.from_yaml_or_toml_or_default", + return_value=config, + ) + mock_post = mocker.patch("reflex_cli.utils.hosting.scale_app") + + result = runner.invoke( + hosting_cli, ["apps", "scale", "--app-name", "random", *command_args] + ) + + assert result.exit_code == 0, result.output + mock_post.assert_called_with( + app_id="fake-id", scale_params=scale_params, client=mock_authenticated_client + ) + + +@pytest.mark.parametrize( + ("config", "scale_params", "command_args"), + [ + ( + Config(vmtype="c1m1", regions=None), + hosting.ScaleParams( + type=hosting.ScaleType(hosting.ScaleType.SIZE), + vm_type="c1m1", + ), + ["--vmtype", "c1m1"], + ), + ( + Config(vmtype=None, regions={"ams": 1}), + hosting.ScaleParams( + type=hosting.ScaleType.REGION, + vm_type=None, + regions=(hosting.Region(name="ams", number_of_machines=1),), + ), + ["--regions", "ams"], + ), + ], +) +def test_scale_correct_post_request_config( + mocker: MockerFixture, + config: Config, + scale_params: hosting.ScaleParams, + command_args: list[str], +): + """Test the correct POST request is made with appropriate parameters from config.""" + mocker.patch("reflex_cli.core.config.Config.exists", return_value=True) + mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "fake-project", + }, + ) + mock_authenticated_client = mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=mock_authenticated_client, + ) + mocker.patch( + "reflex_cli.core.config.Config.from_yaml_or_toml_or_default", + return_value=config, + ) + mock_post = mocker.patch("reflex_cli.utils.hosting.scale_app") + mocker.patch( + "reflex_cli.utils.hosting.ScaleParams.from_config", return_value=scale_params + ) + mock_scale_params = mocker.patch( + "reflex_cli.utils.hosting.ScaleParams.set_type_from_cli_args" + ) + + result = runner.invoke( + hosting_cli, ["apps", "scale", "--app-name", "random", *command_args] + ) + + assert result.exit_code == 0, result.output + mock_post.assert_called_with( + app_id="fake-id", + scale_params=mock_scale_params.return_value, + client=mock_authenticated_client, + ) diff --git a/tests/units/reflex_cli/v2/test_cli.py b/tests/units/reflex_cli/v2/test_cli.py new file mode 100644 index 00000000000..fbeba06841d --- /dev/null +++ b/tests/units/reflex_cli/v2/test_cli.py @@ -0,0 +1,649 @@ +from __future__ import annotations + +import importlib.metadata +from collections.abc import Callable +from unittest.mock import MagicMock + +import click +import httpx +import pytest +from packaging import version +from pytest_mock import MockerFixture, MockFixture +from reflex_cli.utils import hosting +from reflex_cli.v2 import cli + + +def test_login_success_existing_token(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.authenticated_token", + return_value=("fake-code", {}), + ) + mock_authenticate_on_browser = mocker.patch( + "reflex_cli.utils.hosting.authenticate_on_browser", + return_value=("fake-token", {}), + ) + cli.login() + mock_authenticate_on_browser.assert_not_called() + + +def test_login_success_on_browser(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.authenticated_token", + side_effect=[("", {}), ("fake-token", {})], + ) + + mock_authenticate_on_browser = mocker.patch( + "reflex_cli.utils.hosting.authenticate_on_browser", + return_value=("fake-token", {}), + ) + cli.login() + mock_authenticate_on_browser.assert_called_once() + + +def test_login_failure(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.authenticated_token", + return_value=("", {}), + ) + mock_authenticate_on_browser = mocker.patch( + "reflex_cli.utils.hosting.authenticate_on_browser", return_value=("", {}) + ) + with pytest.raises(SystemExit): + cli.login() + mock_authenticate_on_browser.assert_called_once() + + +def test_logout(mocker: MockFixture): + mock_delete_token = mocker.patch( + "reflex_cli.utils.hosting.delete_token_from_config", + ) + mock_success = mocker.patch( + "reflex_cli.utils.console.success", + ) + + cli.logout() + mock_delete_token.assert_called_once() + mock_success.assert_called_once_with("Successfully logged out.") + + +@pytest.fixture +def mock_export_fn(): + rx_version = version.parse(importlib.metadata.version("reflex")) + breaking_version = version.parse("0.7.6") + _mock_export_fn = ( + (lambda arg1, arg2, arg3, arg4, arg5, arg6: ...) + if rx_version <= breaking_version + else (lambda arg1, arg2, arg3, arg4, arg5, arg6, arg7: ...) + ) + + return MagicMock(side_effect=_mock_export_fn) + + +@pytest.fixture +def mock_export_import_error_fn(): + def _mock_export_fn( + arg1: str, arg2: str, arg3: str, arg4: bool, arg5: bool, arg6: bool + ) -> None: + raise ImportError + + return MagicMock(side_effect=_mock_export_fn) + + +def test_deploy_non_interactive_with_invalid_app_name(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", + return_value="fake-project", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_project", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + + with pytest.raises(click.exceptions.Exit): + cli.deploy(app_name="", export_fn=MagicMock(), interactive=False) + + +@pytest.mark.parametrize( + "hostname", + [{"error": "fake-error"}, {"hostname": "fake-hostname", "server": "fake-server"}], +) +def test_deploy_non_interactive_app_not_found( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], + hostname: dict[str, str], +): + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_deployment_args", + return_value="success", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", + return_value="fake-project", + ) + mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value=None, + ) + create_app = mocker.patch( + "reflex_cli.utils.hosting.create_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "fake-project", + }, + ) + + mocker.patch( + "reflex_cli.utils.hosting.get_hostname", + return_value=hostname, + ) + create_deployment = mocker.patch( + "reflex_cli.utils.hosting.create_deployment", + return_value={"error": "fake-error"}, + ) + watch_deployment = mocker.patch( + "reflex_cli.utils.hosting.watch_deployment_status", + return_value={"error": "fake-error"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_project", + ) + + if "error" in hostname: + with pytest.raises(click.exceptions.Exit): + cli.deploy(app_name="fake-app", export_fn=mock_export_fn, interactive=False) + create_app.assert_called_once() + return + + cli.deploy(app_name="fake-app", export_fn=mock_export_fn, interactive=False) + create_app.assert_called_once() + create_deployment.assert_called_once() + watch_deployment.assert_called_once() + + +def test_deploy_create_deployment_failure( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_deployment_args", + return_value="success", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", + return_value="fake-project", + ) + mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value={"name": "fake-app", "id": "fake-id"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_hostname", + return_value={"hostname": "fake-hostname", "server": "fake-server"}, + ) + create_deployment = mocker.patch( + "reflex_cli.utils.hosting.create_deployment", + return_value="deployment failed", + ) + watch_deployment = mocker.patch( + "reflex_cli.utils.hosting.watch_deployment_status", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_project", + ) + + with pytest.raises(click.exceptions.Exit): + cli.deploy(app_name="fake-app", export_fn=mock_export_fn, interactive=False) + create_deployment.assert_called_once() + watch_deployment.assert_not_called() + + +def test_deploy_non_interactive_project_name( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.get_project", + return_value={"name": "fake-project"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", + return_value="fake-project", + ) + mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "fake-project", + }, + ) + search_project = mocker.patch( + "reflex_cli.utils.hosting.search_project", + return_value={"name": "fake-project", "id": "fake-project-id"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_hostname", + return_value={"hostname": "fake-hostname", "server": "fake-server"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.read_config", + return_value={}, + ) + create_deployment = mocker.patch( + "reflex_cli.utils.hosting.create_deployment", + return_value={"deployment_id": "fake-deployment-id"}, + ) + watch_deployment = mocker.patch( + "reflex_cli.utils.hosting.watch_deployment_status", + return_value={"status": "ready"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_deployment_args", + return_value="success", + ) + + cli.deploy( + app_name="fake-app", + export_fn=mock_export_fn, + interactive=False, + project_name="fake-project", + ) + search_project.assert_called_once() + create_deployment.assert_called_once() + watch_deployment.assert_called_once() + + +def test_deploy_non_interactive_project_name_multiple_values( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.get_project", + return_value={"name": "fake-project"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", + return_value="fake-project", + ) + mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "fake-project", + }, + ) + mock_response = MagicMock() + mock_response.json.return_value = [ + { + "name": "fake-project", + "id": "fake-id", + }, + { + "name": "fake-project", + "id": "another-fake-id", + }, + ] + mocker.patch("httpx.get", return_value=mock_response) + + mocker.patch( + "reflex_cli.utils.hosting.get_hostname", + return_value={"hostname": "fake-hostname", "server": "fake-server"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.read_config", + return_value={}, + ) + mocker.patch( + "reflex_cli.utils.hosting.requires_authenticated", return_value="fake_token" + ) + console_error = mocker.patch("reflex_cli.utils.console.error") + + with pytest.raises(click.exceptions.Exit): + cli.deploy( + app_name="fake-app", + export_fn=mock_export_fn, + interactive=False, + project_name="fake-project", + ) + console_error.assert_called_once_with( + "Multiple projects with the name 'fake-project' found. Please provide a unique name." + ) + + +def test_deploy_interactive_project_name_multiple_values( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + get_project = mocker.patch( + "reflex_cli.utils.hosting.get_project", + return_value={"name": "fake-project"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", + return_value="fake-project", + ) + mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value={ + "name": "fake-app", + "id": "fake-id", + "project_id": "fake-project", + }, + ) + mock_response = MagicMock() + mock_response.json.return_value = [ + { + "name": "fake-project", + "id": "fake-id", + }, + { + "name": "fake-project", + "id": "another-fake-id", + }, + ] + mocker.patch("httpx.get", return_value=mock_response) + + mocker.patch( + "reflex_cli.utils.hosting.get_hostname", + return_value={"hostname": "fake-hostname", "server": "fake-server"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.read_config", + return_value={}, + ) + mocker.patch( + "reflex_cli.utils.hosting.requires_authenticated", return_value="fake_token" + ) + mocker.patch( + "reflex_cli.utils.hosting.create_deployment", + return_value={"deployment_id": "fake-deployment-id"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.watch_deployment_status", + return_value={"status": "ready"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_deployment_args", + return_value="success", + ) + console_ask = mocker.patch("reflex_cli.utils.console.ask", return_value="0") + + cli.deploy( + app_name="fake-app", export_fn=mock_export_fn, project_name="fake-project" + ) + console_ask.assert_called_once() + get_project.assert_called_once_with( + "fake-id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + + +@pytest.mark.parametrize( + "app_name, app_id", + [ + (None, None), + ("", ""), + ], +) +def test_deploy_non_interactive_no_app_name_and_id( + mocker: MockerFixture, app_name: str | None, app_id: str | None +): + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", + return_value="fake-project", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_project", + ) + console_error = mocker.patch("reflex_cli.utils.console.error") + + with pytest.raises(click.exceptions.Exit): + cli.deploy( + app_name=app_name, app_id=app_id, export_fn=MagicMock(), interactive=False + ) + + console_error.assert_called_once_with( + "Please provide a valid app name or ID for the deployed instance." + ) + + +def test_deploy_non_interactive_export_failure( + mocker: MockerFixture, mock_export_import_error_fn: MagicMock +): + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_deployment_args", + return_value="success", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", + return_value="fake-project", + ) + mocker.patch( + "reflex_cli.utils.hosting.search_app", + return_value={"name": "fake-app", "id": "fake-id"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_hostname", + return_value={"hostname": "fake-hostname", "server": "fake-server"}, + ) + create_deployment = mocker.patch( + "reflex_cli.utils.hosting.create_deployment", + return_value={"deployment_id": "fake-deployment-id"}, + ) + watch_deployment = mocker.patch( + "reflex_cli.utils.hosting.watch_deployment_status", + return_value={"status": "ready"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_project", + ) + + with pytest.raises(click.exceptions.Exit): + cli.deploy( + app_name="fake-app", + export_fn=mock_export_import_error_fn, + interactive=False, + ) + + create_deployment.assert_not_called() + watch_deployment.assert_not_called() + + +def test_deploy_non_interactive_with_invalid_project(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.get_project", + side_effect=httpx.HTTPStatusError( + "HTTP Error", + request=mocker.Mock(), + response=mocker.Mock(json=lambda: {"detail": "project does not exist"}), + ), + ) + mock_error = mocker.patch( + "reflex_cli.utils.console.error", + ) + with pytest.raises(click.exceptions.Exit): + cli.deploy( + app_name="app-name", + export_fn=MagicMock(), + project="fake-project", + interactive=False, + ) + + mock_error.assert_called_with("project does not exist") + + +def test_deploy_create_deployment_multiple_apps_non_interactive( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", + return_value="fake-project", + ) + mock_response = MagicMock() + mock_response.json.return_value = [ + {"name": "fake-app", "id": "fake-id"}, + {"name": "fake-app", "id": "another-fake-id"}, + ] + mocker.patch("httpx.get", return_value=mock_response) + + mocker.patch( + "reflex_cli.utils.hosting.get_hostname", + return_value={"hostname": "fake-hostname", "server": "fake-server"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_project", + ) + console_error = mocker.patch("reflex_cli.utils.console.error") + mocker.patch( + "reflex_cli.utils.hosting.authenticated_token", + return_value=("fake-code", {}), + ) + mocker.patch( + "reflex_cli.utils.hosting.authenticate_on_browser", + return_value=("fake-token", {}), + ) + + with pytest.raises(click.exceptions.Exit): + cli.deploy( + app_name="fake-app", + export_fn=mock_export_fn, + interactive=False, + token="fake-token", + ) + console_error.assert_called_once_with( + "Multiple apps with the name 'fake-app' found. Please provide a unique name." + ) + + +def test_deploy_create_deployment_multiple_apps_interactive( + mocker: MockerFixture, + mock_export_fn: Callable[[str, str, str, bool, bool, bool, bool], None], +): + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", + return_value="fake-project", + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_deployment_args", + return_value="success", + ) + mock_response = MagicMock() + mock_response.json.return_value = [ + { + "name": "fake-app", + "id": "fake-id", + "project_id": "fake-project", + "project": {"name": "fake-project", "id": "fake-project-id"}, + }, + { + "name": "fake-app", + "id": "another-fake-id", + "project_id": "another-fake-project", + "project": { + "name": "another-fake-project", + "id": "another-fake-project-id", + }, + }, + ] + mocker.patch("httpx.get", return_value=mock_response) + + get_host_name = mocker.patch( + "reflex_cli.utils.hosting.get_hostname", + return_value={"hostname": "fake-hostname", "server": "fake-server"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_project", + ) + mocker.patch( + "reflex_cli.utils.hosting.create_deployment", + return_value={"deployment_id": "fake-deployment-id"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.watch_deployment_status", + return_value={"status": "ready"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.authenticated_token", + return_value=("fake-code", {}), + ) + mocker.patch( + "reflex_cli.utils.hosting.authenticate_on_browser", + return_value=("fake-token", {}), + ) + mocker.patch("reflex_cli.utils.console.print") + console_ask = mocker.patch("reflex_cli.utils.console.ask", return_value="0") + + cli.deploy(app_name="fake-app", export_fn=mock_export_fn, interactive=True) + console_ask.assert_called_once() + get_host_name.assert_called_once_with( + app_id="fake-id", + app_name="fake-app", + hostname=None, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) diff --git a/tests/units/reflex_cli/v2/test_deployments.py b/tests/units/reflex_cli/v2/test_deployments.py new file mode 100644 index 00000000000..48d0a544b89 --- /dev/null +++ b/tests/units/reflex_cli/v2/test_deployments.py @@ -0,0 +1,69 @@ +import importlib.metadata +from unittest.mock import MagicMock + +import click +import httpx +import pytest +from pytest_mock import MockerFixture, MockFixture +from reflex_cli.v2.deployments import check_version + + +@pytest.mark.parametrize( + "installed_version, latest_version, should_exit", + [ + ("1.0.0", "1.0.0", False), + ("1.0.0", "2.0.0", True), + ], +) +def test_check_version( + mocker: MockerFixture, + installed_version: str, + latest_version: str, + should_exit: bool, +): + mocker.patch( + "importlib.metadata.version", + return_value=installed_version, + ) + mock_response = MagicMock() + mock_response.json.return_value = {"info": {"version": latest_version}} + mocker.patch("httpx.get", return_value=mock_response) + mock_console_error = mocker.patch("reflex_cli.utils.console.error") + + if should_exit: + with pytest.raises(click.exceptions.Exit) as excinfo: + check_version() + assert excinfo.value.exit_code == 1 + mock_console_error.assert_called_once_with( + "Warning: You are using reflex-hosting-cli version 1.0.0. A newer version 2.0.0 is available. Upgrade using: pip install --upgrade reflex-hosting-cli" + ) + else: + check_version() + mock_console_error.assert_not_called() + + +def test_check_version_distribution_not_found(mocker: MockFixture): + mocker.patch( + "importlib.metadata.version", + side_effect=importlib.metadata.PackageNotFoundError, + ) + mock_httpx_get = mocker.patch("httpx.get") + + check_version() + mock_httpx_get.assert_not_called() + + +def test_check_version_request_exception(mocker: MockFixture): + mocker.patch("importlib.metadata.version", return_value=MagicMock(version="1.0.0")) + mocker.patch("httpx.get", side_effect=httpx.RequestError("Request failed")) + check_version() + + +def test_check_version_http_status_error(mocker: MockFixture): + mocker.patch("importlib.metadata.version", return_value=MagicMock(version="1.0.0")) + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "HTTP error", request=MagicMock(), response=MagicMock() + ) + mocker.patch("httpx.get", return_value=mock_response) + check_version() diff --git a/tests/units/reflex_cli/v2/test_project.py b/tests/units/reflex_cli/v2/test_project.py new file mode 100644 index 00000000000..81e494cade0 --- /dev/null +++ b/tests/units/reflex_cli/v2/test_project.py @@ -0,0 +1,831 @@ +import json + +import httpx +from click.testing import CliRunner +from pytest_mock import MockFixture +from reflex_cli.utils import hosting +from reflex_cli.utils.exceptions import NotAuthenticatedError +from reflex_cli.v2.deployments import hosting_cli +from typer import Typer +from typer.main import get_command + +hosting_cli = ( + get_command(hosting_cli) if isinstance(hosting_cli, Typer) else hosting_cli +) + +runner = CliRunner() + + +def test_create_project_with_valid_token(mocker: MockFixture): + mock_create_project = mocker.patch( + "reflex_cli.utils.hosting.create_project", + return_value={"name": "test_project", "id": 1}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="valid_token", validated_data={"foo": "bar"} + ), + ) + mock_print_table = mocker.patch("reflex_cli.utils.console.print_table") + + project_name = "test_project" + token = "valid_token" + + args = ["project", "create", project_name, "--token", token] + + result = runner.invoke(hosting_cli, args) + + mock_create_project.assert_called_once_with( + name=project_name, + client=hosting.AuthenticatedClient( + token="valid_token", validated_data={"foo": "bar"} + ), + ) + + headers = list({"name": "test_project", "id": 1}.keys()) + table = [ + [ + str(value) if value is not None else "" + for value in {"name": "test_project", "id": 1}.values() + ] + ] + mock_print_table.assert_called_once_with(table, headers=headers) + + # Asserting the result's exit code is 0 (indicating success) + assert result.exit_code == 0, result.output + + +def test_create_project_with_json_output(mocker: MockFixture): + mock_create_project = mocker.patch( + "reflex_cli.utils.hosting.create_project", + return_value={"name": "test_project", "id": 1}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="valid_token", validated_data={"foo": "bar"} + ), + ) + mock_print = mocker.patch("reflex_cli.utils.console.print") + + project_name = "test_project" + token = "valid_token" + + args = ["project", "create", project_name, "--token", token, "--json"] + + result = runner.invoke(hosting_cli, args) + + mock_create_project.assert_called_once_with( + name=project_name, + client=hosting.AuthenticatedClient( + token="valid_token", validated_data={"foo": "bar"} + ), + ) + + mock_print.assert_called_once_with(json.dumps({"name": "test_project", "id": 1})) + + assert result.exit_code == 0, result.output + + +def test_create_project_without_token(mocker: MockFixture): + mock_create_project = mocker.patch( + "reflex_cli.utils.hosting.create_project", return_value=None + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_print = mocker.patch("reflex_cli.utils.console.print") + + project_name = "test_project" + + args = ["project", "create", project_name] + + result = runner.invoke(hosting_cli, args) + + mock_create_project.assert_called_once_with( + name=project_name, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_print.assert_called_once_with(str(None)) + assert result.exit_code == 0, result.output + + +def test_invite_user_to_project_success(mocker: MockFixture): + mock_invite = mocker.patch( + "reflex_cli.utils.hosting.invite_user_to_project", return_value="success" + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="valid_token", validated_data={"foo": "bar"} + ), + ) + mock_success = mocker.patch("reflex_cli.utils.console.success") + mock_error = mocker.patch("reflex_cli.utils.console.error") + + role = "admin" + user = "user123" + token = "valid_token" + + args = ["project", "invite", role, user, "--token", token] + + result = runner.invoke(hosting_cli, args) + + mock_invite.assert_called_once_with( + role_id=role, + user_id=user, + client=hosting.AuthenticatedClient( + token="valid_token", validated_data={"foo": "bar"} + ), + ) + + mock_success.assert_called_once_with("Successfully invited user to project.") + mock_error.assert_not_called() + + assert result.exit_code == 0, result.output + + +def test_invite_user_to_project_failure(mocker: MockFixture): + mock_invite = mocker.patch( + "reflex_cli.utils.hosting.invite_user_to_project", + return_value="user invite failed: Unauthorized", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_success = mocker.patch("reflex_cli.utils.console.success") + mock_error = mocker.patch("reflex_cli.utils.console.error") + + role = "admin" + user = "user123" + + args = ["project", "invite", role, user] + + result = runner.invoke(hosting_cli, args) + + mock_invite.assert_called_once_with( + role_id=role, + user_id=user, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + + mock_error.assert_called_once_with( + "Unable to invite user to project: user invite failed: Unauthorized" + ) + mock_success.assert_not_called() + + assert result.exit_code == 1 + + +def test_invite_user_to_project_missing_token(mocker: MockFixture): + mock_invite = mocker.patch( + "reflex_cli.utils.hosting.invite_user_to_project", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_success = mocker.patch("reflex_cli.utils.console.success") + mock_error = mocker.patch("reflex_cli.utils.console.error") + + role = "admin" + user = "user123" + + args = ["project", "invite", role, user] + + result = runner.invoke(hosting_cli, args) + + mock_invite.assert_called_once_with( + role_id=role, + user_id=user, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_success.assert_called_once_with("Successfully invited user to project.") + mock_error.assert_not_called() + assert result.exit_code == 0, result.output + + +def test_select_project_success(mocker: MockFixture): + """Test successful project selection.""" + mock_select_project = mocker.patch( + "reflex_cli.utils.hosting.select_project", + return_value="TestProject is now selected.", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch("reflex_cli.utils.hosting.get_project") + mock_success = mocker.patch("reflex_cli.utils.console.success") + mock_error = mocker.patch("reflex_cli.utils.console.error") + + project = "TestProject" + + args = ["project", "select", project] + + result = runner.invoke(hosting_cli, args) + + mock_select_project.assert_called_once_with(project=project, token=None) + + mock_success.assert_called_once_with("TestProject is now selected.") + mock_error.assert_not_called() + + assert result.exit_code == 0, result.output + + +def test_select_project_failure(mocker: MockFixture): + """Test failure during project selection.""" + mock_select_project = mocker.patch( + "reflex_cli.utils.hosting.select_project", + return_value="failed to select project.", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_success = mocker.patch("reflex_cli.utils.console.success") + mock_error = mocker.patch("reflex_cli.utils.console.error") + get_project = mocker.patch("reflex_cli.utils.hosting.get_project") + mock_response = mocker.Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "HTTP Error", + request=mocker.Mock(), + response=mocker.Mock( + json=lambda: { + "detail": "no project with given id found that user has access to." + } + ), + ) + get_project.return_value = mock_response + + project = "InvalidProject" + + args = ["project", "select", project] + + result = runner.invoke(hosting_cli, args) + + mock_select_project.assert_called_once_with(project=project, token=None) + + mock_error.assert_called_once_with("failed to select project.") + mock_success.assert_not_called() + + assert result.exit_code == 1 + + +def test_select_project_valid_project_name(mocker: MockFixture): + """Test successful project selection using project name.""" + mock_select_project = mocker.patch( + "reflex_cli.utils.hosting.select_project", + return_value="test_project_id is now selected.", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.search_project", + return_value={"id": "test_project_id"}, + ) + get_project = mocker.patch( + "reflex_cli.utils.hosting.get_project", + ) + mock_success = mocker.patch("reflex_cli.utils.console.success") + mock_error = mocker.patch("reflex_cli.utils.console.error") + mocker.patch( + "reflex_cli.utils.hosting.requires_authenticated", return_value="fake_token" + ) + token = "test_token" + + args = ["project", "select", "--project-name", "TestProject", "--token", token] + + result = runner.invoke(hosting_cli, args) + + mock_select_project.assert_called_once_with(project="test_project_id", token=token) + + mock_success.assert_called_once_with("test_project_id is now selected.") + mock_error.assert_not_called() + get_project.assert_not_called() + + assert result.exit_code == 0, result.output + + +def test_select_project_invalid_id(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get = mocker.patch("httpx.get") + mock_response = mocker.Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "HTTP Error", + request=mocker.Mock(), + response=mocker.Mock( + json=lambda: { + "detail": "no project with given id found that user has access to." + } + ), + ) + mock_get.return_value = mock_response + mocker.patch( + "reflex_cli.utils.hosting.requires_authenticated", return_value="fake_token" + ) + mocker.patch( + "reflex_cli.utils.hosting.authorization_header", + return_value={"X-API-TOKEN": "fake_token"}, + ) + + mock_error = mocker.patch("reflex_cli.utils.console.error") + + project = "InvalidProject" + + args = ["project", "select", project] + + result = runner.invoke(hosting_cli, args) + + mock_error.assert_called_once_with( + "no project with given id found that user has access to." + ) + assert result.exit_code == 1 + + +def test_select_project_invalid_project_name(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.requires_authenticated", return_value="fake_token" + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mocker.patch( + "reflex_cli.utils.hosting.authorization_header", + return_value={"X-API-TOKEN": "fake_token"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.search_project", + return_value=None, + ) + + mock_error = mocker.patch("reflex_cli.utils.console.error") + + args = [ + "project", + "select", + "--project-name", + "invalid_project", + "--token", + "test_token", + ] + + result = runner.invoke(hosting_cli, args) + + mock_error.assert_called_once_with( + "No project selected. Please provide a valid project ID or name." + ) + assert result.exit_code == 1 + + +def test_get_project_roles_with_project_id(mocker: MockFixture): + """Test retrieving project roles with a provided project ID.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_project_roles = mocker.patch( + "reflex_cli.utils.hosting.get_project_roles", + return_value=[ + {"role": "admin", "user": "user1@example.com"}, + {"role": "viewer", "user": "user2@example.com"}, + ], + ) + mock_console_print_table = mocker.patch("reflex_cli.utils.console.print_table") + + result = runner.invoke( + hosting_cli, + ["project", "roles", "--project-id", "test_project_id"], + ) + + assert result.exit_code == 0, result.output + mock_get_project_roles.assert_called_once_with( + project_id="test_project_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print_table.assert_called_once() + + +def test_get_project_roles_no_project_selected(mocker: MockFixture): + """Test retrieving project roles when no project ID is provided or selected.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_selected_project = mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", return_value=None + ) + mock_console_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["project", "roles"]) + + assert result.exit_code == 1 + mock_get_selected_project.assert_called_once() + mock_console_error.assert_called_once_with( + "no project_id provided or selected. Set it with `reflex cloud project roles --project-id \\[project_id]`" + ) + + +def test_get_project_roles_as_json(mocker: MockFixture): + """Test retrieving project roles with JSON output.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_project_roles = mocker.patch( + "reflex_cli.utils.hosting.get_project_roles", + return_value=[ + {"role": "admin", "user": "user1@example.com"}, + {"role": "viewer", "user": "user2@example.com"}, + ], + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke( + hosting_cli, + ["project", "roles", "--project-id", "test_project_id", "--json"], + ) + + assert result.exit_code == 0, result.output + mock_get_project_roles.assert_called_once_with( + project_id="test_project_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print.assert_called_once_with( + json.dumps([ + {"role": "admin", "user": "user1@example.com"}, + {"role": "viewer", "user": "user2@example.com"}, + ]) + ) + + +def test_get_project_roles_empty_roles(mocker: MockFixture): + """Test retrieving project roles when the result is an empty list.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_project_roles = mocker.patch( + "reflex_cli.utils.hosting.get_project_roles", + return_value=[], + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke( + hosting_cli, + ["project", "roles", "--project-id", "test_project_id"], + ) + + assert result.exit_code == 0, result.output + mock_get_project_roles.assert_called_once_with( + project_id="test_project_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print.assert_called_once_with("[]") + + +def test_get_project_role_permissions_with_role_and_project_id(mocker: MockFixture): + """Test retrieving role permissions with provided role_id and project_id.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_project_role_permissions = mocker.patch( + "reflex_cli.utils.hosting.get_project_role_permissions", + return_value=[ + {"permission": "read", "resource": "resource1"}, + {"permission": "write", "resource": "resource2"}, + ], + ) + mock_console_print_table = mocker.patch("reflex_cli.utils.console.print_table") + + result = runner.invoke( + hosting_cli, + [ + "project", + "role-permissions", + "test_role_id", + "--project-id", + "test_project_id", + ], + ) + + assert result.exit_code == 0, result.output + mock_get_project_role_permissions.assert_called_once_with( + project_id="test_project_id", + role_id="test_role_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print_table.assert_called_once() + + +def test_get_project_role_permissions_no_project_selected(mocker: MockFixture): + """Test retrieving role permissions when no project_id is provided or selected.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_selected_project = mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", return_value=None + ) + mock_console_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["project", "role-permissions", "test_role_id"]) + + assert result.exit_code == 1 + mock_get_selected_project.assert_called_once() + mock_console_error.assert_called_once_with( + "no project_id provided or selected. Set it with `reflex cloud project role-permissions --project-id \\[project_id]`." + ) + + +def test_get_project_role_permissions_not_authenticated(mocker: MockFixture): + """Test retrieving role permissions when the user is not authenticated.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_project_role_permissions = mocker.patch( + "reflex_cli.utils.hosting.get_project_role_permissions", + side_effect=NotAuthenticatedError("not authenticated"), + ) + mock_get_selected_project = mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", + return_value="test_project_id", + ) + mock_console_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["project", "role-permissions", "test_role_id"]) + + assert result.exit_code == 1 + mock_get_selected_project.assert_called_once() + mock_get_project_role_permissions.assert_called_once_with( + project_id="test_project_id", + role_id="test_role_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_error.assert_called_once_with( + "You are not authenticated. Run `reflex login` to authenticate." + ) + + +def test_get_project_role_permissions_as_json(mocker: MockFixture): + """Test retrieving role permissions with JSON output.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_project_role_permissions = mocker.patch( + "reflex_cli.utils.hosting.get_project_role_permissions", + return_value=[ + {"permission": "read", "resource": "resource1"}, + {"permission": "write", "resource": "resource2"}, + ], + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke( + hosting_cli, + [ + "project", + "role-permissions", + "test_role_id", + "--project-id", + "test_project_id", + "--json", + ], + ) + + assert result.exit_code == 0, result.output + mock_get_project_role_permissions.assert_called_once_with( + project_id="test_project_id", + role_id="test_role_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print.assert_called_once_with( + json.dumps([ + {"permission": "read", "resource": "resource1"}, + {"permission": "write", "resource": "resource2"}, + ]) + ) + + +def test_get_project_role_permissions_empty_permissions(mocker: MockFixture): + """Test retrieving role permissions when the result is an empty list.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_project_role_permissions = mocker.patch( + "reflex_cli.utils.hosting.get_project_role_permissions", + return_value=[], + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke( + hosting_cli, + [ + "project", + "role-permissions", + "test_role_id", + "--project-id", + "test_project_id", + ], + ) + + assert result.exit_code == 0, result.output + mock_get_project_role_permissions.assert_called_once_with( + project_id="test_project_id", + role_id="test_role_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print.assert_called_once_with("[]") + + +def test_get_project_role_users_with_project_id(mocker: MockFixture): + """Test retrieving users with a provided project_id.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_project_role_users = mocker.patch( + "reflex_cli.utils.hosting.get_project_role_users", + return_value=[ + {"user_id": "user1", "role": "admin"}, + {"user_id": "user2", "role": "developer"}, + ], + ) + mock_console_print_table = mocker.patch("reflex_cli.utils.console.print_table") + + result = runner.invoke( + hosting_cli, + ["project", "users", "--project-id", "test_project_id"], + ) + + assert result.exit_code == 0, result.output + mock_get_project_role_users.assert_called_once_with( + project_id="test_project_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print_table.assert_called_once() + + +def test_get_project_role_users_no_project_selected(mocker: MockFixture): + """Test retrieving users when no project_id is provided or selected.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_selected_project = mocker.patch( + "reflex_cli.utils.hosting.get_selected_project", return_value=None + ) + mock_console_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["project", "users"]) + + assert result.exit_code == 1 + mock_get_selected_project.assert_called_once() + mock_console_error.assert_called_once_with( + "no project_id provided or selected. Set it with `reflex cloud project users --project-id \\[project_id]`" + ) + + +def test_get_project_role_users_as_json(mocker: MockFixture): + """Test retrieving users with JSON output.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_project_role_users = mocker.patch( + "reflex_cli.utils.hosting.get_project_role_users", + return_value=[ + {"user_id": "user1", "role": "admin"}, + {"user_id": "user2", "role": "developer"}, + ], + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke( + hosting_cli, + [ + "project", + "users", + "--project-id", + "test_project_id", + "--json", + ], + ) + + assert result.exit_code == 0, result.output + mock_get_project_role_users.assert_called_once_with( + project_id="test_project_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print.assert_called_once_with( + json.dumps([ + {"user_id": "user1", "role": "admin"}, + {"user_id": "user2", "role": "developer"}, + ]) + ) + + +def test_get_project_role_users_empty_users(mocker: MockFixture): + """Test retrieving users when the result is an empty list.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_get_project_role_users = mocker.patch( + "reflex_cli.utils.hosting.get_project_role_users", + return_value=[], + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke( + hosting_cli, + ["project", "users", "--project-id", "test_project_id"], + ) + + assert result.exit_code == 0, result.output + mock_get_project_role_users.assert_called_once_with( + project_id="test_project_id", + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print.assert_called_once_with("[]") diff --git a/tests/units/reflex_cli/v2/test_secrets.py b/tests/units/reflex_cli/v2/test_secrets.py new file mode 100644 index 00000000000..0ee199b698f --- /dev/null +++ b/tests/units/reflex_cli/v2/test_secrets.py @@ -0,0 +1,275 @@ +import tempfile +from pathlib import Path + +from click.testing import CliRunner +from pytest_mock import MockFixture +from reflex_cli.utils import hosting +from reflex_cli.v2.deployments import hosting_cli +from typer import Typer +from typer.main import get_command + +hosting_cli = ( + get_command(hosting_cli) if isinstance(hosting_cli, Typer) else hosting_cli +) + +runner = CliRunner() + + +def test_get_secrets_success(mocker: MockFixture): + """Test successful retrieval of secrets.""" + mock_get_secrets = mocker.patch( + "reflex_cli.utils.hosting.get_secrets", + return_value={"secret_key_1": "value1", "secret_key_2": "value2"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print_table = mocker.patch("reflex_cli.utils.console.print_table") + + app_id = "app_id" + + args = ["secrets", "list", app_id] + + result = runner.invoke(hosting_cli, args) + + mock_get_secrets.assert_called_once_with( + app_id=app_id, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + + mock_console_print_table.assert_called_once_with( + [ + ["secret_key_1"], + ["secret_key_2"], + ], + headers=["Keys"], + ) + + assert result.exit_code == 0, result.output + + +def test_get_secrets_error(mocker: MockFixture): + """Test failure to retrieve secrets.""" + mock_get_secrets = mocker.patch( + "reflex_cli.utils.hosting.get_secrets", + return_value="failed to retrieve secrets.", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_error = mocker.patch("reflex_cli.utils.console.error") + + app_id = "app_id" + + args = ["secrets", "list", app_id] + result = runner.invoke(hosting_cli, args) + + mock_get_secrets.assert_called_once_with( + app_id=app_id, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + + mock_console_error.assert_called_once_with("failed to retrieve secrets.") + + assert result.exit_code == 1 + + +def test_get_secrets_json_output(mocker: MockFixture): + """Test JSON output for secrets.""" + mock_get_secrets = mocker.patch( + "reflex_cli.utils.hosting.get_secrets", + return_value={"secret_key_1": "value1", "secret_key_2": "value2"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + app_id = "app_id" + + args = ["secrets", "list", app_id, "--json"] + + result = runner.invoke(hosting_cli, args) + + mock_get_secrets.assert_called_once_with( + app_id=app_id, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_print.assert_called_once_with({ + "secret_key_1": "value1", + "secret_key_2": "value2", + }) + assert result.exit_code == 0, result.output + + +def test_delete_secret_success(mocker: MockFixture): + """Test successful deletion of a secret.""" + mock_delete_secret = mocker.patch( + "reflex_cli.utils.hosting.delete_secret", + return_value="Successfully deleted secret.", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_success = mocker.patch("reflex_cli.utils.console.success") + + result = runner.invoke( + hosting_cli, + ["secrets", "delete", "app_id", "key", "--reboot"], + ) + + assert result.exit_code == 0, result.output + mock_delete_secret.assert_called_once_with( + app_id="app_id", + key="key", + reboot=True, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_success.assert_called_once_with("Successfully deleted secret.") + + +def test_delete_secret_failure(mocker: MockFixture): + """Test failure to delete a secret.""" + mock_delete_secret = mocker.patch( + "reflex_cli.utils.hosting.delete_secret", + return_value="failed to delete secret.", + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke( + hosting_cli, + ["secrets", "delete", "app_id", "key", "--reboot"], + ) + + assert result.exit_code == 1 + mock_delete_secret.assert_called_once_with( + app_id="app_id", + key="key", + reboot=True, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_error.assert_called_once_with("failed to delete secret.") + + +def test_update_secrets_with_envfile(mocker: MockFixture): + """Test updating secrets with an envfile.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + + with tempfile.TemporaryDirectory() as tmpdir: + env_path = Path(tmpdir) / ".env" + env_content = "key1=value1\nkey2=value2" + env_path.write_text(env_content) + + mocker.patch("reflex_cli.utils.hosting.update_secrets") + mocker.patch("reflex_cli.utils.console.warn") + + result = runner.invoke( + hosting_cli, + [ + "secrets", + "update", + "app_id", + "--envfile", + str(env_path), + "--env", + "key3=value3", + ], + ) + + assert result.exit_code == 0, result.output + + +def test_update_secrets_with_envs(mocker: MockFixture): + """Test updating secrets with --env arguments.""" + mock_process_envs = mocker.patch( + "reflex_cli.utils.hosting.process_envs", + return_value={"key1": "value1", "key2": "value2"}, + ) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_update_secrets = mocker.patch("reflex_cli.utils.hosting.update_secrets") + + result = runner.invoke( + hosting_cli, + ["secrets", "update", "app_id", "--env", "key1=value1", "--env", "key2=value2"], + ) + + assert result.exit_code == 0, result.output + mock_process_envs.assert_called_once_with(["key1=value1", "key2=value2"]) + mock_update_secrets.assert_called_once_with( + app_id="app_id", + secrets={"key1": "value1", "key2": "value2"}, + reboot=False, + client=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + + +def test_update_secrets_missing_arguments(mocker: MockFixture): + """Test updating secrets with neither --envfile nor --env.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + mock_console_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["secrets", "update", "app_id"]) + + assert result.exit_code == 1 + mock_console_error.assert_called_once_with("--envfile or --env must be provided") + + +def test_update_secrets_invalid_env_format(mocker: MockFixture): + """Test invalid format for --env.""" + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + return_value=hosting.AuthenticatedClient( + token="fake-token", validated_data={"foo": "bar"} + ), + ) + result = runner.invoke( + hosting_cli, ["secrets", "update", "app_id", "--env", "invalid_env"] + ) + + assert result.exit_code == 1 + assert "Invalid env format: should be =." in result.stdout diff --git a/tests/units/reflex_cli/v2/test_vmtypes_regions.py b/tests/units/reflex_cli/v2/test_vmtypes_regions.py new file mode 100644 index 00000000000..b3231cc84ef --- /dev/null +++ b/tests/units/reflex_cli/v2/test_vmtypes_regions.py @@ -0,0 +1,217 @@ +import json + +import httpx +import pytest +from click.testing import CliRunner +from pytest_mock import MockerFixture, MockFixture +from reflex_cli.v2.deployments import hosting_cli +from typer import Typer +from typer.main import get_command + +hosting_cli = ( + get_command(hosting_cli) if isinstance(hosting_cli, Typer) else hosting_cli +) + + +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( + "reflex_cli.utils.hosting.get_vm_types", + return_value=[ + {"id": "1", "name": "Small", "cpu": 2, "ram": 4}, + {"id": "2", "name": "Medium", "cpu": 4, "ram": 8}, + ], + ) + mock_console_print_table = mocker.patch("reflex_cli.utils.console.print_table") + + result = runner.invoke(hosting_cli, ["vmtypes"]) + + assert result.exit_code == 0, result.output + mock_get_vm_types.assert_called_once() + mock_console_print_table.assert_called_once_with( + [ + ["1", "Small", "2", "4"], + ["2", "Medium", "4", "8"], + ], + headers=["id", "name", "cpu (cores)", "ram (gb)"], + ) + + +def test_get_vm_types_as_json(mocker: MockFixture): + """Test retrieval of VM types with JSON output.""" + mock_get_vm_types = mocker.patch( + "reflex_cli.utils.hosting.get_vm_types", + return_value=[ + {"id": "1", "name": "Small", "cpu": 2, "ram": 4}, + {"id": "2", "name": "Medium", "cpu": 4, "ram": 8}, + ], + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke(hosting_cli, ["vmtypes", "--json"]) + + assert result.exit_code == 0, result.output + mock_get_vm_types.assert_called_once() + mock_console_print.assert_called_once_with( + '[{"id": "1", "name": "Small", "cpu": 2, "ram": 4}, {"id": "2", "name": "Medium", "cpu": 4, "ram": 8}]' + ) + + +def test_get_vm_types_empty(mocker: MockFixture): + """Test retrieval when no VM types are available.""" + mock_get_vm_types = mocker.patch( + "reflex_cli.utils.hosting.get_vm_types", return_value=[] + ) + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke(hosting_cli, ["vmtypes"]) + + assert result.exit_code == 0, result.output + mock_get_vm_types.assert_called_once() + mock_console_print.assert_called_once_with("[]") + + +def test_get_vm_types_invalid_response(mocker: MockFixture): + """Test handling of invalid server response.""" + mock_get_vm_types = mocker.patch( + "reflex_cli.utils.hosting.get_vm_types", + return_value=[{"invalid_key": "value"}], + ) + mock_console_print_table = mocker.patch("reflex_cli.utils.console.print_table") + + result = runner.invoke(hosting_cli, ["vmtypes"]) + + assert result.exit_code == 0, result.output + mock_get_vm_types.assert_called_once() + # Expect the invalid key will not match the displayed table + mock_console_print_table.assert_called_once_with( + [[]], headers=["id", "name", "cpu (cores)", "ram (gb)"] + ) + + +def test_get_vm_types_http_error(mocker: MockFixture): + """Test handling of an HTTP error.""" + mock_get = mocker.patch("httpx.get") + mock_response = mocker.Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "HTTP Error", + request=mocker.Mock(), + response=mocker.Mock(json=lambda: {"detail": "Invalid token"}), + ) + mock_get.return_value = mock_response + mocker.patch("reflex_cli.utils.console.error") + mocker.patch( + "reflex_cli.utils.hosting.requires_authenticated", return_value="fake_token" + ) + mocker.patch("reflex_cli.utils.hosting.get_app", return_value={"id": "fake_app_id"}) + mocker.patch( + "reflex_cli.utils.hosting.authorization_header", + return_value={"X-API-TOKEN": "fake_token"}, + ) + + mock_console_error = mocker.patch("reflex_cli.utils.console.error") + mock_console_print = mocker.patch("reflex_cli.utils.console.print") + result = runner.invoke(hosting_cli, ["vmtypes"]) + + assert result.exit_code == 0, result.output + mock_console_error.assert_called_once_with( + "Unable to get vmtypes due to HTTP Error." + ) + mock_console_print.assert_called_once_with("[]") + + +def test_get_deployment_regions_success(mocker: MockerFixture): + """Test successful retrieval of regions with table output.""" + mock_get_regions = mocker.patch( + "reflex_cli.utils.hosting.get_regions", + return_value=[ + {"name": "Amsterdam, Netherlands", "code": "ams"}, + {"name": "Stockholm, Sweden", "code": "arn"}, + ], + ) + mock_print_table = mocker.patch("reflex_cli.utils.console.print_table") + + result = runner.invoke(hosting_cli, ["regions"]) + + assert result.exit_code == 0, result.output + mock_get_regions.assert_called_once() + mock_print_table.assert_called_once_with( + [["Amsterdam, Netherlands", "ams"], ["Stockholm, Sweden", "arn"]], + headers=["name", "code"], + ) + + +def test_get_deployment_regions_as_json(mocker: MockFixture): + """Test successful retrieval of regions with JSON output.""" + mock_get_regions = mocker.patch( + "reflex_cli.utils.hosting.get_regions", + return_value=[ + {"name": "Amsterdam, Netherlands", "code": "ams"}, + {"name": "Stockholm, Sweden", "code": "arn"}, + ], + ) + mock_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke(hosting_cli, ["regions", "--json"]) + + assert result.exit_code == 0, result.output + mock_get_regions.assert_called_once() + mock_print.assert_called_once_with( + json.dumps([ + {"name": "Amsterdam, Netherlands", "code": "ams"}, + {"name": "Stockholm, Sweden", "code": "arn"}, + ]) + ) + + +def test_get_deployment_regions_empty(mocker: MockFixture): + """Test retrieval when no regions are available.""" + mock_get_regions = mocker.patch( + "reflex_cli.utils.hosting.get_regions", + return_value=[], + ) + mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke(hosting_cli, ["regions"]) + + assert result.exit_code == 0, result.output + mock_get_regions.assert_called_once() + + +def test_get_deployment_regions_http_error(mocker: MockerFixture): + """Test handling of an HTTP error.""" + mock_get = mocker.patch("httpx.get") + mock_response = mocker.Mock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "HTTP Error", + request=mocker.Mock(), + response=mocker.Mock(json=lambda: {"detail": "Invalid token"}), + ) + mock_get.return_value = mock_response + mock_error = mocker.patch("reflex_cli.utils.console.error") + mocker.patch( + "reflex_cli.utils.hosting.requires_authenticated", return_value="fake_token" + ) + mocker.patch("reflex_cli.utils.hosting.get_app", return_value={"id": "fake_app_id"}) + mocker.patch( + "reflex_cli.utils.hosting.authorization_header", + return_value={"X-API-TOKEN": "fake_token"}, + ) + + mock_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["regions"]) + + assert result.exit_code == 0, result.output + mock_error.assert_called_once_with("Unable to get regions due to HTTP Error.") diff --git a/uv.lock b/uv.lock index 819f50d25b4..7cc4a581380 100644 --- a/uv.lock +++ b/uv.lock @@ -31,6 +31,7 @@ members = [ "reflex-components-sonner", "reflex-docgen", "reflex-docs-app", + "reflex-hosting-cli", "reflex-integrations-docs", "reflex-site-shared", ] @@ -3488,6 +3489,7 @@ dev = [ { name = "sqlmodel" }, { name = "starlette-admin" }, { name = "toml" }, + { name = "typer" }, { name = "uvicorn" }, ] @@ -3516,7 +3518,7 @@ requires-dist = [ { name = "reflex-components-react-player", editable = "packages/reflex-components-react-player" }, { name = "reflex-components-recharts", editable = "packages/reflex-components-recharts" }, { name = "reflex-components-sonner", editable = "packages/reflex-components-sonner" }, - { name = "reflex-hosting-cli", specifier = ">=0.1.61" }, + { name = "reflex-hosting-cli", editable = "packages/reflex-hosting-cli" }, { name = "rich", specifier = ">=13,<15" }, { name = "sqlmodel", marker = "extra == 'db'", specifier = ">=0.0.24,<0.1" }, { name = "starlette", specifier = ">=0.47.0" }, @@ -3561,6 +3563,7 @@ dev = [ { name = "sqlmodel" }, { name = "starlette-admin" }, { name = "toml" }, + { name = "typer" }, { name = "uvicorn" }, ] @@ -3795,7 +3798,7 @@ requires-dist = [ { name = "reflex-components-internal", editable = "packages/reflex-components-internal" }, { name = "reflex-docgen", editable = "packages/reflex-docgen" }, { name = "reflex-enterprise" }, - { name = "reflex-hosting-cli" }, + { name = "reflex-hosting-cli", editable = "packages/reflex-hosting-cli" }, { name = "reflex-integrations-docs", editable = "packages/integrations-docs" }, { name = "reflex-pyplot" }, { name = "reflex-site-shared", editable = "packages/reflex-site-shared" }, @@ -3834,8 +3837,7 @@ wheels = [ [[package]] name = "reflex-hosting-cli" -version = "0.1.62" -source = { registry = "https://pypi.org/simple" } +source = { editable = "packages/reflex-hosting-cli" } dependencies = [ { name = "click" }, { name = "httpx" }, @@ -3843,9 +3845,14 @@ dependencies = [ { name = "platformdirs" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/2f/6d9acd7afd3971da8f0cdef4824551b836ec66c598088fc1af3c16d6f7a4/reflex_hosting_cli-0.1.62.tar.gz", hash = "sha256:7a3ab872218a7ebdfa2ea186b83440322e658fa90cf7ea270aec1bafe1eb0d98", size = 35506, upload-time = "2026-03-10T01:12:14.131Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/67/415e1f7fe09e77027e9e0063f39053d74f1299e671e837c9a7745453676d/reflex_hosting_cli-0.1.62-py3-none-any.whl", hash = "sha256:73d517fa827b1d52dcb81ba9024671acfd4889015a436ed223d2eda3c07eab89", size = 45049, upload-time = "2026-03-10T01:12:15.033Z" }, + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.2" }, + { name = "httpx", specifier = ">=0.25.1,<1.0" }, + { name = "packaging", specifier = ">=24.2" }, + { name = "platformdirs", specifier = ">=3.10.0,<5.0" }, + { name = "rich", specifier = ">=13,<15" }, ] [[package]] @@ -4132,6 +4139,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/c7/0c55fbb0275fc368676ea50514ce7d7839d799a8b3ff8425f380186c7626/selenium-4.43.0-py3-none-any.whl", hash = "sha256:4f97639055dcfa9eadf8ccf549ba7b0e49c655d4e2bde19b9a44e916b754e769", size = 9573091, upload-time = "2026-04-10T06:47:01.134Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "simple-websocket" version = "1.1.0" @@ -4446,6 +4462,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bb/4a/2e5583e544bc437d5e8e54b47db87430df9031b29b48d17f26d129fa60c0/trove_classifiers-2026.1.14.14-py3-none-any.whl", hash = "sha256:1f9553927f18d0513d8e5ff80ab8980b8202ce37ecae0e3274ed2ef11880e74d", size = 14197, upload-time = "2026-01-14T14:54:49.067Z" }, ] +[[package]] +name = "typer" +version = "0.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/b8/9ebb531b6c2d377af08ac6746a5df3425b21853a5d2260876919b58a2a4a/typer-0.24.2.tar.gz", hash = "sha256:ec070dcfca1408e85ee203c6365001e818c3b7fffe686fd07ff2d68095ca0480", size = 119849, upload-time = "2026-04-22T17:45:34.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/d1/9484b497e0a0410b901c12b8251c3e746e1e863f7d28419ffe06f7892fda/typer-0.24.2-py3-none-any.whl", hash = "sha256:b618bc3d721f9a8d30f3e05565be26416d06e9bcc29d49bc491dc26aba674fa8", size = 55977, upload-time = "2026-04-22T17:45:33.055Z" }, +] + [[package]] name = "typesense" version = "2.0.0"