diff --git a/docs/api-reference/var_system.md b/docs/api-reference/var_system.md index 6642169c350..2921f0d1d08 100644 --- a/docs/api-reference/var_system.md +++ b/docs/api-reference/var_system.md @@ -30,6 +30,7 @@ If the type is known, it can be any of the following: - `BooleanVar` represents a boolean expression. For example: `false`, `3 > 2`. - `StringVar` represents an expression that evaluates to a string. For example: `'hello'`, `(2).toString()`. - `ArrayVar` represents an expression that evaluates to an array object. For example: `[1, 2, 3]`, `'words'.split()`. +- `SetVar` represents a Python `set` or `frozenset`. The value is an array without duplicates on the frontend, and set operations such as `contains`, `union` and `issubset` compile over it; call `to_array()` to use it as an `ArrayVar`. - `ObjectVar` represents an expression that evaluates to an object. For example: `{a: 2, b: 3}`, `{deeply: {nested: {value: false}}}`. - `NoneVar` represent null values. These can be either `undefined` or `null`. @@ -41,6 +42,7 @@ State fields are converted to `Var` by default. Additionally, you can create a ` rx.Var.create(4) # NumberVar rx.Var.create("hello") # StringVar rx.Var.create([1, 2, 3]) # ArrayVar +rx.Var.create({1, 2, 3}) # SetVar ``` If you want to explicitly create a `Var` from a raw Javascript string, you can instantiate `rx.Var` directly: diff --git a/docs/vars/var-operations.md b/docs/vars/var-operations.md index d6a4a424d42..cae6db4177f 100644 --- a/docs/vars/var-operations.md +++ b/docs/vars/var-operations.md @@ -426,6 +426,37 @@ def var_map_filter_reduce_example(): ) ``` +### Set Operations + +A var annotated as `set` or `frozenset` supports set operations rather than list operations: `contains`, `length`, `union`, `intersection`, `difference`, `symmetric_difference`, `issubset`, `issuperset` and `isdisjoint`. The other operand may be another set var, a list var, or a plain Python set or list. + +Sets have no order or index. To use list operations such as indexing, `map` or `join`, call `to_array()` first; `rx.foreach` accepts a set var directly. + +```python demo exec +class SetState(rx.State): + tags: set[str] = {"python", "reflex"} + featured: set[str] = {"reflex", "react"} + + @rx.event + def toggle_react(self): + self.tags.symmetric_difference_update({"react"}) + + +def var_set_example(): + return rx.vstack( + rx.text(f"Tags: {SetState.tags.to_array().join(', ')}"), + rx.text(f"Has react: {SetState.tags.contains('react')}"), + rx.text( + f"Union: {SetState.tags.union(SetState.featured).to_array().join(', ')}" + ), + rx.text( + f"Intersection: {SetState.tags.intersection(SetState.featured).to_array().join(', ')}" + ), + rx.text(f"Is subset of featured: {SetState.tags.issubset(SetState.featured)}"), + rx.button("Toggle react", on_click=SetState.toggle_react), + ) +``` + ### Lower, Upper, Split The `lower` operator converts a string var to lowercase. The `upper` operator converts a string var to uppercase. The `split` operator splits a string var into a list. diff --git a/news/+set-var-array-operations.breaking.md b/news/+set-var-array-operations.breaking.md new file mode 100644 index 00000000000..ebf342301cf --- /dev/null +++ b/news/+set-var-array-operations.breaking.md @@ -0,0 +1,6 @@ +A `set` or `frozenset` state var is now a `SetVar` rather than an `ArrayVar`, so it no longer type-checks as a sequence in frontend code: indexing, slicing, `map`, `filter`, `join`, `reverse`, `+` and `*` on it are deprecated. They still compile for now with a deprecation warning and will be removed in 1.0. Call `.to_array()` first: + +```python +State.tags[0] # deprecated +State.tags.to_array()[0] +``` diff --git a/news/+set-var.feature.md b/news/+set-var.feature.md new file mode 100644 index 00000000000..10ad45d480f --- /dev/null +++ b/news/+set-var.feature.md @@ -0,0 +1 @@ +Add `rx.vars.SetVar` for `set[T]` and `frozenset[T]` state vars and literals, with set operations (`contains`, `union`, `intersection`, `issubset`, ...) and `to_array()` for list operations and `rx.foreach`. Array operations used directly on a set var are deprecated. diff --git a/packages/reflex-base/news/+set-var-array-operations.breaking.md b/packages/reflex-base/news/+set-var-array-operations.breaking.md new file mode 100644 index 00000000000..ebf342301cf --- /dev/null +++ b/packages/reflex-base/news/+set-var-array-operations.breaking.md @@ -0,0 +1,6 @@ +A `set` or `frozenset` state var is now a `SetVar` rather than an `ArrayVar`, so it no longer type-checks as a sequence in frontend code: indexing, slicing, `map`, `filter`, `join`, `reverse`, `+` and `*` on it are deprecated. They still compile for now with a deprecation warning and will be removed in 1.0. Call `.to_array()` first: + +```python +State.tags[0] # deprecated +State.tags.to_array()[0] +``` diff --git a/packages/reflex-base/news/+set-var.feature.md b/packages/reflex-base/news/+set-var.feature.md new file mode 100644 index 00000000000..3212e46a849 --- /dev/null +++ b/packages/reflex-base/news/+set-var.feature.md @@ -0,0 +1 @@ +Add `SetVar` for `set[T]` and `frozenset[T]` state vars, computed vars and literals, with `contains`, `length`, `union`, `intersection`, `difference`, `symmetric_difference`, `issubset`, `issuperset`, `isdisjoint` and `to_array` operations. Elements compare by value, so sets of tuples work. The value still travels to the frontend as a JSON array; `rx.foreach` accepts a set var directly. `frozenset` is now a serializable state var type and is accepted by `LiteralVar.create`. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index aa79aedf662..d63b79db7e7 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -1827,6 +1827,104 @@ export const pyFlatMap = (arr, fn) => throw new TypeError(`flat_map value is not iterable: ${value}`); }); +/** + * Key a set element by value, so structured elements (tuples, dataclasses) + * compare the way Python compares them rather than by object identity. + * Numbers, booleans and null are their own key; strings and objects get + * distinct prefixes so a string can never share a key with an object's JSON. + * @param {*} x The set element. + * @returns {*} The key of the element. + */ +export const pySetKey = (x) => { + if (typeof x === "string") return "s" + x; + if (x !== null && typeof x === "object") return "o" + JSON.stringify(x); + return x; +}; + +/** + * Check whether a set (as an array) contains an element. + * @param {Array} arr The set. + * @param {*} x The element to look for. + * @returns {boolean} Whether the set contains the element. + */ +export const pySetHas = (arr, x) => { + const key = pySetKey(x); + return arr.some((y) => pySetKey(y) === key); +}; + +/** + * The elements of either set, keeping the order of a then b. + * @param {Array} a The first set. + * @param {Array} b The second set. + * @returns {Array} The union. + */ +export const pySetUnion = (a, b) => { + const seen = new Set(a.map(pySetKey)); + const result = [...a]; + for (const x of b) { + const key = pySetKey(x); + if (!seen.has(key)) { + seen.add(key); + result.push(x); + } + } + return result; +}; + +/** + * The elements of a that are also in b. + * @param {Array} a The first set. + * @param {Array} b The second set. + * @returns {Array} The intersection. + */ +export const pySetIntersection = (a, b) => { + const keys = new Set(b.map(pySetKey)); + return a.filter((x) => keys.has(pySetKey(x))); +}; + +/** + * The elements of a that are not in b. + * @param {Array} a The first set. + * @param {Array} b The second set. + * @returns {Array} The difference. + */ +export const pySetDifference = (a, b) => { + const keys = new Set(b.map(pySetKey)); + return a.filter((x) => !keys.has(pySetKey(x))); +}; + +/** + * The elements in exactly one of a and b, without duplicates even when an + * operand is a plain array that holds some. + * @param {Array} a The first set. + * @param {Array} b The second set. + * @returns {Array} The symmetric difference. + */ +export const pySetSymmetricDifference = (a, b) => + pySetUnion(pySetDifference(a, b), pySetDifference(b, a)); + +/** + * Whether every element of a is in b. + * @param {Array} a The first set. + * @param {Array} b The second set. + * @returns {boolean} Whether a is a subset of b. + */ +export const pySetIsSubset = (a, b) => { + const keys = new Set(b.map(pySetKey)); + return a.every((x) => keys.has(pySetKey(x))); +}; + +/** + * Whether a and b have no element in common. + * @param {Array} a The first set. + * @param {Array} b The second set. + * @returns {boolean} Whether the sets are disjoint. + */ +export const pySetIsDisjoint = (a, b) => { + const keys = new Set(b.map(pySetKey)); + return !a.some((x) => keys.has(pySetKey(x))); +}; + /** * Merge refs into a single callback ref, attaching the node to all of them. * diff --git a/packages/reflex-base/src/reflex_base/utils/serializers.py b/packages/reflex-base/src/reflex_base/utils/serializers.py index 29ac48cf60f..23c2fc75d75 100644 --- a/packages/reflex-base/src/reflex_base/utils/serializers.py +++ b/packages/reflex-base/src/reflex_base/utils/serializers.py @@ -411,6 +411,19 @@ def serialize_set(value: set) -> list: return list(value) +@serializer +def serialize_frozenset(value: frozenset) -> list: + """Serialize a frozenset to a JSON serializable list. + + Args: + value: The frozenset to serialize. + + Returns: + The serialized list. + """ + return list(value) + + @serializer def serialize_sequence(value: Sequence) -> list: """Serialize a sequence to a JSON serializable list. diff --git a/packages/reflex-base/src/reflex_base/vars/__init__.py b/packages/reflex-base/src/reflex_base/vars/__init__.py index aee10ddca58..25ae3aa6b1e 100644 --- a/packages/reflex-base/src/reflex_base/vars/__init__.py +++ b/packages/reflex-base/src/reflex_base/vars/__init__.py @@ -1,6 +1,6 @@ """Immutable-Based Var System.""" -from . import base, color, datetime, function, number, object, sequence +from . import base, color, datetime, function, number, object, sequence, set from .base import ( EMPTY_VAR_INT, EMPTY_VAR_STR, @@ -29,6 +29,7 @@ LiteralStringVar, StringVar, ) +from .set import LiteralSetVar, SetVar from .special import use_hook_var, use_id __all__ = [ @@ -49,11 +50,13 @@ "LiteralColorVar", "LiteralNumberVar", "LiteralObjectVar", + "LiteralSetVar", "LiteralStringVar", "LiteralVar", "NumberVar", "ObjectVar", "RestProp", + "SetVar", "StringVar", "Var", "VarData", @@ -69,6 +72,7 @@ "number", "object", "sequence", + "set", "use_hook_var", "use_id", "var_operation", diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 500579629ce..a970245e477 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -94,6 +94,7 @@ from .number import BooleanVar, LiteralBooleanVar, LiteralNumberVar, NumberVar from .object import LiteralObjectVar, ObjectVar from .sequence import ArrayVar, LiteralArrayVar, LiteralStringVar, StringVar + from .set import LiteralSetVar, SetVar VAR_TYPE = TypeVar("VAR_TYPE", covariant=True) @@ -101,6 +102,7 @@ STRING_T = TypeVar("STRING_T", bound=str) LITERAL_STRING_T = TypeVar("LITERAL_STRING_T", bound=LiteralString) SEQUENCE_TYPE = TypeVar("SEQUENCE_TYPE", bound=Sequence) +SET_TYPE = TypeVar("SET_TYPE", bound=set | frozenset) warnings.filterwarnings("ignore", message="fields may not start with an underscore") @@ -1046,6 +1048,14 @@ def create( _var_data: VarData | None = None, ) -> LiteralArrayVar[SEQUENCE_TYPE]: ... + @overload + @classmethod + def create( + cls, + value: SET_TYPE, + _var_data: VarData | None = None, + ) -> LiteralSetVar[SET_TYPE]: ... + @overload @classmethod def create( @@ -1112,6 +1122,12 @@ def to( output: type[SEQUENCE_TYPE], ) -> ArrayVar[SEQUENCE_TYPE]: ... + @overload + def to( + self, + output: type[SET_TYPE], + ) -> SetVar[SET_TYPE]: ... + @overload def to( self, @@ -1884,6 +1900,7 @@ def _create_literal_var( # (EMPTY_VAR_STR is built while base.py is still loading). from .object import LiteralObjectVar from .sequence import ArrayVar, LiteralStringVar + from .set import LiteralSetVar # noqa: F401 if (literal_subclass := _literal_var_for(value)) is not None: return literal_subclass.create(value, _var_data=_var_data) @@ -2105,6 +2122,16 @@ def var_operation( ) -> Callable[P, ArrayVar[LIST_T]]: ... +SET_T = TypeVar("SET_T", bound=set | frozenset) + + +@overload +def var_operation( + func: Callable[P, CustomVarOperationReturn[SET_T]] + | Callable[P, CustomVarOperationReturn[SET_T | None]], +) -> Callable[P, SetVar[SET_T]]: ... + + OBJECT_TYPE = TypeVar("OBJECT_TYPE", bound=Mapping) @@ -2174,7 +2201,7 @@ def figure_out_type(value: Any) -> types.GenericType: Returns: The type of the value. """ - if isinstance(value, (list, set, tuple, Mapping, Var)): + if isinstance(value, (list, set, frozenset, tuple, Mapping, Var)): if isinstance(value, Var): return value._var_type if has_args(value_type := type(value)): @@ -2183,8 +2210,11 @@ def figure_out_type(value: Any) -> types.GenericType: if not value: return Sequence[NoReturn] return Sequence[unionize(*{figure_out_type(v) for v in value[:100]})] - if isinstance(value, set): - return set[unionize(*{figure_out_type(v) for v in value})] + if isinstance(value, (set, frozenset)): + element_type = unionize(*{figure_out_type(v) for v in value}) + if isinstance(value, frozenset): + return frozenset[element_type] + return set[element_type] if isinstance(value, tuple): if not value: return tuple[NoReturn, ...] @@ -2889,6 +2919,20 @@ def __get__( owner: type, ) -> ArrayVar[tuple[LIST_INSIDE, ...]]: ... + @overload + def __get__( + self: ComputedVar[set[LIST_INSIDE]], + instance: None, + owner: type, + ) -> SetVar[set[LIST_INSIDE]]: ... + + @overload + def __get__( + self: ComputedVar[frozenset[LIST_INSIDE]], + instance: None, + owner: type, + ) -> SetVar[frozenset[LIST_INSIDE]]: ... + @overload def __get__( self: ComputedVar[SQLA_TYPE], @@ -3206,6 +3250,20 @@ def __get__( owner: type, ) -> ArrayVar[tuple[LIST_INSIDE, ...]]: ... + @overload + def __get__( + self: AsyncComputedVar[set[LIST_INSIDE]], + instance: None, + owner: type, + ) -> SetVar[set[LIST_INSIDE]]: ... + + @overload + def __get__( + self: AsyncComputedVar[frozenset[LIST_INSIDE]], + instance: None, + owner: type, + ) -> SetVar[frozenset[LIST_INSIDE]]: ... + @overload def __get__( self: AsyncComputedVar[SQLA_TYPE], @@ -4457,10 +4515,7 @@ def __get__( @overload def __get__( - self: Field[list[V]] - | Field[set[V]] - | Field[list[V] | None] - | Field[set[V] | None], + self: Field[list[V]] | Field[list[V] | None], instance: None, owner: Any, ) -> ArrayVar[Sequence[V]]: ... @@ -4472,6 +4527,13 @@ def __get__( owner: Any, ) -> ArrayVar[SEQUENCE_TYPE]: ... + @overload + def __get__( + self: Field[SET_TYPE] | Field[SET_TYPE | None], + instance: None, + owner: Any, + ) -> SetVar[SET_TYPE]: ... + # The bounds of these type variables admit `None` (for computed vars of an # optional object). Matched against `Field[X | None]`, a `self` union of # `Field[T]` and `Field[T | None]` lets a checker solve `T` to `X | None`, so diff --git a/packages/reflex-base/src/reflex_base/vars/sequence.py b/packages/reflex-base/src/reflex_base/vars/sequence.py index 1ded6c57ab1..357734b236c 100644 --- a/packages/reflex-base/src/reflex_base/vars/sequence.py +++ b/packages/reflex-base/src/reflex_base/vars/sequence.py @@ -63,7 +63,7 @@ VALUE_TYPE = TypeVar("VALUE_TYPE") -class ArrayVar(Var[ARRAY_VAR_TYPE], python_types=(Sequence, set)): +class ArrayVar(Var[ARRAY_VAR_TYPE], python_types=Sequence): """Base class for immutable array vars.""" def join(self, sep: StringVar | str = "") -> StringVar: @@ -117,6 +117,10 @@ def __add__(self, other: ArrayVar[ARRAY_VAR_TYPE]) -> ArrayVar[ARRAY_VAR_TYPE]: ArrayConcatOperation: The concatenation of the two arrays. """ if not isinstance(other, ArrayVar): + from .set import SetVar + + if isinstance(other, SetVar): + return other.__radd__(self) raise_unsupported_operand_types("+", (type(self), type(other))) return array_concat_operation(self, other) @@ -603,6 +607,44 @@ def flat_map(self, fn: Any): ) +def _literal_elements_var_data(elements: Iterable) -> VarData | None: + """Merge the VarData of the elements of a literal collection. + + Args: + elements: The elements of the collection. + + Returns: + The merged VarData, or None if no element carries any. + """ + return VarData.merge(*[ + LiteralVar._get_all_var_data_without_creating_var_dispatch(element) + for element in elements + ]) + + +def _literal_elements_json(elements: Iterable) -> str: + """Render the elements of a literal collection as a JSON array. + + Args: + elements: The elements of the collection. + + Returns: + The JSON array. + + Raises: + TypeError: If an element is not a LiteralVar. + """ + rendered = [] + for element in elements: + element_var = LiteralVar.create(element) + if not isinstance(element_var, LiteralVar): + msg = f"Array elements must be of type LiteralVar, not {type(element_var)}" + raise TypeError(msg) + rendered.append(element_var.json()) + + return "[" + ", ".join(rendered) + "]" + + @dataclasses.dataclass( eq=False, frozen=True, @@ -640,10 +682,7 @@ def _get_all_var_data_without_creating_var(cls, value: Iterable) -> VarData | No Returns: The VarData associated with the Var. """ - return VarData.merge(*[ - LiteralVar._get_all_var_data_without_creating_var_dispatch(element) - for element in value - ]) + return _literal_elements_var_data(value) @cached_property_no_lock def _cached_get_all_var_data(self) -> VarData | None: @@ -653,11 +692,7 @@ def _cached_get_all_var_data(self) -> VarData | None: The VarData associated with the Var. """ return VarData.merge( - *[ - LiteralVar._get_all_var_data_without_creating_var_dispatch(element) - for element in self._var_value - ], - self._var_data, + _literal_elements_var_data(self._var_value), self._var_data ) def json(self) -> str: @@ -665,19 +700,8 @@ def json(self) -> str: Returns: The JSON representation of the var. - - Raises: - TypeError: If the array elements are not of type LiteralVar. """ - elements = [] - for element in self._var_value: - element_var = LiteralVar.create(element) - if not isinstance(element_var, LiteralVar): - msg = f"Array elements must be of type LiteralVar, not {type(element_var)}" - raise TypeError(msg) - elements.append(element_var.json()) - - return "[" + ", ".join(elements) + "]" + return _literal_elements_json(self._var_value) @classmethod def create( @@ -1920,6 +1944,8 @@ def _determine_value_of_array_index( Iterable, list, set, + frozenset, + collections.abc.Set, collections.abc.Sequence, collections.abc.Iterable, ]: diff --git a/packages/reflex-base/src/reflex_base/vars/set.py b/packages/reflex-base/src/reflex_base/vars/set.py new file mode 100644 index 00000000000..031f2e819b6 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/vars/set.py @@ -0,0 +1,727 @@ +"""Immutable set vars. + +A set travels to the frontend as a JSON array (see ``serialize_set``), the same +value that comes back through event arguments, so every operation here compiles +to JavaScript over that array. Operations that produce a set yield an array +without duplicates; membership-style operations build a JS ``Set`` only inside +the compiled expression. +""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Any, TypeVar, overload + +from reflex_base.constants.base import Dirs +from reflex_base.utils import console, types +from reflex_base.utils.imports import ImportVar +from reflex_base.utils.types import GenericType, get_origin, unionize + +from .base import ( + CachedVarOperation, + CustomVarOperationReturn, + LiteralVar, + Var, + VarData, + cached_property_no_lock, + figure_out_type, + var_operation, + var_operation_return, +) +from .number import BooleanVar, NumberVar, raise_unsupported_operand_types +from .sequence import ( + ArrayVar, + _determine_value_of_array_index, + _literal_elements_json, + _literal_elements_var_data, +) + +if TYPE_CHECKING: + from typing_extensions import deprecated + +SET_VAR_TYPE = TypeVar("SET_VAR_TYPE", bound=set | frozenset, covariant=True) +OTHER_SET_VAR_TYPE = TypeVar("OTHER_SET_VAR_TYPE", bound=set | frozenset) +SET_ELEMENT = TypeVar("SET_ELEMENT") + +_OTHER_SET_TYPES = (set, frozenset, list, tuple) + +_ARRAY_OPERATION_REASON = ( + "A set is not a sequence; call .to_array() first to use array operations." +) + + +def _element_type(var_type: GenericType) -> GenericType: + """Get the element type of a set type. + + Args: + var_type: The set type, e.g. ``set[str]``. + + Returns: + The element type, or Any for an unparameterized set. + """ + return _determine_value_of_array_index(var_type) + + +def _set_type_like(var_type: GenericType, element_type: GenericType) -> GenericType: + """Build a set type with the given element type, keeping frozenset-ness. + + Args: + var_type: The set type whose origin (set or frozenset) to keep. + element_type: The element type of the resulting set. + + Returns: + ``set[element_type]``, or ``frozenset[element_type]`` for a frozenset. + """ + origin = get_origin(var_type) or var_type + return (frozenset if origin is frozenset else set)[element_type] + + +def _combined_set_type(lhs: SetVar, rhs: Var) -> GenericType: + """The type of a set holding elements of both operands. + + Args: + lhs: The set whose origin (set or frozenset) the result keeps. + rhs: The other set or array. + + Returns: + The set type whose element type is the union of both element types. + """ + return _set_type_like( + lhs._var_type, + unionize(_element_type(lhs._var_type), _element_type(rhs._var_type)), + ) + + +def _set_helper_operation( + helper: str, *args: Var, var_type: GenericType +) -> CustomVarOperationReturn: + """Call one of the ``pySet*`` helpers of state.js. + + Args: + helper: The name of the helper. + *args: The arguments of the helper. + var_type: The type of the result. + + Returns: + The var operation return. + """ + return var_operation_return( + js_expression=f"{helper}({', '.join(map(str, args))})", + var_type=var_type, + var_data=VarData( + imports={f"$/{Dirs.STATE_PATH}": [ImportVar(tag=helper)]}, + ), + ) + + +def _deprecate_array_operation(name: str) -> None: + """Warn that an array operation is used on a set var. + + Args: + name: The name of the array operation. + """ + console.deprecate( + feature_name=f"SetVar.{name}", + reason=_ARRAY_OPERATION_REASON, + deprecation_version="0.10.1", + removal_version="1.0", + ) + + +def _as_array(value: Any) -> Any: + """View a set var as an array, leaving any other value as it is. + + Args: + value: The operand of an array operation. + + Returns: + The array view of a set var, or the value itself. + """ + return value.to_array() if isinstance(value, SetVar) else value + + +def _deprecated_array_operation(name: str) -> Callable[..., Any]: + """Make a method that runs an array operation on the set as an array. + + The method warns that the operation is deprecated on sets, then delegates + to the same operation on ``to_array()``, with set operands viewed as + arrays too. + + Args: + name: The name of the array operation. + + Returns: + The method. + """ + + def operation(self: SetVar, *args: Any, **kwargs: Any) -> Any: + _deprecate_array_operation(name) + return getattr(self.to_array(), name)(*map(_as_array, args), **kwargs) + + operation.__name__ = name + return operation + + +def _check_other(operation: str, this: SetVar, other: Any) -> None: + """Reject an operand that is not a set or an array. + + Args: + operation: The name of the operation, for the error message. + this: The set var the operation is called on. + other: The operand to check. + """ + if not isinstance(other, (SetVar, ArrayVar, *_OTHER_SET_TYPES)): + raise_unsupported_operand_types(operation, (type(this), type(other))) + + +class SetVar(Var[SET_VAR_TYPE], python_types=(set, frozenset)): + """Base class for immutable set vars.""" + + def contains(self, other: Any) -> BooleanVar: + """Check if the set contains an element. + + Args: + other: The element to check for. + + Returns: + Whether the set contains the element. + """ + return set_contains_operation(self, other) + + has = contains + + def length(self) -> NumberVar[int]: + """Get the number of elements in the set. + + Returns: + The number of elements. + """ + return set_length_operation(self) + + size = length + + def union(self, other: Any) -> SetVar[SET_VAR_TYPE]: + """Get the elements in this set or the other. + + Args: + other: The other set or array. + + Returns: + The union of the two sets. + """ + _check_other("union", self, other) + return set_union_operation(self, other) + + def intersection(self, other: Any) -> SetVar[SET_VAR_TYPE]: + """Get the elements in both this set and the other. + + Args: + other: The other set or array. + + Returns: + The intersection of the two sets. + """ + _check_other("intersection", self, other) + return set_intersection_operation(self, other) + + def difference(self, other: Any) -> SetVar[SET_VAR_TYPE]: + """Get the elements in this set but not in the other. + + Args: + other: The other set or array. + + Returns: + The difference of the two sets. + """ + _check_other("difference", self, other) + return set_difference_operation(self, other) + + def symmetric_difference(self, other: Any) -> SetVar[SET_VAR_TYPE]: + """Get the elements in exactly one of this set and the other. + + Args: + other: The other set or array. + + Returns: + The symmetric difference of the two sets. + """ + _check_other("symmetric_difference", self, other) + return set_symmetric_difference_operation(self, other) + + def issubset(self, other: Any) -> BooleanVar: + """Check if every element of this set is in the other. + + Args: + other: The other set or array. + + Returns: + Whether this set is a subset of the other. + """ + _check_other("issubset", self, other) + return set_issubset_operation(self, other) + + def issuperset(self, other: Any) -> BooleanVar: + """Check if every element of the other is in this set. + + Args: + other: The other set or array. + + Returns: + Whether this set is a superset of the other. + """ + _check_other("issuperset", self, other) + return set_issuperset_operation(self, other) + + def isdisjoint(self, other: Any) -> BooleanVar: + """Check if this set and the other have no element in common. + + Args: + other: The other set or array. + + Returns: + Whether the two sets are disjoint. + """ + _check_other("isdisjoint", self, other) + return set_isdisjoint_operation(self, other) + + @overload + def to_array( + self: SetVar[set[SET_ELEMENT]] | SetVar[frozenset[SET_ELEMENT]], + ) -> ArrayVar[Sequence[SET_ELEMENT]]: ... + + @overload + def to_array(self) -> ArrayVar[Sequence[Any]]: ... + + def to_array(self) -> ArrayVar: + """View the set as an array, for rx.foreach and array operations. + + The frontend value is already an array, so this only changes the type. + An optional set stays optional. + + Returns: + The set as an array var. + """ + array_type = Sequence[_element_type(self._var_type)] + if types.is_optional(self._var_type): + array_type = array_type | None + return self.to(ArrayVar, array_type) + + if TYPE_CHECKING: + + @deprecated(_ARRAY_OPERATION_REASON) + def __radd__(self, other: ArrayVar) -> ArrayVar: + """Concatenate an array with the set as an array (deprecated, use to_array). + + Args: + other: The array. + + Returns: + The concatenated array. + """ + ... + + else: + + def __radd__(self, other: ArrayVar) -> ArrayVar: + """Concatenate an array with the set as an array (deprecated, use to_array). + + Args: + other: The array. + + Returns: + The concatenated array. + """ + _deprecate_array_operation("__radd__") + return other + self.to_array() + + if TYPE_CHECKING: + + @deprecated(_ARRAY_OPERATION_REASON) + def __getitem__(self, i: Any) -> Var: + """Index the set as an array (deprecated, use to_array). + + Args: + i: The index or slice. + + Returns: + The element or slice. + """ + ... + + @deprecated(_ARRAY_OPERATION_REASON) + def __add__(self, other: Any) -> ArrayVar: + """Concatenate the set as an array (deprecated, use to_array). + + Args: + other: The other array. + + Returns: + The concatenated array. + """ + ... + + @deprecated(_ARRAY_OPERATION_REASON) + def __mul__(self, other: Any) -> ArrayVar: + """Repeat the set as an array (deprecated, use to_array). + + Args: + other: The number of repetitions. + + Returns: + The repeated array. + """ + ... + + @deprecated(_ARRAY_OPERATION_REASON) + def join(self, sep: Any = "") -> Var: + """Join the set as an array (deprecated, use to_array). + + Args: + sep: The separator between elements. + + Returns: + The joined string. + """ + ... + + @deprecated(_ARRAY_OPERATION_REASON) + def reverse(self) -> ArrayVar: + """Reverse the set as an array (deprecated, use to_array). + + Returns: + The reversed array. + """ + ... + + @deprecated(_ARRAY_OPERATION_REASON) + def pluck(self, field: Any) -> ArrayVar: + """Pluck a field from the set as an array (deprecated, use to_array). + + Args: + field: The field to pluck. + + Returns: + The plucked array. + """ + ... + + @deprecated(_ARRAY_OPERATION_REASON) + def map(self, fn: Any) -> Var: + """Map over the set as an array (deprecated, use to_array). + + Args: + fn: The function to apply. + + Returns: + The mapped array. + """ + ... + + @deprecated(_ARRAY_OPERATION_REASON) + def foreach(self, fn: Any) -> Var: + """Map over the set as an array (deprecated, use to_array). + + Args: + fn: The function to apply. + + Returns: + The mapped array. + """ + ... + + @deprecated(_ARRAY_OPERATION_REASON) + def filter(self, fn: Any = None) -> ArrayVar: + """Filter the set as an array (deprecated, use to_array). + + Args: + fn: The predicate. + + Returns: + The filtered array. + """ + ... + + @deprecated(_ARRAY_OPERATION_REASON) + def reduce(self, fn: Any, initial: Any = ...) -> Var: + """Reduce the set as an array (deprecated, use to_array). + + Args: + fn: The reducer. + initial: The initial accumulator. + + Returns: + The reduced value. + """ + ... + + @deprecated(_ARRAY_OPERATION_REASON) + def flat_map(self, fn: Any) -> Var: + """Flat-map over the set as an array (deprecated, use to_array). + + Args: + fn: The function to apply. + + Returns: + The flattened array. + """ + ... + + else: + __getitem__ = _deprecated_array_operation("__getitem__") + __add__ = _deprecated_array_operation("__add__") + __mul__ = _deprecated_array_operation("__mul__") + __rmul__ = __mul__ + join = _deprecated_array_operation("join") + reverse = _deprecated_array_operation("reverse") + pluck = _deprecated_array_operation("pluck") + map = _deprecated_array_operation("map") + foreach = _deprecated_array_operation("foreach") + filter = _deprecated_array_operation("filter") + reduce = _deprecated_array_operation("reduce") + flat_map = _deprecated_array_operation("flat_map") + + +@var_operation +def set_contains_operation( + haystack: SetVar, needle: Any | Var +) -> CustomVarOperationReturn[bool]: + """Check if a set contains an element. + + Args: + haystack: The set to check. + needle: The element to check for. + + Returns: + The set contains operation. + """ + return _set_helper_operation("pySetHas", haystack, needle, var_type=bool) + + +@var_operation +def set_length_operation(value: SetVar) -> CustomVarOperationReturn[int]: + """Get the number of elements in a set. + + Args: + value: The set. + + Returns: + The set length operation. + """ + return var_operation_return(js_expression=f"{value!s}.length", var_type=int) + + +@var_operation +def set_union_operation( + lhs: SetVar[SET_VAR_TYPE], rhs: SetVar | ArrayVar +) -> CustomVarOperationReturn[SET_VAR_TYPE]: + """Get the union of two sets. + + Args: + lhs: The left-hand side set. + rhs: The right-hand side set or array. + + Returns: + The set union operation. + """ + return _set_helper_operation( + "pySetUnion", lhs, rhs, var_type=_combined_set_type(lhs, rhs) + ) + + +@var_operation +def set_intersection_operation( + lhs: SetVar[SET_VAR_TYPE], rhs: SetVar | ArrayVar +) -> CustomVarOperationReturn[SET_VAR_TYPE]: + """Get the intersection of two sets. + + Args: + lhs: The left-hand side set. + rhs: The right-hand side set or array. + + Returns: + The set intersection operation. + """ + return _set_helper_operation("pySetIntersection", lhs, rhs, var_type=lhs._var_type) + + +@var_operation +def set_difference_operation( + lhs: SetVar[SET_VAR_TYPE], rhs: SetVar | ArrayVar +) -> CustomVarOperationReturn[SET_VAR_TYPE]: + """Get the elements of a set that are not in another. + + Args: + lhs: The left-hand side set. + rhs: The right-hand side set or array. + + Returns: + The set difference operation. + """ + return _set_helper_operation("pySetDifference", lhs, rhs, var_type=lhs._var_type) + + +@var_operation +def set_symmetric_difference_operation( + lhs: SetVar[SET_VAR_TYPE], rhs: SetVar | ArrayVar +) -> CustomVarOperationReturn[SET_VAR_TYPE]: + """Get the elements in exactly one of two sets. + + Args: + lhs: The left-hand side set. + rhs: The right-hand side set or array. + + Returns: + The set symmetric difference operation. + """ + return _set_helper_operation( + "pySetSymmetricDifference", lhs, rhs, var_type=_combined_set_type(lhs, rhs) + ) + + +@var_operation +def set_issubset_operation( + lhs: SetVar, rhs: SetVar | ArrayVar +) -> CustomVarOperationReturn[bool]: + """Check if every element of a set is in another. + + Args: + lhs: The left-hand side set. + rhs: The right-hand side set or array. + + Returns: + The set issubset operation. + """ + return _set_helper_operation("pySetIsSubset", lhs, rhs, var_type=bool) + + +@var_operation +def set_issuperset_operation( + lhs: SetVar, rhs: SetVar | ArrayVar +) -> CustomVarOperationReturn[bool]: + """Check if a set contains every element of another. + + Args: + lhs: The left-hand side set. + rhs: The right-hand side set or array. + + Returns: + The set issuperset operation. + """ + return _set_helper_operation("pySetIsSubset", rhs, lhs, var_type=bool) + + +@var_operation +def set_isdisjoint_operation( + lhs: SetVar, rhs: SetVar | ArrayVar +) -> CustomVarOperationReturn[bool]: + """Check if two sets have no element in common. + + Args: + lhs: The left-hand side set. + rhs: The right-hand side set or array. + + Returns: + The set isdisjoint operation. + """ + return _set_helper_operation("pySetIsDisjoint", lhs, rhs, var_type=bool) + + +def _ordered(value: set | frozenset) -> list: + """Order the elements of a set literal, so it compiles the same way every run. + + Args: + value: The set to order. + + Returns: + The sorted elements, or the set's own order when they do not compare. + """ + try: + return sorted(value) + except TypeError: + return list(value) + + +@dataclasses.dataclass( + eq=False, + frozen=True, + slots=True, +) +class LiteralSetVar(CachedVarOperation, LiteralVar[SET_VAR_TYPE], SetVar[SET_VAR_TYPE]): + """Base class for immutable literal set vars.""" + + _var_value: set[Var | Any] | frozenset[Var | Any] = dataclasses.field( + default_factory=frozenset + ) + + @cached_property_no_lock + def _cached_var_name(self) -> str: + """The name of the var. + + Returns: + The name of the var. + """ + return ( + "[" + + ", ".join([ + str(LiteralVar.create(element)) for element in _ordered(self._var_value) + ]) + + "]" + ) + + @classmethod + def _get_all_var_data_without_creating_var( + cls, value: set | frozenset + ) -> VarData | None: + """Get all the VarData associated with the Var without creating a Var. + + Args: + value: The value to get the VarData for. + + Returns: + The VarData associated with the Var. + """ + return _literal_elements_var_data(value) + + @cached_property_no_lock + def _cached_get_all_var_data(self) -> VarData | None: + """Get all the VarData associated with the Var. + + Returns: + The VarData associated with the Var. + """ + return VarData.merge( + _literal_elements_var_data(self._var_value), self._var_data + ) + + def json(self) -> str: + """Get the JSON representation of the var. + + Returns: + The JSON representation of the var. + """ + return _literal_elements_json(_ordered(self._var_value)) + + @classmethod + def create( + cls, + value: OTHER_SET_VAR_TYPE, + _var_type: type[OTHER_SET_VAR_TYPE] | None = None, + _var_data: VarData | None = None, + ) -> LiteralSetVar[OTHER_SET_VAR_TYPE]: + """Create a var from a set value. + + Args: + value: The value to create the var from. + _var_type: The type of the var. + _var_data: Additional hooks and imports associated with the Var. + + Returns: + The var. + """ + return LiteralSetVar( + _js_expr="", + _var_type=figure_out_type(value) if _var_type is None else _var_type, + _var_data=_var_data, + _var_value=value, + ) diff --git a/packages/reflex-components-core/news/+foreach-set-var.feature.md b/packages/reflex-components-core/news/+foreach-set-var.feature.md new file mode 100644 index 00000000000..700924c5d9f --- /dev/null +++ b/packages/reflex-components-core/news/+foreach-set-var.feature.md @@ -0,0 +1 @@ +`rx.foreach` iterates a `set` or `frozenset` var through its array view, so set-typed state vars keep working with the new `SetVar`. diff --git a/packages/reflex-components-core/src/reflex_components_core/core/foreach.py b/packages/reflex-components-core/src/reflex_components_core/core/foreach.py index 1df59feda9d..abccd348e1a 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/foreach.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/foreach.py @@ -64,7 +64,7 @@ def create( # noqa: DAR401 with_traceback # noqa: DAR402 UntypedVarError """ - from reflex_base.vars import ArrayVar, ObjectVar, StringVar + from reflex_base.vars import ArrayVar, ObjectVar, SetVar, StringVar from reflex.state import ComponentState @@ -95,6 +95,9 @@ def create( if isinstance(iterable, StringVar): iterable = iterable.split() + if isinstance(iterable, SetVar): + iterable = iterable.to_array() + if not isinstance(iterable, ArrayVar): msg = ( f"Could not foreach over var `{iterable!s}` of type {iterable._var_type}. " diff --git a/reflex/vars/set.py b/reflex/vars/set.py new file mode 100644 index 00000000000..d9c92251e69 --- /dev/null +++ b/reflex/vars/set.py @@ -0,0 +1,4 @@ +# pyright: reportWildcardImportFromLibrary=false +"""Re-export from reflex_base.""" + +from reflex_base.vars.set import * # pragma: no cover diff --git a/tests/integration/tests_playwright/test_set_operations.py b/tests/integration/tests_playwright/test_set_operations.py new file mode 100644 index 00000000000..1081368a4b7 --- /dev/null +++ b/tests/integration/tests_playwright/test_set_operations.py @@ -0,0 +1,207 @@ +from collections.abc import Generator + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + + +def SetOperationsApp(): + import reflex as rx + + class SetOperationsState(rx.State): + tags: rx.Field[set[str]] = rx.field({"a", "b"}) + other: rx.Field[frozenset[str]] = rx.field(frozenset({"b", "c"})) + points: rx.Field[set[tuple[int, int]]] = rx.field({(1, 2), (3, 4)}) + maybe: rx.Field[set[str] | None] = rx.field(None) + received: rx.Field[list[str]] = rx.field([]) + + @rx.event + def add_tag(self): + self.tags.add("c") + + @rx.event + def remove_tag(self): + self.tags.discard("a") + + @rx.event + def set_maybe(self): + self.maybe = {"m"} + + @rx.event + def receive(self, tags: set[str]): + # The frontend sends the set back as a JSON array. + self.received = sorted(tags) + + def sorted_text(var, id: str): + return rx.text(var.to_array().join(","), id=id) + + app = rx.App() + + @app.add_page + def index(): + return rx.vstack( + rx.input( + value=SetOperationsState.router.session.client_token, + read_only=True, + id="token", + ), + sorted_text(SetOperationsState.tags, "tags"), + rx.text(SetOperationsState.tags.length(), id="length"), + rx.text(SetOperationsState.tags.contains("a"), id="has_a"), + rx.text(SetOperationsState.tags.contains("c"), id="has_c"), + sorted_text( + SetOperationsState.tags.union(SetOperationsState.other), "union" + ), + sorted_text( + SetOperationsState.tags.intersection(SetOperationsState.other), + "intersection", + ), + sorted_text( + SetOperationsState.tags.difference(SetOperationsState.other), + "difference", + ), + sorted_text( + SetOperationsState.tags.symmetric_difference(SetOperationsState.other), + "symmetric_difference", + ), + rx.text( + SetOperationsState.tags.issubset(SetOperationsState.other), + id="issubset", + ), + rx.text( + SetOperationsState.tags.issuperset({"b"}), + id="issuperset", + ), + rx.text( + SetOperationsState.tags.isdisjoint(["z"]), + id="isdisjoint", + ), + rx.foreach( + SetOperationsState.tags, + lambda tag: rx.text(tag, class_name="tag"), + ), + # Tuple elements compare by value, not by array identity. + rx.text(SetOperationsState.points.contains((1, 2)), id="has_point"), + rx.text( + SetOperationsState.points.union({(3, 4), (5, 6)}).length(), + id="points_union_length", + ), + rx.text( + SetOperationsState.points.intersection({(3, 4)}).length(), + id="points_intersection_length", + ), + # A plain list operand may hold duplicates; the result is still a set. + rx.text( + SetOperationsState.points.symmetric_difference([ + (3, 4), + (5, 6), + (5, 6), + ]).length(), + id="points_symmetric_difference_length", + ), + rx.text(SetOperationsState.points.contains("o[1,2]"), id="has_key_string"), + # An unset optional set renders no children instead of failing. + rx.foreach( + SetOperationsState.maybe, + lambda tag: rx.text(tag, class_name="maybe"), + ), + rx.text(SetOperationsState.received.join(","), id="received"), + rx.button("Add", id="add", on_click=SetOperationsState.add_tag), + rx.button("Remove", id="remove", on_click=SetOperationsState.remove_tag), + rx.button( + "Set maybe", id="set_maybe", on_click=SetOperationsState.set_maybe + ), + rx.button( + "Send", + id="send", + on_click=SetOperationsState.receive(SetOperationsState.tags), + ), + rx.button( + "Send union", + id="send_union", + on_click=SetOperationsState.receive( + SetOperationsState.tags.union(SetOperationsState.other) + ), + ), + ) + + +@pytest.fixture +def set_operations_app(tmp_path_factory) -> Generator[AppHarness, None, None]: + """Start the set operations app at tmp_path via AppHarness. + + Args: + tmp_path_factory: pytest tmp_path_factory fixture + + Yields: + running AppHarness instance + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("set_operations_app"), + app_source=SetOperationsApp, + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness + + +def _sorted_text(page: Page, id: str) -> str: + text = page.locator(f"id={id}").text_content() or "" + return ",".join(sorted(part for part in text.split(",") if part)) + + +def test_set_operations(set_operations_app: AppHarness, page: Page): + assert set_operations_app.frontend_url is not None + + page.goto(set_operations_app.frontend_url) + expect(page).to_have_url(set_operations_app.frontend_url) + # Wait for the backend connection so clicks reach the event handlers. + expect(page.locator("#token")).not_to_have_value("") + + expect(page.locator("id=length")).to_have_text("2") + assert _sorted_text(page, "tags") == "a,b" + expect(page.locator("id=has_a")).to_have_text("true") + expect(page.locator("id=has_c")).to_have_text("false") + assert _sorted_text(page, "union") == "a,b,c" + assert _sorted_text(page, "intersection") == "b" + assert _sorted_text(page, "difference") == "a" + assert _sorted_text(page, "symmetric_difference") == "a,c" + expect(page.locator("id=issubset")).to_have_text("false") + expect(page.locator("id=issuperset")).to_have_text("true") + expect(page.locator("id=isdisjoint")).to_have_text("true") + expect(page.locator(".tag")).to_have_count(2) + + expect(page.locator("id=has_point")).to_have_text("true") + expect(page.locator("id=points_union_length")).to_have_text("3") + expect(page.locator("id=points_intersection_length")).to_have_text("1") + expect(page.locator("id=points_symmetric_difference_length")).to_have_text("2") + expect(page.locator("id=has_key_string")).to_have_text("false") + + expect(page.locator(".maybe")).to_have_count(0) + page.locator("id=set_maybe").click() + expect(page.locator(".maybe")).to_have_count(1) + + # A backend mutation of the set re-renders every operation. + page.locator("id=add").click() + expect(page.locator("id=length")).to_have_text("3") + expect(page.locator("id=has_c")).to_have_text("true") + assert _sorted_text(page, "tags") == "a,b,c" + assert _sorted_text(page, "intersection") == "b,c" + assert _sorted_text(page, "symmetric_difference") == "a" + expect(page.locator("id=issuperset")).to_have_text("true") + expect(page.locator(".tag")).to_have_count(3) + + page.locator("id=remove").click() + expect(page.locator("id=length")).to_have_text("2") + expect(page.locator("id=has_a")).to_have_text("false") + expect(page.locator("id=issubset")).to_have_text("true") + assert _sorted_text(page, "difference") == "" + + # The set round-trips through an event argument. + page.locator("id=send").click() + expect(page.locator("id=received")).to_have_text("b,c") + page.locator("id=send_union").click() + expect(page.locator("id=received")).to_have_text("b,c") + page.locator("id=add").click() + page.locator("id=send_union").click() + expect(page.locator("id=received")).to_have_text("b,c") diff --git a/tests/units/reflex_base/vars/test_base.py b/tests/units/reflex_base/vars/test_base.py index 7e577adce1c..871ce00d8b1 100644 --- a/tests/units/reflex_base/vars/test_base.py +++ b/tests/units/reflex_base/vars/test_base.py @@ -80,6 +80,7 @@ LiteralRangeVar, StringVar, ) +from reflex_base.vars.set import LiteralSetVar from typing_extensions import TypeAliasType, TypeVarTuple import reflex as rx @@ -2304,9 +2305,11 @@ def test_get_leaf_vars_dissolves_literals() -> None: assert get_leaf_vars(LiteralVar.create(range(3))) == [] assert get_leaf_vars(LiteralVar.create(datetime.datetime(2020, 1, 1))) == [] assert get_leaf_vars(LiteralVar.create({1, 2})) == [] + assert get_leaf_vars(LiteralVar.create(frozenset({1, 2}))) == [] assert _identical(get_leaf_vars(LiteralVar.create([1, a, [b]])), a, b) assert _identical(get_leaf_vars(LiteralVar.create({"k": a, b: 2})), a, b) assert _identical(get_leaf_vars(LiteralVar.create({a})), a) + assert _identical(get_leaf_vars(LiteralVar.create(frozenset({a}))), a) assert _identical(get_leaf_vars(LiteralVar.create([f"{a}-x"])), a) # A var that is the whole of a formatted string is decoded into a new object. (decoded,) = get_leaf_vars(LiteralVar.create([f"{a}"])) @@ -2447,6 +2450,7 @@ def names(classes: Iterable[type]) -> set[str]: RouterDataVar, # Literals read what their value holds, or nothing for a constant. LiteralArrayVar, + LiteralSetVar, LiteralObjectVar, LiteralColorVar, LiteralRangeVar, diff --git a/tests/units/reflex_base/vars/test_set.py b/tests/units/reflex_base/vars/test_set.py new file mode 100644 index 00000000000..9e4ad8230f9 --- /dev/null +++ b/tests/units/reflex_base/vars/test_set.py @@ -0,0 +1,310 @@ +"""Tests for reflex_base.vars.set.""" + +import operator +import re +import typing +from collections.abc import Sequence +from typing import Any + +import pytest +from reflex_base.constants.base import Dirs +from reflex_base.utils import serializers +from reflex_base.utils.exceptions import VarTypeError +from reflex_base.vars.base import LiteralVar, Var, VarData +from reflex_base.vars.number import BooleanVar, NumberVar +from reflex_base.vars.sequence import ArrayVar, LiteralArrayVar, StringVar +from reflex_base.vars.set import LiteralSetVar, SetVar + +import reflex as rx + + +def _tags() -> SetVar[set[str]]: + return Var(_js_expr="state.tags").to(set[str]) + + +def test_set_literal_is_a_set_var(): + """A set literal renders as a sorted array and is typed by its elements.""" + var = LiteralVar.create({"b", "a"}) + typing.assert_type(var, LiteralSetVar[set[str]]) + assert isinstance(var, LiteralSetVar) + assert var._var_type == set[str] + assert str(var) == '["a", "b"]' + assert var.json() == '["a", "b"]' + + +def test_frozenset_literal_is_a_set_var(): + """A frozenset literal keeps its frozenset type.""" + var = LiteralVar.create(frozenset({3, 1, 2})) + typing.assert_type(var, LiteralSetVar[frozenset[int]]) + assert var._var_type == frozenset[int] + assert str(var) == "[1, 2, 3]" + assert LiteralVar.create(frozenset()).json() == "[]" + + +def test_set_literal_with_incomparable_elements(): + """Elements that do not sort stay in the set's own order.""" + var = LiteralVar.create({1, "a"}) + assert str(var) in ('[1, "a"]', '["a", 1]') + + +def test_set_literal_holds_vars(): + """A set literal carries the var data of its elements.""" + var_data = VarData(hooks={"const x = 1": None}) + inner = Var(_js_expr="x", _var_type=str, _var_data=var_data).guess_type() + var = LiteralVar.create({inner}) + assert str(var) == "[x]" + assert var._get_all_var_data() == var_data + assert LiteralSetVar._get_all_var_data_without_creating_var({inner}) == var_data + assert LiteralSetVar._get_all_var_data_without_creating_var({"x"}) is None + + +def _helper_imports(var: Var) -> list[str]: + var_data = var._get_all_var_data() + assert var_data is not None + return [ + import_var.tag + for import_var in dict(var_data.imports)[f"$/{Dirs.STATE_PATH}"] + if import_var.tag + ] + + +def test_set_typed_var_guesses_set_var(): + """A var annotated set[T] or frozenset[T] becomes a SetVar, not an ArrayVar.""" + tags = _tags() + assert isinstance(tags, SetVar) + assert not isinstance(tags, ArrayVar) + frozen = Var(_js_expr="f", _var_type=frozenset[int]).guess_type() + assert isinstance(frozen, SetVar) + assert isinstance(Var(_js_expr="s", _var_type=set).guess_type(), SetVar) + assert isinstance(Var(_js_expr="s").to(set[str]), SetVar) + typing.assert_type(Var(_js_expr="s").to(set[str]), SetVar[set[str]]) + typing.assert_type(Var(_js_expr="s").to(frozenset[str]), SetVar[frozenset[str]]) + assert isinstance(Var(_js_expr="l", _var_type=list[str]).guess_type(), ArrayVar) + + +def test_set_contains_and_length(): + tags = _tags() + contains = tags.contains("x") + typing.assert_type(contains, BooleanVar) + assert isinstance(contains, BooleanVar) + assert str(contains) == 'pySetHas(state.tags, "x")' + assert _helper_imports(contains) == ["pySetHas"] + assert str(tags.has("x")) == 'pySetHas(state.tags, "x")' + length = tags.length() + typing.assert_type(length, NumberVar[int]) + assert str(length) == "state.tags.length" + assert str(tags.size()) == "state.tags.length" + + +@pytest.mark.parametrize( + ("operation", "helper"), + [ + ("union", "pySetUnion"), + ("intersection", "pySetIntersection"), + ("difference", "pySetDifference"), + ("symmetric_difference", "pySetSymmetricDifference"), + ], +) +def test_set_combinations_return_set_vars(operation: str, helper: str): + tags = _tags() + other = Var(_js_expr="state.other", _var_type=set[str]).guess_type() + result = getattr(tags, operation)(other) + assert isinstance(result, SetVar) + assert result._var_type == set[str] + assert str(result) == f"{helper}(state.tags, state.other)" + assert _helper_imports(result) == [helper] + + +@pytest.mark.parametrize( + ("operation", "expected"), + [ + ("issubset", "pySetIsSubset(state.tags, state.other)"), + ("issuperset", "pySetIsSubset(state.other, state.tags)"), + ("isdisjoint", "pySetIsDisjoint(state.tags, state.other)"), + ], +) +def test_set_comparisons_return_boolean_vars(operation: str, expected: str): + tags = _tags() + other = Var(_js_expr="state.other", _var_type=set[str]).guess_type() + result = getattr(tags, operation)(other) + assert isinstance(result, BooleanVar) + assert str(result) == expected + assert _helper_imports(result) == [expected.split("(")[0]] + + +def test_set_operations_accept_literals_and_arrays(): + """The other operand may be a python set, frozenset, list, tuple or an array var.""" + tags = _tags() + assert str(tags.union({"z"})) == 'pySetUnion(state.tags, ["z"])' + assert str(tags.union(frozenset({"z"}))) == 'pySetUnion(state.tags, ["z"])' + assert str(tags.issubset(["a", "b"])) == 'pySetIsSubset(state.tags, ["a", "b"])' + items = Var(_js_expr="state.items", _var_type=list[int]).guess_type() + union = tags.union(items) + assert union._var_type == set[str | int] + assert str(tags.intersection(("a",))) == 'pySetIntersection(state.tags, ["a"])' + + +def test_set_of_tuples(): + """Structured elements render as nested arrays; the helpers compare them by value.""" + points = Var(_js_expr="state.points").to(set[tuple[int, int]]) + assert str(points.contains((1, 2))) == "pySetHas(state.points, [1, 2])" + assert str(LiteralVar.create({(1, 2)}).intersection({(1, 2)})) == ( + "pySetIntersection([[1, 2]], [[1, 2]])" + ) + typing.assert_type(points.to_array()[0], ArrayVar[tuple[int, int]]) + assert points.to_array()[0]._var_type == tuple[int, int] + + +def test_set_operations_keep_frozenset_type(): + frozen = Var(_js_expr="f").to(frozenset[int]) + assert frozen.union({1.5})._var_type == frozenset[int | float] + assert frozen.intersection({1})._var_type == frozenset[int] + assert frozen.symmetric_difference({"a"})._var_type == frozenset[int | str] + + +def test_untyped_set_operations(): + """A bare set type has Any elements.""" + bare = Var(_js_expr="s").to(set) + assert bare.union({1})._var_type == set[Any | int] + assert bare.intersection({1})._var_type is set + assert bare.to_array()._var_type == Sequence[Any] + + +@pytest.mark.parametrize( + "operation", + [ + "union", + "intersection", + "difference", + "symmetric_difference", + "issubset", + "issuperset", + "isdisjoint", + ], +) +def test_set_operations_reject_other_operands(operation: str): + with pytest.raises(VarTypeError): + getattr(_tags(), operation)(1) + with pytest.raises(VarTypeError): + getattr(_tags(), operation)(Var(_js_expr="n", _var_type=int).guess_type()) + + +def test_set_to_array(): + """The array view is the same JS expression, typed as a sequence.""" + tags = _tags() + array = tags.to_array() + typing.assert_type(array, ArrayVar[Sequence[str]]) + assert isinstance(array, ArrayVar) + assert array._var_type == Sequence[str] + assert str(array) == "state.tags" + first = array[0] + typing.assert_type(first, StringVar) + assert str(first) == "state.tags?.at?.(0)" + assert re.fullmatch( + r"state\.tags\.map\(\(\((\w+)\) => \1\.toUpperCase\(\)\)\)", + str(array.map(lambda item: item.upper())), + ) + frozen = Var(_js_expr="f").to(frozenset[int]) + typing.assert_type(frozen.to_array(), ArrayVar[Sequence[int]]) + assert frozen.to_array()._var_type == Sequence[int] + + +def test_optional_set_to_array_stays_optional(): + """An optional set's array view is optional too, so foreach keeps its null guard.""" + maybe = Var(_js_expr="state.maybe", _var_type=set[str] | None).guess_type() + assert isinstance(maybe, SetVar) + assert maybe.to_array()._var_type == Sequence[str] | None + component = rx.foreach(maybe, lambda tag: rx.text(tag)) + assert str(component.iterable) == ("(isTrue(state.maybe) ? state.maybe : [])") + + +def test_set_concatenation_fallbacks(): + """`set + set` and `array + set` still compile, as arrays, with a warning.""" + tags = _tags() + other = Var(_js_expr="state.other").to(set[str]) + items = Var(_js_expr="state.items").to(list[str]) + assert str(tags + other) == "[...state.tags, ...state.other]" # pyright: ignore[reportDeprecated] + assert str(items + tags) == "[...state.items, ...state.tags]" # pyright: ignore[reportDeprecated] + assert str(tags + items) == "[...state.tags, ...state.items]" # pyright: ignore[reportDeprecated] + with pytest.raises(VarTypeError): + _ = items + 1 # pyright: ignore[reportOperatorIssue] + + +def test_set_array_operations_are_deprecated(mocker): + """Sequence operations still work on a set var, with a deprecation warning.""" + deprecate = mocker.patch("reflex_base.vars.set.console.deprecate") + tags = _tags() + + assert str(tags[0]) == "state.tags?.at?.(0)" # pyright: ignore[reportDeprecated] + deprecate.assert_called_once() + assert deprecate.call_args.kwargs["feature_name"] == "SetVar.__getitem__" + assert ".to_array()" in deprecate.call_args.kwargs["reason"] + + deprecate.reset_mock() + assert re.fullmatch( + r"state\.tags\.map\(\(\((\w+)\) => \1\.toUpperCase\(\)\)\)", + str(tags.map(lambda item: item.upper())), # pyright: ignore[reportDeprecated] + ) + assert deprecate.call_args.kwargs["feature_name"] == "SetVar.map" + + deprecate.reset_mock() + assert str(tags.join(",")) == 'state.tags.join(",")' # pyright: ignore[reportDeprecated] + assert str(tags.reverse()) == "state.tags.slice().reverse()" # pyright: ignore[reportDeprecated] + assert str(tags * 2) == ( # pyright: ignore[reportDeprecated] + "Array.from({ length: 2 }).flatMap(() => state.tags)" + ) + assert str(tags + tags.to_array()) == "[...state.tags, ...state.tags]" # pyright: ignore[reportDeprecated] + assert str(tags.filter(lambda item: item == "a")).startswith("state.tags.filter(") # pyright: ignore[reportDeprecated] + assert str(tags.reduce(operator.add, "")).startswith("state.tags.reduce(") # pyright: ignore[reportDeprecated] + assert str(tags.flat_map(lambda item: item)).startswith("pyFlatMap(state.tags") # pyright: ignore[reportDeprecated] + assert str(tags.pluck("a")).startswith("state.tags.map(") # pyright: ignore[reportDeprecated] + assert deprecate.call_count == 8 + + +def test_state_set_fields_are_set_vars(): + """Set-typed state fields and computed vars are typed as SetVar statically and at runtime.""" + + class SetState(rx.State): + tags: rx.Field[set[str]] = rx.field({"a"}) + frozen: rx.Field[frozenset[int]] = rx.field(frozenset({1})) + maybe: rx.Field[set[str] | None] = rx.field(None) + + @rx.var + def doubled(self) -> set[str]: + return {tag * 2 for tag in self.tags} + + @rx.var + def frozen_doubled(self) -> frozenset[int]: + return frozenset(value * 2 for value in self.frozen) + + typing.assert_type(SetState.tags, SetVar[set[str]]) + typing.assert_type(SetState.frozen, SetVar[frozenset[int]]) + typing.assert_type(SetState.maybe, SetVar[set[str]]) + typing.assert_type(SetState.doubled, SetVar[set[str]]) + typing.assert_type(SetState.frozen_doubled, SetVar[frozenset[int]]) + typing.assert_type(SetState.tags.contains("x"), BooleanVar) + assert isinstance(SetState.tags, SetVar) + assert isinstance(SetState.frozen, SetVar) + assert isinstance(SetState.maybe, SetVar) + assert isinstance(SetState.doubled, SetVar) + assert isinstance(SetState.frozen_doubled, SetVar) + assert SetState.tags.to_array()._var_type == Sequence[str] + assert serializers.serialize(frozenset({1})) == [1] + + +def test_foreach_accepts_a_set_var(): + """rx.foreach iterates the array view of a set var.""" + tags = _tags() + component = rx.foreach(tags, lambda tag: rx.text(tag)) + assert component.iterable._var_type == Sequence[str] + assert str(component.iterable) == "state.tags" + literal = rx.foreach({"a", "b"}, lambda tag: rx.text(tag)) + assert str(literal.iterable) == '["a", "b"]' + + +def test_set_inside_array_literal(): + """A set nested in an array literal still renders as an array.""" + var = LiteralVar.create([{2, 1}, "x"]) + assert isinstance(var, LiteralArrayVar) + assert str(var) == '[[1, 2], "x"]' + assert var.json() == '[[1, 2], "x"]' diff --git a/tests/units/test_var.py b/tests/units/test_var.py index fe0639d53ac..c33c8026b5a 100644 --- a/tests/units/test_var.py +++ b/tests/units/test_var.py @@ -435,7 +435,6 @@ def test_datetime_equality_with_other_type_uses_default_comparison(): ("var", "expected"), [ (v([1, 2, 3]), "[1, 2, 3]"), - (v({1, 2, 3}), "[1, 2, 3]"), (v(["1", "2", "3"]), '["1", "2", "3"]'), ( Var(_js_expr="foo")._var_set_state("state").to(list),