diff --git a/news/+pydantic-optional.breaking.md b/news/+pydantic-optional.breaking.md new file mode 100644 index 00000000000..af41ab6c9c8 --- /dev/null +++ b/news/+pydantic-optional.breaking.md @@ -0,0 +1 @@ +`pip install reflex` no longer installs `pydantic`; pydantic model support activates when it is installed. Use the new `reflex[pydantic]` extra (or `reflex[db]`) to keep it. diff --git a/packages/reflex-base/news/+pydantic-optional.breaking.md b/packages/reflex-base/news/+pydantic-optional.breaking.md new file mode 100644 index 00000000000..d7094535614 --- /dev/null +++ b/packages/reflex-base/news/+pydantic-optional.breaking.md @@ -0,0 +1 @@ +`pydantic` is no longer a hard dependency; pydantic model support activates when it is installed. Use the `reflex-base[pydantic]` extra (or `reflex[db]`) to keep it. diff --git a/packages/reflex-base/pyproject.toml b/packages/reflex-base/pyproject.toml index 7fdc1d8599c..36e5b59b261 100644 --- a/packages/reflex-base/pyproject.toml +++ b/packages/reflex-base/pyproject.toml @@ -9,12 +9,14 @@ maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] requires-python = ">=3.10" dependencies = [ "packaging >=24.2,<27", - "pydantic >=2.12.0,<3.0", "rich >=13,<15", "typing_extensions >=4.13.0", "platformdirs >=4.3.7,<5.0", ] +[project.optional-dependencies] +pydantic = ["pydantic >=2.12.0,<3.0"] + [tool.hatch.version] source = "uv-dynamic-versioning" diff --git a/pyproject.toml b/pyproject.toml index 5db2a059ccd..92c466d5b62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ db = [ "pydantic >=2.12.0,<3.0", "sqlmodel >=0.0.24,<0.1", ] +pydantic = ["reflex-base[pydantic]"] [project.urls] homepage = "https://reflex.dev" diff --git a/tests/units/test_optional_pydantic.py b/tests/units/test_optional_pydantic.py new file mode 100644 index 00000000000..a82868ad9de --- /dev/null +++ b/tests/units/test_optional_pydantic.py @@ -0,0 +1,73 @@ +"""Verify reflex imports and works when pydantic is not installed.""" + +import subprocess +import sys + +# Runs in a subprocess with pydantic (and the db extras that require it) +# blocked, mirroring the "without db dependencies" CI job. +_SCRIPT = """ +import sys + +BLOCKED = ("pydantic", "sqlmodel", "alembic", "sqlalchemy") + + +class _Filter: + # Hide blocked packages from every finder so both ``import`` and + # ``find_spec`` see them as absent. + def __init__(self, inner): + self.inner = inner + + def find_spec(self, name, path=None, target=None): + if name.partition(".")[0] in BLOCKED: + return None + finder = getattr(self.inner, "find_spec", None) + return finder(name, path, target) if finder is not None else None + + +sys.meta_path = [_Filter(f) for f in sys.meta_path] + +# find_spec consults sys.modules before meta_path — purge anything a .pth or +# startup hook may have imported eagerly. +for mod in [m for m in sys.modules if m.partition(".")[0] in BLOCKED]: + del sys.modules[mod] + +from importlib.util import find_spec + +assert find_spec("pydantic") is None + +import datetime + +import reflex as rx +from reflex_base.utils import serializers + + +class S(rx.State): + val: rx.Field[str] = rx.field("hello") + + +comp = rx.text(S.val) +assert "val" in str(comp.render()) + +assert serializers.serialize(datetime.datetime(2026, 1, 2, 3, 4, 5)) == "2026-01-02 03:04:05" +assert not any( + getattr(cls, "__module__", "").startswith("pydantic") + for cls in serializers.SERIALIZERS +) + +print("OK") +""" + + +def test_reflex_works_without_pydantic() -> None: + """Core import, state, component render, and serializers work without pydantic.""" + result = subprocess.run( + [sys.executable, "-c", _SCRIPT], + capture_output=True, + text=True, + timeout=120, + check=False, + ) + assert result.returncode == 0, ( + f"reflex failed without pydantic:\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + assert "OK" in result.stdout diff --git a/uv.lock b/uv.lock index e6efb6377a0..0bc63196490 100644 --- a/uv.lock +++ b/uv.lock @@ -3635,6 +3635,9 @@ db = [ { name = "pydantic" }, { name = "sqlmodel" }, ] +pydantic = [ + { name = "reflex-base", extra = ["pydantic"] }, +] [package.dev-dependencies] dev = [ @@ -3695,6 +3698,7 @@ requires-dist = [ { name = "python-socketio", specifier = ">=5.12.0,<6.0" }, { name = "redis", specifier = ">=6.4,<8.0" }, { name = "reflex-base", editable = "packages/reflex-base" }, + { name = "reflex-base", extras = ["pydantic"], marker = "extra == 'pydantic'", editable = "packages/reflex-base" }, { name = "reflex-components-code", editable = "packages/reflex-components-code" }, { name = "reflex-components-core", editable = "packages/reflex-components-core" }, { name = "reflex-components-dataeditor", editable = "packages/reflex-components-dataeditor" }, @@ -3714,7 +3718,7 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.13.0" }, { name = "wrapt", specifier = ">=1.17.0,<2.2" }, ] -provides-extras = ["db"] +provides-extras = ["db", "pydantic"] [package.metadata.requires-dev] dev = [ @@ -3765,19 +3769,24 @@ source = { editable = "packages/reflex-base" } dependencies = [ { name = "packaging" }, { name = "platformdirs" }, - { name = "pydantic" }, { name = "rich" }, { name = "typing-extensions" }, ] +[package.optional-dependencies] +pydantic = [ + { name = "pydantic" }, +] + [package.metadata] requires-dist = [ { name = "packaging", specifier = ">=24.2,<27" }, { name = "platformdirs", specifier = ">=4.3.7,<5.0" }, - { name = "pydantic", specifier = ">=2.12.0,<3.0" }, + { name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.12.0,<3.0" }, { name = "rich", specifier = ">=13,<15" }, { name = "typing-extensions", specifier = ">=4.13.0" }, ] +provides-extras = ["pydantic"] [[package]] name = "reflex-components-code"