From 77e7f98fd6479bba54c55a55b0113eb78754c3ca Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Tue, 1 Nov 2022 23:34:19 -0700 Subject: [PATCH] define state as a generic named tuple (#827) * define state as a generic named tuple * changelog entry --- docs/source/about/changelog.rst | 22 +++++++ src/idom/core/hooks.py | 101 +++++++++++++------------------- src/idom/core/types.py | 18 ++++++ src/idom/types.py | 2 + tests/test_core/test_hooks.py | 16 +++++ 5 files changed, 100 insertions(+), 59 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index fd5124c17..ebc945e05 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -41,6 +41,28 @@ Unreleased ``idom-router``, IDOM's server routes will always take priority. - :pull:`824` - Backend implementations now strip any URL prefix in the pathname for ``use_location``. +- :pull:`827` - ``use_state`` now returns a named tuple with ``value`` and ``set_value`` + fields. This is convenient for adding type annotations if the initial state value is + not the same as the values you might pass to the state setter. Where previously you + might have to do something like: + + .. code-block:: + + value: int | None = None + value, set_value = use_state(value) + + Now you can annotate your state using the ``State`` class: + + .. code-block:: + + state: State[int | None] = use_state(None) + + # access value and setter + state.value + state.set_value + + # can still destructure if you need to + value, set_value = state **Added** diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index 428f9a963..a1353f090 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -26,7 +26,7 @@ from idom.utils import Ref from ._thread_local import ThreadLocal -from .types import ComponentType, Key, VdomDict +from .types import ComponentType, Key, State, VdomDict from .vdom import vdom @@ -46,35 +46,20 @@ logger = getLogger(__name__) -_StateType = TypeVar("_StateType") +_Type = TypeVar("_Type") @overload -def use_state( - initial_value: Callable[[], _StateType], -) -> Tuple[ - _StateType, - Callable[[_StateType | Callable[[_StateType], _StateType]], None], -]: +def use_state(initial_value: Callable[[], _Type]) -> State[_Type]: ... @overload -def use_state( - initial_value: _StateType, -) -> Tuple[ - _StateType, - Callable[[_StateType | Callable[[_StateType], _StateType]], None], -]: +def use_state(initial_value: _Type) -> State[_Type]: ... -def use_state( - initial_value: _StateType | Callable[[], _StateType], -) -> Tuple[ - _StateType, - Callable[[_StateType | Callable[[_StateType], _StateType]], None], -]: +def use_state(initial_value: _Type | Callable[[], _Type]) -> State[_Type]: """See the full :ref:`Use State` docs for details Parameters: @@ -87,16 +72,16 @@ def use_state( A tuple containing the current state and a function to update it. """ current_state = _use_const(lambda: _CurrentState(initial_value)) - return current_state.value, current_state.dispatch + return State(current_state.value, current_state.dispatch) -class _CurrentState(Generic[_StateType]): +class _CurrentState(Generic[_Type]): __slots__ = "value", "dispatch" def __init__( self, - initial_value: Union[_StateType, Callable[[], _StateType]], + initial_value: Union[_Type, Callable[[], _Type]], ) -> None: if callable(initial_value): self.value = initial_value() @@ -105,9 +90,7 @@ def __init__( hook = current_hook() - def dispatch( - new: Union[_StateType, Callable[[_StateType], _StateType]] - ) -> None: + def dispatch(new: Union[_Type, Callable[[_Type], _Type]]) -> None: if callable(new): next_value = new(self.value) else: @@ -234,14 +217,14 @@ def use_debug_value( logger.debug(f"{current_hook().component} {new}") -def create_context(default_value: _StateType) -> Context[_StateType]: +def create_context(default_value: _Type) -> Context[_Type]: """Return a new context type for use in :func:`use_context`""" def context( *children: Any, - value: _StateType = default_value, + value: _Type = default_value, key: Key | None = None, - ) -> ContextProvider[_StateType]: + ) -> ContextProvider[_Type]: return ContextProvider( *children, value=value, @@ -254,19 +237,19 @@ def context( return context -class Context(Protocol[_StateType]): +class Context(Protocol[_Type]): """Returns a :class:`ContextProvider` component""" def __call__( self, *children: Any, - value: _StateType = ..., + value: _Type = ..., key: Key | None = ..., - ) -> ContextProvider[_StateType]: + ) -> ContextProvider[_Type]: ... -def use_context(context: Context[_StateType]) -> _StateType: +def use_context(context: Context[_Type]) -> _Type: """Get the current value for the given context type. See the full :ref:`Use Context` docs for more information. @@ -282,7 +265,7 @@ def use_context(context: Context[_StateType]) -> _StateType: # lastly check that 'value' kwarg exists assert "value" in context.__kwdefaults__, f"{context} has no 'value' kwarg" # then we can safely access the context's default value - return cast(_StateType, context.__kwdefaults__["value"]) + return cast(_Type, context.__kwdefaults__["value"]) subscribers = provider._subscribers @@ -294,13 +277,13 @@ def subscribe_to_context_change() -> Callable[[], None]: return provider._value -class ContextProvider(Generic[_StateType]): +class ContextProvider(Generic[_Type]): def __init__( self, *children: Any, - value: _StateType, + value: _Type, key: Key | None, - type: Context[_StateType], + type: Context[_Type], ) -> None: self.children = children self.key = key @@ -312,7 +295,7 @@ def render(self) -> VdomDict: current_hook().set_context_provider(self) return vdom("", *self.children) - def should_render(self, new: ContextProvider[_StateType]) -> bool: + def should_render(self, new: ContextProvider[_Type]) -> bool: if not strictly_equal(self._value, new._value): for hook in self._subscribers: hook.set_context_provider(new) @@ -328,9 +311,9 @@ def __repr__(self) -> str: def use_reducer( - reducer: Callable[[_StateType, _ActionType], _StateType], - initial_value: _StateType, -) -> Tuple[_StateType, Callable[[_ActionType], None]]: + reducer: Callable[[_Type, _ActionType], _Type], + initial_value: _Type, +) -> Tuple[_Type, Callable[[_ActionType], None]]: """See the full :ref:`Use Reducer` docs for details Parameters: @@ -348,8 +331,8 @@ def use_reducer( def _create_dispatcher( - reducer: Callable[[_StateType, _ActionType], _StateType], - set_state: Callable[[Callable[[_StateType], _StateType]], None], + reducer: Callable[[_Type, _ActionType], _Type], + set_state: Callable[[Callable[[_Type], _Type]], None], ) -> Callable[[_ActionType], None]: def dispatch(action: _ActionType) -> None: set_state(lambda last_state: reducer(last_state, action)) @@ -409,7 +392,7 @@ def setup(function: _CallbackFunc) -> _CallbackFunc: class _LambdaCaller(Protocol): """MyPy doesn't know how to deal with TypeVars only used in function return""" - def __call__(self, func: Callable[[], _StateType]) -> _StateType: + def __call__(self, func: Callable[[], _Type]) -> _Type: ... @@ -423,16 +406,16 @@ def use_memo( @overload def use_memo( - function: Callable[[], _StateType], + function: Callable[[], _Type], dependencies: Sequence[Any] | ellipsis | None = ..., -) -> _StateType: +) -> _Type: ... def use_memo( - function: Optional[Callable[[], _StateType]] = None, + function: Optional[Callable[[], _Type]] = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Union[_StateType, Callable[[Callable[[], _StateType]], _StateType]]: +) -> Union[_Type, Callable[[Callable[[], _Type]], _Type]]: """See the full :ref:`Use Memo` docs for details Parameters: @@ -449,7 +432,7 @@ def use_memo( """ dependencies = _try_to_infer_closure_values(function, dependencies) - memo: _Memo[_StateType] = _use_const(_Memo) + memo: _Memo[_Type] = _use_const(_Memo) if memo.empty(): # we need to initialize on the first run @@ -471,17 +454,17 @@ def use_memo( else: changed = False - setup: Callable[[Callable[[], _StateType]], _StateType] + setup: Callable[[Callable[[], _Type]], _Type] if changed: - def setup(function: Callable[[], _StateType]) -> _StateType: + def setup(function: Callable[[], _Type]) -> _Type: current_value = memo.value = function() return current_value else: - def setup(function: Callable[[], _StateType]) -> _StateType: + def setup(function: Callable[[], _Type]) -> _Type: return memo.value if function is not None: @@ -490,12 +473,12 @@ def setup(function: Callable[[], _StateType]) -> _StateType: return setup -class _Memo(Generic[_StateType]): +class _Memo(Generic[_Type]): """Simple object for storing memoization data""" __slots__ = "value", "deps" - value: _StateType + value: _Type deps: Sequence[Any] def empty(self) -> bool: @@ -507,7 +490,7 @@ def empty(self) -> bool: return False -def use_ref(initial_value: _StateType) -> Ref[_StateType]: +def use_ref(initial_value: _Type) -> Ref[_Type]: """See the full :ref:`Use State` docs for details Parameters: @@ -519,7 +502,7 @@ def use_ref(initial_value: _StateType) -> Ref[_StateType]: return _use_const(lambda: Ref(initial_value)) -def _use_const(function: Callable[[], _StateType]) -> _StateType: +def _use_const(function: Callable[[], _Type]) -> _Type: return current_hook().use_state(function) @@ -670,7 +653,7 @@ def schedule_render(self) -> None: self._schedule_render() return None - def use_state(self, function: Callable[[], _StateType]) -> _StateType: + def use_state(self, function: Callable[[], _Type]) -> _Type: if not self._rendered_atleast_once: # since we're not intialized yet we're just appending state result = function() @@ -689,8 +672,8 @@ def set_context_provider(self, provider: ContextProvider[Any]) -> None: self._context_providers[provider.type] = provider def get_context_provider( - self, context: Context[_StateType] - ) -> ContextProvider[_StateType] | None: + self, context: Context[_Type] + ) -> ContextProvider[_Type] | None: return self._context_providers.get(context) def affect_component_will_render(self, component: ComponentType) -> None: diff --git a/src/idom/core/types.py b/src/idom/core/types.py index a2b7cc902..db366fcd5 100644 --- a/src/idom/core/types.py +++ b/src/idom/core/types.py @@ -1,13 +1,18 @@ from __future__ import annotations +import sys +from collections import namedtuple from types import TracebackType from typing import ( + TYPE_CHECKING, Any, Callable, Dict, + Generic, Iterable, List, Mapping, + NamedTuple, Optional, Sequence, Type, @@ -18,6 +23,19 @@ from typing_extensions import Protocol, TypedDict, runtime_checkable +_Type = TypeVar("_Type") + + +if TYPE_CHECKING or sys.version_info < (3, 9) or sys.version_info >= (3, 11): + + class State(NamedTuple, Generic[_Type]): # pragma: no cover + value: _Type + set_value: Callable[[_Type | Callable[[_Type], _Type]], None] + +else: + State = namedtuple("State", ("value", "set_value")) + + ComponentConstructor = Callable[..., "ComponentType"] """Simple function returning a new component""" diff --git a/src/idom/types.py b/src/idom/types.py index ecb5732b7..73ffef03b 100644 --- a/src/idom/types.py +++ b/src/idom/types.py @@ -18,6 +18,7 @@ Key, LayoutType, RootComponentConstructor, + State, VdomAttributes, VdomAttributesAndChildren, VdomChild, @@ -43,6 +44,7 @@ "LayoutType", "Location", "RootComponentConstructor", + "State", "VdomAttributes", "VdomAttributesAndChildren", "VdomChild", diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 78fce87a4..ce45c433d 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -1379,3 +1379,19 @@ def InnerComponent(): hook.latest.schedule_render() await layout.render() assert inner_render_count.current == 1 + + +async def test_use_state_named_tuple(): + state = idom.Ref() + + @idom.component + def some_component(): + state.current = idom.use_state(1) + return None + + async with idom.Layout(some_component()) as layout: + await layout.render() + assert state.current.value == 1 + state.current.set_value(2) + await layout.render() + assert state.current.value == 2