diff --git a/news/6974.bugfix.md b/news/6974.bugfix.md new file mode 100644 index 00000000000..687d3cf59a6 --- /dev/null +++ b/news/6974.bugfix.md @@ -0,0 +1 @@ +Make `reflex.testing` importable without test-only dependencies and provide a `testing` extra for `AppHarness`. diff --git a/pyproject.toml b/pyproject.toml index 234e06ba2f9..6e030257b18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,11 @@ db = [ "sqlmodel >=0.0.24,<0.1", ] pydantic = ["reflex-base[pydantic]"] +testing = [ + "psutil >=7.0.0,<8.0", + "selenium >=4.0.0,<5.0", + "uvicorn >=0.34.0,<1.0", +] [project.urls] homepage = "https://reflex.dev" diff --git a/reflex/testing.py b/reflex/testing.py index 51c950e637a..660f89b81bf 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -25,7 +25,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar -import uvicorn from reflex_base.components.memo import MEMOS from reflex_base.config import get_config, reload_config from reflex_base.environment import environment @@ -61,12 +60,34 @@ except ImportError: has_selenium = False +if TYPE_CHECKING: + import uvicorn + # The timeout (minutes) to check for the port. DEFAULT_TIMEOUT = 15 POLL_INTERVAL = 0.25 FRONTEND_POPEN_ARGS = {} T = TypeVar("T") TimeoutType = int | float | None + + +def _get_uvicorn(): + """Import uvicorn for an AppHarness server. + + Returns: + The imported uvicorn module. + """ + try: + import uvicorn + except ImportError as exc: + msg = ( + "AppHarness backend support requires `uvicorn`. Install it with " + "`pip install 'reflex[testing]'`." + ) + raise ImportError(msg) from exc + return uvicorn + + if platform.system() == "Windows": FRONTEND_POPEN_ARGS["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # pyright: ignore [reportAttributeAccessIssue] FRONTEND_POPEN_ARGS["shell"] = True @@ -341,6 +362,7 @@ def _start_backend(self, port: int = 0): if self.app_asgi is None: msg = "App was not initialized." raise RuntimeError(msg) + uvicorn = _get_uvicorn() self.backend = uvicorn.Server( uvicorn.Config( app=self.app_asgi, @@ -470,7 +492,14 @@ def __enter__(self) -> Self: def stop(self) -> None: """Stop the frontend and backend servers.""" - import psutil + try: + import psutil + except ImportError as exc: + msg = ( + "AppHarness cleanup requires `psutil`. Install it with " + "`pip install 'reflex[testing]'`." + ) + raise ImportError(msg) from exc # Quit browsers first to avoid any lingering events being sent during shutdown. for driver in self._frontends: @@ -825,6 +854,7 @@ class AppHarnessProd(AppHarness): frontend_server: uvicorn.Server | None = None def _run_frontend(self): + uvicorn = _get_uvicorn() with chdir(self.app_path): frontend_app = reflex.utils.exec._frontend_prod_app() self.frontend_server = uvicorn.Server( @@ -893,6 +923,7 @@ def _start_backend(self): msg = "App was not initialized." raise RuntimeError(msg) environment.REFLEX_SKIP_COMPILE.set(True) + uvicorn = _get_uvicorn() self.backend = uvicorn.Server( uvicorn.Config( app=self.app_asgi, diff --git a/tests/units/test_testing.py b/tests/units/test_testing.py index a38682c06af..917b4e52050 100644 --- a/tests/units/test_testing.py +++ b/tests/units/test_testing.py @@ -19,6 +19,11 @@ from reflex.utils.exec import should_prerender_routes +def test_testing_module_does_not_import_uvicorn_at_module_load(): + """Importing reflex.testing does not require the AppHarness backend runtime.""" + assert "uvicorn" not in reflex_testing.__dict__ + + @pytest.mark.skip("Slow test that makes network requests.") def test_app_harness(tmp_path): """Ensure that AppHarness can compile and start an app. diff --git a/uv.lock b/uv.lock index 4212881b6da..27c78760b53 100644 --- a/uv.lock +++ b/uv.lock @@ -3676,6 +3676,11 @@ db = [ pydantic = [ { name = "reflex-base", extra = ["pydantic"] }, ] +testing = [ + { name = "psutil" }, + { name = "selenium" }, + { name = "uvicorn" }, +] [package.dev-dependencies] dev = [