diff --git a/packages/reflex-base/src/reflex_base/constants/__init__.py b/packages/reflex-base/src/reflex_base/constants/__init__.py index b308500593a..1d9480761a4 100644 --- a/packages/reflex-base/src/reflex_base/constants/__init__.py +++ b/packages/reflex-base/src/reflex_base/constants/__init__.py @@ -45,6 +45,7 @@ GitIgnore, PyprojectToml, RequirementsTxt, + UvLock, ) from .custom_components import CustomComponents from .event import Endpoint, EventTriggers, SocketEvent @@ -120,4 +121,5 @@ "SocketEvent", "StateManagerMode", "Templates", + "UvLock", ] diff --git a/packages/reflex-base/src/reflex_base/constants/config.py b/packages/reflex-base/src/reflex_base/constants/config.py index fce017761ea..e1e8a4c6d74 100644 --- a/packages/reflex-base/src/reflex_base/constants/config.py +++ b/packages/reflex-base/src/reflex_base/constants/config.py @@ -65,6 +65,13 @@ class RequirementsTxt(SimpleNamespace): DEFAULTS_STUB = f"{Reflex.MODULE_NAME}==" +class UvLock(SimpleNamespace): + """uv.lock constants.""" + + # The uv lockfile. + FILE = "uv.lock" + + class DefaultPorts(SimpleNamespace): """Default port constants.""" diff --git a/reflex/constants/__init__.py b/reflex/constants/__init__.py index 69f79271d9e..b37780a3acb 100644 --- a/reflex/constants/__init__.py +++ b/reflex/constants/__init__.py @@ -43,6 +43,7 @@ GitIgnore, PyprojectToml, RequirementsTxt, + UvLock, ) from .custom_components import CustomComponents from .event import Endpoint, EventTriggers, SocketEvent @@ -115,4 +116,5 @@ "SocketEvent", "StateManagerMode", "Templates", + "UvLock", ] diff --git a/reflex/utils/telemetry.py b/reflex/utils/telemetry.py index 44e0fd78261..ebe88f82a71 100644 --- a/reflex/utils/telemetry.py +++ b/reflex/utils/telemetry.py @@ -5,13 +5,17 @@ import importlib.metadata import json import multiprocessing +import os import platform +import sys import warnings from contextlib import suppress from datetime import datetime, timezone +from pathlib import Path from typing import Any, TypedDict, cast from reflex_base import constants +from reflex_base.config import get_config from reflex_base.environment import environment from reflex_base.utils.decorator import once, once_unless_none from reflex_base.utils.exceptions import ReflexError @@ -166,6 +170,38 @@ def get_cpu_count() -> int: return multiprocessing.cpu_count() +def is_in_virtualenv() -> bool: + """Whether the current Python is running inside a virtual environment. + + Returns: + True if a virtual environment appears to be active. + """ + if sys.prefix != sys.base_prefix: + return True + return bool(os.environ.get("VIRTUAL_ENV")) + + +def get_init_environment() -> dict[str, bool]: + """Return Python tooling flags for the current working directory. + + Returns: + A dict with ``in_virtualenv``, ``has_pyproject_toml``, + ``has_requirements_txt``, ``has_uv_lock`` and ``has_reflex_lock`` + boolean flags, or an empty dict when telemetry is disabled (so the + filesystem stats are skipped when their results would be discarded). + """ + if not get_config().telemetry_enabled: + return {} + + return { + "in_virtualenv": is_in_virtualenv(), + "has_pyproject_toml": Path(constants.PyprojectToml.FILE).exists(), + "has_requirements_txt": Path(constants.RequirementsTxt.FILE).exists(), + "has_uv_lock": Path(constants.UvLock.FILE).exists(), + "has_reflex_lock": Path(constants.Bun.ROOT_LOCKFILE_DIR).is_dir(), + } + + def get_reflex_enterprise_version() -> str | None: """Get the version of reflex-enterprise if installed. @@ -349,8 +385,6 @@ def _send( properties: dict[str, Any] | None = None, **kwargs, ) -> bool: - from reflex_base.config import get_config - # Get the telemetry_enabled from the config if it is not specified. if telemetry_enabled is None: telemetry_enabled = get_config().telemetry_enabled diff --git a/reflex/utils/templates.py b/reflex/utils/templates.py index 2751f9e21a4..c1422b06102 100644 --- a/reflex/utils/templates.py +++ b/reflex/utils/templates.py @@ -381,9 +381,12 @@ def initialize_app(app_name: str, template: str | None = None) -> str | None: # Local imports to avoid circular imports. from reflex.utils import telemetry + # Snapshot must reflect the user's CWD, not files the template would create. + init_environment = telemetry.get_init_environment() + # Check if the app is already initialized. if constants.Config.FILE.exists(): - telemetry.send("reinit") + telemetry.send("reinit", properties=init_environment) return None templates: dict[str, Template] = {} @@ -412,7 +415,7 @@ def initialize_app(app_name: str, template: str | None = None) -> str | None: app_name=app_name, template=template, templates=templates ) - telemetry.send("init", template=template) + telemetry.send("init", template=template, properties=init_environment) return template diff --git a/tests/units/test_telemetry.py b/tests/units/test_telemetry.py index 69e18319c26..c43a285a192 100644 --- a/tests/units/test_telemetry.py +++ b/tests/units/test_telemetry.py @@ -1,3 +1,5 @@ +from types import SimpleNamespace + import pytest from packaging.version import parse as parse_python_version from pytest_mock import MockerFixture @@ -198,6 +200,131 @@ def test_prepare_event_does_not_mutate_cached_defaults(event_defaults): assert "duration_ms" not in event_defaults["properties"] +@pytest.fixture +def venv_state(monkeypatch: pytest.MonkeyPatch): + """Force a deterministic `is_in_virtualenv` reading. + + Returns: + A callable that overrides `sys.prefix`, `sys.base_prefix`, and the + `VIRTUAL_ENV` env-var for the duration of the test. + """ + + def configure(*, prefix: str, base_prefix: str, virtual_env: str | None) -> None: + monkeypatch.setattr(telemetry.sys, "prefix", prefix) + monkeypatch.setattr(telemetry.sys, "base_prefix", base_prefix) + if virtual_env is None: + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + else: + monkeypatch.setenv("VIRTUAL_ENV", virtual_env) + + return configure + + +def test_is_in_virtualenv_detects_pep_405_venv(venv_state): + venv_state(prefix="/tmp/venv", base_prefix="/usr", virtual_env=None) + assert telemetry.is_in_virtualenv() is True + + +def test_is_in_virtualenv_falls_back_to_virtual_env_var(venv_state): + venv_state(prefix="/usr", base_prefix="/usr", virtual_env="/tmp/venv") + assert telemetry.is_in_virtualenv() is True + + +def test_is_in_virtualenv_returns_false_for_system_python(venv_state): + venv_state(prefix="/usr", base_prefix="/usr", virtual_env=None) + assert telemetry.is_in_virtualenv() is False + + +@pytest.fixture +def patch_telemetry_config(mocker: MockerFixture): + """Patch ``telemetry.get_config`` with a stub of a chosen ``telemetry_enabled``. + + Returns: + A callable ``patch(enabled)`` that installs the mock on demand. + """ + + def patch(*, enabled: bool) -> None: + mocker.patch( + "reflex.utils.telemetry.get_config", + return_value=SimpleNamespace(telemetry_enabled=enabled), + ) + + return patch + + +@pytest.fixture +def init_environment_cwd( + tmp_path, monkeypatch: pytest.MonkeyPatch, patch_telemetry_config +): + """Chdir into a clean tmp dir and force telemetry-enabled config. + + Returns: + The temporary directory now serving as the working directory. + """ + monkeypatch.chdir(tmp_path) + patch_telemetry_config(enabled=True) + return tmp_path + + +def test_get_init_environment_reports_dependency_files( + init_environment_cwd, venv_state +): + (init_environment_cwd / "pyproject.toml").write_text("") + (init_environment_cwd / "uv.lock").write_text("") + (init_environment_cwd / "reflex.lock").mkdir() + venv_state(prefix="/tmp/venv", base_prefix="/usr", virtual_env=None) + + assert telemetry.get_init_environment() == { + "in_virtualenv": True, + "has_pyproject_toml": True, + "has_requirements_txt": False, + "has_uv_lock": True, + "has_reflex_lock": True, + } + + +def test_get_init_environment_reports_requirements_txt( + init_environment_cwd, venv_state +): + (init_environment_cwd / "requirements.txt").write_text("") + venv_state(prefix="/usr", base_prefix="/usr", virtual_env="/tmp/venv") + + assert telemetry.get_init_environment() == { + "in_virtualenv": True, + "has_pyproject_toml": False, + "has_requirements_txt": True, + "has_uv_lock": False, + "has_reflex_lock": False, + } + + +def test_get_init_environment_empty_directory(init_environment_cwd, venv_state): + venv_state(prefix="/usr", base_prefix="/usr", virtual_env=None) + + assert telemetry.get_init_environment() == { + "in_virtualenv": False, + "has_pyproject_toml": False, + "has_requirements_txt": False, + "has_uv_lock": False, + "has_reflex_lock": False, + } + + +def test_get_init_environment_short_circuits_when_telemetry_disabled( + tmp_path, monkeypatch: pytest.MonkeyPatch, patch_telemetry_config +): + """When telemetry is disabled the env snapshot is skipped entirely. + + A pyproject.toml is staged so a non-short-circuiting implementation would + surface ``has_pyproject_toml: True`` instead of an empty dict. + """ + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("") + patch_telemetry_config(enabled=False) + + assert telemetry.get_init_environment() == {} + + def test_prepare_event_properties_override_kwargs(event_defaults): """If both kwargs and properties supply the same key, properties wins.""" event = telemetry._prepare_event(