Dynamic route vars silently shadow all other vars (#3805)
* fix dynamic route vars silently shadow all other vars * add test * fix: allow multiple dynamic routes with the same arg * add test for multiple dynamic args with the same name * avoid side-effects with DynamicState tests * fix dynamic route integration test which shadowed a var * fix darglint * refactor to DynamicRouteVar * old typing stuff again * from typing_extensions import Self try to keep typing backward compatible with older releases we support * Raise a specific exception when encountering dynamic route arg shadowing --------- Co-authored-by: Masen Furer <m_github@0x26.net>
This commit is contained in:
parent
95631ffdba
commit
63bf1b8817
@ -43,7 +43,7 @@ from reflex.utils.exceptions import (
|
|||||||
VarValueError,
|
VarValueError,
|
||||||
)
|
)
|
||||||
from reflex.utils.format import format_state_name
|
from reflex.utils.format import format_state_name
|
||||||
from reflex.utils.types import GenericType, get_origin
|
from reflex.utils.types import GenericType, Self, get_origin
|
||||||
from reflex.vars import (
|
from reflex.vars import (
|
||||||
REPLACED_NAMES,
|
REPLACED_NAMES,
|
||||||
Var,
|
Var,
|
||||||
@ -1467,7 +1467,7 @@ class ImmutableComputedVar(ImmutableVar[RETURN_TYPE]):
|
|||||||
object.__setattr__(self, "_fget", fget)
|
object.__setattr__(self, "_fget", fget)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def _replace(self, merge_var_data=None, **kwargs: Any) -> ImmutableComputedVar:
|
def _replace(self, merge_var_data=None, **kwargs: Any) -> Self:
|
||||||
"""Replace the attributes of the ComputedVar.
|
"""Replace the attributes of the ComputedVar.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -1499,7 +1499,7 @@ class ImmutableComputedVar(ImmutableVar[RETURN_TYPE]):
|
|||||||
unexpected_kwargs = ", ".join(kwargs.keys())
|
unexpected_kwargs = ", ".join(kwargs.keys())
|
||||||
raise TypeError(f"Unexpected keyword arguments: {unexpected_kwargs}")
|
raise TypeError(f"Unexpected keyword arguments: {unexpected_kwargs}")
|
||||||
|
|
||||||
return ImmutableComputedVar(**field_values)
|
return type(self)(**field_values)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _cache_attr(self) -> str:
|
def _cache_attr(self) -> str:
|
||||||
@ -1773,6 +1773,12 @@ class ImmutableComputedVar(ImmutableVar[RETURN_TYPE]):
|
|||||||
return self._fget
|
return self._fget
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicRouteVar(ImmutableComputedVar[Union[str, List[str]]]):
|
||||||
|
"""A ComputedVar that represents a dynamic route."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
BASE_STATE = TypeVar("BASE_STATE", bound=BaseState)
|
BASE_STATE = TypeVar("BASE_STATE", bound=BaseState)
|
||||||
|
|
||||||
|
@ -35,6 +35,7 @@ from sqlalchemy.orm import DeclarativeBase
|
|||||||
|
|
||||||
from reflex.config import get_config
|
from reflex.config import get_config
|
||||||
from reflex.ivars.base import (
|
from reflex.ivars.base import (
|
||||||
|
DynamicRouteVar,
|
||||||
ImmutableComputedVar,
|
ImmutableComputedVar,
|
||||||
ImmutableVar,
|
ImmutableVar,
|
||||||
immutable_computed_var,
|
immutable_computed_var,
|
||||||
@ -60,7 +61,11 @@ from reflex.event import (
|
|||||||
fix_events,
|
fix_events,
|
||||||
)
|
)
|
||||||
from reflex.utils import console, format, path_ops, prerequisites, types
|
from reflex.utils import console, format, path_ops, prerequisites, types
|
||||||
from reflex.utils.exceptions import ImmutableStateError, LockExpiredError
|
from reflex.utils.exceptions import (
|
||||||
|
DynamicRouteArgShadowsStateVar,
|
||||||
|
ImmutableStateError,
|
||||||
|
LockExpiredError,
|
||||||
|
)
|
||||||
from reflex.utils.exec import is_testing_env
|
from reflex.utils.exec import is_testing_env
|
||||||
from reflex.utils.serializers import SerializedType, serialize, serializer
|
from reflex.utils.serializers import SerializedType, serialize, serializer
|
||||||
from reflex.utils.types import override
|
from reflex.utils.types import override
|
||||||
@ -1023,17 +1028,19 @@ class BaseState(Base, ABC, extra=pydantic.Extra.allow):
|
|||||||
if not args:
|
if not args:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
cls._check_overwritten_dynamic_args(list(args.keys()))
|
||||||
|
|
||||||
def argsingle_factory(param):
|
def argsingle_factory(param):
|
||||||
def inner_func(self) -> str:
|
def inner_func(self) -> str:
|
||||||
return self.router.page.params.get(param, "")
|
return self.router.page.params.get(param, "")
|
||||||
|
|
||||||
return ImmutableComputedVar(fget=inner_func, cache=True)
|
return DynamicRouteVar(fget=inner_func, cache=True)
|
||||||
|
|
||||||
def arglist_factory(param):
|
def arglist_factory(param):
|
||||||
def inner_func(self) -> List:
|
def inner_func(self) -> List[str]:
|
||||||
return self.router.page.params.get(param, [])
|
return self.router.page.params.get(param, [])
|
||||||
|
|
||||||
return ImmutableComputedVar(fget=inner_func, cache=True)
|
return DynamicRouteVar(fget=inner_func, cache=True)
|
||||||
|
|
||||||
for param, value in args.items():
|
for param, value in args.items():
|
||||||
if value == constants.RouteArgType.SINGLE:
|
if value == constants.RouteArgType.SINGLE:
|
||||||
@ -1044,12 +1051,36 @@ class BaseState(Base, ABC, extra=pydantic.Extra.allow):
|
|||||||
continue
|
continue
|
||||||
# to allow passing as a prop, evade python frozen rules (bad practice)
|
# to allow passing as a prop, evade python frozen rules (bad practice)
|
||||||
object.__setattr__(func, "_var_name", param)
|
object.__setattr__(func, "_var_name", param)
|
||||||
cls.vars[param] = cls.computed_vars[param] = func._var_set_state(cls) # type: ignore
|
# cls.vars[param] = cls.computed_vars[param] = func._var_set_state(cls) # type: ignore
|
||||||
|
cls.vars[param] = cls.computed_vars[param] = func._replace(
|
||||||
|
_var_data=VarData.from_state(cls)
|
||||||
|
)
|
||||||
setattr(cls, param, func)
|
setattr(cls, param, func)
|
||||||
|
|
||||||
# Reinitialize dependency tracking dicts.
|
# Reinitialize dependency tracking dicts.
|
||||||
cls._init_var_dependency_dicts()
|
cls._init_var_dependency_dicts()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _check_overwritten_dynamic_args(cls, args: list[str]):
|
||||||
|
"""Check if dynamic args are shadowing existing vars. Recursively checks all child states.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: a dict of args
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DynamicRouteArgShadowsStateVar: If a dynamic arg is shadowing an existing var.
|
||||||
|
"""
|
||||||
|
for arg in args:
|
||||||
|
if (
|
||||||
|
arg in cls.computed_vars
|
||||||
|
and not isinstance(cls.computed_vars[arg], DynamicRouteVar)
|
||||||
|
) or arg in cls.base_vars:
|
||||||
|
raise DynamicRouteArgShadowsStateVar(
|
||||||
|
f"Dynamic route arg '{arg}' is shadowing an existing var in {cls.__module__}.{cls.__name__}"
|
||||||
|
)
|
||||||
|
for substate in cls.get_substates():
|
||||||
|
substate._check_overwritten_dynamic_args(args)
|
||||||
|
|
||||||
def __getattribute__(self, name: str) -> Any:
|
def __getattribute__(self, name: str) -> Any:
|
||||||
"""Get the state var.
|
"""Get the state var.
|
||||||
|
|
||||||
|
@ -87,3 +87,7 @@ class EventHandlerArgMismatch(ReflexError, TypeError):
|
|||||||
|
|
||||||
class EventFnArgMismatch(ReflexError, TypeError):
|
class EventFnArgMismatch(ReflexError, TypeError):
|
||||||
"""Raised when the number of args accepted by a lambda differs from that provided by the event trigger."""
|
"""Raised when the number of args accepted by a lambda differs from that provided by the event trigger."""
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicRouteArgShadowsStateVar(ReflexError, NameError):
|
||||||
|
"""Raised when a dynamic route arg shadows a state var."""
|
||||||
|
@ -111,6 +111,11 @@ RESERVED_BACKEND_VAR_NAMES = {
|
|||||||
"_was_touched",
|
"_was_touched",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 11):
|
||||||
|
from typing import Self as Self
|
||||||
|
else:
|
||||||
|
from typing_extensions import Self as Self
|
||||||
|
|
||||||
|
|
||||||
class Unset:
|
class Unset:
|
||||||
"""A class to represent an unset value.
|
"""A class to represent an unset value.
|
||||||
|
@ -69,7 +69,7 @@ class EmptyState(BaseState):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def index_page():
|
def index_page() -> ComponentCallable:
|
||||||
"""An index page.
|
"""An index page.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@ -83,7 +83,7 @@ def index_page():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def about_page():
|
def about_page() -> ComponentCallable:
|
||||||
"""An about page.
|
"""An about page.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@ -919,9 +919,62 @@ class DynamicState(BaseState):
|
|||||||
on_load_internal = OnLoadInternalState.on_load_internal.fn
|
on_load_internal = OnLoadInternalState.on_load_internal.fn
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_arg_shadow(
|
||||||
|
index_page: ComponentCallable,
|
||||||
|
windows_platform: bool,
|
||||||
|
token: str,
|
||||||
|
app_module_mock: unittest.mock.Mock,
|
||||||
|
mocker,
|
||||||
|
):
|
||||||
|
"""Create app with dynamic route var and try to add a page with a dynamic arg that shadows a state var.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index_page: The index page.
|
||||||
|
windows_platform: Whether the system is windows.
|
||||||
|
token: a Token.
|
||||||
|
app_module_mock: Mocked app module.
|
||||||
|
mocker: pytest mocker object.
|
||||||
|
"""
|
||||||
|
arg_name = "counter"
|
||||||
|
route = f"/test/[{arg_name}]"
|
||||||
|
if windows_platform:
|
||||||
|
route.lstrip("/").replace("/", "\\")
|
||||||
|
app = app_module_mock.app = App(state=DynamicState)
|
||||||
|
assert app.state is not None
|
||||||
|
with pytest.raises(NameError):
|
||||||
|
app.add_page(index_page, route=route, on_load=DynamicState.on_load) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_dynamic_args(
|
||||||
|
index_page: ComponentCallable,
|
||||||
|
windows_platform: bool,
|
||||||
|
token: str,
|
||||||
|
app_module_mock: unittest.mock.Mock,
|
||||||
|
mocker,
|
||||||
|
):
|
||||||
|
"""Create app with multiple dynamic route vars with the same name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index_page: The index page.
|
||||||
|
windows_platform: Whether the system is windows.
|
||||||
|
token: a Token.
|
||||||
|
app_module_mock: Mocked app module.
|
||||||
|
mocker: pytest mocker object.
|
||||||
|
"""
|
||||||
|
arg_name = "my_arg"
|
||||||
|
route = f"/test/[{arg_name}]"
|
||||||
|
route2 = f"/test2/[{arg_name}]"
|
||||||
|
if windows_platform:
|
||||||
|
route = route.lstrip("/").replace("/", "\\")
|
||||||
|
route2 = route2.lstrip("/").replace("/", "\\")
|
||||||
|
app = app_module_mock.app = App(state=EmptyState)
|
||||||
|
app.add_page(index_page, route=route)
|
||||||
|
app.add_page(index_page, route=route2)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dynamic_route_var_route_change_completed_on_load(
|
async def test_dynamic_route_var_route_change_completed_on_load(
|
||||||
index_page,
|
index_page: ComponentCallable,
|
||||||
windows_platform: bool,
|
windows_platform: bool,
|
||||||
token: str,
|
token: str,
|
||||||
app_module_mock: unittest.mock.Mock,
|
app_module_mock: unittest.mock.Mock,
|
||||||
|
Loading…
Reference in New Issue
Block a user