Bind named `to_actor.run()` inputs with partials

PR #481 made target inputs positional and reserved keywords for
actor placement/runtime controls. PR #484 still forwarded target
kwargs, so tests and examples failed local signature binding after
the rebase.

Deats,
- bind named target inputs with `functools.partial()`
- keep placement, naming and runtime controls as direct keywords
- reject invalid target calls locally before actor startup
- require linked one-shots to raise one direct `RemoteActorError`
- doc linked context execution and per-child process reaping

Prompt-IO: ai/prompt-io/opencode/20260819T184640Z_481ba003_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
drop_ria_nursery
Gud Boi 2026-08-19 17:24:40 -04:00
parent f8488401be
commit b645f7fa8c
13 changed files with 264 additions and 126 deletions

View File

@ -0,0 +1,56 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: 76c5d31c-5a2f-4503-9b16-410ee7f4fab3
timestamp: 2026-08-19T18:46:40Z
git_ref: 481ba003
scope: code
substantive: true
raw_file: 20260819T184640Z_481ba003_prompt_io.raw.md
---
## Prompt
Rebase PR #484 onto final PR #481, migrate every affected one-shot call
to the new positional target API and continue through downstream tests,
examples and documentation review.
## Response summary
Converted stale target keyword calls to target partials so previously
named inputs remain explicit while placement/runtime controls stay
direct. Updated error expectations for local signature validation and
linked remote error propagation, then corrected docs which still
described the removed one-shot implementation. Linked spawning and
context lifecycle prose to the corresponding API methods and detailed
context guide.
## Files changed
- `docs/api/core.rst` - describe linked one-shot context execution.
- `docs/guide/rpc.rst` - update placement and target call semantics.
- `docs/guide/spawning.rst` - document positional target inputs.
- `examples/debugging/multi_nested_subactors_error_up_through_nurseries.py` - migrate nested actor target inputs.
- `examples/debugging/root_cancelled_but_child_is_in_tty_lock.py` - preserve named recursive target inputs with partials.
- `tests/test_advanced_streaming.py` - migrate streaming target inputs.
- `tests/test_cancellation.py` - migrate calls and tighten errors.
- `tests/test_infected_asyncio.py` - bind asyncio target options.
- `tests/test_rpc.py` - migrate RPC target argument binding.
- `tests/test_runtime.py` - preserve named runtime target inputs.
- `tests/test_spawning.py` - preserve named spawning target inputs.
## Human edits
The human selected the stack order and final PR #481 base, asked the
agent to continue after each diagnostic step and required a complete
commit plan after independently force-pushing the rebased history.
After reviewing the migration, the human required every formerly named
target input to remain visibly named through `functools.partial()`
rather than becoming positional. These were human-directed agent edits;
the human also required plain `start_actor()` and `open_context()`
references in the spawning and RPC guides to link to their API methods
and the detailed context guide, then clarified that `to_actor.run()`
already uses the full context API while `Portal.run()` should share
linked lifecycle machinery without necessarily delegating through
`Portal.open_context()` or adding a `Started` message. The human made
no direct source-line edits.

View File

@ -0,0 +1,30 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-08-19T18:46:40Z
git_ref: 481ba003
diff_cmd: git diff HEAD~1..HEAD
---
Migrate PR #484's downstream one-shot calls to PR #481's final
`tractor.to_actor.run()` contract after the stack rebase.
> `git diff HEAD~1..HEAD -- docs examples tests`
Pass target arguments positionally and bind target keyword-only inputs
with `functools.partial()`. Keep placement and runtime controls as
direct `to_actor.run()` keywords. Update the invalid-target-argument
test to expect local signature binding before actor startup and require
direct `RemoteActorError` propagation from linked one-shots.
Update API and guide prose to describe positional target inputs,
linked `Portal.open_context()` execution and per-child reaping instead
of the removed `Portal.run()` and target-`**kwargs` conventions.
Verification:
- core and migrated runtime batches: `97 passed`
- discovery and related lifecycle batch: `33 passed, 1 skipped`
- changed executable examples: `9 passed`
- mapped debugger cases: `12 passed, 6 skipped`
- Ruff, compilation and `git diff --check`: clean

View File

