Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/actions/setup_build_env/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,7 @@ runs:
run: uv sync
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
# Concurrent sdist builds (e.g. numpy/pandas on cp315) can hold the
# uv cache lock longer than the default 300s.
UV_LOCK_TIMEOUT: "900"
4 changes: 2 additions & 2 deletions .github/workflows/integration_app_harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
strategy:
matrix:
state_manager: ["redis", "memory"]
python-version: ["3.11", "3.12", "3.13", "3.14"]
python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]
split_index: [1, 2]
fail-fast: false
runs-on: ubuntu-22.04
Expand Down Expand Up @@ -67,7 +67,7 @@ jobs:
strategy:
matrix:
state_manager: ["redis", "memory"]
python-version: ["3.11", "3.12", "3.13", "3.14"]
python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]
fail-fast: false
runs-on: ubuntu-22.04
services:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/unit_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
Comment thread
benedikt-bartscher marked this conversation as resolved.
runs-on: ${{ matrix.os }}

# Service containers to run with `runner-job`
Expand Down Expand Up @@ -88,7 +88,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
runs-on: macos-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down
5 changes: 4 additions & 1 deletion docs/app/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ dependencies = [
"orjson",
"pandas",
"plotly-express",
"psycopg[binary]",
# TODO: revert to a single "psycopg[binary]" once a psycopg release
# ships cp315 wheels for psycopg-binary
"psycopg[binary]; python_version < '3.15'",
"psycopg; python_version >= '3.15'",
"python-frontmatter",
"reflex",
"reflex-docgen",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Lazy imports now cache resolved attributes on the package, so repeated access is a plain attribute lookup instead of a `__getattr__` round-trip. On Python 3.15+, lazy loading delegates to the interpreter's native lazy imports (PEP 810).
1 change: 1 addition & 0 deletions packages/reflex-base/news/+python-3-15.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Python 3.15 is now fully supported and tested in CI.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import typing
from abc import ABC, ABCMeta, abstractmethod
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from dataclasses import _MISSING_TYPE, MISSING
from dataclasses import MISSING
from hashlib import md5
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast
Expand All @@ -38,6 +38,7 @@
)
from reflex_base.style import Style, format_as_emotion
from reflex_base.utils import format, imports, types
from reflex_base.utils.compat import MISSING_TYPE
from reflex_base.utils.imports import ImportDict, ImportVar, ParsedImportDict
from reflex_base.vars import VarData
from reflex_base.vars.base import (
Expand Down Expand Up @@ -66,10 +67,10 @@ class ComponentField(BaseField[FIELD_TYPE]):

def __init__(
self,
default: FIELD_TYPE | _MISSING_TYPE = MISSING,
default: FIELD_TYPE | MISSING_TYPE = MISSING,
default_factory: Callable[[], FIELD_TYPE] | None = None,
is_javascript: bool | None = None,
annotated_type: type[Any] | _MISSING_TYPE = MISSING,
annotated_type: type[Any] | MISSING_TYPE = MISSING,
doc: str | None = None,
) -> None:
"""Initialize the field.
Expand Down Expand Up @@ -131,7 +132,7 @@ def __get__(self, instance: Any, owner: type[Any] | None = None) -> Any:


def field(
default: FIELD_TYPE | _MISSING_TYPE = MISSING,
default: FIELD_TYPE | MISSING_TYPE = MISSING,
default_factory: Callable[[], FIELD_TYPE] | None = None,
is_javascript_property: bool | None = None,
doc: str | None = None,
Expand Down
8 changes: 4 additions & 4 deletions packages/reflex-base/src/reflex_base/components/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
from __future__ import annotations

from collections.abc import Callable
from dataclasses import _MISSING_TYPE, MISSING
from dataclasses import MISSING
from typing import Annotated, Any, Generic, TypeVar, get_origin

from reflex_base.utils import types
from reflex_base.utils.compat import annotations_from_namespace
from reflex_base.utils.compat import MISSING_TYPE, annotations_from_namespace

FIELD_TYPE = TypeVar("FIELD_TYPE")

Expand All @@ -20,9 +20,9 @@ class BaseField(Generic[FIELD_TYPE]):

def __init__(
self,
default: FIELD_TYPE | _MISSING_TYPE = MISSING,
default: FIELD_TYPE | MISSING_TYPE = MISSING,
default_factory: Callable[[], FIELD_TYPE] | None = None,
annotated_type: type[Any] | _MISSING_TYPE = MISSING,
annotated_type: type[Any] | MISSING_TYPE = MISSING,
) -> None:
"""Initialize the field.

Expand Down
9 changes: 5 additions & 4 deletions packages/reflex-base/src/reflex_base/components/props.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

import builtins
from collections.abc import Callable
from dataclasses import _MISSING_TYPE, MISSING
from dataclasses import MISSING
from typing import Any, TypeVar, get_args, get_origin

from typing_extensions import dataclass_transform

from reflex_base.components.field import BaseField, FieldBasedMeta
from reflex_base.event import EventChain, args_specs_from_fields
from reflex_base.utils import format
from reflex_base.utils.compat import MISSING_TYPE
from reflex_base.utils.exceptions import InvalidPropValueError
from reflex_base.utils.serializers import serializer
from reflex_base.utils.types import is_union
Expand Down Expand Up @@ -76,9 +77,9 @@ class PropsField(BaseField[PROPS_FIELD_TYPE]):

def __init__(
self,
default: PROPS_FIELD_TYPE | _MISSING_TYPE = MISSING,
default: PROPS_FIELD_TYPE | MISSING_TYPE = MISSING,
default_factory: Callable[[], PROPS_FIELD_TYPE] | None = None,
annotated_type: type[Any] | _MISSING_TYPE = MISSING,
annotated_type: type[Any] | MISSING_TYPE = MISSING,
) -> None:
"""Initialize the field.

Expand Down Expand Up @@ -141,7 +142,7 @@ def __repr__(self) -> str:


def props_field(
default: PROPS_FIELD_TYPE | _MISSING_TYPE = MISSING,
default: PROPS_FIELD_TYPE | MISSING_TYPE = MISSING,
default_factory: Callable[[], PROPS_FIELD_TYPE] | None = None,
) -> PROPS_FIELD_TYPE:
"""Create a field for a props class.
Expand Down
10 changes: 10 additions & 0 deletions packages/reflex-base/src/reflex_base/utils/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
from collections.abc import Mapping
from typing import Any

if sys.version_info >= (3, 15):
from dataclasses import MISSING

# dataclasses._MISSING_TYPE was removed in Python 3.15
MISSING_TYPE = type(MISSING)
else:
import dataclasses

MISSING_TYPE = dataclasses._MISSING_TYPE


async def windows_hot_reload_lifespan_hack():
"""[REF-3164] A hack to fix hot reload on Windows.
Expand Down
123 changes: 107 additions & 16 deletions packages/reflex-base/src/reflex_base/utils/lazy_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,84 @@

SubmodAttrsType = Mapping[str, Sequence[str | tuple[str, str]]]

# PEP 810 explicit lazy imports, available from Python 3.15
_NATIVE_LAZY_IMPORTS = sys.version_info >= (3, 15)


def _attach_native(
package_name: str,
submodules: set[str],
alias_to_module_and_attr: Mapping[str, tuple[str, str]],
extra_mappings: Mapping[str, str],
) -> bool:
"""Bind native lazy import proxies (PEP 810) in the package namespace.

Names already bound in the package namespace are left untouched.

Args:
package_name: name of the package.
submodules: Set of submodules to attach.
alias_to_module_and_attr: Mapping of alias -> (submodule, attribute).
extra_mappings: Mapping of alias -> absolute dotted import path.

Returns:
False if the caller should fall back to the classic
__getattr__-based mechanism.
"""
package = sys.modules.get(package_name)
if package is None:
return False

# Names are embedded in generated import statements below, so ensure
# they are identifiers and not arbitrary code.
names = [package_name, *submodules]
for alias, (mod, attr) in alias_to_module_and_attr.items():
names += [alias, mod, attr]
for alias, path in extra_mappings.items():
names += [alias, path]
if not all(part.isidentifier() for name in names for part in name.split(".")):
return False

pkg_dict = vars(package)

# Filters preserve the classic lookup priority:
# extra_mappings > submodules > submod_attrs.
lines: list[str] = []
for alias, path in extra_mappings.items():
if alias in pkg_dict:
continue
if "." not in path:
lines.append(f"lazy import {path} as {alias}")
else:
mod, _, attr = path.rpartition(".")
lines.append(f"lazy from {mod} import {attr} as {alias}")
lines += [
f"lazy from {package_name} import {name}"
for name in sorted(submodules)
if name not in pkg_dict and name not in extra_mappings
]
lines += [
f"lazy from {package_name}.{mod} import {attr} as {alias}"
for alias, (mod, attr) in alias_to_module_and_attr.items()
if alias not in pkg_dict
and alias not in extra_mappings
and alias not in submodules
]

if not lines:
return True

try:
code = compile(
"\n".join(lines), f"<lazy_loader.attach {package_name!r}>", "exec"
)
except SyntaxError:
# A name that is not expressible as import syntax (e.g. a keyword)
return False

exec(code, pkg_dict)
return True


def attach(
package_name: str,
Expand All @@ -37,6 +115,9 @@ def attach(
reformats the submod_attrs dictionary to flatten the module list before passing it to
lazy_loader.

On Python 3.15 and newer, this delegates to the interpreter's native
lazy import mechanism (PEP 810) whenever possible.

Args:
package_name: name of the package.
submodules : List of submodules to attach.
Expand Down Expand Up @@ -67,34 +148,44 @@ def __getattr__(name: str): # noqa: N807
if name in extra_mappings:
path = extra_mappings[name]
if "." not in path:
return importlib.import_module(path)
submod_path, attr = path.rsplit(".", 1)
submod = importlib.import_module(submod_path)
return getattr(submod, attr)
if name in submodules:
return importlib.import_module(f"{package_name}.{name}")
if name in alias_to_module_and_attr:
attr = importlib.import_module(path)
else:
submod_path, attr_name = path.rsplit(".", 1)
submod = importlib.import_module(submod_path)
attr = getattr(submod, attr_name)
elif name in submodules:
attr = importlib.import_module(f"{package_name}.{name}")
elif name in alias_to_module_and_attr:
module, attr_name = alias_to_module_and_attr[name]
submod = importlib.import_module(f"{package_name}.{module}")
attr = getattr(submod, attr_name)
else:
msg = f"No {package_name} attribute {name}"
raise AttributeError(msg)

# If the attribute lives in a file (module) with the same
# name as the attribute, ensure that the attribute and *not*
# the module is accessible on the package.
if name == module:
pkg = sys.modules[package_name]
pkg.__dict__[name] = attr
# Cache the resolved value on the package so that subsequent
# accesses bypass __getattr__; this also ensures an attribute
# shadows a same-named submodule.
pkg = sys.modules.get(package_name)
if pkg is not None:
pkg.__dict__[name] = attr

return attr
msg = f"No {package_name} attribute {name}"
raise AttributeError(msg)
return attr

def __dir__(): # noqa: N807
return __all__

if os.environ.get("EAGER_IMPORT", ""):
for attr in set(alias_to_module_and_attr.keys()) | submodules:
__getattr__(attr)
elif _NATIVE_LAZY_IMPORTS:
# On Python 3.15+, bind native lazy imports (PEP 810) directly in
# the package namespace; the returned __getattr__ is then only
# consulted for unknown names. Falls back to the classic
# __getattr__ mechanism when native binding is not possible.
_attach_native(
package_name, submodules, alias_to_module_and_attr, extra_mappings
)

return __getattr__, __dir__, list(__all__)

Expand Down
15 changes: 15 additions & 0 deletions packages/reflex-base/src/reflex_base/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import logging
import sys
import types
import typing
from collections.abc import Callable, Iterable, Mapping, Sequence
from enum import Enum
from functools import cached_property, lru_cache
Expand Down Expand Up @@ -35,6 +36,7 @@
from typing import get_origin as get_origin_og
from typing import get_type_hints as get_type_hints_og

import typing_extensions
from typing_extensions import Self as Self
from typing_extensions import TypeAliasType
from typing_extensions import override as override
Expand Down Expand Up @@ -579,6 +581,15 @@ def get_base_class(cls: GenericType) -> type:
return get_base_class(cls.__origin__) if is_generic_alias(cls) else cls


# "No extra items" sentinels of PEP 728 TypedDicts (typing on Python 3.15+,
# typing_extensions on older versions).
_NO_EXTRA_ITEMS_SENTINELS = tuple(
sentinel
for mod in (typing, typing_extensions)
if (sentinel := getattr(mod, "NoExtraItems", None)) is not None
)


def does_obj_satisfy_typed_dict(
obj: Any,
cls: GenericType,
Expand Down Expand Up @@ -606,6 +617,10 @@ def does_obj_satisfy_typed_dict(
required_keys: frozenset[str] = getattr(cls, "__required_keys__", frozenset())
is_closed = getattr(cls, "__closed__", False)
extra_items_type = getattr(cls, "__extra_items__", Any)
if any(extra_items_type is sentinel for sentinel in _NO_EXTRA_ITEMS_SENTINELS):
# Extra keys of a non-closed TypedDict are unconstrained; a closed
# one already rejected them above.
extra_items_type = Any

for key, value in obj.items():
if is_closed and key not in key_names_to_values:
Expand Down
Loading
Loading