from __future__ import annotations
import asyncio
from collections.abc import Coroutine, MutableMapping, Sequence
from logging import getLogger
from types import FunctionType
from typing import (
TYPE_CHECKING,
Any,
Callable,
Generic,
Protocol,
TypeVar,
cast,
overload,
)
from typing_extensions import TypeAlias
from reactpy.backend.types import Connection, Location
from reactpy.config import REACTPY_DEBUG_MODE
from reactpy.core._life_cycle_hook import current_hook
from reactpy.core.types import Context, Key, State, VdomDict
from reactpy.utils import Ref
if not TYPE_CHECKING:
# make flake8 think that this variable exists
ellipsis = type(...)
__all__ = [
"use_state",
"use_effect",
"use_reducer",
"use_callback",
"use_ref",
"use_memo",
]
logger = getLogger(__name__)
_Type = TypeVar("_Type")
@overload
def use_state(initial_value: Callable[[], _Type]) -> State[_Type]: ...
@overload
def use_state(initial_value: _Type) -> State[_Type]: ...
[docs]def use_state(initial_value: _Type | Callable[[], _Type]) -> State[_Type]:
"""See the full :ref:`Use State` docs for details
Parameters:
initial_value:
Defines the initial value of the state. A callable (accepting no arguments)
can be used as a constructor function to avoid re-creating the initial value
on each render.
Returns:
A tuple containing the current state and a function to update it.
"""
current_state = _use_const(lambda: _CurrentState(initial_value))
return State(current_state.value, current_state.dispatch)
class _CurrentState(Generic[_Type]):
__slots__ = "value", "dispatch"
def __init__(
self,
initial_value: _Type | Callable[[], _Type],
) -> None:
if callable(initial_value):
self.value = initial_value()
else:
self.value = initial_value
hook = current_hook()
def dispatch(new: _Type | Callable[[_Type], _Type]) -> None:
if callable(new):
next_value = new(self.value)
else:
next_value = new
if not strictly_equal(next_value, self.value):
self.value = next_value
hook.schedule_render()
self.dispatch = dispatch
_EffectCleanFunc: TypeAlias = "Callable[[], None]"
_SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]"
_AsyncEffectFunc: TypeAlias = (
"Callable[[], Coroutine[None, None, _EffectCleanFunc | None]]"
)
_EffectApplyFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc"
@overload
def use_effect(
function: None = None,
dependencies: Sequence[Any] | ellipsis | None = ...,
) -> Callable[[_EffectApplyFunc], None]: ...
@overload
def use_effect(
function: _EffectApplyFunc,
dependencies: Sequence[Any] | ellipsis | None = ...,
) -> None: ...
[docs]def use_effect(
function: _EffectApplyFunc | None = None,
dependencies: Sequence[Any] | ellipsis | None = ...,
) -> Callable[[_EffectApplyFunc], None] | None:
"""See the full :ref:`Use Effect` docs for details
Parameters:
function:
Applies the effect and can return a clean-up function
dependencies:
Dependencies for the effect. The effect will only trigger if the identity
of any value in the given sequence changes (i.e. their :func:`id` is
different). By default these are inferred based on local variables that are
referenced by the given function.
Returns:
If not function is provided, a decorator. Otherwise ``None``.
"""
hook = current_hook()
dependencies = _try_to_infer_closure_values(function, dependencies)
memoize = use_memo(dependencies=dependencies)
last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None)
def add_effect(function: _EffectApplyFunc) -> None:
if not asyncio.iscoroutinefunction(function):
sync_function = cast(_SyncEffectFunc, function)
else:
async_function = cast(_AsyncEffectFunc, function)
def sync_function() -> _EffectCleanFunc | None:
task = asyncio.create_task(async_function())
def clean_future() -> None:
if not task.cancel():
try:
clean = task.result()
except asyncio.CancelledError:
pass
else:
if clean is not None:
clean()
return clean_future
async def effect(stop: asyncio.Event) -> None:
if last_clean_callback.current is not None:
last_clean_callback.current()
last_clean_callback.current = None
clean = last_clean_callback.current = sync_function()
await stop.wait()
if clean is not None:
clean()
return memoize(lambda: hook.add_effect(effect))
if function is not None:
add_effect(function)
return None
else:
return add_effect
[docs]def use_debug_value(
message: Any | Callable[[], Any],
dependencies: Sequence[Any] | ellipsis | None = ...,
) -> None:
"""Log debug information when the given message changes.
.. note::
This hook only logs if :data:`~reactpy.config.REACTPY_DEBUG_MODE` is active.
Unlike other hooks, a message is considered to have changed if the old and new
values are ``!=``. Because this comparison is performed on every render of the
component, it may be worth considering the performance cost in some situations.
Parameters:
message:
The value to log or a memoized function for generating the value.
dependencies:
Dependencies for the memoized function. The message will only be recomputed
if the identity of any value in the given sequence changes (i.e. their
:func:`id` is different). By default these are inferred based on local
variables that are referenced by the given function.
"""
old: Ref[Any] = _use_const(lambda: Ref(object()))
memo_func = message if callable(message) else lambda: message
new = use_memo(memo_func, dependencies)
if REACTPY_DEBUG_MODE.current and old.current != new:
old.current = new
logger.debug(f"{current_hook().component} {new}")
[docs]def create_context(default_value: _Type) -> Context[_Type]:
"""Return a new context type for use in :func:`use_context`"""
def context(
*children: Any,
value: _Type = default_value,
key: Key | None = None,
) -> _ContextProvider[_Type]:
return _ContextProvider(
*children,
value=value,
key=key,
type=context,
)
context.__qualname__ = "context"
return context
[docs]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.
"""
hook = current_hook()
provider = hook.get_context_provider(context)
if provider is None:
# same assertions but with normal exceptions
if not isinstance(context, FunctionType):
raise TypeError(f"{context} is not a Context") # nocov
if context.__kwdefaults__ is None:
raise TypeError(f"{context} has no 'value' kwarg") # nocov
if "value" not in context.__kwdefaults__:
raise TypeError(f"{context} has no 'value' kwarg") # nocov
return cast(_Type, context.__kwdefaults__["value"])
return provider.value
# backend implementations should establish this context at the root of an app
ConnectionContext: Context[Connection[Any] | None] = create_context(None)
[docs]def use_connection() -> Connection[Any]:
"""Get the current :class:`~reactpy.backend.types.Connection`."""
conn = use_context(ConnectionContext)
if conn is None: # nocov
msg = "No backend established a connection."
raise RuntimeError(msg)
return conn
[docs]def use_scope() -> MutableMapping[str, Any]:
"""Get the current :class:`~reactpy.backend.types.Connection`'s scope."""
return use_connection().scope
[docs]def use_location() -> Location:
"""Get the current :class:`~reactpy.backend.types.Connection`'s location."""
return use_connection().location
class _ContextProvider(Generic[_Type]):
def __init__(
self,
*children: Any,
value: _Type,
key: Key | None,
type: Context[_Type],
) -> None:
self.children = children
self.key = key
self.type = type
self.value = value
def render(self) -> VdomDict:
current_hook().set_context_provider(self)
return {"tagName": "", "children": self.children}
def __repr__(self) -> str:
return f"ContextProvider({self.type})"
_ActionType = TypeVar("_ActionType")
[docs]def use_reducer(
reducer: Callable[[_Type, _ActionType], _Type],
initial_value: _Type,
) -> tuple[_Type, Callable[[_ActionType], None]]:
"""See the full :ref:`Use Reducer` docs for details
Parameters:
reducer:
A function which applies an action to the current state in order to
produce the next state.
initial_value:
The initial state value (same as for :func:`use_state`)
Returns:
A tuple containing the current state and a function to change it with an action
"""
state, set_state = use_state(initial_value)
return state, _use_const(lambda: _create_dispatcher(reducer, set_state))
def _create_dispatcher(
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))
return dispatch
_CallbackFunc = TypeVar("_CallbackFunc", bound=Callable[..., Any])
@overload
def use_callback(
function: None = None,
dependencies: Sequence[Any] | ellipsis | None = ...,
) -> Callable[[_CallbackFunc], _CallbackFunc]: ...
@overload
def use_callback(
function: _CallbackFunc,
dependencies: Sequence[Any] | ellipsis | None = ...,
) -> _CallbackFunc: ...
[docs]def use_callback(
function: _CallbackFunc | None = None,
dependencies: Sequence[Any] | ellipsis | None = ...,
) -> _CallbackFunc | Callable[[_CallbackFunc], _CallbackFunc]:
"""See the full :ref:`Use Callback` docs for details
Parameters:
function:
The function whose identity will be preserved
dependencies:
Dependencies of the callback. The identity the ``function`` will be updated
if the identity of any value in the given sequence changes (i.e. their
:func:`id` is different). By default these are inferred based on local
variables that are referenced by the given function.
Returns:
The current function
"""
dependencies = _try_to_infer_closure_values(function, dependencies)
memoize = use_memo(dependencies=dependencies)
def setup(function: _CallbackFunc) -> _CallbackFunc:
return memoize(lambda: function)
if function is not None:
return setup(function)
else:
return setup
class _LambdaCaller(Protocol):
"""MyPy doesn't know how to deal with TypeVars only used in function return"""
def __call__(self, func: Callable[[], _Type]) -> _Type: ...
@overload
def use_memo(
function: None = None,
dependencies: Sequence[Any] | ellipsis | None = ...,
) -> _LambdaCaller: ...
@overload
def use_memo(
function: Callable[[], _Type],
dependencies: Sequence[Any] | ellipsis | None = ...,
) -> _Type: ...
[docs]def use_memo(
function: Callable[[], _Type] | None = None,
dependencies: Sequence[Any] | ellipsis | None = ...,
) -> _Type | Callable[[Callable[[], _Type]], _Type]:
"""See the full :ref:`Use Memo` docs for details
Parameters:
function:
The function to be memoized.
dependencies:
Dependencies for the memoized function. The memo will only be recomputed if
the identity of any value in the given sequence changes (i.e. their
:func:`id` is different). By default these are inferred based on local
variables that are referenced by the given function.
Returns:
The current state
"""
dependencies = _try_to_infer_closure_values(function, dependencies)
memo: _Memo[_Type] = _use_const(_Memo)
if memo.empty():
# we need to initialize on the first run
changed = True
memo.deps = () if dependencies is None else dependencies
elif dependencies is None:
changed = True
memo.deps = ()
elif (
len(memo.deps) != len(dependencies)
# if deps are same length check identity for each item
or not all(
strictly_equal(current, new)
for current, new in zip(memo.deps, dependencies)
)
):
memo.deps = dependencies
changed = True
else:
changed = False
setup: Callable[[Callable[[], _Type]], _Type]
if changed:
def setup(function: Callable[[], _Type]) -> _Type:
current_value = memo.value = function()
return current_value
else:
def setup(function: Callable[[], _Type]) -> _Type:
return memo.value
if function is not None:
return setup(function)
else:
return setup
class _Memo(Generic[_Type]):
"""Simple object for storing memoization data"""
__slots__ = "value", "deps"
value: _Type
deps: Sequence[Any]
def empty(self) -> bool:
try:
self.value # noqa: B018
except AttributeError:
return True
else:
return False
[docs]def use_ref(initial_value: _Type) -> Ref[_Type]:
"""See the full :ref:`Use State` docs for details
Parameters:
initial_value: The value initially assigned to the reference.
Returns:
A :class:`Ref` object.
"""
return _use_const(lambda: Ref(initial_value))
def _use_const(function: Callable[[], _Type]) -> _Type:
return current_hook().use_state(function)
def _try_to_infer_closure_values(
func: Callable[..., Any] | None,
values: Sequence[Any] | ellipsis | None,
) -> Sequence[Any] | None:
if values is ...:
if isinstance(func, FunctionType):
return (
[cell.cell_contents for cell in func.__closure__]
if func.__closure__
else []
)
else:
return None
else:
return values
[docs]def strictly_equal(x: Any, y: Any) -> bool:
"""Check if two values are identical or, for a limited set or types, equal.
Only the following types are checked for equality rather than identity:
- ``int``
- ``float``
- ``complex``
- ``str``
- ``bytes``
- ``bytearray``
- ``memoryview``
"""
return x is y or (type(x) in _NUMERIC_TEXT_BINARY_TYPES and x == y)
_NUMERIC_TEXT_BINARY_TYPES = {
# numeric
int,
float,
complex,
# text
str,
# binary types
bytes,
bytearray,
memoryview,
}