@ -58,10 +58,10 @@ One-shot task actors
``trio.to_thread.run_sync()`` and friends) is the
*convenience* one-shot — spawn, run a single task, block on
its result, reap — built entirely on
:meth:`ActorNursery.start_actor` + :meth:`Portal.run` +
:meth:`Portal.cancel_actor`, so don't design around it as the
core model. It supersedes the removed (legacy, non-blocking)
``ActorNursery.run_in_actor()``.
:meth:`ActorNursery.start_actor`, a linked
:meth:`Portal.open_context` call and per-child cancellation/reaping,
so don't design around it as the core model. It supersedes the
removed (legacy, non-blocking) ``ActorNursery.run_in_actor()``.
.. deprecated:: 0.1.0a6

View File

@ -89,7 +89,12 @@ in one blocking call:
.. code:: python
final = await tractor.to_actor.run(fib, an=an, n=10)
from functools import partial
final = await tractor.to_actor.run(
partial(fib, n=10),
an=an,
)
Semantics worth knowing:
@ -98,9 +103,10 @@ Semantics worth knowing:
task.
- "placement" is composable: ``an=`` spawns from an existing
actor-nursery, ``portal=`` reuses an already-running actor
(no spawn/reap, just a ``Portal.run()``), and passing
neither opens a private call-scoped nursery (booting the
runtime if needed).
(no spawn/reap, just a linked
:meth:`~tractor.Portal.open_context` call; see the
:doc:`context guide </guide/context>`), and passing neither
opens a private call-scoped nursery (booting the runtime if needed).
- concurrency composes the plain ``trio`` way: schedule
multiple ``run()`` calls into a local task nursery (see
``examples/parallelism/to_actor_one_shots.py``).
@ -149,7 +155,8 @@ call tears down the entire sub-tree — SC, transitively.
When to graduate to ``Context``
-------------------------------
``portal.run()`` is great for one-shot, request-response calls.
The :meth:`~tractor.Portal.run` method is great for one-shot,
request-response calls.
Reach for :meth:`~tractor.Portal.open_context` with an
``@tractor.context`` endpoint as soon as you want:
@ -162,10 +169,15 @@ Reach for :meth:`~tractor.Portal.open_context` with an
:meth:`~tractor.Portal.cancel_actor` nukes the **entire**
remote runtime and its process.
In fact the source plans for ``Portal.run()`` itself to be
rebuilt on top of ``open_context()`` — contexts *are* the core
inter-actor protocol. Take the full tour in
:doc:`/guide/context`.
:func:`tractor.to_actor.run` already enters the full
:meth:`~tractor.Portal.open_context` lifecycle. The older
:meth:`~tractor.Portal.run` path instead uses the ``Context`` returned
by the lower-level ``Actor.start_remote_task()`` directly, avoiding a
``Started`` handshake but owning less lifecycle machinery. A follow-up
should factor their shared linked-task lifecycle without requiring
``Portal.run()`` to delegate through the public context API or add
another wire message. Take the full tour in
:doc:`the context guide </guide/context>`.
.. seealso::

View File

