Skip to content

Deprecate passing bare coroutine and generator functions to param.rx - #1227

Merged
philippjfr merged 8 commits into
mainfrom
deprecate-bare-async-refs
Oct 30, 2026
Merged

philippjfr merged 8 commits into
mainfrom
deprecate-bare-async-refs

Conversation

@philippjfr

@philippjfr philippjfr commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Description

param.rx treats functions inconsistently. A plain function passed to rx(fn) is wrapped as a value you can call or pipe, but a coroutine, generator or async generator function is called and its result resolved. The same split shows up in operation arguments: rx(x).rx.pipe(f, afunc), rx([1]) + [afunc], rx.where(afunc, ...) and expr.rx.value = afunc all call a bare async function while passing a sync one through untouched. For an API whose job is to wrap values this is surprising, and resolving outside a running event loop leads to errors like "coroutine was never awaited".

This PR starts moving rx to a single rule, where every function is a value unless it is explicitly declared a reference with param.bind or param.depends:

  • Zero-argument coroutine and generator functions are still resolved, but now emit a ParamFutureWarning telling users to wrap them with param.bind(fn), which keeps today's behavior. In a future release rx will keep them as values.
  • Coroutine and generator functions that require arguments are kept as values right away, without a warning. Resolving them always failed with a TypeError, so nothing that works today changes. Arguments bound by a partial count as supplied, and so do defaults, so a function with only defaulted arguments still falls under the deprecation.
import param

async def fetch():
    return 1

async def fetch_url(url):
    ...

param.rx(fetch)                            # ParamFutureWarning: ... Wrap it with param.bind(fetch) to keep resolving it.
param.rx(param.bind(fetch))                # no warning, resolved as before
param.rx(fetch_url).rx.value is fetch_url  # previously TypeError: missing 1 required positional argument

Both rules apply wherever rx resolves a value: rx(fn), operation arguments (including ones nested in lists, tuples, dicts and slices), where branches, collect, rx.value assignments and .rx.overrides. The warning fires once, when the expression or operation is built, not on every re-evaluation.

The warning's advice depends on the entry point. Only rx(fn) actually resolves the function, so only there does the message suggest param.bind(fn) (or param.bind(fn, ...) for a partial). Everywhere else the function is called but its coroutine or async generator is passed on without ever being awaited or iterated, so the message says so and that the function itself will be passed on in future. Functions inside a set are never resolved and don't warn.

Calling an async function held by rx

Once bare async functions are kept as values, the natural way to call one reactively is rx(afunc)(x). Until now that produced an unawaited coroutine, because a call operation is stored as '__call__' and never qualified as async. Calling an 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 exactly like .rx.pipe(afunc, x), including .rx.awaiting and re-resolving when inputs change:

number = param.rx(1)
param.rx(fetch_url)(number)       # resolves to the awaited result
param.rx(client).fetch(number)    # same for async methods

rx(afunc)(x) previously raised a TypeError, so nothing relied on it. rx(obj).amethod(x) previously returned the coroutine object. Whether a call is async is decided from the callee when the call is created, so swapping the held function between a sync and an async one via rx.value keeps the original mode, and a callee coming out of an upstream async node that hasn't resolved yet isn't detected.

Parameters are unchanged

Parameters with allow_refs=True keep resolving bare coroutine and generator functions, without a warning, and still raise when such a function requires arguments, so a forgotten bind fails loudly. Resolving references is the point of allow_refs, and streaming a generator into a parameter is a documented feature that Panel relies on, e.g. pn.pane.Markdown(object=gen) and the refs= argument. An earlier version of this PR that also warned on parameters would have affected three Panel docs pages and Panel's own refs= code path, and broke one Panel unit test. Nothing in Panel's docs, tests or internals passes a bare async function to rx, so this version needs no Panel changes.

One related fix does touch parameters: those that are implicitly allow_refs=False no longer speculatively resolve a bare coroutine or generator function just to check whether to warn about a reference. That speculative resolve started a coroutine nobody awaited, and it could eventually overwrite the parameter's value with the result.

