Skip to content

Commit e493554

Browse files
committed
Runtime cleanups split out from the type hinting work (#114)
Behavioral/code changes separated from the pure typing changes so each can be reviewed in isolation: * watch_effect is a real function instead of a functools.partial, so it has an inspectable signature, a docstring and mkdocstrings rendering in the API reference (the hand-written docs section is replaced by the generated one). * readonly/shallow_reactive/shallow_readonly are real functions instead of partials, for the same reason. * ref() no longer needs the try/except TypedDict definition dance: the generic Ref TypedDict is typing-only (it requires Python 3.11 at runtime), and all Python versions share one implementation. At runtime a Ref was always just a plain (proxied) dict. * The traceback introspection in _run_callback uses an explicit None-check instead of try/except AttributeError, and explicitly deletes its local reference to the traceback: a traceback held in a local of the frame it references forms a reference cycle that would keep the watcher alive until the next gc collection. * init(mode="rendercanvas") without a loop raises a clear TypeError. * loop_factory() looks up asyncio.eager_task_factory once with getattr instead of hasattr + attribute access. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fDq6fyKg6QscqyyN6CcwC
1 parent db471b6 commit e493554

4 files changed

Lines changed: 64 additions & 38 deletions

File tree

docs/reference/api.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,7 @@ Combination of `shallow_reactive` and `readonly`.
5252

5353
::: observ.watcher.watch
5454

55-
### `watch_effect`
56-
57-
```python
58-
watch_effect(fn: Callable[[], Any]) -> Watcher
59-
```
60-
61-
Runs `fn` immediately to collect its dependencies and re-runs it (via the scheduler) whenever they change. Equivalent to `watch(fn, deep=True)` without a callback. See [Watchers](../guide/watchers.md#watch_effect).
55+
::: observ.watcher.watch_effect
6256

6357
::: observ.watcher.computed
6458

observ/init.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ def init(mode="asyncio", loop=None):
1818
scheduler.register_asyncio(loop)
1919

2020
elif mode == "rendercanvas":
21+
if loop is None:
22+
raise TypeError("A loop object is required for the 'rendercanvas' mode")
2123
scheduler.register_rendercanvas(loop)
2224

2325

@@ -28,6 +30,7 @@ def loop_factory():
2830
that observ schedules for async functions and callbacks.
2931
"""
3032
loop = asyncio.new_event_loop()
31-
if hasattr(asyncio, "eager_task_factory"):
32-
loop.set_task_factory(asyncio.eager_task_factory)
33+
eager_task_factory = getattr(asyncio, "eager_task_factory", None)
34+
if eager_task_factory is not None:
35+
loop.set_task_factory(eager_task_factory)
3336
return loop

observ/proxy.py

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

33
from copy import copy, deepcopy
4-
from functools import partial
5-
from typing import Generic, Literal, TypedDict, TypeVar, cast
4+
from typing import TYPE_CHECKING, Generic, TypeVar, cast
65

76
from .proxy_db import proxy_db
87

8+
if TYPE_CHECKING:
9+
from typing import TypedDict
10+
911
T = TypeVar("T")
1012

1113

@@ -104,32 +106,46 @@ def proxy(target: T, readonly=False, shallow=False) -> T:
104106
return cast(T, target)
105107

106108

107-
try:
108-
# for Python >= 3.11
109+
if TYPE_CHECKING:
110+
# Only used for typing: at runtime a Ref is a plain (proxied) dict.
111+
# Defined here (instead of unconditionally) because a generic
112+
# TypedDict requires Python 3.11
109113
class Ref(TypedDict, Generic[T]):
110114
value: T
111115

112-
def ref(target: T) -> Ref[T]:
113-
"""
114-
Returns a reactive dict with a single 'value' key, set to the
115-
given target. Useful for making a single (plain) value reactive.
116-
"""
117-
return proxy(Ref(value=target))
118116

119-
except TypeError:
120-
# before python 3.11 a TypedDict cannot inherit from a non-TypedDict class
121-
def ref(target: T) -> dict[Literal["value"], T]:
122-
"""
123-
Returns a reactive dict with a single 'value' key, set to the
124-
given target. Useful for making a single (plain) value reactive.
125-
"""
126-
return proxy({"value": target})
117+
def ref(target: T) -> Ref[T]:
118+
"""
119+
Returns a reactive dict with a single 'value' key, set to the
120+
given target. Useful for making a single (plain) value reactive.
121+
"""
122+
return proxy(cast("Ref[T]", {"value": target}))
127123

128124

129125
reactive = proxy
130-
readonly = partial(proxy, readonly=True)
131-
shallow_reactive = partial(proxy, shallow=True)
132-
shallow_readonly = partial(proxy, shallow=True, readonly=True)
126+
127+
128+
def readonly(target: T) -> T:
129+
"""
130+
Returns a readonly proxy for the given target: reads are tracked,
131+
but any write raises a ReadonlyError.
132+
"""
133+
return proxy(target, readonly=True)
134+
135+
136+
def shallow_reactive(target: T) -> T:
137+
"""
138+
Returns a shallow proxy for the given target: only the first level
139+
of the target is made reactive, nested values are returned raw.
140+
"""
141+
return proxy(target, shallow=True)
142+
143+
144+
def shallow_readonly(target: T) -> T:
145+
"""
146+
Combination of `shallow_reactive` and `readonly`.
147+
"""
148+
return proxy(target, readonly=True, shallow=True)
133149

134150

135151
def to_raw(target: Proxy[T] | T) -> T:

observ/watcher.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import inspect
1111
from collections import deque
1212
from collections.abc import Awaitable, Container
13-
from functools import partial, wraps
13+
from functools import wraps
1414
from itertools import count
1515
from typing import Any, Callable, Generic, Optional, TypeVar, Union
1616
from weakref import ref
@@ -63,7 +63,17 @@ def watch(
6363
return watcher
6464

6565

66-
watch_effect = partial(watch, immediate=False, deep=True, callback=None)
66+
def watch_effect(
67+
fn: Watchable[T],
68+
sync: bool = False,
69+
deep: bool = True,
70+
) -> Watcher[T]:
71+
"""
72+
Run the given function immediately to collect its dependencies
73+
and re-run it whenever they change. Equivalent to calling `watch`
74+
without a callback.
75+
"""
76+
return watch(fn, callback=None, sync=sync, deep=deep, immediate=False)
6777

6878

6979
def computed(_fn: Callable[[], T] | None = None, *, deep=True) -> Callable[[], T]:
@@ -442,17 +452,20 @@ def _run_callback(self, *args) -> None:
442452
# figure out if the TypeError was caused by wrong number of arguments
443453
# by checking the exception's traceback
444454
wrong_number_of_arguments = False
445-
try:
455+
tb = e.__traceback__
456+
if tb is not None:
446457
_run_callback_is_top_frame = (
447-
e.__traceback__.tb_frame.f_code == self._run_callback.__code__
458+
tb.tb_frame.f_code == self._run_callback.__code__
448459
)
449-
_no_lower_frames = e.__traceback__.tb_next is None
460+
_no_lower_frames = tb.tb_next is None
461+
# The traceback references the current frame, so delete
462+
# the local reference to it to avoid a reference cycle
463+
# that would keep this watcher (and everything it
464+
# references) alive until the next gc collection
465+
del tb
450466
wrong_number_of_arguments = (
451467
_run_callback_is_top_frame and _no_lower_frames
452468
)
453-
except AttributeError:
454-
# if there's no traceback we can't figure this out
455-
pass
456469

457470
if wrong_number_of_arguments:
458471
raise WrongNumberOfArgumentsError(str(e)) from e

0 commit comments

Comments
 (0)