@ -91,17 +91,17 @@ somebody-ing:
What's going on here?
- ``start_actor('frank', enable_modules=[__name__])`` forks off
- :meth:`~tractor.ActorNursery.start_actor` forks off
a new process, boots a ``tractor`` runtime inside it, and
allows it to serve functions from the current module (see the
allowlist section below).
- each ``await portal.run(...)`` schedules a *new* task in
- each :meth:`~tractor.Portal.run` call schedules a *new* task in
frank's task tree and waits on its result — the full RPC story
lives in :doc:`/guide/rpc`.
- frank has no main task to complete, so without the final
``await portal.cancel_actor()`` the nursery block would wait
on him **forever**. Daemon lifetimes are *yours* to end; that
explicitness is the point.
:meth:`~tractor.Portal.cancel_actor` call the nursery block would
wait on him **forever**. Daemon lifetimes are *yours* to end;
that explicitness is the point.
``to_actor.run()``: quick one-shot parallelism
----------------------------------------------
@ -126,7 +126,9 @@ A few details worth knowing:
``name='something_cuter'``.
- the function's module is auto-added to the child's
``enable_modules`` allowlist.
- extra ``**kwargs`` are forwarded to the function itself.
- target arguments are positional; use ``functools.partial()``
to bind target keyword arguments. Keywords passed directly to
``run()`` configure actor placement and spawning.
- the call blocks until the result (or error) lands and the
child is *auto-cancelled* (reaped) right after — so remote
errors raise directly in your calling task (causality_ is
@ -138,14 +140,16 @@ A few details worth knowing:
.. note::
``to_actor.run()`` is a convenience, **not** the core model —
it's built *entirely* on ``start_actor()`` + ``Portal.run()``
+ ``Portal.cancel_actor()``. Teach your fingers to use it for
quick fire-and-collect parallelism — think a per-function
trio-parallel_ style one-shot — and reach for
``start_actor()`` + ``open_context()`` for anything
long-lived, stateful or streaming
(:doc:`/guide/context`).
:func:`tractor.to_actor.run` is a convenience, **not** the core
model — it's built *entirely* on
:meth:`~tractor.ActorNursery.start_actor` plus a linked
:meth:`~tractor.Portal.open_context` call and per-child
cancellation/reaping. Teach your fingers to use it for quick
fire-and-collect parallelism — think a per-function trio-parallel_
style one-shot — and reach for
:meth:`~tractor.ActorNursery.start_actor` plus
:meth:`~tractor.Portal.open_context` for anything long-lived,
stateful or streaming; see :doc:`/guide/context`.
Actor lifetimes and teardown order
----------------------------------
@ -154,10 +158,11 @@ So we have two lifetime flavors:
- **one-shot** (``to_actor.run()``): lives exactly as long as
its single task; reaped the moment its result (or error)
arrives back in the (blocking) call.
- **daemon** (``start_actor()``): lives until *someone* cancels
it — an explicit ``await portal.cancel_actor()``, a bulk
``await an.cancel()``, or the one-cancels-all strategy kicking
in on error.
- **daemon** (:meth:`~tractor.ActorNursery.start_actor`): lives
until *someone* cancels it — an explicit
:meth:`~tractor.Portal.cancel_actor`, a bulk
:meth:`~tractor.ActorNursery.cancel`, or the one-cancels-all
strategy kicking in on error.
On a clean exit of the nursery block the teardown order is:

View File

@ -51,9 +51,11 @@ async def spawn_until(depth=0):
# `name_error` relays through.
depth -= 1
await tractor.to_actor.run(
partial(
spawn_until,
an=an,
depth=depth,
),
an=an,
name=f'spawn_until_{depth}',
)
@ -90,18 +92,22 @@ async def main():
tn.start_soon(
partial(
tractor.to_actor.run,
partial(
spawn_until,
an=an,
depth=3,
),
an=an,
name='spawner0',
)
)
tn.start_soon(
partial(
tractor.to_actor.run,
partial(
spawn_until,
an=an,
depth=4,
),
an=an,
name='spawner1',
)
)

View File

@ -18,9 +18,11 @@ async def spawn_until(depth=0):
else:
depth -= 1
await tractor.to_actor.run(
partial(
spawn_until,
an=an,
depth=depth,
),
an=an,
name=f'spawn_until_{depth}',
)
@ -51,9 +53,11 @@ async def main():
tn.start_soon(
partial(
tractor.to_actor.run,
partial(
spawn_until,
an=an,
depth=1,
),
an=an,
name='spawner1',
)
)
@ -61,9 +65,11 @@ async def main():
# ..while blocking on the shallow (faster to fail) tree
# whose propagated error triggers nursery cancellation.
await tractor.to_actor.run(
partial(
spawn_until,
an=an,
depth=0,
),
an=an,
name='spawner0',
)

View File

@ -248,10 +248,12 @@ def test_dynamic_pub_sub(
tn.start_soon(
partial(
tractor.to_actor.run,
partial(
consumer,
subs=[sub],
),
an=an,
name=f'consumer_{sub}',
subs=[sub],
)
)
@ -259,10 +261,12 @@ def test_dynamic_pub_sub(
tn.start_soon(
partial(
tractor.to_actor.run,
partial(
consumer,
subs=list(_registry.keys()),
),
an=an,
name='consumer_dynamic',
subs=list(_registry.keys()),
)
)

View File

@ -104,7 +104,7 @@ async def do_nuthin():
[
# expected to be thrown in assert_err
({}, AssertionError),
# argument mismatch raised in _invoke()
# argument mismatch rejected locally before spawn
({'unexpected': 10}, TypeError)
],
ids=['no_args', 'unexpected_args'],
@ -130,48 +130,34 @@ def test_remote_error(
# `to_actor.run()` blocks on the one-shot's result and
# raises the remote error directly here in the caller's
# task (a bad-arg `TypeError` likewise relays as a
# `RemoteActorError`).
# task. Invalid target args fail local signature binding
# before any one-shot actor is spawned.
try:
await tractor.to_actor.run(
assert_err,
partial(assert_err, **args),
an=an,
name='errorer',
**args
)
except tractor.RemoteActorError as err:
assert err.boxed_type == errtype
print("Look Maa that actor failed hard, hehh")
raise
# ensure boxed errors
# Invalid args never cross the process boundary.
if args:
with pytest.raises(tractor.RemoteActorError) as excinfo:
with pytest.raises(errtype):
trio.run(main)
else:
# The linked one-shot raises the child's boxed error
# directly in this caller task.
with pytest.raises(
tractor.RemoteActorError,
) as excinfo:
trio.run(main)
assert excinfo.value.boxed_type == errtype
else:
# the root task will also error on the `Portal.result()`
# call so we expect an error from there AND the child.
# |_ tho seems like on new `trio` this doesn't always
# happen?
with pytest.raises((
BaseExceptionGroup,
tractor.RemoteActorError,
)) as excinfo:
trio.run(main)
# ensure boxed errors are `errtype`
err: BaseException = excinfo.value
if isinstance(err, BaseExceptionGroup):
suberrs: list[BaseException] = err.exceptions
else:
suberrs: list[BaseException] = [err]
for exc in suberrs:
assert exc.boxed_type == errtype
def test_multierror(
reg_addr: tuple[str, int],
@ -391,10 +377,9 @@ async def test_some_cancels_all(
tn.start_soon(
partial(
tractor.to_actor.run,
func,
partial(func, **kwargs),
an=an,
name=f'actor_{i}',
**kwargs,
)
)
@ -469,12 +454,14 @@ async def spawn_and_error(
if depth > 0:
args = (
partial(
spawn_and_error,
breadth=breadth,
depth=depth - 1,
),
)
kwargs = {
'name': f'spawner_{i}_depth_{depth}',
'breadth': breadth,
'depth': depth - 1,
}
else:
args = (
@ -705,11 +692,13 @@ async def test_nested_multierrors(
tn.start_soon(
partial(
tractor.to_actor.run,
partial(
spawn_and_error,
an=an,
name=f'spawner_{i}',
breadth=subactor_breadth,
depth=depth,
),
an=an,
name=f'spawner_{i}',
)
)
except (

View File

@ -5,7 +5,7 @@ The hipster way to force SC onto the stdlib's "async": 'infection mode'.
import asyncio
import builtins
from contextlib import ExitStack
# from functools import partial
from functools import partial
import itertools
import importlib
import os
@ -209,10 +209,12 @@ def test_aio_simple_error(
debug_mode=debug_mode,
) as an:
await to_actor.run(
partial(
asyncio_actor,
an=an,
target='sleep_and_err',
expect_err='AssertionError',
),
an=an,
infect_asyncio=True,
)
@ -455,10 +457,14 @@ def test_aio_cancelled_from_aio_causes_trio_cancelled(
# relays the remote error here in the caller's task.
with trio.fail_after(1 + delay):
await to_actor.run(
partial(
asyncio_actor,
an=an,
target='aio_cancel',
expect_err='tractor.to_asyncio.AsyncioCancelled',
expect_err=(
'tractor.to_asyncio.AsyncioCancelled'
),
),
an=an,
infect_asyncio=True,
)
@ -663,10 +669,12 @@ def test_basic_interloop_channel_stream(
) as an:
# should raise RAE diectly
await to_actor.run(
partial(
stream_from_aio,
fan_out=fan_out,
),
an=an,
infect_asyncio=True,
fan_out=fan_out,
)
trio.run(main)
@ -682,9 +690,11 @@ def test_trio_error_cancels_intertask_chan(
) as an:
# should trigger remote actor error
await to_actor.run(
partial(
stream_from_aio,
an=an,
trio_raise_err=True,
),
an=an,
infect_asyncio=True,
)
@ -719,9 +729,11 @@ def test_trio_closes_early_causes_aio_checkpoint_raise(
# should raise RAE diectly
print('waiting on final infected subactor result..')
res: None = await to_actor.run(
partial(
stream_from_aio,
an=an,
trio_exit_early=True,
),
an=an,
infect_asyncio=True,
)
assert res is None
@ -770,11 +782,13 @@ def test_aio_exits_early_relays_AsyncioTaskExited(
# should raise RAE diectly
print('waiting on final infected subactor result..')
res: None = await to_actor.run(
partial(
stream_from_aio,
an=an,
infect_asyncio=True,
trio_exit_early=False,
aio_exit_early=True,
),
an=an,
infect_asyncio=True,
)
assert res is None
print(f'infected subactor returned result: {res!r}\n')
@ -809,9 +823,11 @@ def test_aio_errors_and_channel_propagates_and_closes(
) as an:
# should trigger RAE directly, not an eg.
await to_actor.run(
partial(
stream_from_aio,
an=an,
aio_raise_err=True,
),
an=an,
infect_asyncio=True,
)

View File

@ -4,6 +4,7 @@ related API and error checks.
'''
import itertools
from functools import partial
from unittest.mock import (
AsyncMock,
Mock,
@ -239,19 +240,21 @@ def test_rpc_errors(
actor = tractor.current_actor()
assert actor.is_registrar
await tractor.to_actor.run(
partial(
sleep_back_actor,
an=an,
actor_name=subactor_requests_to,
func_name=funcname,
func_defined=bool(func_defined),
exposed_mods=exposed_mods,
reg_addr=reg_addr,
),
an=an,
name='subactor',
# function from the local exposed module space
# the subactor will invoke when it RPCs back to this actor
func_name=funcname,
exposed_mods=exposed_mods,
func_defined=True if func_defined else False,
# Function from the local exposed module space the
# subactor invokes when it RPCs back to this actor.
enable_modules=subactor_exposed_mods,
reg_addr=reg_addr,
)
def run():

View File

@ -2,6 +2,7 @@
Verifying internal runtime state and undocumented extras.
"""
from functools import partial
import os
import pytest
@ -84,10 +85,12 @@ async def test_lifetime_stack_wipes_tmpfile(
loglevel=loglevel,
) as an:
await tractor.to_actor.run(
partial(
crash_and_clean_tmpdir,
an=an,
tmp_file_path=path,
error=error_in_child,
),
an=an,
)
except (
tractor.RemoteActorError,

View File

@ -51,18 +51,18 @@ async def spawn(
# recursively spawn this same `spawn()` fn as the lone
# task of a one-shot child subactor and get its result.
result = await tractor.to_actor.run(
partial(
spawn,
should_be_root=False,
data=data_to_pass_down,
reg_addr=reg_addr,
),
an=an,
# spawning args
name='sub-actor',
enable_modules=[__name__],
# passed to a subactor-recursive RPC invoke
# of this same `spawn()` fn.
should_be_root=False,
data=data_to_pass_down,
reg_addr=reg_addr,
)
assert result == 10
return result
@ -152,9 +152,11 @@ async def test_most_beautiful_word(
debug_mode=debug_mode,
) as an:
res: Any = await tractor.to_actor.run(
partial(
cellar_door,
an=an,
return_value=return_value,
),
an=an,
name='some_linguist',
)
assert res == return_value
@ -204,10 +206,12 @@ def test_loglevel_propagated_to_subactor(
) as an:
await tractor.to_actor.run(
partial(
check_loglevel,
level=level,
),
an=an,
loglevel=level,
level=level,
)
trio.run(main)
@ -273,19 +277,23 @@ def test_to_actor_run_can_skip_parent_main_inheritance(
# Default: child receives parent __main__ bootstrap data
await tractor.to_actor.run(
partial(
check_parent_main_inheritance,
expect_inherited=True,
),
an=an,
name='replaying-parent-main',
expect_inherited=True,
)
# Opt-out: child gets no parent __main__ data
await tractor.to_actor.run(
partial(
check_parent_main_inheritance,
expect_inherited=False,
),
an=an,
name='isolated-parent-main',
inherit_parent_main=False,
expect_inherited=False,
)
trio.run(main)