From f64b15fe9613ef18d95baa5f54b01956572f35bd Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 10 Jul 2022 23:24:08 -0700 Subject: [PATCH 01/13] fix use_context components which are newly added to the layout after the initial render do not have access to contexts. we solve this by tracking life cycle hooks in stack and copying the context providers from parent to child hooks. we also change how contexts are implemented - instead of needing a create_context function which returns a new Context class, we just return a Context object that constructs a ContextProvider component. the earlier implementation was too clever and did not add anything for it. --- src/idom/__init__.py | 2 + src/idom/backend/flask.py | 2 +- src/idom/backend/sanic.py | 2 +- src/idom/backend/starlette.py | 4 +- src/idom/backend/tornado.py | 2 +- src/idom/core/_f_back.py | 23 +++++ src/idom/core/hooks.py | 158 ++++++++++++++++----------------- src/idom/core/layout.py | 46 ++++++---- src/idom/core/vdom.py | 13 ++- tests/test_core/test_hooks.py | 8 +- tests/test_core/test_layout.py | 8 +- 11 files changed, 148 insertions(+), 120 deletions(-) create mode 100644 src/idom/core/_f_back.py diff --git a/src/idom/__init__.py b/src/idom/__init__.py index e7994185d..7464f23df 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -4,6 +4,7 @@ from .core.component import component from .core.events import event from .core.hooks import ( + Context, create_context, use_callback, use_context, @@ -27,6 +28,7 @@ __all__ = [ "component", "config", + "Context", "create_context", "event", "hooks", diff --git a/src/idom/backend/flask.py b/src/idom/backend/flask.py index a9ee2a12e..d3ae06119 100644 --- a/src/idom/backend/flask.py +++ b/src/idom/backend/flask.py @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__) -ConnectionContext: type[Context[Connection | None]] = create_context( +ConnectionContext: Context[Connection | None] = create_context( None, "ConnectionContext" ) diff --git a/src/idom/backend/sanic.py b/src/idom/backend/sanic.py index 537ed839f..dfd22620e 100644 --- a/src/idom/backend/sanic.py +++ b/src/idom/backend/sanic.py @@ -32,7 +32,7 @@ logger = logging.getLogger(__name__) -ConnectionContext: type[Context[Connection | None]] = create_context( +ConnectionContext: Context[Connection | None] = create_context( None, "ConnectionContext" ) diff --git a/src/idom/backend/starlette.py b/src/idom/backend/starlette.py index 83c69a971..e8ed1c136 100644 --- a/src/idom/backend/starlette.py +++ b/src/idom/backend/starlette.py @@ -30,9 +30,7 @@ logger = logging.getLogger(__name__) -WebSocketContext: type[Context[WebSocket | None]] = create_context( - None, "WebSocketContext" -) +WebSocketContext: Context[WebSocket | None] = create_context(None, "WebSocketContext") def configure( diff --git a/src/idom/backend/tornado.py b/src/idom/backend/tornado.py index ff4bc30ca..03856682c 100644 --- a/src/idom/backend/tornado.py +++ b/src/idom/backend/tornado.py @@ -26,7 +26,7 @@ from .utils import CLIENT_BUILD_DIR, safe_client_build_dir_path -ConnectionContext: type[Context[Connection | None]] = create_context( +ConnectionContext: Context[Connection | None] = create_context( None, "ConnectionContext" ) diff --git a/src/idom/core/_f_back.py b/src/idom/core/_f_back.py new file mode 100644 index 000000000..5ba735cd8 --- /dev/null +++ b/src/idom/core/_f_back.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import inspect +from types import FrameType + + +def f_module_name(index: int = 0) -> str: + frame = f_back(index + 1) + if frame is None: + return "" + name = frame.f_globals.get("__name__", "") + assert isinstance(name, str), "Expected module name to be a string" + return name + + +def f_back(index: int = 0) -> FrameType | None: + frame = inspect.currentframe() + while frame is not None: + if index < 0: + return frame + frame = frame.f_back + index -= 1 + return None diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index 3d00478f1..b1f4b880c 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -8,7 +8,6 @@ Any, Awaitable, Callable, - ClassVar, Dict, Generic, List, @@ -21,12 +20,14 @@ cast, overload, ) +from warnings import warn from typing_extensions import Protocol from idom.config import IDOM_DEBUG_MODE from idom.utils import Ref +from ._f_back import f_module_name from ._thread_local import ThreadLocal from .types import ComponentType, Key, VdomDict from .vdom import vdom @@ -240,107 +241,94 @@ def use_debug_value( def create_context( - default_value: _StateType, name: str | None = None -) -> type[Context[_StateType]]: + default_value: _StateType, name: str = "Context" +) -> Context[_StateType]: """Return a new context type for use in :func:`use_context`""" + warn( + "The 'create_context' function is deprecated. " + "Use the 'Context' class instead. " + "This function will be removed in a future release." + ) + return Context(name, default_value) + - class _Context(Context[_StateType]): - _default_value = default_value +_UNDEFINED = object() - _Context.__name__ = name or "Context" - return _Context +class Context(Generic[_StateType]): + """Returns a :class:`ContextProvider` component""" + def __init__(self, name: str, default_value: _StateType) -> None: + self.name = name + self.default_value = default_value -def use_context(context_type: type[Context[_StateType]]) -> _StateType: + def __call__( + self, + *children: Any, + value: _StateType = _UNDEFINED, + key: Key | None = None, + ) -> ( + # users don't need to see that this is a ContextProvider + ComponentType + ): + return ContextProvider( + *children, + value=self.default_value if value is _UNDEFINED else value, + key=key, + type=self, + ) + + def __repr__(self): + return f"{type(self).__name__}({self.name!r})" + + +def use_context(context: Context[_StateType]) -> _StateType: """Get the current value for the given context type. See the full :ref:`Use Context` docs for more information. """ - # We have to use a Ref here since, if initially context_type._current is None, and - # then on a subsequent render it is present, we need to be able to dynamically adopt - # that newly present current context. When we update it though, we don't need to - # schedule a new render since we're already rending right now. Thus we can't do this - # with use_state() since we'd incur an extra render when calling set_state. - context_ref: Ref[Context[_StateType] | None] = use_ref(None) - - if context_ref.current is None: - provided_context = context_type._current.get() - if provided_context is None: - # Cast required because of: https://github.com/python/mypy/issues/5144 - return cast(_StateType, context_type._default_value) - context_ref.current = provided_context - - # We need the hook now so that we can schedule an update when hook = current_hook() - context = context_ref.current + provider = hook.get_context_provider(context) + if provider is None: + return context.default_value @use_effect def subscribe_to_context_change() -> Callable[[], None]: - def set_context(new: Context[_StateType]) -> None: - # We don't need to check if `new is not context_ref.current` because we only - # trigger this callback when the value of a context, and thus the context - # itself changes. Therefore we can always schedule a render. - context_ref.current = new - hook.schedule_render() - - context.subscribers.add(set_context) - return lambda: context.subscribers.remove(set_context) - - return context.value - + provider.subscribers.add(hook) + return lambda: provider.subscribers.remove(hook) -_UNDEFINED: Any = object() + return provider.value -class Context(Generic[_StateType]): - - # This should be _StateType instead of Any, but it can't due to this limitation: - # https://github.com/python/mypy/issues/5144 - _default_value: ClassVar[Any] - - _current: ClassVar[ThreadLocal[Context[Any] | None]] - - def __init_subclass__(cls) -> None: - # every context type tracks which of its instances are currently in use - cls._current = ThreadLocal(lambda: None) - +class ContextProvider(Generic[_StateType]): def __init__( self, *children: Any, - value: _StateType = _UNDEFINED, - key: Key | None = None, + value: _StateType, + key: Key | None, + type: Context[_StateType], ) -> None: self.children = children - self.value: _StateType = self._default_value if value is _UNDEFINED else value + self.value = value self.key = key - self.subscribers: set[Callable[[Context[_StateType]], None]] = set() - self.type = self.__class__ + self.subscribers: set[LifeCycleHook] = set() + self.type = type def render(self) -> VdomDict: - current_ctx = self.__class__._current - - prior_ctx = current_ctx.get() - current_ctx.set(self) - - def reset_ctx() -> None: - current_ctx.set(prior_ctx) - - current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, reset_ctx) - + current_hook().set_context_provider(self) return vdom("", *self.children) - def should_render(self, new: Context[_StateType]) -> bool: + def should_render(self, new: ContextProvider[_StateType]) -> bool: if self.value is not new.value: - new.subscribers.update(self.subscribers) - for set_context in self.subscribers: - set_context(new) + for hook in self.subscribers: + hook.set_context_provider(new) + hook.schedule_render() return True return False def __repr__(self) -> str: - return f"{type(self).__name__}({id(self)})" + return f"{type(self).__name__}({self.type.name!r})" _ActionType = TypeVar("_ActionType") @@ -558,14 +546,14 @@ def _try_to_infer_closure_values( def current_hook() -> LifeCycleHook: """Get the current :class:`LifeCycleHook`""" - hook = _current_hook.get() - if hook is None: + hook_stack = _hook_stack.get() + if not hook_stack: msg = "No life cycle hook is active. Are you rendering in a layout?" raise RuntimeError(msg) - return hook + return hook_stack[-1] -_current_hook: ThreadLocal[LifeCycleHook | None] = ThreadLocal(lambda: None) +_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) EffectType = NewType("EffectType", str) @@ -630,9 +618,8 @@ class LifeCycleHook: hook.affect_component_did_render() - # This should only be called after any child components yielded by - # component_instance.render() have also been rendered because effects of - # this type must run after the full set of changes have been resolved. + # This should only be called after the full set of changes associated with a + # given render have been completed. hook.affect_layout_did_render() # Typically an event occurs and a new render is scheduled, thus begining @@ -650,6 +637,7 @@ class LifeCycleHook: __slots__ = ( "__weakref__", + "_context_providers", "_current_state_index", "_event_effects", "_is_rendering", @@ -666,6 +654,7 @@ def __init__( self, schedule_render: Callable[[], None], ) -> None: + self._context_providers: dict[Context[Any], ContextProvider[Any]] = {} self._schedule_render_callback = schedule_render self._schedule_render_later = False self._is_rendering = False @@ -700,6 +689,14 @@ def add_effect(self, effect_type: EffectType, function: Callable[[], None]) -> N """Trigger a function on the occurance of the given effect type""" self._event_effects[effect_type].append(function) + 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: + return self._context_providers.get(context) + def affect_component_will_render(self, component: ComponentType) -> None: """The component is about to render""" self.component = component @@ -753,13 +750,16 @@ def set_current(self) -> None: This method is called by a layout before entering the render method of this hook's associated component. """ - _current_hook.set(self) + hook_stack = _hook_stack.get() + if hook_stack: + parent = hook_stack[-1] + self._context_providers.update(parent._context_providers) + hook_stack.append(self) def unset_current(self) -> None: """Unset this hook as the active hook in this thread""" # this assertion should never fail - primarilly useful for debug - assert _current_hook.get() is self - _current_hook.set(None) + assert _hook_stack.get().pop() is self def _schedule_render(self) -> None: try: diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index d77431bc1..eba536cef 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -3,6 +3,7 @@ import abc import asyncio from collections import Counter +from contextlib import ExitStack from functools import wraps from logging import getLogger from typing import ( @@ -158,14 +159,10 @@ async def render(self) -> LayoutUpdate: def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdate: new_state = _copy_component_model_state(old_state) - component = new_state.life_cycle_state.component - self._render_component(old_state, new_state, component) - # hook effects must run after the update is complete - for model_state in _iter_model_state_children(new_state): - if model_state.is_component_state: - model_state.life_cycle_state.hook.affect_layout_did_render() + with ExitStack() as exit_stack: + self._render_component(exit_stack, old_state, new_state, component) old_model: Optional[VdomJson] try: @@ -181,6 +178,7 @@ def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdate: def _render_component( self, + exit_stack: ExitStack, old_state: Optional[_ModelState], new_state: _ModelState, component: ComponentType, @@ -200,19 +198,17 @@ def _render_component( else: life_cycle_hook = life_cycle_state.hook life_cycle_hook.affect_component_will_render(component) + exit_stack.callback(life_cycle_hook.affect_layout_did_render) + life_cycle_hook.set_current() try: - life_cycle_hook.set_current() - try: - raw_model = component.render() - finally: - life_cycle_hook.unset_current() + raw_model = component.render() # wrap the model in a fragment (i.e. tagName="") to ensure components have # a separate node in the model state tree. This could be removed if this # components are given a node in the tree some other way wrapper_model: VdomDict = {"tagName": ""} if raw_model is not None: wrapper_model["children"] = [raw_model] - self._render_model(old_state, new_state, wrapper_model) + self._render_model(exit_stack, old_state, new_state, wrapper_model) except Exception as error: logger.exception(f"Failed to render {component}") new_state.model.current = { @@ -224,6 +220,7 @@ def _render_component( ), } finally: + life_cycle_hook.unset_current() life_cycle_hook.affect_component_did_render() try: @@ -243,6 +240,7 @@ def _render_component( def _render_model( self, + exit_stack: ExitStack, old_state: Optional[_ModelState], new_state: _ModelState, raw_model: Any, @@ -253,7 +251,9 @@ def _render_model( if "importSource" in raw_model: new_state.model.current["importSource"] = raw_model["importSource"] self._render_model_attributes(old_state, new_state, raw_model) - self._render_model_children(old_state, new_state, raw_model.get("children", [])) + self._render_model_children( + exit_stack, old_state, new_state, raw_model.get("children", []) + ) def _render_model_attributes( self, @@ -320,6 +320,7 @@ def _render_model_event_handlers_without_old_state( def _render_model_children( self, + exit_stack: ExitStack, old_state: Optional[_ModelState], new_state: _ModelState, raw_children: Any, @@ -329,7 +330,9 @@ def _render_model_children( if old_state is None: if raw_children: - self._render_model_children_without_old_state(new_state, raw_children) + self._render_model_children_without_old_state( + exit_stack, new_state, raw_children + ) return None elif not raw_children: self._unmount_model_states(list(old_state.children_by_key.values())) @@ -377,7 +380,7 @@ def _render_model_children( new_state, index, ) - self._render_model(old_child_state, new_child_state, child) + self._render_model(exit_stack, old_child_state, new_child_state, child) new_children.append(new_child_state.model.current) new_state.children_by_key[key] = new_child_state elif child_type is _COMPONENT_TYPE: @@ -411,7 +414,9 @@ def _render_model_children( child, self._rendering_queue.put, ) - self._render_component(old_child_state, new_child_state, child) + self._render_component( + exit_stack, old_child_state, new_child_state, child + ) else: old_child_state = old_state.children_by_key.get(key) if old_child_state is not None: @@ -419,7 +424,10 @@ def _render_model_children( new_children.append(child) def _render_model_children_without_old_state( - self, new_state: _ModelState, raw_children: List[Any] + self, + exit_stack: ExitStack, + new_state: _ModelState, + raw_children: List[Any], ) -> None: child_type_key_tuples = list(_process_child_type_and_key(raw_children)) @@ -435,14 +443,14 @@ def _render_model_children_without_old_state( for index, (child, child_type, key) in enumerate(child_type_key_tuples): if child_type is _DICT_TYPE: child_state = _make_element_model_state(new_state, index, key) - self._render_model(None, child_state, child) + self._render_model(exit_stack, None, child_state, child) new_children.append(child_state.model.current) new_state.children_by_key[key] = child_state elif child_type is _COMPONENT_TYPE: child_state = _make_component_model_state( new_state, index, key, child, self._rendering_queue.put ) - self._render_component(None, child_state, child) + self._render_component(exit_stack, None, child_state, child) else: new_children.append(child) diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index 0470c15be..b2b333663 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -23,6 +23,8 @@ VdomJson, ) +from ._f_back import f_module_name + logger = logging.getLogger() @@ -223,13 +225,10 @@ def constructor( "element represented by a :class:`VdomDict`." ) - frame = inspect.currentframe() - if frame is not None and frame.f_back is not None and frame.f_back is not None: - module = frame.f_back.f_globals.get("__name__") # module in outer frame - if module is not None: - qualname = module + "." + tag - constructor.__module__ = module - constructor.__qualname__ = qualname + module_name = f_module_name(1) + if module_name: + constructor.__module__ = module_name + constructor.__qualname__ = f"{module_name}.{tag}" return constructor diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index e0d5694fe..fbf659844 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -935,11 +935,9 @@ def ComponentUsesContext(): def test_context_repr(): - Context = idom.create_context(None) - assert re.match(r"Context\(.*\)", repr(Context())) - - MyContext = idom.create_context(None, name="MyContext") - assert re.match(r"MyContext\(.*\)", repr(MyContext())) + sample_context = idom.Context("sample_context", None) + assert repr(sample_context) == "Context('sample_context')" + assert repr(sample_context()) == "ContextProvider('sample_context')" async def test_use_context_only_renders_for_value_change(): diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index bf77b6b63..ce419c0e4 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -1027,7 +1027,7 @@ async def test_element_keys_inside_components_do_not_reset_state_of_component(): reset in any `Child()` components but there was a bug where that happened. """ - effect_calls_without_state = [] + effect_calls_without_state = set() set_child_key_num = StaticEventHandler() did_call_effect = asyncio.Event() @@ -1051,7 +1051,7 @@ def Child(child_key): async def record_if_state_is_reset(): if state: return - effect_calls_without_state.append(child_key) + effect_calls_without_state.add(child_key) set_state(1) did_call_effect.set() @@ -1063,13 +1063,13 @@ async def record_if_state_is_reset(): async with idom.Layout(Parent()) as layout: await layout.render() await did_call_effect.wait() - assert effect_calls_without_state == ["some-key", "key-0"] + assert effect_calls_without_state == {"some-key", "key-0"} did_call_effect.clear() for i in range(1, 5): await layout.deliver(LayoutEvent(set_child_key_num.target, [])) await layout.render() - assert effect_calls_without_state == ["some-key", "key-0"] + assert effect_calls_without_state == {"some-key", "key-0"} did_call_effect.clear() From 8cd7d589a7b52f62af4921aa79eee247fbc7476f Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 12 Jul 2022 21:58:57 -0700 Subject: [PATCH 02/13] add test --- tests/test_core/test_hooks.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index fbf659844..ae89c0898 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -1245,3 +1245,32 @@ def SomeComponent(): with assert_idom_did_not_log(r"SomeComponent\(.*?\) message is 'bye'"): await layout.render() + + +async def test_conditionally_rendered_components_can_use_context(): + set_state = idom.Ref() + used_context_values = [] + some_context = idom.Context("some_context", None) + + @idom.component + def SomeComponent(): + state, set_state.current = idom.use_state(True) + if state: + return FirstCondition() + else: + return SecondCondition() + + @idom.component + def FirstCondition(): + used_context_values.append(idom.use_context(some_context)) + + @idom.component + def SecondCondition(): + used_context_values.append(idom.use_context(some_context)) + + async with idom.Layout(some_context(SomeComponent(), value="the-value")) as layout: + await layout.render() + assert used_context_values == ["the-value"] + set_state.current(False) + await layout.render() + assert used_context_values == ["the-value", "the-value"] From eb77261c130d8f9065f92a5ca3bbd0568a47aa41 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 12 Jul 2022 22:05:20 -0700 Subject: [PATCH 03/13] add changelog entry --- docs/source/about/changelog.rst | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 1f9e595c3..9a0f0d275 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -23,13 +23,18 @@ more info, see the :ref:`Contributor Guide `. Unreleased ---------- -**Added** +**Fixed** -- :pull:`123` - ``asgiref`` as a dependency +- :issue:`789` - Conditionally rendered components cannot use contexts **Changed** - :pull:`123` - set default timeout on playwright page for testing +- :pull:`787` - Track contexts in hooks as state + +**Added** + +- :pull:`123` - ``asgiref`` as a dependency v0.39.0 From b0a9b067a2f0b6735ed2316e0fed5a32be37dbc7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 12 Jul 2022 22:12:33 -0700 Subject: [PATCH 04/13] fix mypy --- src/idom/core/hooks.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index b1f4b880c..20125ad4e 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -252,7 +252,7 @@ def create_context( return Context(name, default_value) -_UNDEFINED = object() +_UNDEFINED = cast(Any, object()) class Context(Generic[_StateType]): @@ -267,10 +267,7 @@ def __call__( *children: Any, value: _StateType = _UNDEFINED, key: Key | None = None, - ) -> ( - # users don't need to see that this is a ContextProvider - ComponentType - ): + ) -> ContextProvider[_StateType]: return ContextProvider( *children, value=self.default_value if value is _UNDEFINED else value, @@ -278,7 +275,7 @@ def __call__( type=self, ) - def __repr__(self): + def __repr__(self) -> str: return f"{type(self).__name__}({self.name!r})" @@ -292,13 +289,14 @@ def use_context(context: Context[_StateType]) -> _StateType: provider = hook.get_context_provider(context) if provider is None: return context.default_value + subscribers = provider._subscribers @use_effect def subscribe_to_context_change() -> Callable[[], None]: - provider.subscribers.add(hook) - return lambda: provider.subscribers.remove(hook) + subscribers.add(hook) + return lambda: subscribers.remove(hook) - return provider.value + return provider._value class ContextProvider(Generic[_StateType]): @@ -310,18 +308,18 @@ def __init__( type: Context[_StateType], ) -> None: self.children = children - self.value = value self.key = key - self.subscribers: set[LifeCycleHook] = set() self.type = type + self._subscribers: set[LifeCycleHook] = set() + self._value = value def render(self) -> VdomDict: current_hook().set_context_provider(self) return vdom("", *self.children) def should_render(self, new: ContextProvider[_StateType]) -> bool: - if self.value is not new.value: - for hook in self.subscribers: + if self._value is not new._value: + for hook in self._subscribers: hook.set_context_provider(new) hook.schedule_render() return True From f6b326ee304484fe00714787888aa58d5acdd355 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 12 Jul 2022 22:32:41 -0700 Subject: [PATCH 05/13] fix tornado dev server --- src/idom/backend/tornado.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/idom/backend/tornado.py b/src/idom/backend/tornado.py index 03856682c..fed571133 100644 --- a/src/idom/backend/tornado.py +++ b/src/idom/backend/tornado.py @@ -10,6 +10,7 @@ from tornado.httpserver import HTTPServer from tornado.httputil import HTTPServerRequest +from tornado.ioloop import IOLoop from tornado.log import enable_pretty_logging from tornado.platform.asyncio import AsyncIOMainLoop from tornado.web import Application, RequestHandler, StaticFileHandler @@ -67,8 +68,7 @@ async def serve_development_app( ) -> None: enable_pretty_logging() - # setup up tornado to use asyncio - AsyncIOMainLoop().install() + AsyncIOMainLoop.current().install() server = HTTPServer(app) server.listen(port, host) From 7065b7d8190f774b9726f83d2686cf8aa110e937 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 12 Jul 2022 23:00:27 -0700 Subject: [PATCH 06/13] remove unused func --- src/idom/core/_f_back.py | 4 ++-- src/idom/core/layout.py | 6 ------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/idom/core/_f_back.py b/src/idom/core/_f_back.py index 5ba735cd8..81e66a4f1 100644 --- a/src/idom/core/_f_back.py +++ b/src/idom/core/_f_back.py @@ -7,7 +7,7 @@ def f_module_name(index: int = 0) -> str: frame = f_back(index + 1) if frame is None: - return "" + return "" # pragma: no cover name = frame.f_globals.get("__name__", "") assert isinstance(name, str), "Expected module name to be a string" return name @@ -20,4 +20,4 @@ def f_back(index: int = 0) -> FrameType | None: return frame frame = frame.f_back index -= 1 - return None + return None # pragma: no cover diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index eba536cef..a02be353e 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -481,12 +481,6 @@ def _check_should_render(old: ComponentType, new: ComponentType) -> bool: return False -def _iter_model_state_children(model_state: _ModelState) -> Iterator[_ModelState]: - yield model_state - for child in model_state.children_by_key.values(): - yield from _iter_model_state_children(child) - - def _new_root_model_state( component: ComponentType, schedule_render: Callable[[_LifeCycleStateId], None] ) -> _ModelState: From 720519ae60a16406666bdebcfd258630303acfa0 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 12 Jul 2022 23:02:08 -0700 Subject: [PATCH 07/13] improve test --- tests/test_core/test_hooks.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index ae89c0898..5edfff57c 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -1262,15 +1262,15 @@ def SomeComponent(): @idom.component def FirstCondition(): - used_context_values.append(idom.use_context(some_context)) + used_context_values.append(idom.use_context(some_context) + "-1") @idom.component def SecondCondition(): - used_context_values.append(idom.use_context(some_context)) + used_context_values.append(idom.use_context(some_context) + "-2") async with idom.Layout(some_context(SomeComponent(), value="the-value")) as layout: await layout.render() - assert used_context_values == ["the-value"] + assert used_context_values == ["the-value-1"] set_state.current(False) await layout.render() - assert used_context_values == ["the-value", "the-value"] + assert used_context_values == ["the-value-1", "the-value-2"] From 07397366c38eaa1b150262d4999793999c9d4822 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 12 Jul 2022 23:07:06 -0700 Subject: [PATCH 08/13] remove unused method --- src/idom/core/_thread_local.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/idom/core/_thread_local.py b/src/idom/core/_thread_local.py index f1168cc20..80a42d069 100644 --- a/src/idom/core/_thread_local.py +++ b/src/idom/core/_thread_local.py @@ -20,6 +20,3 @@ def get(self) -> _StateType: else: state = self._state[thread] return state - - def set(self, state: _StateType) -> None: - self._state[current_thread()] = state From 6450049c54f9f1de3da4769e17c1b8a6b49ffabf Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 12 Jul 2022 23:15:12 -0700 Subject: [PATCH 09/13] fix style issues --- src/idom/backend/tornado.py | 1 - src/idom/core/hooks.py | 1 - src/idom/core/vdom.py | 1 - tests/test_core/test_hooks.py | 1 - 4 files changed, 4 deletions(-) diff --git a/src/idom/backend/tornado.py b/src/idom/backend/tornado.py index fed571133..039c5f73d 100644 --- a/src/idom/backend/tornado.py +++ b/src/idom/backend/tornado.py @@ -10,7 +10,6 @@ from tornado.httpserver import HTTPServer from tornado.httputil import HTTPServerRequest -from tornado.ioloop import IOLoop from tornado.log import enable_pretty_logging from tornado.platform.asyncio import AsyncIOMainLoop from tornado.web import Application, RequestHandler, StaticFileHandler diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index 20125ad4e..33520e50e 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -27,7 +27,6 @@ from idom.config import IDOM_DEBUG_MODE from idom.utils import Ref -from ._f_back import f_module_name from ._thread_local import ThreadLocal from .types import ComponentType, Key, VdomDict from .vdom import vdom diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index b2b333663..4af5bb009 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -1,6 +1,5 @@ from __future__ import annotations -import inspect import logging from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, cast diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 5edfff57c..8ee0b2f7c 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -1,5 +1,4 @@ import asyncio -import re import pytest From 13cc71a93c0fe16708c32337a3a69c14b6b4360c Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 13 Jul 2022 01:39:02 -0700 Subject: [PATCH 10/13] get clever with functions --- src/idom/__init__.py | 2 -- src/idom/backend/flask.py | 4 +-- src/idom/backend/sanic.py | 4 +-- src/idom/backend/starlette.py | 2 +- src/idom/backend/tornado.py | 4 +-- src/idom/core/hooks.py | 58 +++++++++++++++++------------------ tests/test_core/test_hooks.py | 2 +- 7 files changed, 33 insertions(+), 43 deletions(-) diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 7464f23df..e7994185d 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -4,7 +4,6 @@ from .core.component import component from .core.events import event from .core.hooks import ( - Context, create_context, use_callback, use_context, @@ -28,7 +27,6 @@ __all__ = [ "component", "config", - "Context", "create_context", "event", "hooks", diff --git a/src/idom/backend/flask.py b/src/idom/backend/flask.py index d3ae06119..55e20618b 100644 --- a/src/idom/backend/flask.py +++ b/src/idom/backend/flask.py @@ -37,9 +37,7 @@ logger = logging.getLogger(__name__) -ConnectionContext: Context[Connection | None] = create_context( - None, "ConnectionContext" -) +ConnectionContext: Context[Connection | None] = create_context(None) def configure( diff --git a/src/idom/backend/sanic.py b/src/idom/backend/sanic.py index dfd22620e..aa0b45405 100644 --- a/src/idom/backend/sanic.py +++ b/src/idom/backend/sanic.py @@ -32,9 +32,7 @@ logger = logging.getLogger(__name__) -ConnectionContext: Context[Connection | None] = create_context( - None, "ConnectionContext" -) +ConnectionContext: Context[Connection | None] = create_context(None) def configure( diff --git a/src/idom/backend/starlette.py b/src/idom/backend/starlette.py index e8ed1c136..ebee12dd0 100644 --- a/src/idom/backend/starlette.py +++ b/src/idom/backend/starlette.py @@ -30,7 +30,7 @@ logger = logging.getLogger(__name__) -WebSocketContext: Context[WebSocket | None] = create_context(None, "WebSocketContext") +WebSocketContext: Context[WebSocket | None] = create_context(None) def configure( diff --git a/src/idom/backend/tornado.py b/src/idom/backend/tornado.py index 039c5f73d..febd4db3a 100644 --- a/src/idom/backend/tornado.py +++ b/src/idom/backend/tornado.py @@ -26,9 +26,7 @@ from .utils import CLIENT_BUILD_DIR, safe_client_build_dir_path -ConnectionContext: Context[Connection | None] = create_context( - None, "ConnectionContext" -) +ConnectionContext: Context[Connection | None] = create_context(None) def configure( diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index 33520e50e..57df34fdb 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -20,13 +20,13 @@ cast, overload, ) -from warnings import warn from typing_extensions import Protocol from idom.config import IDOM_DEBUG_MODE from idom.utils import Ref +from ._f_back import f_module_name from ._thread_local import ThreadLocal from .types import ComponentType, Key, VdomDict from .vdom import vdom @@ -239,43 +239,36 @@ def use_debug_value( logger.debug(f"{current_hook().component} {new}") -def create_context( - default_value: _StateType, name: str = "Context" -) -> Context[_StateType]: +def create_context(default_value: _StateType) -> Context[_StateType]: """Return a new context type for use in :func:`use_context`""" - warn( - "The 'create_context' function is deprecated. " - "Use the 'Context' class instead. " - "This function will be removed in a future release." - ) - return Context(name, default_value) + def context( + *children: Any, + value: _StateType = default_value, + key: Key | None = None, + ) -> ContextProvider[_StateType]: + return ContextProvider( + *children, + value=value, + key=key, + type=context, + ) -_UNDEFINED = cast(Any, object()) + context.__qualname__ = "context" + return context -class Context(Generic[_StateType]): - """Returns a :class:`ContextProvider` component""" - def __init__(self, name: str, default_value: _StateType) -> None: - self.name = name - self.default_value = default_value +class Context(Protocol[_StateType]): + """Returns a :class:`ContextProvider` component""" def __call__( self, *children: Any, - value: _StateType = _UNDEFINED, - key: Key | None = None, + value: _StateType = ..., + key: Key | None = ..., ) -> ContextProvider[_StateType]: - return ContextProvider( - *children, - value=self.default_value if value is _UNDEFINED else value, - key=key, - type=self, - ) - - def __repr__(self) -> str: - return f"{type(self).__name__}({self.name!r})" + ... def use_context(context: Context[_StateType]) -> _StateType: @@ -284,10 +277,15 @@ def use_context(context: Context[_StateType]) -> _StateType: See the full :ref:`Use Context` docs for more information. """ hook = current_hook() - provider = hook.get_context_provider(context) + if provider is None: - return context.default_value + try: + # force type checker to realize this is just a normal function + assert isinstance(context, FunctionType) + return cast(_StateType, context.__kwdefaults__["value"]) + except KeyError: + raise TypeError(f"{context} does not implement the Context interface") subscribers = provider._subscribers @use_effect @@ -325,7 +323,7 @@ def should_render(self, new: ContextProvider[_StateType]) -> bool: return False def __repr__(self) -> str: - return f"{type(self).__name__}({self.type.name!r})" + return f"{type(self).__name__}({self.type!r})" _ActionType = TypeVar("_ActionType") diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index 8ee0b2f7c..c468eb386 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -1249,7 +1249,7 @@ def SomeComponent(): async def test_conditionally_rendered_components_can_use_context(): set_state = idom.Ref() used_context_values = [] - some_context = idom.Context("some_context", None) + some_context = idom.create_context("some_context", None) @idom.component def SomeComponent(): From 1c41e1c2bbc83a49ae32b38fc44c6731e7ce0012 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 13 Jul 2022 19:09:51 -0700 Subject: [PATCH 11/13] fix tests --- src/idom/core/hooks.py | 2 +- tests/test_core/test_hooks.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index 57df34fdb..16005938d 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -323,7 +323,7 @@ def should_render(self, new: ContextProvider[_StateType]) -> bool: return False def __repr__(self) -> str: - return f"{type(self).__name__}({self.type!r})" + return f"{type(self).__name__}({self.type})" _ActionType = TypeVar("_ActionType") diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index c468eb386..a2eeb9508 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -934,9 +934,8 @@ def ComponentUsesContext(): def test_context_repr(): - sample_context = idom.Context("sample_context", None) - assert repr(sample_context) == "Context('sample_context')" - assert repr(sample_context()) == "ContextProvider('sample_context')" + sample_context = idom.create_context(None) + assert repr(sample_context()) == f"ContextProvider({sample_context})" async def test_use_context_only_renders_for_value_change(): @@ -1065,8 +1064,8 @@ def Inner(): async def test_neighboring_contexts_do_not_conflict(): - LeftContext = idom.create_context(None, name="Left") - RightContext = idom.create_context(None, name="Right") + LeftContext = idom.create_context(None) + RightContext = idom.create_context(None) set_left = idom.Ref() set_right = idom.Ref() @@ -1249,7 +1248,7 @@ def SomeComponent(): async def test_conditionally_rendered_components_can_use_context(): set_state = idom.Ref() used_context_values = [] - some_context = idom.create_context("some_context", None) + some_context = idom.create_context(None) @idom.component def SomeComponent(): From 200fec24753d01d76be5dd6fdd2a7b23de8e4dd3 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 13 Jul 2022 19:10:48 -0700 Subject: [PATCH 12/13] update changelog --- docs/source/about/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 9a0f0d275..c2d2384d9 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -31,6 +31,7 @@ Unreleased - :pull:`123` - set default timeout on playwright page for testing - :pull:`787` - Track contexts in hooks as state +- :pull:`787` - remove non-standard ``name`` argument from ``create_context`` **Added** From 60feda71222a563414b319507ee459bc92174f35 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 13 Jul 2022 19:19:56 -0700 Subject: [PATCH 13/13] get coverage --- src/idom/core/hooks.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index 16005938d..0abb47795 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -26,7 +26,6 @@ from idom.config import IDOM_DEBUG_MODE from idom.utils import Ref -from ._f_back import f_module_name from ._thread_local import ThreadLocal from .types import ComponentType, Key, VdomDict from .vdom import vdom @@ -280,12 +279,15 @@ def use_context(context: Context[_StateType]) -> _StateType: provider = hook.get_context_provider(context) if provider is None: - try: - # force type checker to realize this is just a normal function - assert isinstance(context, FunctionType) - return cast(_StateType, context.__kwdefaults__["value"]) - except KeyError: - raise TypeError(f"{context} does not implement the Context interface") + # force type checker to realize this is just a normal function + assert isinstance(context, FunctionType), f"{context} is not a Context" + # __kwdefault__ can be None if no kwarg only parameters exist + assert context.__kwdefaults__ is not None, f"{context} has no 'value' kwarg" + # 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"]) + subscribers = provider._subscribers @use_effect