From 608f0213baf418c2b40ef31891d34548d7a2a47e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 23 Oct 2026 17:40:44 +0200 Subject: [PATCH 1/7] Deprecate bare coroutine and generator functions as references Passing a coroutine, generator or async generator function as a reference without param.bind or param.depends now emits a ParamFutureWarning pointing to param.bind(fn). In a future release such functions will be kept as plain values, like any other function. --- doc/user_guide/Generators.ipynb | 12 ++++---- doc/user_guide/References.ipynb | 2 +- param/parameterized.py | 30 ++++++++++++++++++- tests/testreactive.py | 32 ++++++++++---------- tests/testrefs.py | 52 ++++++++++++++++++++++++++++----- 5 files changed, 97 insertions(+), 31 deletions(-) diff --git a/doc/user_guide/Generators.ipynb b/doc/user_guide/Generators.ipynb index 878643420..0e681873d 100644 --- a/doc/user_guide/Generators.ipynb +++ b/doc/user_guide/Generators.ipynb @@ -78,7 +78,7 @@ "id": "0623a51f-c5f4-442b-94b6-e7202553ec9a", "metadata": {}, "source": [ - "Note how we declare that the `height` parameter to `allow_refs`, i.e. we enable the ability for it to resolve references, which include generators." + "Note how we declare that the `height` parameter to `allow_refs`, i.e. we enable the ability for it to resolve references. To turn our generator function into a reference we wrap it with `param.bind`, which tells Param to call it and stream the values it yields into the parameter (passing the bare generator function is deprecated and will eventually be treated like any other function value):" ] }, { @@ -88,7 +88,7 @@ "metadata": {}, "outputs": [], "source": [ - "gauge = TidalGauge(height=poll_sensor)" + "gauge = TidalGauge(height=param.bind(poll_sensor))" ] }, { @@ -140,7 +140,7 @@ "id": "63e76938-6eac-4f28-8362-7b9637ed935f", "metadata": {}, "source": [ - "Note that `rx` can also evaluate a generator directly:" + "Note that `rx` can also evaluate a bound generator directly:" ] }, { @@ -150,7 +150,7 @@ "metadata": {}, "outputs": [], "source": [ - "gauge_rx = param.rx(poll_sensor)\n", + "gauge_rx = param.rx(param.bind(poll_sensor))\n", "\n", "gauge_rx" ] @@ -242,7 +242,7 @@ " def _update_message(self):\n", " self.message = 'Risk of flooding' if self.height > 10 else 'Everything is normal'\n", "\n", - "warn = TidalWarning(height=poll_sensor)\n", + "warn = TidalWarning(height=param.bind(poll_sensor))\n", "\n", "warn.param['message'].rx()" ] @@ -273,7 +273,7 @@ " yield tidal_gauge()\n", " await asyncio.sleep(timeout)\n", "\n", - "async_gauge = TidalGauge(height=poll_sensor_async)\n", + "async_gauge = TidalGauge(height=param.bind(poll_sensor_async))\n", "\n", "async_gauge.param.height.rx()" ] diff --git a/doc/user_guide/References.ipynb b/doc/user_guide/References.ipynb index e45c842c8..00e2d4281 100644 --- a/doc/user_guide/References.ipynb +++ b/doc/user_guide/References.ipynb @@ -146,7 +146,7 @@ "- [Functions or methods with dependencies](Dependencies_and_Watchers.ipynb#dependencies) added using `param.depends`\n", "- {ref}`Reactive Functions ` using `param.bind`\n", "- [Reactive expressions](Reactive_Expressions.ipynb) declared using `param.rx`\n", - "- [(Asynchronous) generators](Generators.ipynb)\n", + "- [(Asynchronous) generators](Generators.ipynb) and coroutine functions wrapped with `param.bind`\n", "- Custom objects transformed into a valid reference with a hook registered with `param.parameterized.register_reference_transform`.\n", "\n", "There are two utility functions which allow resolving all parameters a reference depends on and the current value of the reference:" diff --git a/param/parameterized.py b/param/parameterized.py index 38bd6851f..ce0f15a61 100644 --- a/param/parameterized.py +++ b/param/parameterized.py @@ -312,6 +312,32 @@ def resolve_value(value: t.Any, recursive: bool = True) -> t.Any: value = getattr(value.owner, value.name) return value +def _is_bare_async_ref(value: t.Any) -> bool: + """ + Whether value is a coroutine or generator function that is resolved as a + reference without having been declared one with ``bind`` or ``depends``. + """ + return not hasattr(value, '_dinfo') and ( + iscoroutinefunction(value) or inspect.isgeneratorfunction(value) + ) + +def _warn_bare_async_ref(value: t.Any) -> None: + if inspect.isasyncgenfunction(value): + kind = 'async generator' + elif inspect.isgeneratorfunction(value): + kind = 'generator' + else: + kind = 'coroutine' + name = getattr(value, '__name__', repr(value)) + warnings.warn( + f"Passing the {kind} function {name!r} as a reference is deprecated. " + "In a future version it will be treated like any other function, i.e. " + "kept as a value instead of being called and resolved. Wrap it with " + f"param.bind({name}) to keep resolving it.", + category=_ParamFutureWarning, + stacklevel=_find_stack_level(), + ) + def resolve_ref(reference: t.Any, recursive: bool = False) -> list[Parameter]: """Resolve all parameters a dynamic reference depends on.""" if recursive: @@ -2937,7 +2963,7 @@ def _setup_params(self_, **params): # speculatively evaluate values to check whether they # contain a reference and warn the user that the # behavior may change in future. - if name not in self_.cls._param__private.explicit_no_refs: + if name not in self_.cls._param__private.explicit_no_refs and not _is_bare_async_ref(val): resolved = val try: ref, _, resolved, _, _ = self_._resolve_ref(pobj, val) @@ -3047,6 +3073,8 @@ def _resolve_ref(self_, pobj: Parameter, value: t.Any): deps = resolve_ref(value, recursive=pobj.nested_refs) if not (deps or is_async or is_gen): return None, None, value, False, False + if _is_bare_async_ref(value): + _warn_bare_async_ref(value) ref = value try: value = resolve_value(value, recursive=pobj.nested_refs) diff --git a/tests/testreactive.py b/tests/testreactive.py index 315850131..b9355ed0f 100644 --- a/tests/testreactive.py +++ b/tests/testreactive.py @@ -1147,7 +1147,7 @@ async def async_func(): await asyncio.sleep(0.02) return {'value': 42} - expr = rx(async_func)['value'] + expr = rx(bind(async_func))['value'] assert expr.rx.value is param.Undefined assert expr.rx.awaiting @@ -1159,7 +1159,7 @@ async def async_func(): await asyncio.sleep(0.02) return {'value': 42} - source = rx(async_func) + source = rx(bind(async_func)) expr = source[rx('value')] assert expr.rx.value is param.Undefined @@ -1274,7 +1274,7 @@ async def async_func(): await asyncio.sleep(0.02) return 2 - expr = rx(async_func) + 2 + expr = rx(bind(async_func)) + 2 assert expr.rx.value is param.Undefined assert expr.rx.awaiting @@ -1303,7 +1303,7 @@ def gen(): time.sleep(0.1) yield 2 - expr = rx(gen) + 100 + expr = rx(bind(gen)) + 100 assert expr.rx.value is param.Undefined assert expr.rx.awaiting @@ -1328,7 +1328,7 @@ async def async_func(): assert expr.rx.value == 1 assert not expr.rx.awaiting - p.value = async_func + p.value = bind(async_func) assert expr.rx.awaiting await async_wait_until(lambda: expr.rx.value == 8) assert not expr.rx.awaiting @@ -1353,7 +1353,7 @@ def gen(): return yield # pragma: no cover - expr = rx(gen) + 100 + expr = rx(bind(gen)) + 100 assert expr.rx.value is param.Undefined assert expr.rx.awaiting @@ -1652,7 +1652,7 @@ async def async_func(): assert expr.rx.value == 1 assert not expr.rx.stale - p.value = async_func + p.value = bind(async_func) assert expr.rx.stale await async_wait_until(lambda: expr.rx.value == 8) assert not expr.rx.stale @@ -2046,7 +2046,7 @@ async def slow(): await asyncio.Event().wait() source = Source() - outlet = Outlet(x=slow) + outlet = Outlet(x=bind(slow)) expr = outlet.param.x.rx() + 1 updating = expr.rx.updating() expr.rx.watch(lambda v: None) @@ -2061,7 +2061,7 @@ async def slow(): await asyncio.sleep(0) assert updating.rx.value is False - outlet.x = slow + outlet.x = bind(slow) assert expr.rx.awaiting assert updating.rx.value is True @@ -2087,7 +2087,7 @@ async def slow(): expr = outlet.param.x.rx() + 1 updating = expr.rx.updating() expr.rx.watch(lambda v: None) - outlet.x = slow + outlet.x = bind(slow) updating.rx.watch(lambda value: (_ for _ in ()).throw(ValueError) if not value else None) with pytest.raises(ValueError): @@ -2107,7 +2107,7 @@ async def slow(): expr = outlet.param.x.rx() + 1 updating = expr.rx.updating() expr.rx.watch(lambda value: None) - outlet.x = slow + outlet.x = bind(slow) with pytest.raises(ValueError): outlet.x = 'bad' @@ -3060,7 +3060,7 @@ async def async_func(): await asyncio.sleep(0.02) return 2 - async_rx = rx(async_func, lazy=lazy) + 2 + async_rx = rx(bind(async_func), lazy=lazy) + 2 assert async_rx.rx.value is param.Undefined await async_wait_until(lambda: async_rx.rx.value == 4) @@ -3080,7 +3080,7 @@ def gen(): time.sleep(0.05) yield 2 - rxgen = rx(gen) + rxgen = rx(bind(gen)) assert rxgen.rx.value is param.Undefined await async_wait_until(lambda: rxgen.rx.value == 1, interval=10) await async_wait_until(lambda: rxgen.rx.value == 2) @@ -3095,7 +3095,7 @@ def gen(): time.sleep(0.05) yield 2 - rxgen = rx(gen) + rxgen = rx(bind(gen)) assert rxgen.rx.value is param.Undefined await async_wait_until(lambda: rxgen.rx.value == 1, interval=10) await asyncio.sleep(0.1) @@ -3175,7 +3175,7 @@ async def gen(): await asyncio.sleep(0.05) yield 2 - rxgen = rx(gen) + rxgen = rx(bind(gen)) assert rxgen.rx.value is param.Undefined await async_wait_until(lambda: rxgen.rx.value == 1, interval=10) await async_wait_until(lambda: rxgen.rx.value == 2) @@ -3190,7 +3190,7 @@ async def gen(): await asyncio.sleep(0.05) yield 2 - rxgen = rx(gen) + rxgen = rx(bind(gen)) assert rxgen.rx.value is param.Undefined await async_wait_until(lambda: rxgen.rx.value == 1, interval=10) await asyncio.sleep(0.1) diff --git a/tests/testrefs.py b/tests/testrefs.py index 7402f6503..d470b5e1b 100644 --- a/tests/testrefs.py +++ b/tests/testrefs.py @@ -5,6 +5,7 @@ import param import pytest +from param._utils import ParamFutureWarning from param.parameterized import Skip, resolve_ref from param.reactive import bind, rx @@ -260,11 +261,48 @@ async def gen_strings(): await asyncio.sleep(0.02) return 'string!' - p = Parameters(string=gen_strings) + p = Parameters(string=bind(gen_strings)) await asyncio.sleep(0.1) assert p.string == 'string!' +async def _coroutine_string(): + return 'string!' + +async def _async_gen_string(): + yield 'string!' + +def _gen_string(): + yield 'string!' + +BARE_ASYNC_REFS = [_coroutine_string, _async_gen_string, _gen_string] + +@pytest.mark.parametrize('fn', BARE_ASYNC_REFS) +async def test_bare_async_ref_warns_on_init(fn): + with pytest.warns(ParamFutureWarning, match=rf"param\.bind\({fn.__name__}\)"): + p = Parameters(string=fn) + await wait_for_value(p, 'string', 'string!') + +@pytest.mark.parametrize('fn', BARE_ASYNC_REFS) +async def test_bare_async_ref_warns_on_set(fn): + p = Parameters() + with pytest.warns(ParamFutureWarning, match=rf"param\.bind\({fn.__name__}\)"): + p.string = fn + await wait_for_value(p, 'string', 'string!') + +@pytest.mark.parametrize('fn', BARE_ASYNC_REFS) +async def test_bare_async_ref_warns_in_rx(fn): + with pytest.warns(ParamFutureWarning, match=rf"param\.bind\({fn.__name__}\)"): + expr = rx(fn) + await wait_for_value(expr.rx, 'value', 'string!') + +@pytest.mark.parametrize('fn', BARE_ASYNC_REFS) +def test_bare_async_function_on_non_ref_parameter_is_kept(fn): + class NoRefs(param.Parameterized): + value = param.Parameter() + + assert NoRefs(value=fn).value is fn + async def test_async_bind_ref(): p = Parameters() @@ -285,7 +323,7 @@ async def gen_strings(): await asyncio.sleep(0.02) yield 'string!' - p = Parameters(string=gen_strings) + p = Parameters(string=bind(gen_strings)) await wait_for_value(p, 'string', 'string?', delay=0.01, timeout=0.1) await wait_for_value(p, 'string', 'string!', delay=0.1, timeout=0.1) @@ -296,7 +334,7 @@ def gen_strings(): time.sleep(0.05) yield 'string!' - p = Parameters(string=gen_strings) + p = Parameters(string=bind(gen_strings)) await wait_for_value(p, 'string', 'string?', delay=0.01, timeout=0.1) await wait_for_value(p, 'string', 'string!', delay=0.05, timeout=0.3) @@ -317,10 +355,10 @@ async def gen_strings2(): yield str(i) await asyncio.sleep(0.01) - p = Parameters(string=gen_strings1) + p = Parameters(string=bind(gen_strings1)) task1 = await wait_for_async_ref(p, 'string', delay=0.01, timeout=0.1) assert p.string is not None - p.string = gen_strings2 + p.string = bind(gen_strings2) task2 = await wait_for_async_ref(p, 'string', delay=0.01, timeout=0.1) assert len(tasks) == 2 async_task1, async_task2 = tasks @@ -395,10 +433,10 @@ def gen_strings2(): yield str(i) time.sleep(0.01) - p = Parameters(string=gen_strings1) + p = Parameters(string=bind(gen_strings1)) task1 = await wait_for_async_ref(p, 'string', delay=0.03, timeout=0.1) assert p.string is not None - p.string = gen_strings2 + p.string = bind(gen_strings2) task2 = await wait_for_async_ref(p, 'string', delay=0.06, timeout=0.2) assert task1 is not task2 assert task1.done() From cdd4137652d1a449f39993a84e735f975b4dfe39 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 29 Oct 2026 11:41:43 +0100 Subject: [PATCH 2/7] Warn for bare async refs resolved anywhere and name partials correctly Move the deprecation warning into resolve_value so rx pipe arguments, operands, where branches and rx.value assignments also warn, unwrap partials in the warning message, and make the non-ref parameter test wait for any scheduled task. Assisted-by: Claude Code:claude-opus-5-5 --- param/parameterized.py | 17 +++++++++----- tests/testrefs.py | 53 +++++++++++++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/param/parameterized.py b/param/parameterized.py index ce0f15a61..6edb6bd5a 100644 --- a/param/parameterized.py +++ b/param/parameterized.py @@ -304,7 +304,10 @@ def resolve_value(value: t.Any, recursive: bool = True) -> t.Any: ) value = transform_reference(value) is_gen = inspect.isgeneratorfunction(value) - if hasattr(value, '_dinfo') or iscoroutinefunction(value) or is_gen: + has_deps = hasattr(value, '_dinfo') + if has_deps or iscoroutinefunction(value) or is_gen: + if not has_deps: + _warn_bare_async_ref(value) value = eval_function_with_deps(value) if is_gen: value = _to_async_gen(value) @@ -315,7 +318,7 @@ def resolve_value(value: t.Any, recursive: bool = True) -> t.Any: def _is_bare_async_ref(value: t.Any) -> bool: """ Whether value is a coroutine or generator function that is resolved as a - reference without having been declared one with ``bind`` or ``depends``. + reference without having been declared with ``bind`` or ``depends``. """ return not hasattr(value, '_dinfo') and ( iscoroutinefunction(value) or inspect.isgeneratorfunction(value) @@ -328,12 +331,16 @@ def _warn_bare_async_ref(value: t.Any) -> None: kind = 'generator' else: kind = 'coroutine' - name = getattr(value, '__name__', repr(value)) + func = value + while isinstance(func, partial): + func = func.func + name = getattr(func, '__name__', repr(func)) + bound = f"param.bind({name}, ...)" if func is not value else f"param.bind({name})" warnings.warn( f"Passing the {kind} function {name!r} as a reference is deprecated. " "In a future version it will be treated like any other function, i.e. " "kept as a value instead of being called and resolved. Wrap it with " - f"param.bind({name}) to keep resolving it.", + f"{bound} to keep resolving it.", category=_ParamFutureWarning, stacklevel=_find_stack_level(), ) @@ -3073,8 +3080,6 @@ def _resolve_ref(self_, pobj: Parameter, value: t.Any): deps = resolve_ref(value, recursive=pobj.nested_refs) if not (deps or is_async or is_gen): return None, None, value, False, False - if _is_bare_async_ref(value): - _warn_bare_async_ref(value) ref = value try: value = resolve_value(value, recursive=pobj.nested_refs) diff --git a/tests/testrefs.py b/tests/testrefs.py index d470b5e1b..40249e19a 100644 --- a/tests/testrefs.py +++ b/tests/testrefs.py @@ -2,6 +2,8 @@ import threading import time +from functools import partial + import param import pytest @@ -275,33 +277,56 @@ async def _async_gen_string(): def _gen_string(): yield 'string!' -BARE_ASYNC_REFS = [_coroutine_string, _async_gen_string, _gen_string] - -@pytest.mark.parametrize('fn', BARE_ASYNC_REFS) -async def test_bare_async_ref_warns_on_init(fn): - with pytest.warns(ParamFutureWarning, match=rf"param\.bind\({fn.__name__}\)"): +async def _coroutine_concat(a, b): + return a + b + +BARE_ASYNC_REFS = [ + pytest.param(_coroutine_string, r"param\.bind\(_coroutine_string\)", id='coroutine'), + pytest.param(_async_gen_string, r"param\.bind\(_async_gen_string\)", id='async_gen'), + pytest.param(_gen_string, r"param\.bind\(_gen_string\)", id='gen'), + pytest.param( + partial(_coroutine_concat, 'string', '!'), + r"param\.bind\(_coroutine_concat, \.\.\.\)", id='partial' + ), +] + +@pytest.mark.parametrize('fn, match', BARE_ASYNC_REFS) +async def test_bare_async_ref_warns_on_init(fn, match): + with pytest.warns(ParamFutureWarning, match=match): p = Parameters(string=fn) await wait_for_value(p, 'string', 'string!') -@pytest.mark.parametrize('fn', BARE_ASYNC_REFS) -async def test_bare_async_ref_warns_on_set(fn): +@pytest.mark.parametrize('fn, match', BARE_ASYNC_REFS) +async def test_bare_async_ref_warns_on_set(fn, match): p = Parameters() - with pytest.warns(ParamFutureWarning, match=rf"param\.bind\({fn.__name__}\)"): + with pytest.warns(ParamFutureWarning, match=match): p.string = fn await wait_for_value(p, 'string', 'string!') -@pytest.mark.parametrize('fn', BARE_ASYNC_REFS) -async def test_bare_async_ref_warns_in_rx(fn): - with pytest.warns(ParamFutureWarning, match=rf"param\.bind\({fn.__name__}\)"): +@pytest.mark.parametrize('fn, match', BARE_ASYNC_REFS) +async def test_bare_async_ref_warns_in_rx(fn, match): + with pytest.warns(ParamFutureWarning, match=match): expr = rx(fn) await wait_for_value(expr.rx, 'value', 'string!') -@pytest.mark.parametrize('fn', BARE_ASYNC_REFS) -def test_bare_async_function_on_non_ref_parameter_is_kept(fn): +@pytest.mark.parametrize('fn, match', BARE_ASYNC_REFS) +async def test_bare_async_function_on_non_ref_parameter_is_kept(fn, match): class NoRefs(param.Parameterized): value = param.Parameter() - assert NoRefs(value=fn).value is fn + p = NoRefs(value=fn) + await asyncio.sleep(0.1) + assert p.value is fn + +@pytest.mark.parametrize('resolve', [ + pytest.param(lambda fn: rx(1).rx.pipe(lambda a, b: b, fn).rx.value, id='pipe_arg'), + pytest.param(lambda fn: (rx([1]) + [fn]).rx.value, id='operand'), + pytest.param(lambda fn: rx(True).rx.where(fn, 0).rx.value, id='where'), + pytest.param(lambda fn: setattr(rx(1).rx, 'value', fn), id='set_value'), +]) +def test_bare_async_ref_warns_in_rx_operations(resolve): + with pytest.warns(ParamFutureWarning, match=r"param\.bind\(_gen_string\)"): + resolve(_gen_string) async def test_async_bind_ref(): p = Parameters() From 1f5ea44e349b2fd03a7dc32cdbede977206f0ba1 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 30 Oct 2026 10:42:49 +0100 Subject: [PATCH 3/7] Limit the bare async ref deprecation to param.rx Parameters with allow_refs=True keep resolving bare coroutine and generator functions, since streaming into a parameter is a documented feature. Only rx warns, at construction, for rx(fn), operation arguments, where branches, collect and rx.value assignments. Assisted-by: Claude Code:claude-opus-5-5 --- doc/user_guide/Generators.ipynb | 10 +++---- doc/user_guide/References.ipynb | 2 +- param/parameterized.py | 17 +++++------- param/reactive.py | 30 +++++++++++++++++++-- tests/testreactive.py | 12 ++++----- tests/testrefs.py | 48 +++++++++++++++++++++++---------- 6 files changed, 81 insertions(+), 38 deletions(-) diff --git a/doc/user_guide/Generators.ipynb b/doc/user_guide/Generators.ipynb index 0e681873d..9664d6878 100644 --- a/doc/user_guide/Generators.ipynb +++ b/doc/user_guide/Generators.ipynb @@ -78,7 +78,7 @@ "id": "0623a51f-c5f4-442b-94b6-e7202553ec9a", "metadata": {}, "source": [ - "Note how we declare that the `height` parameter to `allow_refs`, i.e. we enable the ability for it to resolve references. To turn our generator function into a reference we wrap it with `param.bind`, which tells Param to call it and stream the values it yields into the parameter (passing the bare generator function is deprecated and will eventually be treated like any other function value):" + "Note how we declare that the `height` parameter to `allow_refs`, i.e. we enable the ability for it to resolve references, which include generators." ] }, { @@ -88,7 +88,7 @@ "metadata": {}, "outputs": [], "source": [ - "gauge = TidalGauge(height=param.bind(poll_sensor))" + "gauge = TidalGauge(height=poll_sensor)" ] }, { @@ -140,7 +140,7 @@ "id": "63e76938-6eac-4f28-8362-7b9637ed935f", "metadata": {}, "source": [ - "Note that `rx` can also evaluate a bound generator directly:" + "Note that `rx` can also evaluate a generator. To make it a reference we wrap it with `param.bind`, since passing the bare generator function to `rx` is deprecated and will eventually wrap the function itself, just like any other function:" ] }, { @@ -242,7 +242,7 @@ " def _update_message(self):\n", " self.message = 'Risk of flooding' if self.height > 10 else 'Everything is normal'\n", "\n", - "warn = TidalWarning(height=param.bind(poll_sensor))\n", + "warn = TidalWarning(height=poll_sensor)\n", "\n", "warn.param['message'].rx()" ] @@ -273,7 +273,7 @@ " yield tidal_gauge()\n", " await asyncio.sleep(timeout)\n", "\n", - "async_gauge = TidalGauge(height=param.bind(poll_sensor_async))\n", + "async_gauge = TidalGauge(height=poll_sensor_async)\n", "\n", "async_gauge.param.height.rx()" ] diff --git a/doc/user_guide/References.ipynb b/doc/user_guide/References.ipynb index 00e2d4281..e45c842c8 100644 --- a/doc/user_guide/References.ipynb +++ b/doc/user_guide/References.ipynb @@ -146,7 +146,7 @@ "- [Functions or methods with dependencies](Dependencies_and_Watchers.ipynb#dependencies) added using `param.depends`\n", "- {ref}`Reactive Functions ` using `param.bind`\n", "- [Reactive expressions](Reactive_Expressions.ipynb) declared using `param.rx`\n", - "- [(Asynchronous) generators](Generators.ipynb) and coroutine functions wrapped with `param.bind`\n", + "- [(Asynchronous) generators](Generators.ipynb)\n", "- Custom objects transformed into a valid reference with a hook registered with `param.parameterized.register_reference_transform`.\n", "\n", "There are two utility functions which allow resolving all parameters a reference depends on and the current value of the reference:" diff --git a/param/parameterized.py b/param/parameterized.py index 6edb6bd5a..e15ab872e 100644 --- a/param/parameterized.py +++ b/param/parameterized.py @@ -304,10 +304,7 @@ def resolve_value(value: t.Any, recursive: bool = True) -> t.Any: ) value = transform_reference(value) is_gen = inspect.isgeneratorfunction(value) - has_deps = hasattr(value, '_dinfo') - if has_deps or iscoroutinefunction(value) or is_gen: - if not has_deps: - _warn_bare_async_ref(value) + if hasattr(value, '_dinfo') or iscoroutinefunction(value) or is_gen: value = eval_function_with_deps(value) if is_gen: value = _to_async_gen(value) @@ -317,8 +314,8 @@ def resolve_value(value: t.Any, recursive: bool = True) -> t.Any: def _is_bare_async_ref(value: t.Any) -> bool: """ - Whether value is a coroutine or generator function that is resolved as a - reference without having been declared with ``bind`` or ``depends``. + Whether value is a coroutine or generator function that has not been + declared a reference with ``bind`` or ``depends``. """ return not hasattr(value, '_dinfo') and ( iscoroutinefunction(value) or inspect.isgeneratorfunction(value) @@ -337,10 +334,10 @@ def _warn_bare_async_ref(value: t.Any) -> None: name = getattr(func, '__name__', repr(func)) bound = f"param.bind({name}, ...)" if func is not value else f"param.bind({name})" warnings.warn( - f"Passing the {kind} function {name!r} as a reference is deprecated. " - "In a future version it will be treated like any other function, i.e. " - "kept as a value instead of being called and resolved. Wrap it with " - f"{bound} to keep resolving it.", + f"Passing the {kind} function {name!r} to a reactive expression is " + "deprecated. In a future version rx will treat it like any other " + "function, i.e. keep it as a value instead of calling and resolving " + f"it. Wrap it with {bound} to keep resolving it.", category=_ParamFutureWarning, stacklevel=_find_stack_level(), ) diff --git a/param/reactive.py b/param/reactive.py index a2e7561a8..1ce81d3ea 100644 --- a/param/reactive.py +++ b/param/reactive.py @@ -113,8 +113,9 @@ from .parameterized import ( Comparator, Parameter, Parameterized, Skip, Undefined, eval_function_with_deps, get_method_owner, register_reference_transform, resolve_ref, resolve_value, - transform_reference, _notify_weak_listeners, _register_weak_listener, - _watch_async_ref_settle_change, _watch_ref_change + transform_reference, _is_bare_async_ref, _notify_weak_listeners, + _register_weak_listener, _warn_bare_async_ref, _watch_async_ref_settle_change, + _watch_ref_change ) from .parameters import Boolean, Event, String from ._utils import _to_async_gen, iscoroutinefunction, full_groupby @@ -1805,6 +1806,7 @@ def where(self, x, y) -> 'rx': >>> rx_result.rx.value '5 or below' """ + _warn_bare_async_inputs(x, y) xrefs = resolve_ref(x) yrefs = resolve_ref(y) if isinstance(self._reactive, rx): @@ -2009,6 +2011,7 @@ def value(self, new): "expression wrapping a Parameter or another dynamic " "reference cannot be updated." ) + _warn_bare_async_inputs(new) self._reactive._wrapper.object = resolve_value(new) def watch(self, fn=None, onlychanged=True, queued=False, precedence=0, process_failures=False): @@ -2563,6 +2566,7 @@ def collect(*args, error_mode='raise', **kwargs) -> 'rx': >>> collected.args.rx.pipe(sum).rx.value 3 """ + _warn_bare_async_inputs(*args, *kwargs.values()) operation = { 'fn': _collect_marker, 'args': args, @@ -2817,6 +2821,8 @@ def __new__(cls, obj=None, **kwargs): wrapper = kwargs.pop('_wrapper', None) elif inspect.isgeneratorfunction(obj) or iscoroutinefunction(obj): # Resolves generator and coroutine functions lazily + if _is_bare_async_ref(obj): + _warn_bare_async_ref(obj) wrapper = t.cast('Callable', GenWrapper)(object=obj) fn = bind(lambda obj: obj, wrapper.param.object) obj = Undefined @@ -3798,6 +3804,8 @@ def _clone(self, operation=None, copy=False, **kwargs) -> Self: kwargs = dict(prev=self, **dict(self._kwargs, **kwargs)) kwargs = t.cast('dict[str, t.Any]', dict(self._display_opts, **kwargs)) error_mode = t.cast('str', kwargs.pop('error_mode', self._error_mode)) + if not reuse and operation is not None: + _warn_bare_async_inputs(*operation.get('args', ()), *(operation.get('kwargs') or {}).values()) if not reuse and error_mode == 'raise' and operation is not None: inputs = (*operation.get('args', ()), *(operation.get('kwargs') or {}).values()) if any(isinstance(arg, rx) and arg._error_mode == 'propagate' for arg in inputs): @@ -4271,6 +4279,24 @@ def _iter_rx(value: t.Any) -> Iterator[rx]: yield from _iter_rx(v) +def _warn_bare_async_inputs(*values: t.Any) -> None: + """ + Warn about bare coroutine and generator functions an rx operation would + resolve, descending into the containers ``resolve_value`` descends into. + """ + for value in values: + if isinstance(value, rx): + continue + elif isinstance(value, (list, tuple, set)): + _warn_bare_async_inputs(*value) + elif isinstance(value, dict): + _warn_bare_async_inputs(*value.keys(), *value.values()) + elif isinstance(value, slice): + _warn_bare_async_inputs(value.start, value.stop, value.step) + elif _is_bare_async_ref(value := transform_reference(value)): + _warn_bare_async_ref(value) + + def _iter_bare_params(value: t.Any) -> Iterator[Parameter]: """ Yield ``Parameter`` objects nested anywhere inside an operation diff --git a/tests/testreactive.py b/tests/testreactive.py index b9355ed0f..05e572536 100644 --- a/tests/testreactive.py +++ b/tests/testreactive.py @@ -1328,7 +1328,7 @@ async def async_func(): assert expr.rx.value == 1 assert not expr.rx.awaiting - p.value = bind(async_func) + p.value = async_func assert expr.rx.awaiting await async_wait_until(lambda: expr.rx.value == 8) assert not expr.rx.awaiting @@ -1652,7 +1652,7 @@ async def async_func(): assert expr.rx.value == 1 assert not expr.rx.stale - p.value = bind(async_func) + p.value = async_func assert expr.rx.stale await async_wait_until(lambda: expr.rx.value == 8) assert not expr.rx.stale @@ -2046,7 +2046,7 @@ async def slow(): await asyncio.Event().wait() source = Source() - outlet = Outlet(x=bind(slow)) + outlet = Outlet(x=slow) expr = outlet.param.x.rx() + 1 updating = expr.rx.updating() expr.rx.watch(lambda v: None) @@ -2061,7 +2061,7 @@ async def slow(): await asyncio.sleep(0) assert updating.rx.value is False - outlet.x = bind(slow) + outlet.x = slow assert expr.rx.awaiting assert updating.rx.value is True @@ -2087,7 +2087,7 @@ async def slow(): expr = outlet.param.x.rx() + 1 updating = expr.rx.updating() expr.rx.watch(lambda v: None) - outlet.x = bind(slow) + outlet.x = slow updating.rx.watch(lambda value: (_ for _ in ()).throw(ValueError) if not value else None) with pytest.raises(ValueError): @@ -2107,7 +2107,7 @@ async def slow(): expr = outlet.param.x.rx() + 1 updating = expr.rx.updating() expr.rx.watch(lambda value: None) - outlet.x = bind(slow) + outlet.x = slow with pytest.raises(ValueError): outlet.x = 'bad' diff --git a/tests/testrefs.py b/tests/testrefs.py index 40249e19a..e74a92acb 100644 --- a/tests/testrefs.py +++ b/tests/testrefs.py @@ -1,6 +1,7 @@ import asyncio import threading import time +import warnings from functools import partial @@ -263,7 +264,7 @@ async def gen_strings(): await asyncio.sleep(0.02) return 'string!' - p = Parameters(string=bind(gen_strings)) + p = Parameters(string=gen_strings) await asyncio.sleep(0.1) assert p.string == 'string!' @@ -291,15 +292,17 @@ async def _coroutine_concat(a, b): ] @pytest.mark.parametrize('fn, match', BARE_ASYNC_REFS) -async def test_bare_async_ref_warns_on_init(fn, match): - with pytest.warns(ParamFutureWarning, match=match): +async def test_bare_async_ref_resolves_on_init_without_warning(fn, match): + with warnings.catch_warnings(): + warnings.simplefilter('error', ParamFutureWarning) p = Parameters(string=fn) await wait_for_value(p, 'string', 'string!') @pytest.mark.parametrize('fn, match', BARE_ASYNC_REFS) -async def test_bare_async_ref_warns_on_set(fn, match): +async def test_bare_async_ref_resolves_on_set_without_warning(fn, match): p = Parameters() - with pytest.warns(ParamFutureWarning, match=match): + with warnings.catch_warnings(): + warnings.simplefilter('error', ParamFutureWarning) p.string = fn await wait_for_value(p, 'string', 'string!') @@ -309,6 +312,13 @@ async def test_bare_async_ref_warns_in_rx(fn, match): expr = rx(fn) await wait_for_value(expr.rx, 'value', 'string!') +@pytest.mark.parametrize('fn, match', BARE_ASYNC_REFS) +async def test_bound_async_ref_in_rx_does_not_warn(fn, match): + with warnings.catch_warnings(): + warnings.simplefilter('error', ParamFutureWarning) + expr = rx(bind(fn)) + await wait_for_value(expr.rx, 'value', 'string!') + @pytest.mark.parametrize('fn, match', BARE_ASYNC_REFS) async def test_bare_async_function_on_non_ref_parameter_is_kept(fn, match): class NoRefs(param.Parameterized): @@ -319,15 +329,25 @@ class NoRefs(param.Parameterized): assert p.value is fn @pytest.mark.parametrize('resolve', [ - pytest.param(lambda fn: rx(1).rx.pipe(lambda a, b: b, fn).rx.value, id='pipe_arg'), - pytest.param(lambda fn: (rx([1]) + [fn]).rx.value, id='operand'), - pytest.param(lambda fn: rx(True).rx.where(fn, 0).rx.value, id='where'), + pytest.param(lambda fn: rx(1).rx.pipe(lambda a, b: b, fn), id='pipe_arg'), + pytest.param(lambda fn: rx([1]) + [fn], id='operand'), + pytest.param(lambda fn: rx(True).rx.where(fn, 0), id='where'), pytest.param(lambda fn: setattr(rx(1).rx, 'value', fn), id='set_value'), + pytest.param(lambda fn: param.reactive.collect(fn), id='collect'), ]) def test_bare_async_ref_warns_in_rx_operations(resolve): with pytest.warns(ParamFutureWarning, match=r"param\.bind\(_gen_string\)"): resolve(_gen_string) +def test_rx_operation_warns_once(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always', ParamFutureWarning) + source = rx(1) + expr = source.rx.pipe(lambda a, b: a, _gen_string) + source.rx.value = 2 + assert expr.rx.value == 2 + assert sum(issubclass(w.category, ParamFutureWarning) for w in caught) == 1 + async def test_async_bind_ref(): p = Parameters() @@ -348,7 +368,7 @@ async def gen_strings(): await asyncio.sleep(0.02) yield 'string!' - p = Parameters(string=bind(gen_strings)) + p = Parameters(string=gen_strings) await wait_for_value(p, 'string', 'string?', delay=0.01, timeout=0.1) await wait_for_value(p, 'string', 'string!', delay=0.1, timeout=0.1) @@ -359,7 +379,7 @@ def gen_strings(): time.sleep(0.05) yield 'string!' - p = Parameters(string=bind(gen_strings)) + p = Parameters(string=gen_strings) await wait_for_value(p, 'string', 'string?', delay=0.01, timeout=0.1) await wait_for_value(p, 'string', 'string!', delay=0.05, timeout=0.3) @@ -380,10 +400,10 @@ async def gen_strings2(): yield str(i) await asyncio.sleep(0.01) - p = Parameters(string=bind(gen_strings1)) + p = Parameters(string=gen_strings1) task1 = await wait_for_async_ref(p, 'string', delay=0.01, timeout=0.1) assert p.string is not None - p.string = bind(gen_strings2) + p.string = gen_strings2 task2 = await wait_for_async_ref(p, 'string', delay=0.01, timeout=0.1) assert len(tasks) == 2 async_task1, async_task2 = tasks @@ -458,10 +478,10 @@ def gen_strings2(): yield str(i) time.sleep(0.01) - p = Parameters(string=bind(gen_strings1)) + p = Parameters(string=gen_strings1) task1 = await wait_for_async_ref(p, 'string', delay=0.03, timeout=0.1) assert p.string is not None - p.string = bind(gen_strings2) + p.string = gen_strings2 task2 = await wait_for_async_ref(p, 'string', delay=0.06, timeout=0.2) assert task1 is not task2 assert task1.done() From 895f8980c3214867a0a66a4dc4a2aec41609f9bb Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 30 Oct 2026 10:49:10 +0100 Subject: [PATCH 4/7] Keep async and generator functions requiring arguments as values in rx Calling them to resolve a reference always failed with a TypeError, so rx now keeps them as values like any other function, without a warning. Parameters keep calling them, so a forgotten bind still fails loudly. Assisted-by: Claude Code:claude-opus-5-5 --- param/reactive.py | 51 +++++++++++++++++++++++++++++++++++++----- tests/testrefs.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/param/reactive.py b/param/reactive.py index 1ce81d3ea..4ab6f76d7 100644 --- a/param/reactive.py +++ b/param/reactive.py @@ -1826,7 +1826,7 @@ def trigger_y(*args): bind(trigger_y, *yrefs, watch=True) def ternary(condition, _): - return resolve_value(x) if condition else resolve_value(y) + return _resolve_operand(x) if condition else _resolve_operand(y) return rx(bind(ternary, self._reactive, trigger.param.value)) def distinct(self, fn=None) -> 'rx': @@ -2012,7 +2012,7 @@ def value(self, new): "reference cannot be updated." ) _warn_bare_async_inputs(new) - self._reactive._wrapper.object = resolve_value(new) + self._reactive._wrapper.object = _resolve_operand(new) def watch(self, fn=None, onlychanged=True, queued=False, precedence=0, process_failures=False): """ @@ -2630,8 +2630,10 @@ def _untracked_reference(value) -> bool: return False if isinstance(value, (list, tuple, dict, slice)): return any(_iter_rx(value)) or bool(resolve_ref(value, recursive=True)) - if hasattr(value, '_dinfo') or iscoroutinefunction(value) or inspect.isgeneratorfunction(value): + if hasattr(value, '_dinfo'): return True + if iscoroutinefunction(value) or inspect.isgeneratorfunction(value): + return not _held_as_value(value) return isinstance(value, Parameter) and value.name is not None @@ -2819,7 +2821,7 @@ def __new__(cls, obj=None, **kwargs): # rx._clone codepath fn = kwargs.pop('fn') wrapper = kwargs.pop('_wrapper', None) - elif inspect.isgeneratorfunction(obj) or iscoroutinefunction(obj): + elif (inspect.isgeneratorfunction(obj) or iscoroutinefunction(obj)) and not _held_as_value(obj): # Resolves generator and coroutine functions lazily if _is_bare_async_ref(obj): _warn_bare_async_ref(obj) @@ -4121,7 +4123,7 @@ def _resolve_input(self, arg): return value if any(ref._settling for ref in _iter_rx(arg)): raise Skip - val = resolve_value(arg) + val = _resolve_operand(arg) if val is Skip or val is Undefined: raise Skip return val @@ -4279,6 +4281,43 @@ def _iter_rx(value: t.Any) -> Iterator[rx]: yield from _iter_rx(v) +def _held_as_value(fn: t.Any) -> bool: + """ + Whether rx keeps a bare coroutine or generator function as a value, + because it cannot be called without arguments to resolve it. + """ + if hasattr(fn, '_dinfo') or not (iscoroutinefunction(fn) or inspect.isgeneratorfunction(fn)): + return False + try: + sig = inspect.signature(fn) + except (TypeError, ValueError): + return False + return any( + p.default is p.empty and p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD) + for p in sig.parameters.values() + ) + + +def _resolve_operand(value: t.Any) -> t.Any: + """ + ``resolve_value`` for rx inputs, keeping functions that require + arguments as values instead of calling them. + """ + if isinstance(value, (list, tuple)): + return type(value)(_resolve_operand(v) for v in value) + elif isinstance(value, dict): + return type(value)((_resolve_operand(k), _resolve_operand(v)) for k, v in value.items()) + elif isinstance(value, slice): + return slice( + _resolve_operand(value.start), + _resolve_operand(value.stop), + _resolve_operand(value.step) + ) + if _held_as_value(transform_reference(value)): + return value + return resolve_value(value, recursive=False) + + def _warn_bare_async_inputs(*values: t.Any) -> None: """ Warn about bare coroutine and generator functions an rx operation would @@ -4293,7 +4332,7 @@ def _warn_bare_async_inputs(*values: t.Any) -> None: _warn_bare_async_inputs(*value.keys(), *value.values()) elif isinstance(value, slice): _warn_bare_async_inputs(value.start, value.stop, value.step) - elif _is_bare_async_ref(value := transform_reference(value)): + elif _is_bare_async_ref(value := transform_reference(value)) and not _held_as_value(value): _warn_bare_async_ref(value) diff --git a/tests/testrefs.py b/tests/testrefs.py index e74a92acb..ec2fe624c 100644 --- a/tests/testrefs.py +++ b/tests/testrefs.py @@ -348,6 +348,63 @@ def test_rx_operation_warns_once(): assert expr.rx.value == 2 assert sum(issubclass(w.category, ParamFutureWarning) for w in caught) == 1 +async def _coroutine_echo(value): + return value + +async def _async_gen_echo(value): + yield value + +def _gen_echo(value): + yield value + +ARG_ASYNC_FUNCTIONS = [ + pytest.param(_coroutine_echo, id='coroutine'), + pytest.param(_async_gen_echo, id='async_gen'), + pytest.param(_gen_echo, id='gen'), + pytest.param(partial(_coroutine_concat, 'string'), id='partial'), +] + +@pytest.mark.parametrize('fn', ARG_ASYNC_FUNCTIONS) +async def test_rx_keeps_async_function_requiring_args_as_value(fn): + with warnings.catch_warnings(): + warnings.simplefilter('error', ParamFutureWarning) + expr = rx(fn) + await asyncio.sleep(0.05) + assert expr.rx.value is fn + +@pytest.mark.parametrize('fn', ARG_ASYNC_FUNCTIONS) +@pytest.mark.parametrize('build, expected', [ + pytest.param(lambda fn: rx(1).rx.pipe(lambda a, b: b, fn), lambda fn: fn, id='pipe_arg'), + pytest.param(lambda fn: rx([1]) + [fn], lambda fn: [1, fn], id='operand'), + pytest.param(lambda fn: rx(True).rx.where(fn, 0), lambda fn: fn, id='where'), +]) +async def test_rx_operation_keeps_async_function_requiring_args_as_value(fn, build, expected): + with warnings.catch_warnings(): + warnings.simplefilter('error', ParamFutureWarning) + expr = build(fn) + await asyncio.sleep(0.05) + assert expr.rx.value == expected(fn) + +@pytest.mark.parametrize('fn', ARG_ASYNC_FUNCTIONS) +async def test_rx_value_set_keeps_async_function_requiring_args(fn): + expr = rx(1) + with warnings.catch_warnings(): + warnings.simplefilter('error', ParamFutureWarning) + expr.rx.value = fn + assert expr.rx.value is fn + +async def test_rx_resolves_async_function_with_only_default_args(): + async def fetch(value='string!'): + return value + + with pytest.warns(ParamFutureWarning, match=r"param\.bind\(fetch\)"): + expr = rx(fetch) + await wait_for_value(expr.rx, 'value', 'string!') + +def test_parameter_still_calls_async_function_requiring_args(): + with pytest.raises(TypeError, match="missing 1 required positional argument"): + Parameters(string=_gen_echo) + async def test_async_bind_ref(): p = Parameters() From 5d063e792371978afde4a9562b1d5aded1bba42a Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 30 Oct 2026 11:59:13 +0100 Subject: [PATCH 5/7] Address review of the bare async ref deprecation - Read signatures without evaluating annotations on Python 3.14+, so annotations naming TYPE_CHECKING-only imports no longer raise NameError - Give operation inputs their own warning, since they are called but never resolved, instead of suggesting param.bind - Warn when a bare async function is assigned as an override - Keep functions requiring arguments as values when assigned to the .rx.value of a root built from an async function - Do not warn for functions inside sets, which are never resolved - Consolidate the coroutine/generator function check into _is_async_fn - Update the awaiting docstrings and drop unused test parameters Assisted-by: Claude Code:claude-opus-5-5 --- param/parameterized.py | 56 +++++++++++++++++++------------- param/reactive.py | 72 ++++++++++++++++++++++++++++++------------ tests/testrefs.py | 47 +++++++++++++++++++++++---- 3 files changed, 125 insertions(+), 50 deletions(-) diff --git a/param/parameterized.py b/param/parameterized.py index e15ab872e..276a452a7 100644 --- a/param/parameterized.py +++ b/param/parameterized.py @@ -303,8 +303,8 @@ def resolve_value(value: t.Any, recursive: bool = True) -> t.Any: resolve_value(value.step) ) value = transform_reference(value) - is_gen = inspect.isgeneratorfunction(value) - if hasattr(value, '_dinfo') or iscoroutinefunction(value) or is_gen: + if hasattr(value, '_dinfo') or _is_async_fn(value): + is_gen = inspect.isgeneratorfunction(value) value = eval_function_with_deps(value) if is_gen: value = _to_async_gen(value) @@ -312,16 +312,23 @@ def resolve_value(value: t.Any, recursive: bool = True) -> t.Any: value = getattr(value.owner, value.name) return value +def _is_async_fn(value: t.Any) -> bool: + """Whether value is a coroutine or generator function.""" + return iscoroutinefunction(value) or inspect.isgeneratorfunction(value) + def _is_bare_async_ref(value: t.Any) -> bool: """ Whether value is a coroutine or generator function that has not been declared a reference with ``bind`` or ``depends``. """ - return not hasattr(value, '_dinfo') and ( - iscoroutinefunction(value) or inspect.isgeneratorfunction(value) - ) + return not hasattr(value, '_dinfo') and _is_async_fn(value) -def _warn_bare_async_ref(value: t.Any) -> None: +def _warn_bare_async_ref(value: t.Any, operand: bool = False) -> None: + """ + Warn that rx will stop calling the bare coroutine or generator function + ``value``. ``operand`` marks an input to an rx operation, which is called + but whose result is never awaited or iterated. + """ if inspect.isasyncgenfunction(value): kind = 'async generator' elif inspect.isgeneratorfunction(value): @@ -332,15 +339,23 @@ def _warn_bare_async_ref(value: t.Any) -> None: while isinstance(func, partial): func = func.func name = getattr(func, '__name__', repr(func)) - bound = f"param.bind({name}, ...)" if func is not value else f"param.bind({name})" - warnings.warn( - f"Passing the {kind} function {name!r} to a reactive expression is " - "deprecated. In a future version rx will treat it like any other " - "function, i.e. keep it as a value instead of calling and resolving " - f"it. Wrap it with {bound} to keep resolving it.", - category=_ParamFutureWarning, - stacklevel=_find_stack_level(), - ) + if operand: + result = 'coroutine' if kind == 'coroutine' else 'async generator' + message = ( + f"Passing the {kind} function {name!r} as an input to a reactive " + f"operation is deprecated. It is currently called and the resulting " + f"{result} is passed on without being awaited or iterated; in a " + "future version the function itself will be passed on." + ) + else: + bound = f"param.bind({name}, ...)" if func is not value else f"param.bind({name})" + message = ( + f"Passing the {kind} function {name!r} to a reactive expression is " + "deprecated. In a future version rx will treat it like any other " + "function, i.e. keep it as a value instead of calling and resolving " + f"it. Wrap it with {bound} to keep resolving it." + ) + warnings.warn(message, category=_ParamFutureWarning, stacklevel=_find_stack_level()) def resolve_ref(reference: t.Any, recursive: bool = False) -> list[Parameter]: """Resolve all parameters a dynamic reference depends on.""" @@ -3042,8 +3057,7 @@ def _sync_refs(self_, *events): # Skip updating value if dependency has not changed recursive = self_[pname].nested_refs deps = resolve_ref(ref, recursive) - is_gen = inspect.isgeneratorfunction(ref) - is_async = iscoroutinefunction(ref) or is_gen + is_async = _is_async_fn(ref) if not any((dep.owner is e.obj and dep.name == e.name) for dep in deps for e in events) and not is_async: continue @@ -3072,10 +3086,9 @@ def _sync_refs(self_, *events): _notify_async_ref_settle_change(self_.self, pname) def _resolve_ref(self_, pobj: Parameter, value: t.Any): - is_gen = inspect.isgeneratorfunction(value) - is_async = iscoroutinefunction(value) or is_gen + is_async = _is_async_fn(value) deps = resolve_ref(value, recursive=pobj.nested_refs) - if not (deps or is_async or is_gen): + if not (deps or is_async): return None, None, value, False, False ref = value try: @@ -3132,8 +3145,7 @@ def _awaiting_ref(self_, pname: str) -> bool: return False private = self_.self._param__private ref = private.refs.get(pname) - is_async = iscoroutinefunction(ref) or inspect.isgeneratorfunction(ref) - return is_async and private.async_ref_scheduled[pname] != private.async_ref_settled[pname] + return _is_async_fn(ref) and private.async_ref_scheduled[pname] != private.async_ref_settled[pname] async def _async_ref(self_, pname: str, awaitable: t.Awaitable[t.Any], generation: int = 0): if self_.self is None: diff --git a/param/reactive.py b/param/reactive.py index 4ab6f76d7..f162e9c27 100644 --- a/param/reactive.py +++ b/param/reactive.py @@ -95,6 +95,7 @@ import logging import math import operator +import sys import typing as t import warnings import weakref @@ -113,7 +114,7 @@ from .parameterized import ( Comparator, Parameter, Parameterized, Skip, Undefined, eval_function_with_deps, get_method_owner, register_reference_transform, resolve_ref, resolve_value, - transform_reference, _is_bare_async_ref, _notify_weak_listeners, + transform_reference, _is_async_fn, _is_bare_async_ref, _notify_weak_listeners, _register_weak_listener, _warn_bare_async_ref, _watch_async_ref_settle_change, _watch_ref_change ) @@ -175,6 +176,22 @@ class GenWrapper(Parameterized): object: t.Any = Parameter(allow_refs=True) +class _HeldValue: + """ + Box for a function assigned to a ``GenWrapper`` root that rx keeps as a + value, so that the reference machinery does not call it. + """ + + __slots__ = ('value',) + + def __init__(self, value: t.Any): + self.value = value + + +def _unbox_held(obj: t.Any) -> t.Any: + return obj.value if isinstance(obj, _HeldValue) else obj + + class Trigger(Parameterized): """Helper class to allow triggering an event under some condition.""" @@ -390,6 +407,7 @@ def __getitem__(self, key: t.Any) -> t.Any: def _set(self, key: t.Any, value: t.Any): """Mask a resolved ``key`` without invalidating the node.""" + _warn_bare_async_inputs(value) node = self._node operation = t.cast('dict', node._operation) overrides = operation.get('overrides') @@ -1346,7 +1364,8 @@ def awaiting(self) -> builtins.bool: Both routes an asynchronous callable can take are tracked: one applied as an operation, e.g. passed to ``.rx.pipe``, and one passed to ``rx`` - as the object itself, which is held on a parameter and resolved by the + as the object itself after declaring it a reference with + ``param.bind``, which is held on a parameter and resolved by the reference machinery. A generator settles on each value it yields, so it reports ``True`` only until its next value arrives rather than until it is exhausted. Accessed on a parameter rather than an expression this is @@ -2012,7 +2031,11 @@ def value(self, new): "reference cannot be updated." ) _warn_bare_async_inputs(new) - self._reactive._wrapper.object = _resolve_operand(new) + wrapper = self._reactive._wrapper + value = _resolve_operand(new) + if isinstance(wrapper, GenWrapper) and _held_as_value(transform_reference(value)): + value = _HeldValue(value) + wrapper.object = value def watch(self, fn=None, onlychanged=True, queued=False, precedence=0, process_failures=False): """ @@ -2632,7 +2655,7 @@ def _untracked_reference(value) -> bool: return any(_iter_rx(value)) or bool(resolve_ref(value, recursive=True)) if hasattr(value, '_dinfo'): return True - if iscoroutinefunction(value) or inspect.isgeneratorfunction(value): + if _is_async_fn(value): return not _held_as_value(value) return isinstance(value, Parameter) and value.name is not None @@ -2821,12 +2844,12 @@ def __new__(cls, obj=None, **kwargs): # rx._clone codepath fn = kwargs.pop('fn') wrapper = kwargs.pop('_wrapper', None) - elif (inspect.isgeneratorfunction(obj) or iscoroutinefunction(obj)) and not _held_as_value(obj): + elif _is_async_fn(obj) and not _held_as_value(obj): # Resolves generator and coroutine functions lazily if _is_bare_async_ref(obj): _warn_bare_async_ref(obj) wrapper = t.cast('Callable', GenWrapper)(object=obj) - fn = bind(lambda obj: obj, wrapper.param.object) + fn = bind(_unbox_held, wrapper.param.object) obj = Undefined elif isinstance(obj, (FunctionType, MethodType)) and hasattr(obj, '_dinfo'): # Bound functions and methods are resolved on access @@ -2902,7 +2925,7 @@ def __init__( # Define special trigger parameter if operation has to be lazily evaluated self._trigger: Trigger | None - if operation and (iscoroutinefunction(operation['fn']) or inspect.isgeneratorfunction(operation['fn'])): + if operation and _is_async_fn(operation['fn']): self._trigger = Trigger(internal=True) self._current_ = Undefined self._dirty = True # Otherwise current will be stuck as Undefined. @@ -3011,11 +3034,7 @@ def _is_async(self) -> bool: if not self._operation: return False fn = self._operation["fn"] - return ( - inspect.iscoroutinefunction(fn) or - inspect.isasyncgenfunction(fn) or - inspect.isgeneratorfunction(fn) - ) + return _is_async_fn(fn) @property def _awaiting(self) -> bool: @@ -3035,10 +3054,11 @@ def _awaiting_ref(self) -> bool: Whether an asynchronous reference feeding this node has not yet produced a value for the current inputs. - A coroutine or generator function passed to ``rx`` as the object rather - than as an operation is held on a parameter and resolved by the - reference machinery, so its settlement is tracked there instead of by - this node's own generations. + A coroutine or generator function declared a reference with + ``param.bind`` and passed to ``rx`` as the object rather than as an + operation is held on a parameter and resolved by the reference + machinery, so its settlement is tracked there instead of by this + node's own generations. """ for p in self._internal_params: owner, name = p.owner, p.name @@ -4281,15 +4301,25 @@ def _iter_rx(value: t.Any) -> Iterator[rx]: yield from _iter_rx(v) +if sys.version_info >= (3, 14): + import annotationlib + + # Annotations are irrelevant here and evaluating them can raise, e.g. on + # names only imported under TYPE_CHECKING. + _signature = partial(inspect.signature, annotation_format=annotationlib.Format.FORWARDREF) +else: + _signature = inspect.signature + + def _held_as_value(fn: t.Any) -> bool: """ Whether rx keeps a bare coroutine or generator function as a value, because it cannot be called without arguments to resolve it. """ - if hasattr(fn, '_dinfo') or not (iscoroutinefunction(fn) or inspect.isgeneratorfunction(fn)): + if hasattr(fn, '_dinfo') or not _is_async_fn(fn): return False try: - sig = inspect.signature(fn) + sig = _signature(fn) except (TypeError, ValueError): return False return any( @@ -4321,19 +4351,19 @@ def _resolve_operand(value: t.Any) -> t.Any: def _warn_bare_async_inputs(*values: t.Any) -> None: """ Warn about bare coroutine and generator functions an rx operation would - resolve, descending into the containers ``resolve_value`` descends into. + call, descending into the containers ``resolve_value`` descends into. """ for value in values: if isinstance(value, rx): continue - elif isinstance(value, (list, tuple, set)): + elif isinstance(value, (list, tuple)): _warn_bare_async_inputs(*value) elif isinstance(value, dict): _warn_bare_async_inputs(*value.keys(), *value.values()) elif isinstance(value, slice): _warn_bare_async_inputs(value.start, value.stop, value.step) elif _is_bare_async_ref(value := transform_reference(value)) and not _held_as_value(value): - _warn_bare_async_ref(value) + _warn_bare_async_ref(value, operand=True) def _iter_bare_params(value: t.Any) -> Iterator[Parameter]: diff --git a/tests/testrefs.py b/tests/testrefs.py index ec2fe624c..b5b783ac1 100644 --- a/tests/testrefs.py +++ b/tests/testrefs.py @@ -1,4 +1,5 @@ import asyncio +import sys import threading import time import warnings @@ -291,15 +292,17 @@ async def _coroutine_concat(a, b): ), ] -@pytest.mark.parametrize('fn, match', BARE_ASYNC_REFS) -async def test_bare_async_ref_resolves_on_init_without_warning(fn, match): +BARE_ASYNC_FUNCTIONS = [pytest.param(p.values[0], id=p.id) for p in BARE_ASYNC_REFS] + +@pytest.mark.parametrize('fn', BARE_ASYNC_FUNCTIONS) +async def test_bare_async_ref_resolves_on_init_without_warning(fn): with warnings.catch_warnings(): warnings.simplefilter('error', ParamFutureWarning) p = Parameters(string=fn) await wait_for_value(p, 'string', 'string!') -@pytest.mark.parametrize('fn, match', BARE_ASYNC_REFS) -async def test_bare_async_ref_resolves_on_set_without_warning(fn, match): +@pytest.mark.parametrize('fn', BARE_ASYNC_FUNCTIONS) +async def test_bare_async_ref_resolves_on_set_without_warning(fn): p = Parameters() with warnings.catch_warnings(): warnings.simplefilter('error', ParamFutureWarning) @@ -319,8 +322,8 @@ async def test_bound_async_ref_in_rx_does_not_warn(fn, match): expr = rx(bind(fn)) await wait_for_value(expr.rx, 'value', 'string!') -@pytest.mark.parametrize('fn, match', BARE_ASYNC_REFS) -async def test_bare_async_function_on_non_ref_parameter_is_kept(fn, match): +@pytest.mark.parametrize('fn', BARE_ASYNC_FUNCTIONS) +async def test_bare_async_function_on_non_ref_parameter_is_kept(fn): class NoRefs(param.Parameterized): value = param.Parameter() @@ -334,10 +337,30 @@ class NoRefs(param.Parameterized): pytest.param(lambda fn: rx(True).rx.where(fn, 0), id='where'), pytest.param(lambda fn: setattr(rx(1).rx, 'value', fn), id='set_value'), pytest.param(lambda fn: param.reactive.collect(fn), id='collect'), + pytest.param( + lambda fn: rx(1).rx.pipe(lambda a, b: b, rx(2)).rx.overrides.args.__setitem__(1, fn), + id='override' + ), ]) def test_bare_async_ref_warns_in_rx_operations(resolve): - with pytest.warns(ParamFutureWarning, match=r"param\.bind\(_gen_string\)"): + with pytest.warns(ParamFutureWarning, match="function itself will be passed on") as record: resolve(_gen_string) + assert not any('param.bind' in str(w.message) for w in record) + +def test_bare_async_function_in_set_does_not_warn(): + with warnings.catch_warnings(): + warnings.simplefilter('error', ParamFutureWarning) + expr = rx(1).rx.pipe(lambda a, b: b, {_gen_string}) + assert expr.rx.value == {_gen_string} + +@pytest.mark.skipif(sys.version_info < (3, 14), reason='Annotations are evaluated lazily from Python 3.14') +async def test_rx_async_function_with_unresolvable_annotation(): + async def fetch() -> Unresolvable: # noqa: F821 + return 'string!' + + with pytest.warns(ParamFutureWarning, match=r"param\.bind\(fetch\)"): + expr = rx(fetch) + await wait_for_value(expr.rx, 'value', 'string!') def test_rx_operation_warns_once(): with warnings.catch_warnings(record=True) as caught: @@ -393,6 +416,16 @@ async def test_rx_value_set_keeps_async_function_requiring_args(fn): expr.rx.value = fn assert expr.rx.value is fn +@pytest.mark.parametrize('fn', ARG_ASYNC_FUNCTIONS) +async def test_rx_value_set_on_async_root_keeps_async_function_requiring_args(fn): + expr = rx(bind(_gen_string)) + await wait_for_value(expr.rx, 'value', 'string!') + with warnings.catch_warnings(): + warnings.simplefilter('error', ParamFutureWarning) + expr.rx.value = fn + await asyncio.sleep(0.05) + assert expr.rx.value is fn + async def test_rx_resolves_async_function_with_only_default_args(): async def fetch(value='string!'): return value From b865118b1666b950064cfc5deaade438beb8a004 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 30 Oct 2026 13:52:00 +0100 Subject: [PATCH 6/7] Await the result of calling an async function held by rx Calling an rx expression whose value is a coroutine, generator or async generator function, or an async method of the wrapped object, now awaits or iterates the result like .rx.pipe does instead of producing an unawaited coroutine. This prepares rx(afunc)() for when bare async functions are kept as values. Whether the call is async is decided from the callee when the call is created. Assisted-by: Claude Code:claude-opus-5-5 --- doc/user_guide/Reactive_Expressions.ipynb | 31 ++++++++++ param/reactive.py | 22 ++++++- tests/testreactive.py | 71 +++++++++++++++++++++++ 3 files changed, 121 insertions(+), 3 deletions(-) diff --git a/doc/user_guide/Reactive_Expressions.ipynb b/doc/user_guide/Reactive_Expressions.ipynb index 3f64e8a6e..027a15061 100644 --- a/doc/user_guide/Reactive_Expressions.ipynb +++ b/doc/user_guide/Reactive_Expressions.ipynb @@ -928,6 +928,37 @@ "execution_count": null, "outputs": [] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Calling an expression that holds an asynchronous function, or an asynchronous method of the object it wraps, awaits the result in the same way:" + ], + "id": "call-async-md" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class Doubler:\n", + " async def double(self, value):\n", + " await asyncio.sleep(1)\n", + " return value * 2\n", + "\n", + "number = rx(1)\n", + "called = rx(slow_double)(number)\n", + "method = rx(Doubler()).double(number)\n", + "\n", + "print(f'requested: called={called.rx.value!r}, method={method.rx.value!r}')\n", + "\n", + "await asyncio.sleep(1.5)\n", + "\n", + "print(f'settled: called={called.rx.value!r}, method={method.rx.value!r}')" + ], + "id": "call-async-code" + }, { "cell_type": "markdown", "id": "9c4f1a52", diff --git a/param/reactive.py b/param/reactive.py index db780c2ae..4c68c5aba 100644 --- a/param/reactive.py +++ b/param/reactive.py @@ -2953,7 +2953,7 @@ def __init__( # Define special trigger parameter if operation has to be lazily evaluated self._trigger: Trigger | None - if operation and _is_async_fn(operation['fn']): + if operation and _is_async_operation(operation): self._trigger = Trigger(internal=True) self._current_ = Undefined self._dirty = True # Otherwise current will be stuck as Undefined. @@ -3061,8 +3061,7 @@ def _obj_dirty(self) -> bool: def _is_async(self) -> bool: if not self._operation: return False - fn = self._operation["fn"] - return _is_async_fn(fn) + return _is_async_operation(self._operation) @property def _awaiting(self) -> bool: @@ -4025,6 +4024,11 @@ def __call__(self, *args, **kwargs): if method in rx._method_handlers: handler = rx._method_handlers[method] method = handler(self) + callee = None + elif new._method: + callee = getattr(new._current_, new._method, None) + else: + callee = new._current_ new._method = None kwargs = dict(kwargs) operation = { @@ -4034,6 +4038,10 @@ def __call__(self, *args, **kwargs): 'reverse': False, 'process_failures': self._process_failures, } + if _is_async_fn(callee): + # The callee is only known when the call is created, so its + # result is awaited only if it was async at that point. + operation['async'] = True return new._clone(operation) #---------------------------------------------------------------- @@ -4396,6 +4404,14 @@ def _iter_rx(value: t.Any) -> Iterator[rx]: _signature = inspect.signature +def _is_async_operation(operation: dict[str, t.Any]) -> bool: + """ + Whether an operation produces an awaitable or (async) generator, either + because its fn is async or because it calls an async function. + """ + return operation.get('async', False) or _is_async_fn(operation['fn']) + + def _held_as_value(fn: t.Any) -> bool: """ Whether rx keeps a bare coroutine or generator function as a value, diff --git a/tests/testreactive.py b/tests/testreactive.py index 34524f0dd..5c56c67b5 100644 --- a/tests/testreactive.py +++ b/tests/testreactive.py @@ -1117,6 +1117,77 @@ async def async_func(value): await async_wait_until(lambda: expr.rx.value == 2) assert not expr.rx.awaiting + async def test_reactive_call_async_function(self): + async def async_func(value): + await asyncio.sleep(0.02) + return value + 2 + + number = rx(0) + expr = rx(async_func)(number) + + assert expr.rx.value is param.Undefined + assert expr.rx.awaiting + await async_wait_until(lambda: expr.rx.value == 2) + assert not expr.rx.awaiting + + number.rx.value = 3 + assert expr.rx.value == 2 + assert expr.rx.awaiting + await async_wait_until(lambda: expr.rx.value == 5) + + async def test_reactive_call_async_function_downstream(self): + async def async_func(value): + await asyncio.sleep(0.02) + return value + 2 + + expr = rx(async_func)(1) * 10 + + assert expr.rx.value is param.Undefined + await async_wait_until(lambda: expr.rx.value == 30) + + async def test_reactive_call_async_gen_function(self): + async def async_gen(value): + yield value + await asyncio.sleep(0.02) + yield value + 1 + + expr = rx(async_gen)(1) + expr.rx.value + + await async_wait_until(lambda: expr.rx.value == 2) + assert not expr.rx.awaiting + + async def test_reactive_call_gen_function(self): + def gen(value): + yield value + yield value + 1 + + expr = rx(gen)(1) + expr.rx.value + + await async_wait_until(lambda: expr.rx.value == 2) + + async def test_reactive_call_async_method(self): + class Fetcher: + async def fetch(self, value): + await asyncio.sleep(0.02) + return f'fetched {value}' + + number = rx(1) + expr = rx(Fetcher()).fetch(number) + + assert expr.rx.value is param.Undefined + await async_wait_until(lambda: expr.rx.value == 'fetched 1') + + number.rx.value = 2 + await async_wait_until(lambda: expr.rx.value == 'fetched 2') + + def test_reactive_call_sync_function_resolves_synchronously(self): + expr = rx(lambda value: value + 2)(rx(0)) + + assert expr.rx.value == 2 + assert not expr.rx.awaiting + async def test_reactive_awaiting_not_scheduled_until_requested(self): """ Reading awaiting must not itself schedule a resolution, so an expression From 9bbbf25dce2e03e91d3302e95b0b3491854f14f1 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 30 Oct 2026 14:57:05 +0100 Subject: [PATCH 7/7] Update param/reactive.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Simon Høxbro Hansen --- param/reactive.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/param/reactive.py b/param/reactive.py index 4c68c5aba..6f7b45b73 100644 --- a/param/reactive.py +++ b/param/reactive.py @@ -4039,8 +4039,6 @@ def __call__(self, *args, **kwargs): 'process_failures': self._process_failures, } if _is_async_fn(callee): - # The callee is only known when the call is created, so its - # result is awaited only if it was async at that point. operation['async'] = True return new._clone(operation)