Skip to content

Commit 2f71cdf

Browse files
benedikt-bartscherFarhanAliRazamasenf
authored
feat(event): accept any sequence of events where a list of them is accepted (#7402)
* feat(event): accept any sequence of events where a list of them is accepted * fix(app): read a page's on_load and the form on_submit events as any sequence * perf(event): check the single event kinds before the Sequence ABC in from_event_type * fix(internal): read the forms' on_submit sequence without the new helper, so the declared reflex floor still type-checks * fix(internal): reject a string as the forms' on_submit events instead of splitting it into characters * fix(event): split any sequence an event lambda returns into its events call_event_fn only split a list or tuple, so a lambda returning another sequence such as a UserList was wrapped as one event and rejected. * refactor(event): share one fast check for a single event or a sequence is_non_string_sequence checks list and tuple before the Sequence ABC, and excludes bytes as well as str. _check_valid_yield uses it instead of its own copy of the check. from_event_type, fix_events and call_event_fn wrap a single event through one _event_sequence helper. EventChain.create keeps its own branches, so a bad top-level value still reports "Invalid event chain". The demo and intro forms drop the string guard: the type already excludes a str, and the event chain rejects one anyway. Tests cover a handler that returns or yields a tuple through the event processor, and which values count as a sequence. * build(reflex): require the reflex-base that has is_non_string_sequence reflex/app.py imports is_non_string_sequence, which reflex-base 0.10.0 lacks. * Revert "build(reflex): require the reflex-base that has is_non_string_sequence" pin-exact rewrites the reflex-base requirement to == <version> at build time, so a released reflex never installs with an older reflex-base. * feat(event): make event_sequence public and use it in the internal forms The demo and intro forms turn their on_submit into a list of events with reflex_base.event.event_sequence instead of their own isinstance check. * build(internal): require the reflex that has event_sequence The demo and intro forms import event_sequence, which reflex 0.10.0 and older lack. --------- Co-authored-by: Farhan <www.mfarvirus@gmail.com> Co-authored-by: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Co-authored-by: Masen Furer <m_github@0x26.net>
1 parent 81f463b commit 2f71cdf

19 files changed

Lines changed: 277 additions & 35 deletions

File tree

‎news/+event-sequences.feature.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
A page's `on_load` accepts any sequence of events, e.g. a tuple, like a list of them.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Event props and the events an event handler returns accept any sequence of events, e.g. a tuple or a variable typed `list[EventSpec]`, not only a list literal. The new `reflex_base.event.event_sequence` helper returns such a value as a sequence of events, wrapping a single event in a list.

‎packages/reflex-base/src/reflex_base/event/__init__.py‎

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
ArgsSpec,
5151
GenericType,
5252
Unset,
53+
is_non_string_sequence,
5354
is_optional,
5455
safe_issubclass,
5556
typehint_issubclass,
@@ -108,7 +109,7 @@ def state_cls(self) -> "type[BaseState]":
108109
@classmethod
109110
def from_event_type(
110111
cls,
111-
events: "IndividualEventType | list[IndividualEventType] | None",
112+
events: "IndividualEventType | Sequence[IndividualEventType] | None",
112113
*,
113114
router_data: dict[str, Any] | None = None,
114115
) -> "list[Event]":
@@ -125,13 +126,9 @@ def from_event_type(
125126
if events is None:
126127
return []
127128

128-
# If the handler returns a single event, wrap it in a list.
129-
if not isinstance(events, list):
130-
events = [events]
131-
132129
# Fix the events created by the handler.
133130
out = []
134-
for e in events:
131+
for e in event_sequence(events):
135132
if callable(e) and getattr(e, "__name__", "") == "<lambda>":
136133
# A lambda was returned, assume the user wants to call it with no args.
137134
e = e()
@@ -1025,7 +1022,7 @@ def create(
10251022
events: list[EventSpec | EventVar | FunctionVar] = []
10261023

10271024
# If the input is a list of event handlers, create an event chain.
1028-
if isinstance(value, list):
1025+
if is_non_string_sequence(value):
10291026
for v in value:
10301027
if isinstance(v, (EventHandler, EventSpec)):
10311028
# Call the event handler to get the event.
@@ -2398,6 +2395,28 @@ def check_fn_match_arg_spec(
23982395
raise EventFnArgMismatchError(msg)
23992396

24002397

2398+
def event_sequence(events: Any) -> Sequence[Any]:
2399+
"""Get events as a sequence, wrapping a single event-like value in a list.
2400+
2401+
A str or bytes value counts as a single value. The single event kinds are
2402+
checked first: they are the common case, and cheaper to check than the
2403+
Sequence ABC.
2404+
2405+
Args:
2406+
events: A single event-like value or a sequence of them.
2407+
2408+
Returns:
2409+
The events as a sequence.
2410+
"""
2411+
if (
2412+
isinstance(events, (Event, EventSpec))
2413+
or callable(events)
2414+
or not is_non_string_sequence(events)
2415+
):
2416+
return [events]
2417+
return events
2418+
2419+
24012420
def call_event_fn(
24022421
fn: Callable,
24032422
arg_spec: ArgsSpec | Sequence[ArgsSpec],
@@ -2435,10 +2454,6 @@ def call_event_fn(
24352454
# Call the function with the parsed args.
24362455
out = fn(*[*parsed_args][:number_of_fn_args])
24372456

2438-
# Normalize common heterogeneous event collections into individual events
2439-
# while keeping other scalar values for validation below.
2440-
out = list(out) if isinstance(out, (list, tuple)) else [out]
2441-
24422457
def _dispatch_mixed_event_var(event_like_var: Var) -> FunctionVar:
24432458
"""Wrap a mixed event-like Var into a callable frontend dispatcher.
24442459
@@ -2494,7 +2509,7 @@ def _dispatch_mixed_event_var(event_like_var: Var) -> FunctionVar:
24942509

24952510
# Convert any event specs to event specs.
24962511
events = []
2497-
for e in out:
2512+
for e in event_sequence(out):
24982513
if isinstance(e, EventHandler):
24992514
# An un-called EventHandler gets all of the args of the event trigger.
25002515
e = call_event_handler(e, arg_spec, key=key)
@@ -2549,7 +2564,7 @@ def get_handler_args(
25492564

25502565

25512566
def fix_events(
2552-
events: list[EventSpec | EventHandler] | None,
2567+
events: Sequence[EventSpec | EventHandler] | None,
25532568
token: str | None = None,
25542569
router_data: dict[str, Any] | None = None,
25552570
) -> list[Event]:
@@ -2580,13 +2595,9 @@ def fix_events(
25802595
if events is None:
25812596
return []
25822597

2583-
# If the handler returns a single event, wrap it in a list.
2584-
if not isinstance(events, list):
2585-
events = [events]
2586-
25872598
# Fix the events created by the handler.
25882599
out = []
2589-
for e in events:
2600+
for e in event_sequence(events):
25902601
if callable(e) and getattr(e, "__name__", "") == "<lambda>":
25912602
# A lambda was returned, assume the user wants to call it with no args.
25922603
e = e()
@@ -3082,7 +3093,10 @@ def __call__(self, *args: Var) -> Any:
30823093
type_params=(ARGS,),
30833094
)
30843095

3085-
ItemOrList = V | list[V]
3096+
3097+
# A Sequence rather than a list: lists are invariant, so a `list[EventSpec]`
3098+
# variable would not count as a list of every kind of event.
3099+
ItemOrList = V | Sequence[V]
30863100

30873101
BASIC_EVENT_TYPES = TypeAliasType(
30883102
"BASIC_EVENT_TYPES", EventSpec | EventHandler | Var[Any], type_params=()
@@ -3156,6 +3170,7 @@ class EventNamespace:
31563170
upload_files_chunk = upload_files_chunk
31573171
stop_propagation = stop_propagation
31583172
prevent_default = prevent_default
3173+
event_sequence = staticmethod(event_sequence)
31593174

31603175
# Private/Internal Functions
31613176
resolve_upload_handler_param = staticmethod(resolve_upload_handler_param)

‎packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from reflex_base.utils.form import form_data_as_dict, transform_form_data
2727
from reflex_base.utils.format import format_event_handler
2828
from reflex_base.utils.serializers import deserializers
29+
from reflex_base.utils.types import is_non_string_sequence
2930

3031
logger = logging.getLogger(__name__)
3132

@@ -85,7 +86,7 @@ def _is_valid_type(events: Any) -> bool:
8586
if events is None or _is_valid_type(events):
8687
return events
8788

88-
if not (isinstance(events, Sequence) and not isinstance(events, (str, bytes))):
89+
if not is_non_string_sequence(events):
8990
events = [events]
9091

9192
try:

‎packages/reflex-base/src/reflex_base/utils/format.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from reflex_base import constants
1616
from reflex_base.utils import exceptions
17+
from reflex_base.utils.types import is_non_string_sequence
1718

1819
if TYPE_CHECKING:
1920
from reflex_base.components.component import ComponentStyle
@@ -570,7 +571,7 @@ def _default_args_spec():
570571
arg_def = "()"
571572

572573
payloads = []
573-
if not isinstance(events, list):
574+
if not is_non_string_sequence(events):
574575
events = [events]
575576

576577
# Process each event/spec/lambda (similar to Component._create_event_chain).

‎packages/reflex-base/src/reflex_base/utils/types.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from typing import get_type_hints as get_type_hints_og
4141

4242
import typing_extensions
43-
from typing_extensions import TypeAliasType, TypeVarTuple
43+
from typing_extensions import TypeAliasType, TypeIs, TypeVarTuple
4444
from typing_extensions import override as override
4545

4646
from reflex_base import constants
@@ -572,6 +572,25 @@ def is_none(cls: GenericType) -> bool:
572572
return cls is type(None) or cls is None
573573

574574

575+
def is_non_string_sequence(value: object) -> TypeIs[Sequence[Any]]:
576+
"""Check if a value is a sequence other than a str or bytes, e.g. a list of events.
577+
578+
Separate list and tuple checks come first: they are the common case, and
579+
cheaper than the Sequence ABC check.
580+
581+
Args:
582+
value: The value to check.
583+
584+
Returns:
585+
Whether the value is a sequence and not a str or bytes.
586+
"""
587+
return (
588+
isinstance(value, list) # noqa: SIM101 - a lone list check is the fastest
589+
or isinstance(value, tuple)
590+
or (isinstance(value, Sequence) and not isinstance(value, (str, bytes)))
591+
)
592+
593+
575594
def is_union(cls: GenericType) -> bool:
576595
"""Check if a class is a Union.
577596
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The demo and intro forms take any sequence of `on_submit` events, e.g. a tuple, like a list of them.

‎packages/reflex-components-internal/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ classifiers = [
1616
"Programming Language :: Python :: 3.15",
1717
]
1818
dependencies = [
19-
"reflex >= 0.9.4",
19+
"reflex >= 0.10.1.dev0",
2020
]
2121

2222
[tool.hatch.version]

‎packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import Any
99

1010
import reflex as rx
11-
from reflex.event import EventType
11+
from reflex.event import EventType, event_sequence
1212
from reflex.experimental.client_state import ClientStateVar
1313
from reflex.vars.base import get_unique_variable_name
1414
from reflex_components_internal.blocks.telemetry.posthog import (
@@ -315,9 +315,7 @@ def _demo_form(
315315
"""
316316
prefix = id_prefix or get_unique_variable_name()
317317
email_id = f"{prefix}_user_email"
318-
extra_on_submit = (
319-
on_submit if isinstance(on_submit, list) else [on_submit] if on_submit else []
320-
)
318+
extra_on_submit = event_sequence(on_submit) if on_submit is not None else []
321319
fields = (
322320
rx.el.div(
323321
input_field("First name", "John", "first_name", "text", True),

‎packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Any
77

88
import reflex as rx
9-
from reflex.event import EventType, IndividualEventType
9+
from reflex.event import EventType, event_sequence
1010
from reflex.experimental.client_state import ClientStateVar
1111
from reflex.vars.base import get_unique_variable_name
1212
from reflex_components_internal.blocks.telemetry.posthog import (
@@ -292,9 +292,7 @@ def intro_form(
292292
prefix = id_prefix or get_unique_variable_name()
293293
email_id = f"{prefix}_user_email"
294294

295-
extra: list[IndividualEventType[dict[str, Any]]] = (
296-
on_submit if isinstance(on_submit, list) else [on_submit] if on_submit else []
297-
)
295+
extra = event_sequence(on_submit) if on_submit is not None else []
298296

299297
form = rx.el.form(
300298
rx.el.div(

0 commit comments

Comments
 (0)