Skip to content

Commit 146ceef

Browse files
committed
Fix test failures
1 parent c60ab84 commit 146ceef

File tree

10 files changed

+97
-54
lines changed

10 files changed

+97
-54
lines changed

src/reactpy/core/_life_cycle_hook.py

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
from __future__ import annotations
22

33
import logging
4+
import sys
45
from asyncio import Event, Task, create_task, gather
56
from contextvars import ContextVar, Token
67
from typing import Any, Callable, Protocol, TypeVar
78

89
from anyio import Semaphore
910

11+
from reactpy.core._thread_local import ThreadLocal
1012
from reactpy.types import ComponentType, Context, ContextProviderType
13+
from reactpy.utils import Singleton
1114

1215
T = TypeVar("T")
1316

@@ -17,23 +20,40 @@ async def __call__(self, stop: Event) -> None: ...
1720

1821

1922
logger = logging.getLogger(__name__)
20-
_HOOK_STATE: ContextVar[list[LifeCycleHook]] = ContextVar("_hook_state")
2123

2224

23-
def clear_hook_state(token: Token[list]) -> None:
24-
hook_stack = _HOOK_STATE.get()
25-
if hook_stack:
26-
logger.warning("clear_hook_state: Hook stack was not empty")
27-
_HOOK_STATE.reset(token)
25+
class __HookStack(Singleton): # pragma: no cover
26+
"""A singleton object which manages the current component tree's hooks.
27+
Life cycle hooks can be stored in a thread local or context variable depending
28+
on the platform."""
29+
30+
_state: ThreadLocal[list[LifeCycleHook]] | ContextVar[list[LifeCycleHook]] = (
31+
ThreadLocal(list) if sys.platform == "emscripten" else ContextVar("hook_state")
32+
)
33+
34+
def get(self) -> list[LifeCycleHook]:
35+
return self._state.get()
36+
37+
def initialize(self) -> Token[list[LifeCycleHook]] | None:
38+
return None if isinstance(self._state, ThreadLocal) else self._state.set([])
39+
40+
def reset(self, token: Token[list[LifeCycleHook]] | None) -> None:
41+
if isinstance(self._state, ThreadLocal):
42+
self._state.get().clear()
43+
elif token:
44+
self._state.reset(token)
45+
else:
46+
raise RuntimeError("Hook stack is an ContextVar but no token was provided")
47+
48+
def current_hook(self) -> LifeCycleHook:
49+
hook_stack = self.get()
50+
if not hook_stack:
51+
msg = "No life cycle hook is active. Are you rendering in a layout?"
52+
raise RuntimeError(msg)
53+
return hook_stack[-1]
2854

2955

30-
def current_hook() -> LifeCycleHook:
31-
"""Get the current :class:`LifeCycleHook`"""
32-
hook_stack = _HOOK_STATE.get()
33-
if not hook_stack:
34-
msg = "No life cycle hook is active. Are you rendering in a layout?"
35-
raise RuntimeError(msg)
36-
return hook_stack[-1]
56+
HOOK_STACK = __HookStack()
3757

3858