Tests and docs

The existing rx tests and the rx example in the Generators user guide now use param.bind(...), and the .rx.awaiting section of the Reactive Expressions guide shows calling an async function and an async method. New tests in tests/testrefs.py cover, for coroutine, async generator, generator and partial functions:

  • the warning for every rx entry point, its message per entry point, and that it fires only once
  • that bound functions don't warn, and that functions in a set don't either
  • that functions requiring arguments are kept as values in every entry point, including rx.value on a root built from an async function, while defaults-only functions are still resolved
  • that an async function whose annotations can't be evaluated (e.g. TYPE_CHECKING-only imports on Python 3.14+) still works
  • that parameters keep resolving silently, still raise for functions requiring arguments, and leave the value alone when refs are implicitly disabled

New tests in tests/testreactive.py cover calling async, async generator and generator functions and async methods through rx.

The full Param test suite passes locally on Python 3.11 and 3.14. Against this branch, Panel's test_reactive, pane and layout suites pass, and of the common Panel patterns only pn.rx(fn) warns.

AI Disclosure

Tool & Model: Claude Code + Claude Opus 5.5
Usage: Investigated the reference resolution code paths, implemented the warning, the handling of functions requiring arguments and awaiting async calls, addressed review findings, updated tests and docs, and measured the impact on Panel's docs and test suite.

  • I have tested all AI-generated content in my PR.
  • I take responsibility for all AI-generated content in my PR.

Checklist

  • Tests added and are passing
  • Added documentation

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.
@codecov

codecov Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.90722% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.48%. Comparing base (74d0fe1) to head (9bbbf25).

Files with missing lines Patch % Lines
param/reactive.py 95.71% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1227      +/-   ##
==========================================
+ Coverage   89.42%   89.48%   +0.06%     
==========================================
  Files           9        9              
  Lines        6588     6666      +78     
==========================================
+ Hits         5891     5965      +74     
- Misses        697      701       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@philippjfr
philippjfr requested a review from hoxbro October 26, 2026 10:22

@hoxbro hoxbro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is some other places which accepts bare async function with no warnings. Is that expected?

These was flagged by AI: .rx.pipe(f, afunc), rx([1]) + [afunc], rx.where operands and .rx.value = afunc

Comment thread param/parameterized.py Resolved
Comment thread tests/testrefs.py Resolved
Comment thread param/parameterized.py Resolved
Comment thread param/parameterized.py Resolved
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
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
@philippjfr philippjfr changed the title Deprecate bare coroutine and generator functions as references Deprecate passing bare coroutine and generator functions to param.rx Oct 30, 2026
@philippjfr

philippjfr commented Oct 30, 2026 •

Copy link
Copy Markdown
Member Author

Thanks for the review. The scope has changed since, so here's where things landed:

  • rx paths without a warning: Good catch. Those paths are now the whole scope of the PR. rx(fn), operation arguments (pipe args and operands, including nested in containers), where branches, collect and .rx.value = all warn, once, when the expression is built. Parameters with allow_refs=True no longer warn at all and will keep resolving bare async and generator functions, since streaming into a parameter is a documented feature Panel relies on. The description has the details and the Panel impact numbers behind the change.
  • Non-ref parameter test: Applied, with an await asyncio.sleep(0.1), so it now catches the old speculative resolve overwriting the value.
  • partial: Detection already unwrapped partials, but the message printed the partial's repr. It now suggests param.bind(fn, ...), and partials are part of the parametrized tests.
  • Duplicate checks in _resolve_ref: Gone, since the warning no longer lives there.
  • Docstring wording: Applied.

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
- 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
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
Comment thread param/reactive.py Resolved
Co-authored-by: Simon Høxbro Hansen <simon.hansen@me.com>
@philippjfr
philippjfr merged commit af94e0c into main Oct 30, 2026
19 checks passed
@philippjfr
philippjfr deleted the deprecate-bare-async-refs branch October 30, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants