From c0d29715728b1e4c6f6f0a329e566a5eac39d8d5 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 20 May 2026 00:38:51 +0500 Subject: [PATCH 1/3] feat: capture pre-init Python environment in init telemetry Snapshot virtualenv state and presence of pyproject.toml/requirements.txt before template scaffolding runs so the "init" event reflects the user's actual starting environment, not files the template created. --- reflex/utils/telemetry.py | 28 ++++++++++++++ reflex/utils/templates.py | 6 ++- tests/units/test_telemetry.py | 69 +++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/reflex/utils/telemetry.py b/reflex/utils/telemetry.py index 902a75d7f3b..031c5cc4715 100644 --- a/reflex/utils/telemetry.py +++ b/reflex/utils/telemetry.py @@ -5,10 +5,13 @@ 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 @@ -166,6 +169,31 @@ 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`` and + ``has_requirements_txt`` boolean flags. + """ + return { + "in_virtualenv": is_in_virtualenv(), + "has_pyproject_toml": Path(constants.PyprojectToml.FILE).exists(), + "has_requirements_txt": Path(constants.RequirementsTxt.FILE).exists(), + } + + def get_reflex_enterprise_version() -> str | None: """Get the version of reflex-enterprise if installed. diff --git a/reflex/utils/templates.py b/reflex/utils/templates.py index 2751f9e21a4..2912bbea708 100644 --- a/reflex/utils/templates.py +++ b/reflex/utils/templates.py @@ -386,6 +386,10 @@ def initialize_app(app_name: str, template: str | None = None) -> str | None: telemetry.send("reinit") return None + # Captured before scaffolding so the snapshot reflects the user's CWD, + # not files the template will create. + init_environment = telemetry.get_init_environment() + templates: dict[str, Template] = {} # Don't fetch app templates if the user directly asked for DEFAULT. @@ -412,7 +416,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 ac477e688eb..25b0dad5ed5 100644 --- a/tests/units/test_telemetry.py +++ b/tests/units/test_telemetry.py @@ -121,6 +121,75 @@ def test_prepare_event_does_not_mutate_cached_defaults(mocker: MockerFixture): assert "duration_ms" not in cached["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 init_environment_cwd(tmp_path, monkeypatch: pytest.MonkeyPatch): + """Chdir into a clean tmp dir and let the caller stage dependency files. + + Returns: + The temporary directory now serving as the working directory. + """ + monkeypatch.chdir(tmp_path) + return tmp_path + + +def test_get_init_environment_reports_dependency_files( + init_environment_cwd, venv_state +): + (init_environment_cwd / "pyproject.toml").write_text("") + 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, + } + + +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, + } + + def test_prepare_event_properties_override_kwargs(mocker: MockerFixture): """If both kwargs and properties supply the same key, properties wins.""" mocker.patch( From c0407302c42115cdf2eafca4ac676bf6258d9753 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 20 May 2026 00:49:31 +0500 Subject: [PATCH 2/3] test: cover requirements.txt detection in init telemetry env --- tests/units/test_telemetry.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/units/test_telemetry.py b/tests/units/test_telemetry.py index 25b0dad5ed5..9e8f2a9c919 100644 --- a/tests/units/test_telemetry.py +++ b/tests/units/test_telemetry.py @@ -180,6 +180,19 @@ def test_get_init_environment_reports_dependency_files( } +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, + } + + def test_get_init_environment_empty_directory(init_environment_cwd, venv_state): venv_state(prefix="/usr", base_prefix="/usr", virtual_env=None) From c8c41a527f8b8e735a5827452072e28a88ba51b1 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 20 May 2026 20:43:14 +0500 Subject: [PATCH 3/3] feat(telemetry): extend init env snapshot with uv.lock and reflex.lock Also short-circuit get_init_environment() when telemetry is disabled and attach the snapshot to reinit events so reinitialized projects are captured alongside fresh inits. --- .../src/reflex_base/constants/__init__.py | 2 + .../src/reflex_base/constants/config.py | 7 +++ reflex/constants/__init__.py | 2 + reflex/utils/telemetry.py | 14 ++++-- reflex/utils/templates.py | 9 ++-- tests/units/test_telemetry.py | 49 ++++++++++++++++++- 6 files changed, 72 insertions(+), 11 deletions(-) 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 f1a6f12ebe2..ebe88f82a71 100644 --- a/reflex/utils/telemetry.py +++ b/reflex/utils/telemetry.py @@ -15,6 +15,7 @@ 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 @@ -184,13 +185,20 @@ 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`` and - ``has_requirements_txt`` boolean flags. + 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(), } @@ -377,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 2912bbea708..c1422b06102 100644 --- a/reflex/utils/templates.py +++ b/reflex/utils/templates.py @@ -381,15 +381,14 @@ 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 - # Captured before scaffolding so the snapshot reflects the user's CWD, - # not files the template will create. - init_environment = telemetry.get_init_environment() - templates: dict[str, Template] = {} # Don't fetch app templates if the user directly asked for DEFAULT. diff --git a/tests/units/test_telemetry.py b/tests/units/test_telemetry.py index 869581178a6..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 @@ -234,13 +236,33 @@ def test_is_in_virtualenv_returns_false_for_system_python(venv_state): @pytest.fixture -def init_environment_cwd(tmp_path, monkeypatch: pytest.MonkeyPatch): - """Chdir into a clean tmp dir and let the caller stage dependency files. +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 @@ -248,12 +270,16 @@ 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, } @@ -267,6 +293,8 @@ def test_get_init_environment_reports_requirements_txt( "in_virtualenv": True, "has_pyproject_toml": False, "has_requirements_txt": True, + "has_uv_lock": False, + "has_reflex_lock": False, } @@ -277,9 +305,26 @@ def test_get_init_environment_empty_directory(init_environment_cwd, venv_state): "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(