3959
class LifeCycleHook:
@@ -43,7 +63,7 @@ class LifeCycleHook:
4363
a component is first rendered until it is removed from the layout. The life cycle
4464
is ultimately driven by the layout itself, but components can "hook" into those
4565
events to perform actions. Components gain access to their own life cycle hook
46-
by calling :func:`current_hook`. They can then perform actions such as:
66+
by calling :func:`HOOK_STACK.current_hook`. They can then perform actions such as:
4767
4868
1. Adding state via :meth:`use_state`
4969
2. Adding effects via :meth:`add_effect`
@@ -63,7 +83,7 @@ class LifeCycleHook:
6383
.. testcode::
6484
6585
from reactpy.core._life_cycle_hook import LifeCycleHook
66-
from reactpy.core.hooks import current_hook
86+
from reactpy.core.hooks import HOOK_STACK
6787
6888
# this function will come from a layout implementation
6989
schedule_render = lambda: ...
@@ -81,15 +101,15 @@ class LifeCycleHook:
81101
...
82102
83103
# the component may access the current hook
84-
assert current_hook() is hook
104+
assert HOOK_STACK.current_hook() is hook
85105
86106
# and save state or add effects
87-
current_hook().use_state(lambda: ...)
107+
HOOK_STACK.current_hook().use_state(lambda: ...)
88108
89109
async def my_effect(stop_event):
90110
...
91111
92-
current_hook().add_effect(my_effect)
112+
HOOK_STACK.current_hook().add_effect(my_effect)
93113
finally:
94114
await hook.affect_component_did_render()
95115
@@ -238,13 +258,13 @@ def set_current(self) -> None:
238258
This method is called by a layout before entering the render method
239259
of this hook's associated component.
240260
"""
241-
hook_stack = _HOOK_STATE.get()
261+
hook_stack = HOOK_STACK.get()
242262
if hook_stack:
243263
parent = hook_stack[-1]
244264
self._context_providers.update(parent._context_providers)
245265
hook_stack.append(self)
246266

247267
def unset_current(self) -> None:
248268
"""Unset this hook as the active hook in this thread"""
249-
if _HOOK_STATE.get().pop() is not self:
269+
if HOOK_STACK.get().pop() is not self:
250270
raise RuntimeError("Hook stack is in an invalid state") # nocov

src/reactpy/core/_thread_local.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
_StateType = TypeVar("_StateType")
66

77

8-
class ThreadLocal(Generic[_StateType]):
9-
"""Utility for managing per-thread state information"""
8+
class ThreadLocal(Generic[_StateType]): # pragma: no cover
9+
"""Utility for managing per-thread state information. This is only used in
10+
environments where ContextVars are not available, such as the `pyodide`
11+
executor."""
1012

1113
def __init__(self, default: Callable[[], _StateType]):
1214
self._default = default

src/reactpy/core/hooks.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from typing_extensions import TypeAlias
2020

2121
from reactpy.config import REACTPY_DEBUG
22-
from reactpy.core._life_cycle_hook import current_hook
22+
from reactpy.core._life_cycle_hook import HOOK_STACK
2323
from reactpy.types import Connection, Context, Key, Location, State, VdomDict
2424
from reactpy.utils import Ref
2525

@@ -83,7 +83,7 @@ def __init__(
8383
else:
8484
self.value = initial_value
8585

86-
hook = current_hook()
86+
hook = HOOK_STACK.current_hook()
8787

8888
def dispatch(new: _Type | Callable[[_Type], _Type]) -> None:
8989
next_value = new(self.value) if callable(new) else new # type: ignore
@@ -139,7 +139,7 @@ def use_effect(
139139
Returns:
140140
If not function is provided, a decorator. Otherwise ``None``.
141141
"""
142-
hook = current_hook()
142+
hook = HOOK_STACK.current_hook()
143143
dependencies = _try_to_infer_closure_values(function, dependencies)
144144
memoize = use_memo(dependencies=dependencies)
145145
cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None)
@@ -212,7 +212,7 @@ def use_async_effect(
212212
Returns:
213213
If not function is provided, a decorator. Otherwise ``None``.
214214
"""
215-
hook = current_hook()
215+
hook = HOOK_STACK.current_hook()
216216
dependencies = _try_to_infer_closure_values(function, dependencies)
217217
memoize = use_memo(dependencies=dependencies)
218218
cleanup_func: Ref[_EffectCleanFunc | None] = use_ref(None)
@@ -280,7 +280,7 @@ def use_debug_value(
280280

281281
if REACTPY_DEBUG.current and old.current != new:
282282
old.current = new
283-
logger.debug(f"{current_hook().component} {new}")
283+
logger.debug(f"{HOOK_STACK.current_hook().component} {new}")
284284

285285

286286
def create_context(default_value: _Type) -> Context[_Type]:
@@ -308,7 +308,7 @@ def use_context(context: Context[_Type]) -> _Type:
308308
309309
See the full :ref:`Use Context` docs for more information.
310310
"""
311-
hook = current_hook()
311+
hook = HOOK_STACK.current_hook()
312312
provider = hook.get_context_provider(context)
313313

314314
if provider is None:
@@ -361,7 +361,7 @@ def __init__(
361361
self.value = value
362362

363363
def render(self) -> VdomDict:
364-
current_hook().set_context_provider(self)
364+
HOOK_STACK.current_hook().set_context_provider(self)
365365
return {"tagName": "", "children": self.children}
366366

367367
def __repr__(self) -> str:
@@ -554,7 +554,7 @@ def use_ref(initial_value: _Type) -> Ref[_Type]:
554554

555555

556556
def _use_const(function: Callable[[], _Type]) -> _Type:
557-
return current_hook().use_state(function)
557+
return HOOK_STACK.current_hook().use_state(function)
558558

559559

560560
def _try_to_infer_closure_values(

src/reactpy/core/serve.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from anyio.abc import TaskGroup
1010

1111
from reactpy.config import REACTPY_DEBUG
12-
from reactpy.core._life_cycle_hook import _HOOK_STATE, clear_hook_state
12+
from reactpy.core._life_cycle_hook import HOOK_STACK
1313
from reactpy.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage
1414

1515
logger = getLogger(__name__)
@@ -64,7 +64,7 @@ async def _single_outgoing_loop(
6464
send: SendCoroutine,
6565
) -> None:
6666
while True:
67-
token = _HOOK_STATE.set([])
67+
token = HOOK_STACK.initialize()
6868
try:
6969
update = await layout.render()
7070
try:
@@ -79,7 +79,7 @@ async def _single_outgoing_loop(
7979
logger.error(msg)
8080
raise
8181
finally:
82-
clear_hook_state(token)
82+
HOOK_STACK.reset(token)
8383

8484

8585
async def _single_incoming_loop(

src/reactpy/pyscript/utils.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ def extend_pyscript_config(
145145

146146

147147
def reactpy_version_string() -> str: # pragma: no cover
148+
from reactpy.testing.common import GITHUB_ACTIONS
149+
148150
local_version = reactpy.__version__
149151

150152
# Get a list of all versions via `pip index versions`
@@ -170,14 +172,16 @@ def reactpy_version_string() -> str: # pragma: no cover
170172
symbol_postion = line.index(latest_version_symbol)
171173
latest_version = line[symbol_postion + len(latest_version_symbol) :].strip()
172174

173-
# Return early if local version of ReactPy is available on PyPi
174-
if local_version in known_versions:
175+
# Return early if the version is available on PyPi and we're not in a CI environment
176+
if local_version in known_versions and not GITHUB_ACTIONS:
175177
return f"reactpy=={local_version}"
176178

177-
# Begin determining an alternative method of installing ReactPy
178-
179-
if not latest_version:
180-
_logger.warning("Failed to determine the latest version of ReactPy on PyPi. ")
179+
# We are now determining an alternative method of installing ReactPy for PyScript
180+
if not GITHUB_ACTIONS:
181+
_logger.warning(
182+
"Your current version of ReactPy isn't available on PyPi. Since a packaged version "
183+
"of ReactPy is required for PyScript, we are attempting to find an alternative method..."
184+
)
181185

182186
# Build a local wheel for ReactPy, if needed
183187
dist_dir = Path(reactpy.__file__).parent.parent.parent / "dist"
@@ -202,19 +206,18 @@ def reactpy_version_string() -> str: # pragma: no cover
202206
)
203207
return f"reactpy=={latest_version}"
204208
_logger.error(
205-
"Failed to build a local wheel for ReactPy and could not determine the latest version on PyPi. "
209+
"Failed to build a local wheel for ReactPy, and could not determine the latest version on PyPi. "
206210
"PyScript functionality may not work as expected.",
207211
)
208212
return f"reactpy=={local_version}"
209213

210-
# Move the local file to the web modules directory, if needed
214+
# Move the local wheel file to the web modules directory, if needed
211215
wheel_file = Path(wheel_glob[0])
212216
new_path = REACTPY_WEB_MODULES_DIR.current / wheel_file.name
213217
if not new_path.exists():
214218
_logger.warning(
215-
"'reactpy==%s' is not available on PyPi. "
216-
"PyScript will utilize a local wheel of ReactPy instead.",
217-
local_version,
219+
"PyScript will utilize local wheel '%s'.",
220+
wheel_file.name,
218221
)
219222
shutil.copy(wheel_file, new_path)
220223
return f"{REACTPY_PATH_PREFIX.current}modules/{wheel_file.name}"

src/reactpy/testing/common.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from typing_extensions import ParamSpec
1515

1616
from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR
17-
from reactpy.core._life_cycle_hook import LifeCycleHook, current_hook
17+
from reactpy.core._life_cycle_hook import HOOK_STACK, LifeCycleHook
1818
from reactpy.core.events import EventHandler, to_event_handler_function
1919

2020

@@ -153,7 +153,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
153153
if self is None:
154154
raise RuntimeError("Hook catcher has been garbage collected")
155155

156-
hook = current_hook()
156+
hook = HOOK_STACK.current_hook()
157157
if self.index_by_kwarg is not None:
158158
self.index[kwargs[self.index_by_kwarg]] = hook
159159
self.latest = hook

src/reactpy/utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,3 +334,13 @@ def import_dotted_path(dotted_path: str) -> Any:
334334
except AttributeError as error:
335335
msg = f'ReactPy failed to import "{component_name}" from "{module_name}"'
336336
raise AttributeError(msg) from error
337+
338+
339+
class Singleton:
340+
"""A class that only allows one instance to be created."""
341+
342+
def __new__(cls, *args, **kw):
343+
if not hasattr(cls, "_instance"):
344+
orig = super()
345+
cls._instance = orig.__new__(cls, *args, **kw)
346+
return cls._instance

tests/conftest.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,19 @@ def rebuild():
4646

4747
@pytest.fixture(autouse=True, scope="function")
4848
def create_hook_state():
49-
from reactpy.core._life_cycle_hook import _HOOK_STATE
49+
"""This fixture is a bug fix related to `pytest_asyncio`.
5050
51-
token = _HOOK_STATE.set([])
51+
Usually the hook stack is created automatically within the display fixture, but context
52+
variables aren't retained within `pytest_asyncio` async fixtures. As a workaround,
53+
this fixture ensures that the hook stack is created before each test is run.
54+
55+
Ref: https://github.com/pytest-dev/pytest-asyncio/issues/127
56+
"""
57+
from reactpy.core._life_cycle_hook import HOOK_STACK
58+
59+
token = HOOK_STACK.initialize()
5260
yield token
53-
_HOOK_STATE.reset(token)
61+
HOOK_STACK.reset(token)
5462

5563

5664
@pytest.fixture

tests/test_core/test_layout.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ async def test_root_component_life_cycle_hook_is_garbage_collected():
343343
def add_to_live_hooks(constructor):
344344
def wrapper(*args, **kwargs):
345345
result = constructor(*args, **kwargs)
346-
hook = reactpy.hooks.current_hook()
346+
hook = reactpy.hooks.HOOK_STACK.current_hook()
347347
hook_id = id(hook)
348348
live_hooks.add(hook_id)
349349
finalize(hook, live_hooks.discard, hook_id)
@@ -375,7 +375,7 @@ async def test_life_cycle_hooks_are_garbage_collected():
375375
def add_to_live_hooks(constructor):
376376
def wrapper(*args, **kwargs):
377377
result = constructor(*args, **kwargs)
378-
hook = reactpy.hooks.current_hook()
378+
hook = reactpy.hooks.HOOK_STACK.current_hook()
379379
hook_id = id(hook)
380380
live_hooks.add(hook_id)
381381
finalize(hook, live_hooks.discard, hook_id)
@@ -625,7 +625,7 @@ def Outer():
625625
@reactpy.component
626626
def Inner(finalizer_id):
627627
if finalizer_id not in registered_finalizers:
628-
hook = reactpy.hooks.current_hook()
628+
hook = reactpy.hooks.HOOK_STACK.current_hook()
629629
finalize(hook, lambda: garbage_collect_items.append(finalizer_id))
630630
registered_finalizers.add(finalizer_id)
631631
return reactpy.html.div(finalizer_id)

tests/tooling/hooks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from reactpy.core.hooks import current_hook, use_state
1+
from reactpy.core.hooks import HOOK_STACK, use_state
22

33

44
def use_force_render():
5-
return current_hook().schedule_render
5+
return HOOK_STACK.current_hook().schedule_render
66

77

88
def use_toggle(init=False):

0 commit comments

Comments
 (0)