Compare commits

..

No commits in common. "main" and "runtime_to_msgspec" have entirely different histories.

70 changed files with 2527 additions and 6751 deletions

View File

@ -62,9 +62,7 @@ async def recv_and_spawn_net_killers(
await ctx.started() await ctx.started()
async with ( async with (
ctx.open_stream() as stream, ctx.open_stream() as stream,
trio.open_nursery( trio.open_nursery() as n,
strict_exception_groups=False,
) as tn,
): ):
async for i in stream: async for i in stream:
print(f'child echoing {i}') print(f'child echoing {i}')
@ -79,11 +77,11 @@ async def recv_and_spawn_net_killers(
i >= break_ipc_after i >= break_ipc_after
): ):
broke_ipc = True broke_ipc = True
tn.start_soon( n.start_soon(
iter_ipc_stream, iter_ipc_stream,
stream, stream,
) )
tn.start_soon( n.start_soon(
partial( partial(
break_ipc_then_error, break_ipc_then_error,
stream=stream, stream=stream,

View File

@ -1,16 +1,8 @@
'''
Examples of using the builtin `breakpoint()` from an `asyncio.Task`
running in a subactor spawned with `infect_asyncio=True`.
'''
import asyncio import asyncio
import trio import trio
import tractor import tractor
from tractor import ( from tractor import to_asyncio
to_asyncio,
Portal,
)
async def aio_sleep_forever(): async def aio_sleep_forever():
@ -25,21 +17,21 @@ async def bp_then_error(
) -> None: ) -> None:
# sync with `trio`-side (caller) task # sync with ``trio``-side (caller) task
to_trio.send_nowait('start') to_trio.send_nowait('start')
# NOTE: what happens here inside the hook needs some refinement.. # NOTE: what happens here inside the hook needs some refinement..
# => seems like it's still `._debug._set_trace()` but # => seems like it's still `._debug._set_trace()` but
# we set `Lock.local_task_in_debug = 'sync'`, we probably want # we set `Lock.local_task_in_debug = 'sync'`, we probably want
# some further, at least, meta-data about the task/actor in debug # some further, at least, meta-data about the task/actoq in debug
# in terms of making it clear it's `asyncio` mucking about. # in terms of making it clear it's asyncio mucking about.
breakpoint() # asyncio-side breakpoint()
# short checkpoint / delay # short checkpoint / delay
await asyncio.sleep(0.5) # asyncio-side await asyncio.sleep(0.5)
if raise_after_bp: if raise_after_bp:
raise ValueError('asyncio side error!') raise ValueError('blah')
# TODO: test case with this so that it gets cancelled? # TODO: test case with this so that it gets cancelled?
else: else:
@ -57,21 +49,23 @@ async def trio_ctx(
# this will block until the ``asyncio`` task sends a "first" # this will block until the ``asyncio`` task sends a "first"
# message, see first line in above func. # message, see first line in above func.
async with ( async with (
to_asyncio.open_channel_from( to_asyncio.open_channel_from(
bp_then_error, bp_then_error,
# raise_after_bp=not bp_before_started, raise_after_bp=not bp_before_started,
) as (first, chan), ) as (first, chan),
trio.open_nursery() as tn, trio.open_nursery() as n,
): ):
assert first == 'start' assert first == 'start'
if bp_before_started: if bp_before_started:
await tractor.pause() # trio-side await tractor.breakpoint()
await ctx.started(first) # trio-side await ctx.started(first)
tn.start_soon( n.start_soon(
to_asyncio.run_task, to_asyncio.run_task,
aio_sleep_forever, aio_sleep_forever,
) )
@ -79,50 +73,39 @@ async def trio_ctx(
async def main( async def main(
bps_all_over: bool = True, bps_all_over: bool = False,
# TODO, WHICH OF THESE HAZ BUGZ?
cancel_from_root: bool = False,
err_from_root: bool = False,
) -> None: ) -> None:
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, # debug_mode=True,
maybe_enable_greenback=True, ) as n:
# loglevel='devx',
) as an: p = await n.start_actor(
ptl: Portal = await an.start_actor(
'aio_daemon', 'aio_daemon',
enable_modules=[__name__], enable_modules=[__name__],
infect_asyncio=True, infect_asyncio=True,
debug_mode=True, debug_mode=True,
# loglevel='cancel', loglevel='cancel',
) )
async with ptl.open_context( async with p.open_context(
trio_ctx, trio_ctx,
bp_before_started=bps_all_over, bp_before_started=bps_all_over,
) as (ctx, first): ) as (ctx, first):
assert first == 'start' assert first == 'start'
# pause in parent to ensure no cross-actor if bps_all_over:
# locking problems exist! await tractor.breakpoint()
await tractor.pause() # trio-root
if cancel_from_root:
await ctx.cancel()
if err_from_root:
assert 0
else:
await trio.sleep_forever()
# await trio.sleep_forever()
await ctx.cancel()
assert 0
# TODO: case where we cancel from trio-side while asyncio task # TODO: case where we cancel from trio-side while asyncio task
# has debugger lock? # has debugger lock?
# await ptl.cancel_actor() # await p.cancel_actor()
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -1,5 +1,5 @@
''' '''
Fast fail test with a `Context`. Fast fail test with a context.
Ensure the partially initialized sub-actor process Ensure the partially initialized sub-actor process
doesn't cause a hang on error/cancel of the parent doesn't cause a hang on error/cancel of the parent

View File

@ -7,7 +7,7 @@ async def breakpoint_forever():
try: try:
while True: while True:
yield 'yo' yield 'yo'
await tractor.pause() await tractor.breakpoint()
except BaseException: except BaseException:
tractor.log.get_console_log().exception( tractor.log.get_console_log().exception(
'Cancelled while trying to enter pause point!' 'Cancelled while trying to enter pause point!'
@ -21,14 +21,11 @@ async def name_error():
async def main(): async def main():
''' """Test breakpoint in a streaming actor.
Test breakpoint in a streaming actor. """
'''
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
loglevel='cancel', loglevel='cancel',
# loglevel='devx',
) as n: ) as n:
p0 = await n.start_actor('bp_forever', enable_modules=[__name__]) p0 = await n.start_actor('bp_forever', enable_modules=[__name__])

View File

@ -10,7 +10,7 @@ async def name_error():
async def breakpoint_forever(): async def breakpoint_forever():
"Indefinitely re-enter debugger in child actor." "Indefinitely re-enter debugger in child actor."
while True: while True:
await tractor.pause() await tractor.breakpoint()
# NOTE: if the test never sent 'q'/'quit' commands # NOTE: if the test never sent 'q'/'quit' commands
# on the pdb repl, without this checkpoint line the # on the pdb repl, without this checkpoint line the

View File

@ -40,7 +40,7 @@ async def main():
""" """
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
loglevel='devx', # loglevel='cancel',
) as n: ) as n:
# spawn both actors # spawn both actors

View File

@ -6,7 +6,7 @@ async def breakpoint_forever():
"Indefinitely re-enter debugger in child actor." "Indefinitely re-enter debugger in child actor."
while True: while True:
await trio.sleep(0.1) await trio.sleep(0.1)
await tractor.pause() await tractor.breakpoint()
async def name_error(): async def name_error():

View File

@ -6,44 +6,19 @@ import tractor
async def main() -> None: async def main() -> None:
async with tractor.open_nursery(debug_mode=True) as an:
# intially unset, no entry. assert os.environ['PYTHONBREAKPOINT'] == 'tractor._debug._set_trace'
orig_pybp_var: int = os.environ.get('PYTHONBREAKPOINT')
assert orig_pybp_var in {None, "0"}
async with tractor.open_nursery(
debug_mode=True,
) as an:
assert an
assert (
(pybp_var := os.environ['PYTHONBREAKPOINT'])
==
'tractor.devx._debug._sync_pause_from_builtin'
)
# TODO: an assert that verifies the hook has indeed been, hooked # TODO: an assert that verifies the hook has indeed been, hooked
# XD # XD
assert ( assert sys.breakpointhook is not tractor._debug._set_trace
(pybp_hook := sys.breakpointhook)
is not tractor.devx._debug._set_trace
)
print( breakpoint()
f'$PYTHONOBREAKPOINT: {pybp_var!r}\n'
f'`sys.breakpointhook`: {pybp_hook!r}\n'
)
breakpoint() # first bp, tractor hook set.
# XXX AFTER EXIT (of actor-runtime) verify the hook is unset.. # TODO: an assert that verifies the hook is unhooked..
#
# YES, this is weird but it's how stdlib docs say to do it..
# https://docs.python.org/3/library/sys.html#sys.breakpointhook
assert os.environ.get('PYTHONBREAKPOINT') is orig_pybp_var
assert sys.breakpointhook assert sys.breakpointhook
breakpoint()
# now ensure a regular builtin pause still works
breakpoint() # last bp, stdlib hook restored
if __name__ == '__main__': if __name__ == '__main__':
trio.run(main) trio.run(main)

View File

@ -10,7 +10,7 @@ async def main():
await trio.sleep(0.1) await trio.sleep(0.1)
await tractor.pause() await tractor.breakpoint()
await trio.sleep(0.1) await trio.sleep(0.1)

View File

@ -11,7 +11,7 @@ async def main(
# loglevel='runtime', # loglevel='runtime',
): ):
while True: while True:
await tractor.pause() await tractor.breakpoint()
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -1,83 +0,0 @@
'''
Verify we can dump a `stackscope` tree on a hang.
'''
import os
import signal
import trio
import tractor
@tractor.context
async def start_n_shield_hang(
ctx: tractor.Context,
):
# actor: tractor.Actor = tractor.current_actor()
# sync to parent-side task
await ctx.started(os.getpid())
print('Entering shield sleep..')
with trio.CancelScope(shield=True):
await trio.sleep_forever() # in subactor
# XXX NOTE ^^^ since this shields, we expect
# the zombie reaper (aka T800) to engage on
# SIGINT from the user and eventually hard-kill
# this subprocess!
async def main(
from_test: bool = False,
) -> None:
async with (
tractor.open_nursery(
debug_mode=True,
enable_stack_on_sig=True,
# maybe_enable_greenback=False,
loglevel='devx',
) as an,
):
ptl: tractor.Portal = await an.start_actor(
'hanger',
enable_modules=[__name__],
debug_mode=True,
)
async with ptl.open_context(
start_n_shield_hang,
) as (ctx, cpid):
_, proc, _ = an._children[ptl.chan.uid]
assert cpid == proc.pid
print(
'Yo my child hanging..?\n'
# "i'm a user who wants to see a `stackscope` tree!\n"
)
# XXX simulate the wrapping test's "user actions"
# (i.e. if a human didn't run this manually but wants to
# know what they should do to reproduce test behaviour)
if from_test:
print(
f'Sending SIGUSR1 to {cpid!r}!\n'
)
os.kill(
cpid,
signal.SIGUSR1,
)
# simulate user cancelling program
await trio.sleep(0.5)
os.kill(
os.getpid(),
signal.SIGINT,
)
else:
# actually let user send the ctl-c
await trio.sleep_forever() # in root
if __name__ == '__main__':
trio.run(main)

View File

@ -4,9 +4,9 @@ import trio
async def gen(): async def gen():
yield 'yo' yield 'yo'
await tractor.pause() await tractor.breakpoint()
yield 'yo' yield 'yo'
await tractor.pause() await tractor.breakpoint()
@tractor.context @tractor.context
@ -15,7 +15,7 @@ async def just_bp(
) -> None: ) -> None:
await ctx.started() await ctx.started()
await tractor.pause() await tractor.breakpoint()
# TODO: bps and errors in this call.. # TODO: bps and errors in this call..
async for val in gen(): async for val in gen():

View File

@ -4,13 +4,6 @@ import time
import trio import trio
import tractor import tractor
# TODO: only import these when not running from test harness?
# can we detect `pexpect` usage maybe?
# from tractor.devx._debug import (
# get_lock,
# get_debug_req,
# )
def sync_pause( def sync_pause(
use_builtin: bool = False, use_builtin: bool = False,
@ -25,13 +18,7 @@ def sync_pause(
breakpoint(hide_tb=hide_tb) breakpoint(hide_tb=hide_tb)
else: else:
# TODO: maybe for testing some kind of cm style interface
# where the `._set_trace()` call doesn't happen until block
# exit?
# assert get_lock().ctx_in_debug is None
# assert get_debug_req().repl is None
tractor.pause_from_sync() tractor.pause_from_sync()
# assert get_debug_req().repl is None
if error: if error:
raise RuntimeError('yoyo sync code error') raise RuntimeError('yoyo sync code error')
@ -54,11 +41,10 @@ async def start_n_sync_pause(
async def main() -> None: async def main() -> None:
async with ( async with (
tractor.open_nursery( tractor.open_nursery(
debug_mode=True, # NOTE: required for pausing from sync funcs
maybe_enable_greenback=True, maybe_enable_greenback=True,
enable_stack_on_sig=True, debug_mode=True,
# loglevel='warning', # loglevel='cancel',
# loglevel='devx',
) as an, ) as an,
trio.open_nursery() as tn, trio.open_nursery() as tn,
): ):
@ -152,9 +138,7 @@ async def main() -> None:
# the case 2. from above still exists! # the case 2. from above still exists!
use_builtin=True, use_builtin=True,
), ),
# TODO: with this `False` we can hang!??! abandon_on_cancel=False,
# abandon_on_cancel=False,
abandon_on_cancel=True,
thread_name='inline_root_bg_thread', thread_name='inline_root_bg_thread',
) )

View File

@ -91,7 +91,7 @@ async def main() -> list[int]:
an: ActorNursery an: ActorNursery
async with tractor.open_nursery( async with tractor.open_nursery(
loglevel='cancel', loglevel='cancel',
# debug_mode=True, debug_mode=True,
) as an: ) as an:
seed = int(1e3) seed = int(1e3)

View File

@ -3,18 +3,20 @@ import trio
import tractor import tractor
async def sleepy_jane() -> None: async def sleepy_jane():
uid: tuple = tractor.current_actor().uid uid = tractor.current_actor().uid
print(f'Yo i am actor {uid}') print(f'Yo i am actor {uid}')
await trio.sleep_forever() await trio.sleep_forever()
async def main(): async def main():
''' '''
Spawn a flat actor cluster, with one process per detected core. Spawn a flat actor cluster, with one process per
detected core.
''' '''
portal_map: dict[str, tractor.Portal] portal_map: dict[str, tractor.Portal]
results: dict[str, str]
# look at this hip new syntax! # look at this hip new syntax!
async with ( async with (
@ -23,16 +25,11 @@ async def main():
modules=[__name__] modules=[__name__]
) as portal_map, ) as portal_map,
trio.open_nursery( trio.open_nursery() as n,
strict_exception_groups=False,
) as tn,
): ):
for (name, portal) in portal_map.items(): for (name, portal) in portal_map.items():
tn.start_soon( n.start_soon(portal.run, sleepy_jane)
portal.run,
sleepy_jane,
)
await trio.sleep(0.5) await trio.sleep(0.5)
@ -44,4 +41,4 @@ if __name__ == '__main__':
try: try:
trio.run(main) trio.run(main)
except KeyboardInterrupt: except KeyboardInterrupt:
print('trio cancelled by KBI') pass

View File

@ -1,18 +0,0 @@
First generate a built disti:
```
python -m pip install --upgrade build
python -m build --sdist --outdir dist/alpha5/
```
Then try a test ``pypi`` upload:
```
python -m twine upload --repository testpypi dist/alpha5/*
```
The push to `pypi` for realz.
```
python -m twine upload --repository testpypi dist/alpha5/*
```

View File

@ -37,14 +37,16 @@ dependencies = [
# https://packaging.python.org/en/latest/discussions/install-requires-vs-requirements/#id5 # https://packaging.python.org/en/latest/discussions/install-requires-vs-requirements/#id5
# TODO, for 3.13 we must go go `0.27` which means we have to # TODO, for 3.13 we must go go `0.27` which means we have to
# disable strict egs or port to handling them internally! # disable strict egs or port to handling them internally!
"trio>0.27", # trio='^0.27'
"trio>=0.24,<0.25",
"tricycle>=0.4.1,<0.5", "tricycle>=0.4.1,<0.5",
"wrapt>=1.16.0,<2", "wrapt>=1.16.0,<2",
"colorlog>=6.8.2,<7", "colorlog>=6.8.2,<7",
# built-in multi-actor `pdb` REPL # built-in multi-actor `pdb` REPL
"pdbp>=1.6,<2", # windows only (from `pdbp`) "pdbp>=1.5.0,<2",
# typed IPC msging # typed IPC msging
"msgspec>=0.19.0", # TODO, get back on release once 3.13 support is out!
"msgspec",
] ]
# ------ project ------ # ------ project ------
@ -54,14 +56,18 @@ dev = [
# test suite # test suite
# TODO: maybe some of these layout choices? # TODO: maybe some of these layout choices?
# https://docs.pytest.org/en/8.0.x/explanation/goodpractices.html#choosing-a-test-layout-import-rules # https://docs.pytest.org/en/8.0.x/explanation/goodpractices.html#choosing-a-test-layout-import-rules
"pytest>=8.3.5", "pytest>=8.2.0,<9",
"pexpect>=4.9.0,<5", "pexpect>=4.9.0,<5",
# `tractor.devx` tooling # `tractor.devx` tooling
"greenback>=1.2.1,<2", "greenback>=1.2.1,<2",
"stackscope>=0.2.2,<0.3", "stackscope>=0.2.2,<0.3",
# xonsh usage/integration (namely as @goodboy's sh of choice Bp)
"xonsh>=0.19.1",
"xontrib-vox>=0.0.1,<0.0.2",
"prompt-toolkit>=3.0.43,<4",
"xonsh-vox-tabcomplete>=0.5,<0.6",
"pyperclip>=1.9.0", "pyperclip>=1.9.0",
"prompt-toolkit>=3.0.50",
"xonsh>=0.19.2",
] ]
# TODO, add these with sane versions; were originally in # TODO, add these with sane versions; were originally in
# `requirements-docs.txt`.. # `requirements-docs.txt`..
@ -72,39 +78,21 @@ dev = [
# ------ dependency-groups ------ # ------ dependency-groups ------
# ------ dependency-groups ------
[tool.uv.sources] [tool.uv.sources]
# XXX NOTE, only for @goodboy's hacking on `pprint(sort_dicts=False)` msgspec = { git = "https://github.com/jcrist/msgspec.git" }
# for the `pp` alias..
# pdbp = { path = "../pdbp", editable = true }
# ------ tool.uv.sources ------ # ------ tool.uv.sources ------
# TODO, distributed (multi-host) extensions # TODO, distributed (multi-host) extensions
# linux kernel networking # linux kernel networking
# 'pyroute2 # 'pyroute2
# ------ tool.uv.sources ------
[tool.uv]
# XXX NOTE, prefer the sys python bc apparently the distis from
# `astral` are built in a way that breaks `pdbp`+`tabcompleter`'s
# likely due to linking against `libedit` over `readline`..
# |_https://docs.astral.sh/uv/concepts/python-versions/#managed-python-distributions
# |_https://gregoryszorc.com/docs/python-build-standalone/main/quirks.html#use-of-libedit-on-linux
#
# https://docs.astral.sh/uv/reference/settings/#python-preference
python-preference = 'system'
# ------ tool.uv ------
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
include = ["tractor"] include = ["tractor"]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
include = ["tractor"] include = ["tractor"]
# ------ tool.hatch ------ # ------ dependency-groups ------
[tool.towncrier] [tool.towncrier]
package = "tractor" package = "tractor"
@ -154,5 +142,3 @@ log_cli = false
# TODO: maybe some of these layout choices? # TODO: maybe some of these layout choices?
# https://docs.pytest.org/en/8.0.x/explanation/goodpractices.html#choosing-a-test-layout-import-rules # https://docs.pytest.org/en/8.0.x/explanation/goodpractices.html#choosing-a-test-layout-import-rules
# pythonpath = "src" # pythonpath = "src"
# ------ tool.pytest ------

View File

@ -75,10 +75,7 @@ def pytest_configure(config):
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def debug_mode(request): def debug_mode(request):
debug_mode: bool = request.config.option.tractor_debug_mode return request.config.option.tractor_debug_mode
# if debug_mode:
# breakpoint()
return debug_mode
@pytest.fixture(scope='session', autouse=True) @pytest.fixture(scope='session', autouse=True)
@ -95,12 +92,6 @@ def spawn_backend(request) -> str:
return request.config.option.spawn_backend return request.config.option.spawn_backend
# @pytest.fixture(scope='function', autouse=True)
# def debug_enabled(request) -> str:
# from tractor import _state
# if _state._runtime_vars['_debug_mode']:
# breakpoint()
_ci_env: bool = os.environ.get('CI', False) _ci_env: bool = os.environ.get('CI', False)
@ -159,18 +150,6 @@ def pytest_generate_tests(metafunc):
metafunc.parametrize("start_method", [spawn_backend], scope='module') metafunc.parametrize("start_method", [spawn_backend], scope='module')
# TODO: a way to let test scripts (like from `examples/`)
# guarantee they won't registry addr collide!
# @pytest.fixture
# def open_test_runtime(
# reg_addr: tuple,
# ) -> AsyncContextManager:
# return partial(
# tractor.open_nursery,
# registry_addrs=[reg_addr],
# )
def sig_prog(proc, sig): def sig_prog(proc, sig):
"Kill the actor-process with ``sig``." "Kill the actor-process with ``sig``."
proc.send_signal(sig) proc.send_signal(sig)

View File

View File

@ -1,243 +0,0 @@
'''
`tractor.devx.*` tooling sub-pkg test space.
'''
import time
from typing import (
Callable,
)
import pytest
from pexpect.exceptions import (
TIMEOUT,
)
from pexpect.spawnbase import SpawnBase
from tractor._testing import (
mk_cmd,
)
from tractor.devx._debug import (
_pause_msg as _pause_msg,
_crash_msg as _crash_msg,
_repl_fail_msg as _repl_fail_msg,
_ctlc_ignore_header as _ctlc_ignore_header,
)
from ..conftest import (
_ci_env,
)
@pytest.fixture
def spawn(
start_method,
testdir: pytest.Pytester,
reg_addr: tuple[str, int],
) -> Callable[[str], None]:
'''
Use the `pexpect` module shipped via `testdir.spawn()` to
run an `./examples/..` script by name.
'''
if start_method != 'trio':
pytest.skip(
'`pexpect` based tests only supported on `trio` backend'
)
def unset_colors():
'''
Python 3.13 introduced colored tracebacks that break patt
matching,
https://docs.python.org/3/using/cmdline.html#envvar-PYTHON_COLORS
https://docs.python.org/3/using/cmdline.html#using-on-controlling-color
'''
import os
os.environ['PYTHON_COLORS'] = '0'
def _spawn(
cmd: str,
**mkcmd_kwargs,
):
unset_colors()
return testdir.spawn(
cmd=mk_cmd(
cmd,
**mkcmd_kwargs,
),
expect_timeout=3,
# preexec_fn=unset_colors,
# ^TODO? get `pytest` core to expose underlying
# `pexpect.spawn()` stuff?
)
# such that test-dep can pass input script name.
return _spawn
@pytest.fixture(
params=[False, True],
ids='ctl-c={}'.format,
)
def ctlc(
request,
ci_env: bool,
) -> bool:
use_ctlc = request.param
node = request.node
markers = node.own_markers
for mark in markers:
if mark.name == 'has_nested_actors':
pytest.skip(
f'Test {node} has nested actors and fails with Ctrl-C.\n'
f'The test can sometimes run fine locally but until'
' we solve' 'this issue this CI test will be xfail:\n'
'https://github.com/goodboy/tractor/issues/320'
)
if mark.name == 'ctlcs_bish':
pytest.skip(
f'Test {node} prolly uses something from the stdlib (namely `asyncio`..)\n'
f'The test and/or underlying example script can *sometimes* run fine '
f'locally but more then likely until the cpython peeps get their sh#$ together, '
f'this test will definitely not behave like `trio` under SIGINT..\n'
)
if use_ctlc:
# XXX: disable pygments highlighting for auto-tests
# since some envs (like actions CI) will struggle
# the the added color-char encoding..
from tractor.devx._debug import TractorConfig
TractorConfig.use_pygements = False
yield use_ctlc
def expect(
child,
# normally a `pdb` prompt by default
patt: str,
**kwargs,
) -> None:
'''
Expect wrapper that prints last seen console
data before failing.
'''
try:
child.expect(
patt,
**kwargs,
)
except TIMEOUT:
before = str(child.before.decode())
print(before)
raise
PROMPT = r"\(Pdb\+\)"
def in_prompt_msg(
child: SpawnBase,
parts: list[str],
pause_on_false: bool = False,
err_on_false: bool = False,
print_prompt_on_false: bool = True,
) -> bool:
'''
Predicate check if (the prompt's) std-streams output has all
`str`-parts in it.
Can be used in test asserts for bulk matching expected
log/REPL output for a given `pdb` interact point.
'''
__tracebackhide__: bool = False
before: str = str(child.before.decode())
for part in parts:
if part not in before:
if pause_on_false:
import pdbp
pdbp.set_trace()
if print_prompt_on_false:
print(before)
if err_on_false:
raise ValueError(
f'Could not find pattern in `before` output?\n'
f'part: {part!r}\n'
)
return False
return True
# TODO: todo support terminal color-chars stripping so we can match
# against call stack frame output from the the 'll' command the like!
# -[ ] SO answer for stipping ANSI codes: https://stackoverflow.com/a/14693789
def assert_before(
child: SpawnBase,
patts: list[str],
**kwargs,
) -> None:
__tracebackhide__: bool = False
assert in_prompt_msg(
child=child,
parts=patts,
# since this is an "assert" helper ;)
err_on_false=True,
**kwargs
)
def do_ctlc(
child,
count: int = 3,
delay: float = 0.1,
patt: str|None = None,
# expect repl UX to reprint the prompt after every
# ctrl-c send.
# XXX: no idea but, in CI this never seems to work even on 3.10 so
# needs some further investigation potentially...
expect_prompt: bool = not _ci_env,
) -> str|None:
before: str|None = None
# make sure ctl-c sends don't do anything but repeat output
for _ in range(count):
time.sleep(delay)
child.sendcontrol('c')
# TODO: figure out why this makes CI fail..
# if you run this test manually it works just fine..
if expect_prompt:
time.sleep(delay)
child.expect(PROMPT)
before = str(child.before.decode())
time.sleep(delay)
if patt:
# should see the last line on console
assert patt in before
# return the console content up to the final prompt
return before

View File

@ -1,381 +0,0 @@
'''
That "foreign loop/thread" debug REPL support better ALSO WORK!
Same as `test_native_pause.py`.
All these tests can be understood (somewhat) by running the
equivalent `examples/debugging/` scripts manually.
'''
from contextlib import (
contextmanager as cm,
)
# from functools import partial
# import itertools
import time
# from typing import (
# Iterator,
# )
import pytest
from pexpect.exceptions import (
TIMEOUT,
EOF,
)
from .conftest import (
# _ci_env,
do_ctlc,
PROMPT,
# expect,
in_prompt_msg,
assert_before,
_pause_msg,
_crash_msg,
_ctlc_ignore_header,
# _repl_fail_msg,
)
@cm
def maybe_expect_timeout(
ctlc: bool = False,
) -> None:
try:
yield
except TIMEOUT:
# breakpoint()
if ctlc:
pytest.xfail(
'Some kinda redic threading SIGINT bug i think?\n'
'See the notes in `examples/debugging/sync_bp.py`..\n'
)
raise
@pytest.mark.ctlcs_bish
def test_pause_from_sync(
spawn,
ctlc: bool,
):
'''
Verify we can use the `pdbp` REPL from sync functions AND from
any thread spawned with `trio.to_thread.run_sync()`.
`examples/debugging/sync_bp.py`
'''
child = spawn('sync_bp')
# first `sync_pause()` after nurseries open
child.expect(PROMPT)
assert_before(
child,
[
# pre-prompt line
_pause_msg,
"<Task '__main__.main'",
"('root'",
]
)
if ctlc:
do_ctlc(child)
# ^NOTE^ subactor not spawned yet; don't need extra delay.
child.sendline('c')
# first `await tractor.pause()` inside `p.open_context()` body
child.expect(PROMPT)
# XXX shouldn't see gb loaded message with PDB loglevel!
# assert not in_prompt_msg(
# child,
# ['`greenback` portal opened!'],
# )
# should be same root task
assert_before(
child,
[
_pause_msg,
"<Task '__main__.main'",
"('root'",
]
)
if ctlc:
do_ctlc(
child,
# NOTE: setting this to 0 (or some other sufficient
# small val) can cause the test to fail since the
# `subactor` suffers a race where the root/parent
# sends an actor-cancel prior to it hitting its pause
# point; by def the value is 0.1
delay=0.4,
)
# XXX, fwiw without a brief sleep here the SIGINT might actually
# trigger "subactor" cancellation by its parent before the
# shield-handler is engaged.
#
# => similar to the `delay` input to `do_ctlc()` below, setting
# this too low can cause the test to fail since the `subactor`
# suffers a race where the root/parent sends an actor-cancel
# prior to the context task hitting its pause point (and thus
# engaging the `sigint_shield()` handler in time); this value
# seems be good enuf?
time.sleep(0.6)
# one of the bg thread or subactor should have
# `Lock.acquire()`-ed
# (NOT both, which will result in REPL clobbering!)
attach_patts: dict[str, list[str]] = {
'subactor': [
"'start_n_sync_pause'",
"('subactor'",
],
'inline_root_bg_thread': [
"<Thread(inline_root_bg_thread",
"('root'",
],
'start_soon_root_bg_thread': [
"<Thread(start_soon_root_bg_thread",
"('root'",
],
}
conts: int = 0 # for debugging below matching logic on failure
while attach_patts:
child.sendline('c')
conts += 1
child.expect(PROMPT)
before = str(child.before.decode())
for key in attach_patts:
if key in before:
attach_key: str = key
expected_patts: str = attach_patts.pop(key)
assert_before(
child,
[_pause_msg]
+
expected_patts
)
break
else:
pytest.fail(
f'No keys found?\n\n'
f'{attach_patts.keys()}\n\n'
f'{before}\n'
)
# ensure no other task/threads engaged a REPL
# at the same time as the one that was detected above.
for key, other_patts in attach_patts.copy().items():
assert not in_prompt_msg(
child,
other_patts,
)
if ctlc:
do_ctlc(
child,
patt=attach_key,
# NOTE same as comment above
delay=0.4,
)
child.sendline('c')
# XXX TODO, weird threading bug it seems despite the
# `abandon_on_cancel: bool` setting to
# `trio.to_thread.run_sync()`..
with maybe_expect_timeout(
ctlc=ctlc,
):
child.expect(EOF)
def expect_any_of(
attach_patts: dict[str, list[str]],
child, # what type?
ctlc: bool = False,
prompt: str = _ctlc_ignore_header,
ctlc_delay: float = .4,
) -> list[str]:
'''
Receive any of a `list[str]` of patterns provided in
`attach_patts`.
Used to test racing prompts from multiple actors and/or
tasks using a common root process' `pdbp` REPL.
'''
assert attach_patts
child.expect(PROMPT)
before = str(child.before.decode())
for attach_key in attach_patts:
if attach_key in before:
expected_patts: str = attach_patts.pop(attach_key)
assert_before(
child,
expected_patts
)
break # from for
else:
pytest.fail(
f'No keys found?\n\n'
f'{attach_patts.keys()}\n\n'
f'{before}\n'
)
# ensure no other task/threads engaged a REPL
# at the same time as the one that was detected above.
for key, other_patts in attach_patts.copy().items():
assert not in_prompt_msg(
child,
other_patts,
)
if ctlc:
do_ctlc(
child,
patt=prompt,
# NOTE same as comment above
delay=ctlc_delay,
)
return expected_patts
@pytest.mark.ctlcs_bish
def test_sync_pause_from_aio_task(
spawn,
ctlc: bool
# ^TODO, fix for `asyncio`!!
):
'''
Verify we can use the `pdbp` REPL from an `asyncio.Task` spawned using
APIs in `.to_asyncio`.
`examples/debugging/asycio_bp.py`
'''
child = spawn('asyncio_bp')
# RACE on whether trio/asyncio task bps first
attach_patts: dict[str, list[str]] = {
# first pause in guest-mode (aka "infecting")
# `trio.Task`.
'trio-side': [
_pause_msg,
"<Task 'trio_ctx'",
"('aio_daemon'",
],
# `breakpoint()` from `asyncio.Task`.
'asyncio-side': [
_pause_msg,
"<Task pending name='Task-2' coro=<greenback_shim()",
"('aio_daemon'",
],
}
while attach_patts:
expect_any_of(
attach_patts=attach_patts,
child=child,
ctlc=ctlc,
)
child.sendline('c')
# NOW in race order,
# - the asyncio-task will error
# - the root-actor parent task will pause
#
attach_patts: dict[str, list[str]] = {
# error raised in `asyncio.Task`
"raise ValueError('asyncio side error!')": [
_crash_msg,
"<Task 'trio_ctx'",
"@ ('aio_daemon'",
"ValueError: asyncio side error!",
# XXX, we no longer show this frame by default!
# 'return await chan.receive()', # `.to_asyncio` impl internals in tb
],
# parent-side propagation via actor-nursery/portal
# "tractor._exceptions.RemoteActorError: remote task raised a 'ValueError'": [
"remote task raised a 'ValueError'": [
_crash_msg,
"src_uid=('aio_daemon'",
"('aio_daemon'",
],
# a final pause in root-actor
"<Task '__main__.main'": [
_pause_msg,
"<Task '__main__.main'",
"('root'",
],
}
while attach_patts:
expect_any_of(
attach_patts=attach_patts,
child=child,
ctlc=ctlc,
)
child.sendline('c')
assert not attach_patts
# final boxed error propagates to root
assert_before(
child,
[
_crash_msg,
"<Task '__main__.main'",
"('root'",
"remote task raised a 'ValueError'",
"ValueError: asyncio side error!",
]
)
if ctlc:
do_ctlc(
child,
# NOTE: setting this to 0 (or some other sufficient
# small val) can cause the test to fail since the
# `subactor` suffers a race where the root/parent
# sends an actor-cancel prior to it hitting its pause
# point; by def the value is 0.1
delay=0.4,
)
child.sendline('c')
# with maybe_expect_timeout():
child.expect(EOF)
def test_sync_pause_from_non_greenbacked_aio_task():
'''
Where the `breakpoint()` caller task is NOT spawned by
`tractor.to_asyncio` and thus never activates
a `greenback.ensure_portal()` beforehand, presumably bc the task
was started by some lib/dep as in often seen in the field.
Ensure sync pausing works when the pause is in,
- the root actor running in infected-mode?
|_ since we don't need any IPC to acquire the debug lock?
|_ is there some way to handle this like the non-main-thread case?
All other cases need to error out appropriately right?
- for any subactor we can't avoid needing the repl lock..
|_ is there a way to hook into `asyncio.ensure_future(obj)`?
'''
pass

View File

@ -1,172 +0,0 @@
'''
That "native" runtime-hackin toolset better be dang useful!
Verify the funtion of a variety of "developer-experience" tools we
offer from the `.devx` sub-pkg:
- use of the lovely `stackscope` for dumping actor `trio`-task trees
during operation and hangs.
TODO:
- demonstration of `CallerInfo` call stack frame filtering such that
for logging and REPL purposes a user sees exactly the layers needed
when debugging a problem inside the stack vs. in their app.
'''
import os
import signal
import time
from .conftest import (
expect,
assert_before,
in_prompt_msg,
PROMPT,
_pause_msg,
)
from pexpect.exceptions import (
# TIMEOUT,
EOF,
)
def test_shield_pause(
spawn,
):
'''
Verify the `tractor.pause()/.post_mortem()` API works inside an
already cancelled `trio.CancelScope` and that you can step to the
next checkpoint wherein the cancelled will get raised.
'''
child = spawn(
'shield_hang_in_sub'
)
expect(
child,
'Yo my child hanging..?',
)
assert_before(
child,
[
'Entering shield sleep..',
'Enabling trace-trees on `SIGUSR1` since `stackscope` is installed @',
]
)
script_pid: int = child.pid
print(
f'Sending SIGUSR1 to {script_pid}\n'
f'(kill -s SIGUSR1 {script_pid})\n'
)
os.kill(
script_pid,
signal.SIGUSR1,
)
time.sleep(0.2)
expect(
child,
# end-of-tree delimiter
"end-of-\('root'",
)
assert_before(
child,
[
# 'Srying to dump `stackscope` tree..',
# 'Dumping `stackscope` tree for actor',
"('root'", # uid line
# TODO!? this used to show?
# -[ ] mk reproducable for @oremanj?
#
# parent block point (non-shielded)
# 'await trio.sleep_forever() # in root',
]
)
expect(
child,
# end-of-tree delimiter
"end-of-\('hanger'",
)
assert_before(
child,
[
# relay to the sub should be reported
'Relaying `SIGUSR1`[10] to sub-actor',
"('hanger'", # uid line
# TODO!? SEE ABOVE
# hanger LOC where it's shield-halted
# 'await trio.sleep_forever() # in subactor',
]
)
# simulate the user sending a ctl-c to the hanging program.
# this should result in the terminator kicking in since
# the sub is shield blocking and can't respond to SIGINT.
os.kill(
child.pid,
signal.SIGINT,
)
expect(
child,
'Shutting down actor runtime',
timeout=6,
)
assert_before(
child,
[
'raise KeyboardInterrupt',
# 'Shutting down actor runtime',
'#T-800 deployed to collect zombie B0',
"'--uid', \"('hanger',",
]
)
def test_breakpoint_hook_restored(
spawn,
):
'''
Ensures our actor runtime sets a custom `breakpoint()` hook
on open then restores the stdlib's default on close.
The hook state validation is done via `assert`s inside the
invoked script with only `breakpoint()` (not `tractor.pause()`)
calls used.
'''
child = spawn('restore_builtin_breakpoint')
child.expect(PROMPT)
assert_before(
child,
[
_pause_msg,
"<Task '__main__.main'",
"('root'",
"first bp, tractor hook set",
]
)
child.sendline('c')
child.expect(PROMPT)
assert_before(
child,
[
"last bp, stdlib hook restored",
]
)
# since the stdlib hook was already restored there should be NO
# `tractor` `log.pdb()` content from console!
assert not in_prompt_msg(
child,
[
_pause_msg,
"<Task '__main__.main'",
"('root'",
],
)
child.sendline('c')
child.expect(EOF)

View File

@ -3,6 +3,7 @@ Sketchy network blackoutz, ugly byzantine gens, puedes eschuchar la
cancelacion?.. cancelacion?..
''' '''
import itertools
from functools import partial from functools import partial
from types import ModuleType from types import ModuleType
@ -229,10 +230,13 @@ def test_ipc_channel_break_during_stream(
# get raw instance from pytest wrapper # get raw instance from pytest wrapper
value = excinfo.value value = excinfo.value
if isinstance(value, ExceptionGroup): if isinstance(value, ExceptionGroup):
excs = value.exceptions value = next(
assert len(excs) == 1 itertools.dropwhile(
final_exc = excs[0] lambda exc: not isinstance(exc, expect_final_exc),
assert isinstance(final_exc, expect_final_exc) value.exceptions,
)
)
assert value
@tractor.context @tractor.context
@ -255,16 +259,15 @@ async def break_ipc_after_started(
def test_stream_closed_right_after_ipc_break_and_zombie_lord_engages(): def test_stream_closed_right_after_ipc_break_and_zombie_lord_engages():
''' '''
Verify that is a subactor's IPC goes down just after bringing up Verify that is a subactor's IPC goes down just after bringing up a stream
a stream the parent can trigger a SIGINT and the child will be the parent can trigger a SIGINT and the child will be reaped out-of-IPC by
reaped out-of-IPC by the localhost process supervision machinery: the localhost process supervision machinery: aka "zombie lord".
aka "zombie lord".
''' '''
async def main(): async def main():
with trio.fail_after(3): with trio.fail_after(3):
async with tractor.open_nursery() as an: async with tractor.open_nursery() as n:
portal = await an.start_actor( portal = await n.start_actor(
'ipc_breaker', 'ipc_breaker',
enable_modules=[__name__], enable_modules=[__name__],
) )

View File

@ -307,15 +307,7 @@ async def inf_streamer(
async with ( async with (
ctx.open_stream() as stream, ctx.open_stream() as stream,
trio.open_nursery() as tn,
# XXX TODO, INTERESTING CASE!!
# - if we don't collapse the eg then the embedded
# `trio.EndOfChannel` doesn't propagate directly to the above
# .open_stream() parent, resulting in it also raising instead
# of gracefully absorbing as normal.. so how to handle?
trio.open_nursery(
strict_exception_groups=False,
) as tn,
): ):
async def close_stream_on_sentinel(): async def close_stream_on_sentinel():
async for msg in stream: async for msg in stream:

View File

@ -130,7 +130,7 @@ def test_multierror(
try: try:
await portal2.result() await portal2.result()
except tractor.RemoteActorError as err: except tractor.RemoteActorError as err:
assert err.boxed_type is AssertionError assert err.boxed_type == AssertionError
print("Look Maa that first actor failed hard, hehh") print("Look Maa that first actor failed hard, hehh")
raise raise
@ -182,7 +182,7 @@ def test_multierror_fast_nursery(reg_addr, start_method, num_subactors, delay):
for exc in exceptions: for exc in exceptions:
assert isinstance(exc, tractor.RemoteActorError) assert isinstance(exc, tractor.RemoteActorError)
assert exc.boxed_type is AssertionError assert exc.boxed_type == AssertionError
async def do_nothing(): async def do_nothing():
@ -504,9 +504,7 @@ def test_cancel_via_SIGINT_other_task(
if is_win(): # smh if is_win(): # smh
timeout += 1 timeout += 1
async def spawn_and_sleep_forever( async def spawn_and_sleep_forever(task_status=trio.TASK_STATUS_IGNORED):
task_status=trio.TASK_STATUS_IGNORED
):
async with tractor.open_nursery() as tn: async with tractor.open_nursery() as tn:
for i in range(3): for i in range(3):
await tn.run_in_actor( await tn.run_in_actor(
@ -519,9 +517,7 @@ def test_cancel_via_SIGINT_other_task(
async def main(): async def main():
# should never timeout since SIGINT should cancel the current program # should never timeout since SIGINT should cancel the current program
with trio.fail_after(timeout): with trio.fail_after(timeout):
async with trio.open_nursery( async with trio.open_nursery() as n:
strict_exception_groups=False,
) as n:
await n.start(spawn_and_sleep_forever) await n.start(spawn_and_sleep_forever)
if 'mp' in spawn_backend: if 'mp' in spawn_backend:
time.sleep(0.1) time.sleep(0.1)
@ -614,12 +610,6 @@ def test_fast_graceful_cancel_when_spawn_task_in_soft_proc_wait_for_daemon(
nurse.start_soon(delayed_kbi) nurse.start_soon(delayed_kbi)
await p.run(do_nuthin) await p.run(do_nuthin)
# need to explicitly re-raise the lone kbi..now
except* KeyboardInterrupt as kbi_eg:
assert (len(excs := kbi_eg.exceptions) == 1)
raise excs[0]
finally: finally:
duration = time.time() - start duration = time.time() - start
if duration > timeout: if duration > timeout:

View File

@ -0,0 +1,917 @@
'''
Low-level functional audits for our
"capability based messaging"-spec feats.
B~)
'''
import typing
from typing import (
Any,
Type,
Union,
)
from msgspec import (
structs,
msgpack,
Struct,
ValidationError,
)
import pytest
import tractor
from tractor import (
_state,
MsgTypeError,
Context,
)
from tractor.msg import (
_codec,
_ctxvar_MsgCodec,
NamespacePath,
MsgCodec,
mk_codec,
apply_codec,
current_codec,
)
from tractor.msg.types import (
_payload_msgs,
log,
PayloadMsg,
Started,
mk_msg_spec,
)
import trio
def mk_custom_codec(
pld_spec: Union[Type]|Any,
add_hooks: bool,
) -> MsgCodec:
'''
Create custom `msgpack` enc/dec-hooks and set a `Decoder`
which only loads `pld_spec` (like `NamespacePath`) types.
'''
uid: tuple[str, str] = tractor.current_actor().uid
# XXX NOTE XXX: despite defining `NamespacePath` as a type
# field on our `PayloadMsg.pld`, we still need a enc/dec_hook() pair
# to cast to/from that type on the wire. See the docs:
# https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types
def enc_nsp(obj: Any) -> Any:
print(f'{uid} ENC HOOK')
match obj:
case NamespacePath():
print(
f'{uid}: `NamespacePath`-Only ENCODE?\n'
f'obj-> `{obj}`: {type(obj)}\n'
)
# if type(obj) != NamespacePath:
# breakpoint()
return str(obj)
print(
f'{uid}\n'
'CUSTOM ENCODE\n'
f'obj-arg-> `{obj}`: {type(obj)}\n'
)
logmsg: str = (
f'{uid}\n'
'FAILED ENCODE\n'
f'obj-> `{obj}: {type(obj)}`\n'
)
raise NotImplementedError(logmsg)
def dec_nsp(
obj_type: Type,
obj: Any,
) -> Any:
print(
f'{uid}\n'
'CUSTOM DECODE\n'
f'type-arg-> {obj_type}\n'
f'obj-arg-> `{obj}`: {type(obj)}\n'
)
nsp = None
if (
obj_type is NamespacePath
and isinstance(obj, str)
and ':' in obj
):
nsp = NamespacePath(obj)
# TODO: we could built a generic handler using
# JUST matching the obj_type part?
# nsp = obj_type(obj)
if nsp:
print(f'Returning NSP instance: {nsp}')
return nsp
logmsg: str = (
f'{uid}\n'
'FAILED DECODE\n'
f'type-> {obj_type}\n'
f'obj-arg-> `{obj}`: {type(obj)}\n\n'
f'current codec:\n'
f'{current_codec()}\n'
)
# TODO: figure out the ignore subsys for this!
# -[ ] option whether to defense-relay backc the msg
# inside an `Invalid`/`Ignore`
# -[ ] how to make this handling pluggable such that a
# `Channel`/`MsgTransport` can intercept and process
# back msgs either via exception handling or some other
# signal?
log.warning(logmsg)
# NOTE: this delivers the invalid
# value up to `msgspec`'s decoding
# machinery for error raising.
return obj
# raise NotImplementedError(logmsg)
nsp_codec: MsgCodec = mk_codec(
ipc_pld_spec=pld_spec,
# NOTE XXX: the encode hook MUST be used no matter what since
# our `NamespacePath` is not any of a `Any` native type nor
# a `msgspec.Struct` subtype - so `msgspec` has no way to know
# how to encode it unless we provide the custom hook.
#
# AGAIN that is, regardless of whether we spec an
# `Any`-decoded-pld the enc has no knowledge (by default)
# how to enc `NamespacePath` (nsp), so we add a custom
# hook to do that ALWAYS.
enc_hook=enc_nsp if add_hooks else None,
# XXX NOTE: pretty sure this is mutex with the `type=` to
# `Decoder`? so it won't work in tandem with the
# `ipc_pld_spec` passed above?
dec_hook=dec_nsp if add_hooks else None,
)
return nsp_codec
def chk_codec_applied(
expect_codec: MsgCodec,
enter_value: MsgCodec|None = None,
) -> MsgCodec:
'''
buncha sanity checks ensuring that the IPC channel's
context-vars are set to the expected codec and that are
ctx-var wrapper APIs match the same.
'''
# TODO: play with tricyle again, bc this is supposed to work
# the way we want?
#
# TreeVar
# task: trio.Task = trio.lowlevel.current_task()
# curr_codec = _ctxvar_MsgCodec.get_in(task)
# ContextVar
# task_ctx: Context = task.context
# assert _ctxvar_MsgCodec in task_ctx
# curr_codec: MsgCodec = task.context[_ctxvar_MsgCodec]
# NOTE: currently we use this!
# RunVar
curr_codec: MsgCodec = current_codec()
last_read_codec = _ctxvar_MsgCodec.get()
# assert curr_codec is last_read_codec
assert (
(same_codec := expect_codec) is
# returned from `mk_codec()`
# yielded value from `apply_codec()`
# read from current task's `contextvars.Context`
curr_codec is
last_read_codec
# the default `msgspec` settings
is not _codec._def_msgspec_codec
is not _codec._def_tractor_codec
)
if enter_value:
enter_value is same_codec
def iter_maybe_sends(
send_items: dict[Union[Type], Any] | list[tuple],
ipc_pld_spec: Union[Type] | Any,
add_codec_hooks: bool,
codec: MsgCodec|None = None,
) -> tuple[Any, bool]:
if isinstance(send_items, dict):
send_items = send_items.items()
for (
send_type_spec,
send_value,
) in send_items:
expect_roundtrip: bool = False
# values-to-typespec santiy
send_type = type(send_value)
assert send_type == send_type_spec or (
(subtypes := getattr(send_type_spec, '__args__', None))
and send_type in subtypes
)
spec_subtypes: set[Union[Type]] = (
getattr(
ipc_pld_spec,
'__args__',
{ipc_pld_spec,},
)
)
send_in_spec: bool = (
send_type == ipc_pld_spec
or (
ipc_pld_spec != Any
and # presume `Union` of types
send_type in spec_subtypes
)
or (
ipc_pld_spec == Any
and
send_type != NamespacePath
)
)
expect_roundtrip = (
send_in_spec
# any spec should support all other
# builtin py values that we send
# except our custom nsp type which
# we should be able to send as long
# as we provide the custom codec hooks.
or (
ipc_pld_spec == Any
and
send_type == NamespacePath
and
add_codec_hooks
)
)
if codec is not None:
# XXX FIRST XXX ensure roundtripping works
# before touching any IPC primitives/APIs.
wire_bytes: bytes = codec.encode(
Started(
cid='blahblah',
pld=send_value,
)
)
# NOTE: demonstrates the decoder loading
# to via our native SCIPP msg-spec
# (structurred-conc-inter-proc-protocol)
# implemented as per,
try:
msg: Started = codec.decode(wire_bytes)
if not expect_roundtrip:
pytest.fail(
f'NOT-EXPECTED able to roundtrip value given spec:\n'
f'ipc_pld_spec -> {ipc_pld_spec}\n'
f'value -> {send_value}: {send_type}\n'
)
pld = msg.pld
assert pld == send_value
except ValidationError:
if expect_roundtrip:
pytest.fail(
f'EXPECTED to roundtrip value given spec:\n'
f'ipc_pld_spec -> {ipc_pld_spec}\n'
f'value -> {send_value}: {send_type}\n'
)
yield (
str(send_type),
send_value,
expect_roundtrip,
)
def dec_type_union(
type_names: list[str],
) -> Type:
'''
Look up types by name, compile into a list and then create and
return a `typing.Union` from the full set.
'''
import importlib
types: list[Type] = []
for type_name in type_names:
for mod in [
typing,
importlib.import_module(__name__),
]:
if type_ref := getattr(
mod,
type_name,
False,
):
types.append(type_ref)
# special case handling only..
# ipc_pld_spec: Union[Type] = eval(
# pld_spec_str,
# {}, # globals
# {'typing': typing}, # locals
# )
return Union[*types]
def enc_type_union(
union_or_type: Union[Type]|Type,
) -> list[str]:
'''
Encode a type-union or single type to a list of type-name-strings
ready for IPC interchange.
'''
type_strs: list[str] = []
for typ in getattr(
union_or_type,
'__args__',
{union_or_type,},
):
type_strs.append(typ.__qualname__)
return type_strs
@tractor.context
async def send_back_values(
ctx: Context,
expect_debug: bool,
pld_spec_type_strs: list[str],
add_hooks: bool,
started_msg_bytes: bytes,
expect_ipc_send: dict[str, tuple[Any, bool]],
) -> None:
'''
Setup up a custom codec to load instances of `NamespacePath`
and ensure we can round trip a func ref with our parent.
'''
uid: tuple = tractor.current_actor().uid
# debug mode sanity check (prolly superfluous but, meh)
assert expect_debug == _state.debug_mode()
# init state in sub-actor should be default
chk_codec_applied(
expect_codec=_codec._def_tractor_codec,
)
# load pld spec from input str
ipc_pld_spec = dec_type_union(
pld_spec_type_strs,
)
pld_spec_str = str(ipc_pld_spec)
# same as on parent side config.
nsp_codec: MsgCodec = mk_custom_codec(
pld_spec=ipc_pld_spec,
add_hooks=add_hooks,
)
with (
apply_codec(nsp_codec) as codec,
):
chk_codec_applied(
expect_codec=nsp_codec,
enter_value=codec,
)
print(
f'{uid}: attempting `Started`-bytes DECODE..\n'
)
try:
msg: Started = nsp_codec.decode(started_msg_bytes)
expected_pld_spec_str: str = msg.pld
assert pld_spec_str == expected_pld_spec_str
# TODO: maybe we should add our own wrapper error so as to
# be interchange-lib agnostic?
# -[ ] the error type is wtv is raised from the hook so we
# could also require a type-class of errors for
# indicating whether the hook-failure can be handled by
# a nasty-dialog-unprot sub-sys?
except ValidationError:
# NOTE: only in the `Any` spec case do we expect this to
# work since otherwise no spec covers a plain-ol'
# `.pld: str`
if pld_spec_str == 'Any':
raise
else:
print(
f'{uid}: (correctly) unable to DECODE `Started`-bytes\n'
f'{started_msg_bytes}\n'
)
iter_send_val_items = iter(expect_ipc_send.values())
sent: list[Any] = []
for send_value, expect_send in iter_send_val_items:
try:
print(
f'{uid}: attempting to `.started({send_value})`\n'
f'=> expect_send: {expect_send}\n'
f'SINCE, ipc_pld_spec: {ipc_pld_spec}\n'
f'AND, codec: {codec}\n'
)
await ctx.started(send_value)
sent.append(send_value)
if not expect_send:
# XXX NOTE XXX THIS WON'T WORK WITHOUT SPECIAL
# `str` handling! or special debug mode IPC
# msgs!
await tractor.pause()
raise RuntimeError(
f'NOT-EXPECTED able to roundtrip value given spec:\n'
f'ipc_pld_spec -> {ipc_pld_spec}\n'
f'value -> {send_value}: {type(send_value)}\n'
)
break # move on to streaming block..
except tractor.MsgTypeError:
await tractor.pause()
if expect_send:
raise RuntimeError(
f'EXPECTED to `.started()` value given spec:\n'
f'ipc_pld_spec -> {ipc_pld_spec}\n'
f'value -> {send_value}: {type(send_value)}\n'
)
async with ctx.open_stream() as ipc:
print(
f'{uid}: Entering streaming block to send remaining values..'
)
for send_value, expect_send in iter_send_val_items:
send_type: Type = type(send_value)
print(
'------ - ------\n'
f'{uid}: SENDING NEXT VALUE\n'
f'ipc_pld_spec: {ipc_pld_spec}\n'
f'expect_send: {expect_send}\n'
f'val: {send_value}\n'
'------ - ------\n'
)
try:
await ipc.send(send_value)
print(f'***\n{uid}-CHILD sent {send_value!r}\n***\n')
sent.append(send_value)
# NOTE: should only raise above on
# `.started()` or a `Return`
# if not expect_send:
# raise RuntimeError(
# f'NOT-EXPECTED able to roundtrip value given spec:\n'
# f'ipc_pld_spec -> {ipc_pld_spec}\n'
# f'value -> {send_value}: {send_type}\n'
# )
except ValidationError:
print(f'{uid} FAILED TO SEND {send_value}!')
# await tractor.pause()
if expect_send:
raise RuntimeError(
f'EXPECTED to roundtrip value given spec:\n'
f'ipc_pld_spec -> {ipc_pld_spec}\n'
f'value -> {send_value}: {send_type}\n'
)
# continue
else:
print(
f'{uid}: finished sending all values\n'
'Should be exiting stream block!\n'
)
print(f'{uid}: exited streaming block!')
# TODO: this won't be true bc in streaming phase we DO NOT
# msgspec check outbound msgs!
# -[ ] once we implement the receiver side `InvalidMsg`
# then we can expect it here?
# assert (
# len(sent)
# ==
# len([val
# for val, expect in
# expect_ipc_send.values()
# if expect is True])
# )
def ex_func(*args):
print(f'ex_func({args})')
@pytest.mark.parametrize(
'ipc_pld_spec',
[
Any,
NamespacePath,
NamespacePath|None, # the "maybe" spec Bo
],
ids=[
'any_type',
'nsp_type',
'maybe_nsp_type',
]
)
@pytest.mark.parametrize(
'add_codec_hooks',
[
True,
False,
],
ids=['use_codec_hooks', 'no_codec_hooks'],
)
def test_codec_hooks_mod(
debug_mode: bool,
ipc_pld_spec: Union[Type]|Any,
# send_value: None|str|NamespacePath,
add_codec_hooks: bool,
):
'''
Audit the `.msg.MsgCodec` override apis details given our impl
uses `contextvars` to accomplish per `trio` task codec
application around an inter-proc-task-comms context.
'''
async def main():
nsp = NamespacePath.from_ref(ex_func)
send_items: dict[Union, Any] = {
Union[None]: None,
Union[NamespacePath]: nsp,
Union[str]: str(nsp),
}
# init default state for actor
chk_codec_applied(
expect_codec=_codec._def_tractor_codec,
)
async with tractor.open_nursery(
debug_mode=debug_mode,
) as an:
p: tractor.Portal = await an.start_actor(
'sub',
enable_modules=[__name__],
)
# TODO: 2 cases:
# - codec not modified -> decode nsp as `str`
# - codec modified with hooks -> decode nsp as
# `NamespacePath`
nsp_codec: MsgCodec = mk_custom_codec(
pld_spec=ipc_pld_spec,
add_hooks=add_codec_hooks,
)
with apply_codec(nsp_codec) as codec:
chk_codec_applied(
expect_codec=nsp_codec,
enter_value=codec,
)
expect_ipc_send: dict[str, tuple[Any, bool]] = {}
report: str = (
'Parent report on send values with\n'
f'ipc_pld_spec: {ipc_pld_spec}\n'
' ------ - ------\n'
)
for val_type_str, val, expect_send in iter_maybe_sends(
send_items,
ipc_pld_spec,
add_codec_hooks=add_codec_hooks,
):
report += (
f'send_value: {val}: {type(val)} '
f'=> expect_send: {expect_send}\n'
)
expect_ipc_send[val_type_str] = (val, expect_send)
print(
report +
' ------ - ------\n'
)
assert len(expect_ipc_send) == len(send_items)
# now try over real IPC with a the subactor
# expect_ipc_rountrip: bool = True
expected_started = Started(
cid='cid',
pld=str(ipc_pld_spec),
)
# build list of values we expect to receive from
# the subactor.
expect_to_send: list[Any] = [
val
for val, expect_send in expect_ipc_send.values()
if expect_send
]
pld_spec_type_strs: list[str] = enc_type_union(ipc_pld_spec)
# XXX should raise an mte (`MsgTypeError`)
# when `add_codec_hooks == False` bc the input
# `expect_ipc_send` kwarg has a nsp which can't be
# serialized!
#
# TODO:can we ensure this happens from the
# `Return`-side (aka the sub) as well?
if not add_codec_hooks:
try:
async with p.open_context(
send_back_values,
expect_debug=debug_mode,
pld_spec_type_strs=pld_spec_type_strs,
add_hooks=add_codec_hooks,
started_msg_bytes=nsp_codec.encode(expected_started),
# XXX NOTE bc we send a `NamespacePath` in this kwarg
expect_ipc_send=expect_ipc_send,
) as (ctx, first):
pytest.fail('ctx should fail to open without custom enc_hook!?')
# this test passes bc we can go no further!
except MsgTypeError:
# teardown nursery
await p.cancel_actor()
return
# TODO: send the original nsp here and
# test with `limit_msg_spec()` above?
# await tractor.pause()
print('PARENT opening IPC ctx!\n')
async with (
# XXX should raise an mte (`MsgTypeError`)
# when `add_codec_hooks == False`..
p.open_context(
send_back_values,
expect_debug=debug_mode,
pld_spec_type_strs=pld_spec_type_strs,
add_hooks=add_codec_hooks,
started_msg_bytes=nsp_codec.encode(expected_started),
expect_ipc_send=expect_ipc_send,
) as (ctx, first),
ctx.open_stream() as ipc,
):
# ensure codec is still applied across
# `tractor.Context` + its embedded nursery.
chk_codec_applied(
expect_codec=nsp_codec,
enter_value=codec,
)
print(
'root: ENTERING CONTEXT BLOCK\n'
f'type(first): {type(first)}\n'
f'first: {first}\n'
)
expect_to_send.remove(first)
# TODO: explicit values we expect depending on
# codec config!
# assert first == first_val
# assert first == f'{__name__}:ex_func'
async for next_sent in ipc:
print(
'Parent: child sent next value\n'
f'{next_sent}: {type(next_sent)}\n'
)
if expect_to_send:
expect_to_send.remove(next_sent)
else:
print('PARENT should terminate stream loop + block!')
# all sent values should have arrived!
assert not expect_to_send
await p.cancel_actor()
trio.run(main)
def chk_pld_type(
payload_spec: Type[Struct]|Any,
pld: Any,
expect_roundtrip: bool|None = None,
) -> bool:
pld_val_type: Type = type(pld)
# TODO: verify that the overridden subtypes
# DO NOT have modified type-annots from original!
# 'Start', .pld: FuncSpec
# 'StartAck', .pld: IpcCtxSpec
# 'Stop', .pld: UNSEt
# 'Error', .pld: ErrorData
codec: MsgCodec = mk_codec(
# NOTE: this ONLY accepts `PayloadMsg.pld` fields of a specified
# type union.
ipc_pld_spec=payload_spec,
)
# make a one-off dec to compare with our `MsgCodec` instance
# which does the below `mk_msg_spec()` call internally
ipc_msg_spec: Union[Type[Struct]]
msg_types: list[PayloadMsg[payload_spec]]
(
ipc_msg_spec,
msg_types,
) = mk_msg_spec(
payload_type_union=payload_spec,
)
_enc = msgpack.Encoder()
_dec = msgpack.Decoder(
type=ipc_msg_spec or Any, # like `PayloadMsg[Any]`
)
assert (
payload_spec
==
codec.pld_spec
)
# assert codec.dec == dec
#
# ^-XXX-^ not sure why these aren't "equal" but when cast
# to `str` they seem to match ?? .. kk
assert (
str(ipc_msg_spec)
==
str(codec.msg_spec)
==
str(_dec.type)
==
str(codec.dec.type)
)
# verify the boxed-type for all variable payload-type msgs.
if not msg_types:
breakpoint()
roundtrip: bool|None = None
pld_spec_msg_names: list[str] = [
td.__name__ for td in _payload_msgs
]
for typedef in msg_types:
skip_runtime_msg: bool = typedef.__name__ not in pld_spec_msg_names
if skip_runtime_msg:
continue
pld_field = structs.fields(typedef)[1]
assert pld_field.type is payload_spec # TODO-^ does this need to work to get all subtypes to adhere?
kwargs: dict[str, Any] = {
'cid': '666',
'pld': pld,
}
enc_msg: PayloadMsg = typedef(**kwargs)
_wire_bytes: bytes = _enc.encode(enc_msg)
wire_bytes: bytes = codec.enc.encode(enc_msg)
assert _wire_bytes == wire_bytes
ve: ValidationError|None = None
try:
dec_msg = codec.dec.decode(wire_bytes)
_dec_msg = _dec.decode(wire_bytes)
# decoded msg and thus payload should be exactly same!
assert (roundtrip := (
_dec_msg
==
dec_msg
==
enc_msg
))
if (
expect_roundtrip is not None
and expect_roundtrip != roundtrip
):
breakpoint()
assert (
pld
==
dec_msg.pld
==
enc_msg.pld
)
# assert (roundtrip := (_dec_msg == enc_msg))
except ValidationError as _ve:
ve = _ve
roundtrip: bool = False
if pld_val_type is payload_spec:
raise ValueError(
'Got `ValidationError` despite type-var match!?\n'
f'pld_val_type: {pld_val_type}\n'
f'payload_type: {payload_spec}\n'
) from ve
else:
# ow we good cuz the pld spec mismatched.
print(
'Got expected `ValidationError` since,\n'
f'{pld_val_type} is not {payload_spec}\n'
)
else:
if (
payload_spec is not Any
and
pld_val_type is not payload_spec
):
raise ValueError(
'DID NOT `ValidationError` despite expected type match!?\n'
f'pld_val_type: {pld_val_type}\n'
f'payload_type: {payload_spec}\n'
)
# full code decode should always be attempted!
if roundtrip is None:
breakpoint()
return roundtrip
def test_limit_msgspec():
async def main():
async with tractor.open_root_actor(
debug_mode=True
):
# ensure we can round-trip a boxing `PayloadMsg`
assert chk_pld_type(
payload_spec=Any,
pld=None,
expect_roundtrip=True,
)
# verify that a mis-typed payload value won't decode
assert not chk_pld_type(
payload_spec=int,
pld='doggy',
)
# parametrize the boxed `.pld` type as a custom-struct
# and ensure that parametrization propagates
# to all payload-msg-spec-able subtypes!
class CustomPayload(Struct):
name: str
value: Any
assert not chk_pld_type(
payload_spec=CustomPayload,
pld='doggy',
)
assert chk_pld_type(
payload_spec=CustomPayload,
pld=CustomPayload(name='doggy', value='urmom')
)
# yah, we can `.pause_from_sync()` now!
# breakpoint()
trio.run(main)

View File

@ -95,8 +95,8 @@ async def trio_main(
# stash a "service nursery" as "actor local" (aka a Python global) # stash a "service nursery" as "actor local" (aka a Python global)
global _nursery global _nursery
tn = _nursery n = _nursery
assert tn assert n
async def consume_stream(): async def consume_stream():
async with wrapper_mngr() as stream: async with wrapper_mngr() as stream:
@ -104,10 +104,10 @@ async def trio_main(
print(msg) print(msg)
# run 2 tasks to ensure broadcaster chan use # run 2 tasks to ensure broadcaster chan use
tn.start_soon(consume_stream) n.start_soon(consume_stream)
tn.start_soon(consume_stream) n.start_soon(consume_stream)
tn.start_soon(trio_sleep_and_err) n.start_soon(trio_sleep_and_err)
await trio.sleep_forever() await trio.sleep_forever()
@ -117,10 +117,8 @@ async def open_actor_local_nursery(
ctx: tractor.Context, ctx: tractor.Context,
): ):
global _nursery global _nursery
async with trio.open_nursery( async with trio.open_nursery() as n:
strict_exception_groups=False, _nursery = n
) as tn:
_nursery = tn
await ctx.started() await ctx.started()
await trio.sleep(10) await trio.sleep(10)
# await trio.sleep(1) # await trio.sleep(1)
@ -134,7 +132,7 @@ async def open_actor_local_nursery(
# never yields back.. aka a scenario where the # never yields back.. aka a scenario where the
# ``tractor.context`` task IS NOT in the service n's cancel # ``tractor.context`` task IS NOT in the service n's cancel
# scope. # scope.
tn.cancel_scope.cancel() n.cancel_scope.cancel()
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -159,7 +157,7 @@ def test_actor_managed_trio_nursery_task_error_cancels_aio(
async with tractor.open_nursery() as n: async with tractor.open_nursery() as n:
p = await n.start_actor( p = await n.start_actor(
'nursery_mngr', 'nursery_mngr',
infect_asyncio=asyncio_mode, # TODO, is this enabling debug mode? infect_asyncio=asyncio_mode,
enable_modules=[__name__], enable_modules=[__name__],
) )
async with ( async with (

View File

@ -38,9 +38,9 @@ from tractor._testing import (
# - standard setup/teardown: # - standard setup/teardown:
# ``Portal.open_context()`` starts a new # ``Portal.open_context()`` starts a new
# remote task context in another actor. The target actor's task must # remote task context in another actor. The target actor's task must
# call ``Context.started()`` to unblock this entry on the parent side. # call ``Context.started()`` to unblock this entry on the caller side.
# the child task executes until complete and returns a final value # the callee task executes until complete and returns a final value
# which is delivered to the parent side and retreived via # which is delivered to the caller side and retreived via
# ``Context.result()``. # ``Context.result()``.
# - cancel termination: # - cancel termination:
@ -170,9 +170,9 @@ async def assert_state(value: bool):
[False, ValueError, KeyboardInterrupt], [False, ValueError, KeyboardInterrupt],
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
'child_blocks_forever', 'callee_blocks_forever',
[False, True], [False, True],
ids=lambda item: f'child_blocks_forever={item}' ids=lambda item: f'callee_blocks_forever={item}'
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
'pointlessly_open_stream', 'pointlessly_open_stream',
@ -181,7 +181,7 @@ async def assert_state(value: bool):
) )
def test_simple_context( def test_simple_context(
error_parent, error_parent,
child_blocks_forever, callee_blocks_forever,
pointlessly_open_stream, pointlessly_open_stream,
debug_mode: bool, debug_mode: bool,
): ):
@ -204,13 +204,13 @@ def test_simple_context(
portal.open_context( portal.open_context(
simple_setup_teardown, simple_setup_teardown,
data=10, data=10,
block_forever=child_blocks_forever, block_forever=callee_blocks_forever,
) as (ctx, sent), ) as (ctx, sent),
): ):
assert current_ipc_ctx() is ctx assert current_ipc_ctx() is ctx
assert sent == 11 assert sent == 11
if child_blocks_forever: if callee_blocks_forever:
await portal.run(assert_state, value=True) await portal.run(assert_state, value=True)
else: else:
assert await ctx.result() == 'yo' assert await ctx.result() == 'yo'
@ -220,7 +220,7 @@ def test_simple_context(
if error_parent: if error_parent:
raise error_parent raise error_parent
if child_blocks_forever: if callee_blocks_forever:
await ctx.cancel() await ctx.cancel()
else: else:
# in this case the stream will send a # in this case the stream will send a
@ -259,9 +259,9 @@ def test_simple_context(
@pytest.mark.parametrize( @pytest.mark.parametrize(
'child_returns_early', 'callee_returns_early',
[True, False], [True, False],
ids=lambda item: f'child_returns_early={item}' ids=lambda item: f'callee_returns_early={item}'
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
'cancel_method', 'cancel_method',
@ -273,14 +273,14 @@ def test_simple_context(
[True, False], [True, False],
ids=lambda item: f'chk_ctx_result_before_exit={item}' ids=lambda item: f'chk_ctx_result_before_exit={item}'
) )
def test_parent_cancels( def test_caller_cancels(
cancel_method: str, cancel_method: str,
chk_ctx_result_before_exit: bool, chk_ctx_result_before_exit: bool,
child_returns_early: bool, callee_returns_early: bool,
debug_mode: bool, debug_mode: bool,
): ):
''' '''
Verify that when the opening side of a context (aka the parent) Verify that when the opening side of a context (aka the caller)
cancels that context, the ctx does not raise a cancelled when cancels that context, the ctx does not raise a cancelled when
either calling `.result()` or on context exit. either calling `.result()` or on context exit.
@ -294,7 +294,7 @@ def test_parent_cancels(
if ( if (
cancel_method == 'portal' cancel_method == 'portal'
and not child_returns_early and not callee_returns_early
): ):
try: try:
res = await ctx.result() res = await ctx.result()
@ -318,7 +318,7 @@ def test_parent_cancels(
pytest.fail(f'should not have raised ctxc\n{ctxc}') pytest.fail(f'should not have raised ctxc\n{ctxc}')
# we actually get a result # we actually get a result
if child_returns_early: if callee_returns_early:
assert res == 'yo' assert res == 'yo'
assert ctx.outcome is res assert ctx.outcome is res
assert ctx.maybe_error is None assert ctx.maybe_error is None
@ -362,14 +362,14 @@ def test_parent_cancels(
) )
timeout: float = ( timeout: float = (
0.5 0.5
if not child_returns_early if not callee_returns_early
else 2 else 2
) )
with trio.fail_after(timeout): with trio.fail_after(timeout):
async with ( async with (
expect_ctxc( expect_ctxc(
yay=( yay=(
not child_returns_early not callee_returns_early
and cancel_method == 'portal' and cancel_method == 'portal'
) )
), ),
@ -377,13 +377,13 @@ def test_parent_cancels(
portal.open_context( portal.open_context(
simple_setup_teardown, simple_setup_teardown,
data=10, data=10,
block_forever=not child_returns_early, block_forever=not callee_returns_early,
) as (ctx, sent), ) as (ctx, sent),
): ):
if child_returns_early: if callee_returns_early:
# ensure we block long enough before sending # ensure we block long enough before sending
# a cancel such that the child has already # a cancel such that the callee has already
# returned it's result. # returned it's result.
await trio.sleep(0.5) await trio.sleep(0.5)
@ -421,7 +421,7 @@ def test_parent_cancels(
# which should in turn cause `ctx._scope` to # which should in turn cause `ctx._scope` to
# catch any cancellation? # catch any cancellation?
if ( if (
not child_returns_early not callee_returns_early
and cancel_method != 'portal' and cancel_method != 'portal'
): ):
assert not ctx._scope.cancelled_caught assert not ctx._scope.cancelled_caught
@ -430,11 +430,11 @@ def test_parent_cancels(
# basic stream terminations: # basic stream terminations:
# - child context closes without using stream # - callee context closes without using stream
# - parent context closes without using stream # - caller context closes without using stream
# - parent context calls `Context.cancel()` while streaming # - caller context calls `Context.cancel()` while streaming
# is ongoing resulting in child being cancelled # is ongoing resulting in callee being cancelled
# - child calls `Context.cancel()` while streaming and parent # - callee calls `Context.cancel()` while streaming and caller
# sees stream terminated in `RemoteActorError` # sees stream terminated in `RemoteActorError`
# TODO: future possible features # TODO: future possible features
@ -443,6 +443,7 @@ def test_parent_cancels(
@tractor.context @tractor.context
async def close_ctx_immediately( async def close_ctx_immediately(
ctx: Context, ctx: Context,
) -> None: ) -> None:
@ -453,24 +454,13 @@ async def close_ctx_immediately(
async with ctx.open_stream(): async with ctx.open_stream():
pass pass
print('child returning!')
@pytest.mark.parametrize(
'parent_send_before_receive',
[
False,
True,
],
ids=lambda item: f'child_send_before_receive={item}'
)
@tractor_test @tractor_test
async def test_child_exits_ctx_after_stream_open( async def test_callee_closes_ctx_after_stream_open(
debug_mode: bool, debug_mode: bool,
parent_send_before_receive: bool,
): ):
''' '''
child context closes without using stream. callee context closes without using stream.
This should result in a msg sequence This should result in a msg sequence
|_<root>_ |_<root>_
@ -484,9 +474,6 @@ async def test_child_exits_ctx_after_stream_open(
=> {'stop': True, 'cid': <str>} => {'stop': True, 'cid': <str>}
''' '''
timeout: float = (
0.5 if not debug_mode else 999
)
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=debug_mode, debug_mode=debug_mode,
) as an: ) as an:
@ -495,7 +482,7 @@ async def test_child_exits_ctx_after_stream_open(
enable_modules=[__name__], enable_modules=[__name__],
) )
with trio.fail_after(timeout): with trio.fail_after(0.5):
async with portal.open_context( async with portal.open_context(
close_ctx_immediately, close_ctx_immediately,
@ -507,56 +494,41 @@ async def test_child_exits_ctx_after_stream_open(
with trio.fail_after(0.4): with trio.fail_after(0.4):
async with ctx.open_stream() as stream: async with ctx.open_stream() as stream:
if parent_send_before_receive:
print('sending first msg from parent!')
await stream.send('yo')
# should fall through since ``StopAsyncIteration`` # should fall through since ``StopAsyncIteration``
# should be raised through translation of # should be raised through translation of
# a ``trio.EndOfChannel`` by # a ``trio.EndOfChannel`` by
# ``trio.abc.ReceiveChannel.__anext__()`` # ``trio.abc.ReceiveChannel.__anext__()``
msg = 10 async for _ in stream:
async for msg in stream:
# trigger failure if we DO NOT # trigger failure if we DO NOT
# get an EOC! # get an EOC!
assert 0 assert 0
else: else:
# never should get anythinig new from
# the underlying stream
assert msg == 10
# verify stream is now closed # verify stream is now closed
try: try:
with trio.fail_after(0.3): with trio.fail_after(0.3):
print('parent trying to `.receive()` on EoC stream!')
await stream.receive() await stream.receive()
assert 0, 'should have raised eoc!?'
except trio.EndOfChannel: except trio.EndOfChannel:
print('parent got EoC as expected!')
pass pass
# raise
# TODO: should be just raise the closed resource err # TODO: should be just raise the closed resource err
# directly here to enforce not allowing a re-open # directly here to enforce not allowing a re-open
# of a stream to the context (at least until a time of # of a stream to the context (at least until a time of
# if/when we decide that's a good idea?) # if/when we decide that's a good idea?)
try: try:
with trio.fail_after(timeout): with trio.fail_after(0.5):
async with ctx.open_stream() as stream: async with ctx.open_stream() as stream:
pass pass
except trio.ClosedResourceError: except trio.ClosedResourceError:
pass pass
# if ctx._rx_chan._state.data:
# await tractor.pause()
await portal.cancel_actor() await portal.cancel_actor()
@tractor.context @tractor.context
async def expect_cancelled( async def expect_cancelled(
ctx: Context, ctx: Context,
send_before_receive: bool = False,
) -> None: ) -> None:
global _state global _state
@ -566,10 +538,6 @@ async def expect_cancelled(
try: try:
async with ctx.open_stream() as stream: async with ctx.open_stream() as stream:
if send_before_receive:
await stream.send('yo')
async for msg in stream: async for msg in stream:
await stream.send(msg) # echo server await stream.send(msg) # echo server
@ -596,49 +564,26 @@ async def expect_cancelled(
raise raise
else: else:
assert 0, "child wasn't cancelled !?" assert 0, "callee wasn't cancelled !?"
@pytest.mark.parametrize(
'child_send_before_receive',
[
False,
True,
],
ids=lambda item: f'child_send_before_receive={item}'
)
@pytest.mark.parametrize(
'rent_wait_for_msg',
[
False,
True,
],
ids=lambda item: f'rent_wait_for_msg={item}'
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
'use_ctx_cancel_method', 'use_ctx_cancel_method',
[ [False, True],
False,
'pre_stream',
'post_stream_open',
'post_stream_close',
],
ids=lambda item: f'use_ctx_cancel_method={item}'
) )
@tractor_test @tractor_test
async def test_parent_exits_ctx_after_child_enters_stream( async def test_caller_closes_ctx_after_callee_opens_stream(
use_ctx_cancel_method: bool|str, use_ctx_cancel_method: bool,
debug_mode: bool, debug_mode: bool,
rent_wait_for_msg: bool,
child_send_before_receive: bool,
): ):
''' '''
Parent-side of IPC context closes without sending on `MsgStream`. caller context closes without using/opening stream
''' '''
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=debug_mode, debug_mode=debug_mode,
) as an: ) as an:
root: Actor = current_actor() root: Actor = current_actor()
portal = await an.start_actor( portal = await an.start_actor(
'ctx_cancelled', 'ctx_cancelled',
@ -647,52 +592,41 @@ async def test_parent_exits_ctx_after_child_enters_stream(
async with portal.open_context( async with portal.open_context(
expect_cancelled, expect_cancelled,
send_before_receive=child_send_before_receive,
) as (ctx, sent): ) as (ctx, sent):
assert sent is None assert sent is None
await portal.run(assert_state, value=True) await portal.run(assert_state, value=True)
# call `ctx.cancel()` explicitly # call `ctx.cancel()` explicitly
if use_ctx_cancel_method == 'pre_stream': if use_ctx_cancel_method:
await ctx.cancel() await ctx.cancel()
# NOTE: means the local side `ctx._scope` will # NOTE: means the local side `ctx._scope` will
# have been cancelled by an ctxc ack and thus # have been cancelled by an ctxc ack and thus
# `._scope.cancelled_caught` should be set. # `._scope.cancelled_caught` should be set.
async with ( try:
expect_ctxc(
# XXX: the cause is US since we call
# `Context.cancel()` just above!
yay=True,
# XXX: must be propagated to __aexit__
# and should be silently absorbed there
# since we called `.cancel()` just above ;)
reraise=True,
) as maybe_ctxc,
):
async with ctx.open_stream() as stream: async with ctx.open_stream() as stream:
async for msg in stream:
pass
if rent_wait_for_msg: except tractor.ContextCancelled as ctxc:
async for msg in stream: # XXX: the cause is US since we call
print(f'PARENT rx: {msg!r}\n') # `Context.cancel()` just above!
break assert (
ctxc.canceller
==
current_actor().uid
==
root.uid
)
if use_ctx_cancel_method == 'post_stream_open': # XXX: must be propagated to __aexit__
await ctx.cancel() # and should be silently absorbed there
# since we called `.cancel()` just above ;)
raise
if use_ctx_cancel_method == 'post_stream_close': else:
await ctx.cancel() assert 0, "Should have context cancelled?"
ctxc: tractor.ContextCancelled = maybe_ctxc.value
assert (
ctxc.canceller
==
current_actor().uid
==
root.uid
)
# channel should still be up # channel should still be up
assert portal.channel.connected() assert portal.channel.connected()
@ -703,20 +637,13 @@ async def test_parent_exits_ctx_after_child_enters_stream(
value=False, value=False,
) )
# XXX CHILD-BLOCKS case, we SHOULD NOT exit from the
# `.open_context()` before the child has returned,
# errored or been cancelled!
else: else:
try: try:
with trio.fail_after( with trio.fail_after(0.2):
0.5 # if not debug_mode else 999 await ctx.result()
):
res = await ctx.wait_for_result()
assert res is not tractor._context.Unresolved
assert 0, "Callee should have blocked!?" assert 0, "Callee should have blocked!?"
except trio.TooSlowError: except trio.TooSlowError:
# NO-OP -> since already triggered by # NO-OP -> since already called above
# `trio.fail_after()` above!
await ctx.cancel() await ctx.cancel()
# NOTE: local scope should have absorbed the cancellation since # NOTE: local scope should have absorbed the cancellation since
@ -756,7 +683,7 @@ async def test_parent_exits_ctx_after_child_enters_stream(
@tractor_test @tractor_test
async def test_multitask_parent_cancels_from_nonroot_task( async def test_multitask_caller_cancels_from_nonroot_task(
debug_mode: bool, debug_mode: bool,
): ):
async with tractor.open_nursery( async with tractor.open_nursery(
@ -808,6 +735,7 @@ async def test_multitask_parent_cancels_from_nonroot_task(
@tractor.context @tractor.context
async def cancel_self( async def cancel_self(
ctx: Context, ctx: Context,
) -> None: ) -> None:
@ -847,11 +775,11 @@ async def cancel_self(
@tractor_test @tractor_test
async def test_child_cancels_before_started( async def test_callee_cancels_before_started(
debug_mode: bool, debug_mode: bool,
): ):
''' '''
Callee calls `Context.cancel()` while streaming and parent Callee calls `Context.cancel()` while streaming and caller
sees stream terminated in `ContextCancelled`. sees stream terminated in `ContextCancelled`.
''' '''
@ -898,13 +826,14 @@ async def never_open_stream(
@tractor.context @tractor.context
async def keep_sending_from_child( async def keep_sending_from_callee(
ctx: Context, ctx: Context,
msg_buffer_size: int|None = None, msg_buffer_size: int|None = None,
) -> None: ) -> None:
''' '''
Send endlessly on the child stream. Send endlessly on the calleee stream.
''' '''
await ctx.started() await ctx.started()
@ -912,7 +841,7 @@ async def keep_sending_from_child(
msg_buffer_size=msg_buffer_size, msg_buffer_size=msg_buffer_size,
) as stream: ) as stream:
for msg in count(): for msg in count():
print(f'child sending {msg}') print(f'callee sending {msg}')
await stream.send(msg) await stream.send(msg)
await trio.sleep(0.01) await trio.sleep(0.01)
@ -920,12 +849,12 @@ async def keep_sending_from_child(
@pytest.mark.parametrize( @pytest.mark.parametrize(
'overrun_by', 'overrun_by',
[ [
('parent', 1, never_open_stream), ('caller', 1, never_open_stream),
('child', 0, keep_sending_from_child), ('callee', 0, keep_sending_from_callee),
], ],
ids=[ ids=[
('parent_1buf_never_open_stream'), ('caller_1buf_never_open_stream'),
('child_0buf_keep_sending_from_child'), ('callee_0buf_keep_sending_from_callee'),
] ]
) )
def test_one_end_stream_not_opened( def test_one_end_stream_not_opened(
@ -956,7 +885,8 @@ def test_one_end_stream_not_opened(
) as (ctx, sent): ) as (ctx, sent):
assert sent is None assert sent is None
if 'parent' in overrunner: if 'caller' in overrunner:
async with ctx.open_stream() as stream: async with ctx.open_stream() as stream:
# itersend +1 msg more then the buffer size # itersend +1 msg more then the buffer size
@ -971,7 +901,7 @@ def test_one_end_stream_not_opened(
await trio.sleep_forever() await trio.sleep_forever()
else: else:
# child overruns parent case so we do nothing here # callee overruns caller case so we do nothing here
await trio.sleep_forever() await trio.sleep_forever()
await portal.cancel_actor() await portal.cancel_actor()
@ -979,19 +909,19 @@ def test_one_end_stream_not_opened(
# 2 overrun cases and the no overrun case (which pushes right up to # 2 overrun cases and the no overrun case (which pushes right up to
# the msg limit) # the msg limit)
if ( if (
overrunner == 'parent' overrunner == 'caller'
): ):
with pytest.raises(tractor.RemoteActorError) as excinfo: with pytest.raises(tractor.RemoteActorError) as excinfo:
trio.run(main) trio.run(main)
assert excinfo.value.boxed_type == StreamOverrun assert excinfo.value.boxed_type == StreamOverrun
elif overrunner == 'child': elif overrunner == 'callee':
with pytest.raises(tractor.RemoteActorError) as excinfo: with pytest.raises(tractor.RemoteActorError) as excinfo:
trio.run(main) trio.run(main)
# TODO: embedded remote errors so that we can verify the source # TODO: embedded remote errors so that we can verify the source
# error? the child delivers an error which is an overrun # error? the callee delivers an error which is an overrun
# wrapped in a remote actor error. # wrapped in a remote actor error.
assert excinfo.value.boxed_type == tractor.RemoteActorError assert excinfo.value.boxed_type == tractor.RemoteActorError
@ -1001,7 +931,8 @@ def test_one_end_stream_not_opened(
@tractor.context @tractor.context
async def echo_back_sequence( async def echo_back_sequence(
ctx: Context,
ctx: Context,
seq: list[int], seq: list[int],
wait_for_cancel: bool, wait_for_cancel: bool,
allow_overruns_side: str, allow_overruns_side: str,
@ -1010,12 +941,12 @@ async def echo_back_sequence(
) -> None: ) -> None:
''' '''
Send endlessly on the child stream using a small buffer size Send endlessly on the calleee stream using a small buffer size
setting on the contex to simulate backlogging that would normally setting on the contex to simulate backlogging that would normally
cause overruns. cause overruns.
''' '''
# NOTE: ensure that if the parent is expecting to cancel this task # NOTE: ensure that if the caller is expecting to cancel this task
# that we stay echoing much longer then they are so we don't # that we stay echoing much longer then they are so we don't
# return early instead of receive the cancel msg. # return early instead of receive the cancel msg.
total_batches: int = ( total_batches: int = (
@ -1024,7 +955,7 @@ async def echo_back_sequence(
) )
await ctx.started() await ctx.started()
# await tractor.pause() # await tractor.breakpoint()
async with ctx.open_stream( async with ctx.open_stream(
msg_buffer_size=msg_buffer_size, msg_buffer_size=msg_buffer_size,
@ -1065,18 +996,18 @@ async def echo_back_sequence(
if be_slow: if be_slow:
await trio.sleep(0.05) await trio.sleep(0.05)
print('child waiting on next') print('callee waiting on next')
print(f'child echoing back latest batch\n{batch}') print(f'callee echoing back latest batch\n{batch}')
for msg in batch: for msg in batch:
print(f'child sending msg\n{msg}') print(f'callee sending msg\n{msg}')
await stream.send(msg) await stream.send(msg)
try: try:
return 'yo' return 'yo'
finally: finally:
print( print(
'exiting child with context:\n' 'exiting callee with context:\n'
f'{pformat(ctx)}\n' f'{pformat(ctx)}\n'
) )
@ -1130,7 +1061,7 @@ def test_maybe_allow_overruns_stream(
debug_mode=debug_mode, debug_mode=debug_mode,
) as an: ) as an:
portal = await an.start_actor( portal = await an.start_actor(
'child_sends_forever', 'callee_sends_forever',
enable_modules=[__name__], enable_modules=[__name__],
loglevel=loglevel, loglevel=loglevel,
debug_mode=debug_mode, debug_mode=debug_mode,

View File

@ -13,25 +13,26 @@ TODO:
from functools import partial from functools import partial
import itertools import itertools
import platform import platform
import pathlib
import time import time
import pytest import pytest
import pexpect
from pexpect.exceptions import ( from pexpect.exceptions import (
TIMEOUT, TIMEOUT,
EOF, EOF,
) )
from .conftest import ( from tractor._testing import (
do_ctlc, examples_dir,
PROMPT, )
from tractor.devx._debug import (
_pause_msg, _pause_msg,
_crash_msg, _crash_msg,
_repl_fail_msg, _repl_fail_msg,
) )
from .conftest import ( from .conftest import (
expect, _ci_env,
in_prompt_msg,
assert_before,
) )
# TODO: The next great debugger audit could be done by you! # TODO: The next great debugger audit could be done by you!
@ -51,6 +52,15 @@ if platform.system() == 'Windows':
) )
def mk_cmd(ex_name: str) -> str:
'''
Generate a command suitable to pass to ``pexpect.spawn()``.
'''
script_path: pathlib.Path = examples_dir() / 'debugging' / f'{ex_name}.py'
return ' '.join(['python', str(script_path)])
# TODO: was trying to this xfail style but some weird bug i see in CI # TODO: was trying to this xfail style but some weird bug i see in CI
# that's happening at collect time.. pretty soon gonna dump actions i'm # that's happening at collect time.. pretty soon gonna dump actions i'm
# thinkin... # thinkin...
@ -69,6 +79,142 @@ has_nested_actors = pytest.mark.has_nested_actors
# ) # )
@pytest.fixture
def spawn(
start_method,
testdir,
reg_addr,
) -> 'pexpect.spawn':
if start_method != 'trio':
pytest.skip(
"Debugger tests are only supported on the trio backend"
)
def _spawn(cmd):
return testdir.spawn(
cmd=mk_cmd(cmd),
expect_timeout=3,
)
return _spawn
PROMPT = r"\(Pdb\+\)"
def expect(
child,
# prompt by default
patt: str = PROMPT,
**kwargs,
) -> None:
'''
Expect wrapper that prints last seen console
data before failing.
'''
try:
child.expect(
patt,
**kwargs,
)
except TIMEOUT:
before = str(child.before.decode())
print(before)
raise
def in_prompt_msg(
prompt: str,
parts: list[str],
pause_on_false: bool = False,
print_prompt_on_false: bool = True,
) -> bool:
'''
Predicate check if (the prompt's) std-streams output has all
`str`-parts in it.
Can be used in test asserts for bulk matching expected
log/REPL output for a given `pdb` interact point.
'''
__tracebackhide__: bool = False
for part in parts:
if part not in prompt:
if pause_on_false:
import pdbp
pdbp.set_trace()
if print_prompt_on_false:
print(prompt)
return False
return True
# TODO: todo support terminal color-chars stripping so we can match
# against call stack frame output from the the 'll' command the like!
# -[ ] SO answer for stipping ANSI codes: https://stackoverflow.com/a/14693789
def assert_before(
child,
patts: list[str],
**kwargs,
) -> None:
__tracebackhide__: bool = False
# as in before the prompt end
before: str = str(child.before.decode())
assert in_prompt_msg(
prompt=before,
parts=patts,
**kwargs
)
@pytest.fixture(
params=[False, True],
ids='ctl-c={}'.format,
)
def ctlc(
request,
ci_env: bool,
) -> bool:
use_ctlc = request.param
node = request.node
markers = node.own_markers
for mark in markers:
if mark.name == 'has_nested_actors':
pytest.skip(
f'Test {node} has nested actors and fails with Ctrl-C.\n'
f'The test can sometimes run fine locally but until'
' we solve' 'this issue this CI test will be xfail:\n'
'https://github.com/goodboy/tractor/issues/320'
)
if use_ctlc:
# XXX: disable pygments highlighting for auto-tests
# since some envs (like actions CI) will struggle
# the the added color-char encoding..
from tractor.devx._debug import TractorConfig
TractorConfig.use_pygements = False
yield use_ctlc
@pytest.mark.parametrize( @pytest.mark.parametrize(
'user_in_out', 'user_in_out',
[ [
@ -92,15 +238,14 @@ def test_root_actor_error(
# scan for the prompt # scan for the prompt
expect(child, PROMPT) expect(child, PROMPT)
before = str(child.before.decode())
# make sure expected logging and error arrives # make sure expected logging and error arrives
assert in_prompt_msg( assert in_prompt_msg(
child, before,
[ [_crash_msg, "('root'"]
_crash_msg,
"('root'",
'AssertionError',
]
) )
assert 'AssertionError' in before
# send user command # send user command
child.sendline(user_input) child.sendline(user_input)
@ -119,10 +264,8 @@ def test_root_actor_error(
ids=lambda item: f'{item[0]} -> {item[1]}', ids=lambda item: f'{item[0]} -> {item[1]}',
) )
def test_root_actor_bp(spawn, user_in_out): def test_root_actor_bp(spawn, user_in_out):
''' """Demonstrate breakpoint from in root actor.
Demonstrate breakpoint from in root actor. """
'''
user_input, expect_err_str = user_in_out user_input, expect_err_str = user_in_out
child = spawn('root_actor_breakpoint') child = spawn('root_actor_breakpoint')
@ -136,7 +279,7 @@ def test_root_actor_bp(spawn, user_in_out):
child.expect('\r\n') child.expect('\r\n')
# process should exit # process should exit
child.expect(EOF) child.expect(pexpect.EOF)
if expect_err_str is None: if expect_err_str is None:
assert 'Error' not in str(child.before) assert 'Error' not in str(child.before)
@ -144,6 +287,38 @@ def test_root_actor_bp(spawn, user_in_out):
assert expect_err_str in str(child.before) assert expect_err_str in str(child.before)
def do_ctlc(
child,
count: int = 3,
delay: float = 0.1,
patt: str|None = None,
# expect repl UX to reprint the prompt after every
# ctrl-c send.
# XXX: no idea but, in CI this never seems to work even on 3.10 so
# needs some further investigation potentially...
expect_prompt: bool = not _ci_env,
) -> None:
# make sure ctl-c sends don't do anything but repeat output
for _ in range(count):
time.sleep(delay)
child.sendcontrol('c')
# TODO: figure out why this makes CI fail..
# if you run this test manually it works just fine..
if expect_prompt:
before = str(child.before.decode())
time.sleep(delay)
child.expect(PROMPT)
time.sleep(delay)
if patt:
# should see the last line on console
assert patt in before
def test_root_actor_bp_forever( def test_root_actor_bp_forever(
spawn, spawn,
ctlc: bool, ctlc: bool,
@ -183,7 +358,7 @@ def test_root_actor_bp_forever(
# quit out of the loop # quit out of the loop
child.sendline('q') child.sendline('q')
child.expect(EOF) child.expect(pexpect.EOF)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -205,12 +380,10 @@ def test_subactor_error(
# scan for the prompt # scan for the prompt
child.expect(PROMPT) child.expect(PROMPT)
before = str(child.before.decode())
assert in_prompt_msg( assert in_prompt_msg(
child, before,
[ [_crash_msg, "('name_error'"]
_crash_msg,
"('name_error'",
]
) )
if do_next: if do_next:
@ -229,15 +402,17 @@ def test_subactor_error(
child.sendline('continue') child.sendline('continue')
child.expect(PROMPT) child.expect(PROMPT)
before = str(child.before.decode())
# root actor gets debugger engaged
assert in_prompt_msg( assert in_prompt_msg(
child, before,
[ [_crash_msg, "('root'"]
_crash_msg, )
# root actor gets debugger engaged # error is a remote error propagated from the subactor
"('root'", assert in_prompt_msg(
# error is a remote error propagated from the subactor before,
"('name_error'", [_crash_msg, "('name_error'"]
]
) )
# another round # another round
@ -248,7 +423,7 @@ def test_subactor_error(
child.expect('\r\n') child.expect('\r\n')
# process should exit # process should exit
child.expect(EOF) child.expect(pexpect.EOF)
def test_subactor_breakpoint( def test_subactor_breakpoint(
@ -258,11 +433,14 @@ def test_subactor_breakpoint(
"Single subactor with an infinite breakpoint loop" "Single subactor with an infinite breakpoint loop"
child = spawn('subactor_breakpoint') child = spawn('subactor_breakpoint')
# scan for the prompt
child.expect(PROMPT) child.expect(PROMPT)
before = str(child.before.decode())
assert in_prompt_msg( assert in_prompt_msg(
child, before,
[_pause_msg, [_pause_msg, "('breakpoint_forever'"]
"('breakpoint_forever'",]
) )
# do some "next" commands to demonstrate recurrent breakpoint # do some "next" commands to demonstrate recurrent breakpoint
@ -278,8 +456,9 @@ def test_subactor_breakpoint(
for _ in range(5): for _ in range(5):
child.sendline('continue') child.sendline('continue')
child.expect(PROMPT) child.expect(PROMPT)
before = str(child.before.decode())
assert in_prompt_msg( assert in_prompt_msg(
child, before,
[_pause_msg, "('breakpoint_forever'"] [_pause_msg, "('breakpoint_forever'"]
) )
@ -292,8 +471,9 @@ def test_subactor_breakpoint(
# child process should exit but parent will capture pdb.BdbQuit # child process should exit but parent will capture pdb.BdbQuit
child.expect(PROMPT) child.expect(PROMPT)
before = str(child.before.decode())
assert in_prompt_msg( assert in_prompt_msg(
child, before,
['RemoteActorError:', ['RemoteActorError:',
"('breakpoint_forever'", "('breakpoint_forever'",
'bdb.BdbQuit',] 'bdb.BdbQuit',]
@ -306,16 +486,14 @@ def test_subactor_breakpoint(
child.sendline('c') child.sendline('c')
# process should exit # process should exit
child.expect(EOF) child.expect(pexpect.EOF)
before = str(child.before.decode())
assert in_prompt_msg( assert in_prompt_msg(
child, [ before,
'MessagingError:', ['RemoteActorError:',
'RemoteActorError:',
"('breakpoint_forever'", "('breakpoint_forever'",
'bdb.BdbQuit', 'bdb.BdbQuit',]
],
pause_on_false=True,
) )
@ -336,7 +514,7 @@ def test_multi_subactors(
before = str(child.before.decode()) before = str(child.before.decode())
assert in_prompt_msg( assert in_prompt_msg(
child, before,
[_pause_msg, "('breakpoint_forever'"] [_pause_msg, "('breakpoint_forever'"]
) )
@ -357,14 +535,12 @@ def test_multi_subactors(
# first name_error failure # first name_error failure
child.expect(PROMPT) child.expect(PROMPT)
before = str(child.before.decode())
assert in_prompt_msg( assert in_prompt_msg(
child, before,
[ [_crash_msg, "('name_error'"]
_crash_msg,
"('name_error'",
"NameError",
]
) )
assert "NameError" in before
if ctlc: if ctlc:
do_ctlc(child) do_ctlc(child)
@ -388,8 +564,9 @@ def test_multi_subactors(
# breakpoint loop should re-engage # breakpoint loop should re-engage
child.sendline('c') child.sendline('c')
child.expect(PROMPT) child.expect(PROMPT)
before = str(child.before.decode())
assert in_prompt_msg( assert in_prompt_msg(
child, before,
[_pause_msg, "('breakpoint_forever'"] [_pause_msg, "('breakpoint_forever'"]
) )
@ -452,7 +629,7 @@ def test_multi_subactors(
# process should exit # process should exit
child.sendline('c') child.sendline('c')
child.expect(EOF) child.expect(pexpect.EOF)
# repeat of previous multierror for final output # repeat of previous multierror for final output
assert_before(child, [ assert_before(child, [
@ -482,28 +659,25 @@ def test_multi_daemon_subactors(
# the root's tty lock first so anticipate either crash # the root's tty lock first so anticipate either crash
# message on the first entry. # message on the first entry.
bp_forev_parts = [ bp_forev_parts = [_pause_msg, "('bp_forever'"]
_pause_msg,
"('bp_forever'",
]
bp_forev_in_msg = partial( bp_forev_in_msg = partial(
in_prompt_msg, in_prompt_msg,
parts=bp_forev_parts, parts=bp_forev_parts,
) )
name_error_msg: str = "NameError: name 'doggypants' is not defined" name_error_msg = "NameError: name 'doggypants' is not defined"
name_error_parts: list[str] = [name_error_msg] name_error_parts = [name_error_msg]
before = str(child.before.decode()) before = str(child.before.decode())
if bp_forev_in_msg(child=child): if bp_forev_in_msg(prompt=before):
next_parts = name_error_parts next_parts = name_error_parts
elif name_error_msg in before: elif name_error_msg in before:
next_parts = bp_forev_parts next_parts = bp_forev_parts
else: else:
raise ValueError('Neither log msg was found !?') raise ValueError("Neither log msg was found !?")
if ctlc: if ctlc:
do_ctlc(child) do_ctlc(child)
@ -572,12 +746,14 @@ def test_multi_daemon_subactors(
# wait for final error in root # wait for final error in root
# where it crashs with boxed error # where it crashs with boxed error
while True: while True:
child.sendline('c') try:
child.expect(PROMPT) child.sendline('c')
if not in_prompt_msg( child.expect(PROMPT)
child, assert_before(
bp_forev_parts child,
): bp_forev_parts
)
except AssertionError:
break break
assert_before( assert_before(
@ -593,7 +769,7 @@ def test_multi_daemon_subactors(
) )
child.sendline('c') child.sendline('c')
child.expect(EOF) child.expect(pexpect.EOF)
@has_nested_actors @has_nested_actors
@ -669,7 +845,7 @@ def test_multi_subactors_root_errors(
]) ])
child.sendline('c') child.sendline('c')
child.expect(EOF) child.expect(pexpect.EOF)
assert_before(child, [ assert_before(child, [
# "Attaching to pdb in crashed actor: ('root'", # "Attaching to pdb in crashed actor: ('root'",
@ -758,13 +934,10 @@ def test_root_nursery_cancels_before_child_releases_tty_lock(
child = spawn('root_cancelled_but_child_is_in_tty_lock') child = spawn('root_cancelled_but_child_is_in_tty_lock')
child.expect(PROMPT) child.expect(PROMPT)
assert_before(
child, before = str(child.before.decode())
[ assert "NameError: name 'doggypants' is not defined" in before
"NameError: name 'doggypants' is not defined", assert "tractor._exceptions.RemoteActorError: ('name_error'" not in before
"tractor._exceptions.RemoteActorError: ('name_error'",
],
)
time.sleep(0.5) time.sleep(0.5)
if ctlc: if ctlc:
@ -802,7 +975,7 @@ def test_root_nursery_cancels_before_child_releases_tty_lock(
for i in range(3): for i in range(3):
try: try:
child.expect(EOF, timeout=0.5) child.expect(pexpect.EOF, timeout=0.5)
break break
except TIMEOUT: except TIMEOUT:
child.sendline('c') child.sendline('c')
@ -844,7 +1017,7 @@ def test_root_cancels_child_context_during_startup(
do_ctlc(child) do_ctlc(child)
child.sendline('c') child.sendline('c')
child.expect(EOF) child.expect(pexpect.EOF)
def test_different_debug_mode_per_actor( def test_different_debug_mode_per_actor(
@ -855,8 +1028,9 @@ def test_different_debug_mode_per_actor(
child.expect(PROMPT) child.expect(PROMPT)
# only one actor should enter the debugger # only one actor should enter the debugger
before = str(child.before.decode())
assert in_prompt_msg( assert in_prompt_msg(
child, before,
[_crash_msg, "('debugged_boi'", "RuntimeError"], [_crash_msg, "('debugged_boi'", "RuntimeError"],
) )
@ -864,7 +1038,9 @@ def test_different_debug_mode_per_actor(
do_ctlc(child) do_ctlc(child)
child.sendline('c') child.sendline('c')
child.expect(EOF) child.expect(pexpect.EOF)
before = str(child.before.decode())
# NOTE: this debugged actor error currently WON'T show up since the # NOTE: this debugged actor error currently WON'T show up since the
# root will actually cancel and terminate the nursery before the error # root will actually cancel and terminate the nursery before the error
@ -883,6 +1059,103 @@ def test_different_debug_mode_per_actor(
) )
def test_pause_from_sync(
spawn,
ctlc: bool
):
'''
Verify we can use the `pdbp` REPL from sync functions AND from
any thread spawned with `trio.to_thread.run_sync()`.
`examples/debugging/sync_bp.py`
'''
child = spawn('sync_bp')
# first `sync_pause()` after nurseries open
child.expect(PROMPT)
assert_before(
child,
[
# pre-prompt line
_pause_msg,
"<Task '__main__.main'",
"('root'",
]
)
if ctlc:
do_ctlc(child)
child.sendline('c')
# first `await tractor.pause()` inside `p.open_context()` body
child.expect(PROMPT)
# XXX shouldn't see gb loaded message with PDB loglevel!
before = str(child.before.decode())
assert not in_prompt_msg(
before,
['`greenback` portal opened!'],
)
# should be same root task
assert_before(
child,
[
_pause_msg,
"<Task '__main__.main'",
"('root'",
]
)
if ctlc:
do_ctlc(child)
# one of the bg thread or subactor should have
# `Lock.acquire()`-ed
# (NOT both, which will result in REPL clobbering!)
attach_patts: dict[str, list[str]] = {
'subactor': [
"'start_n_sync_pause'",
"('subactor'",
],
'inline_root_bg_thread': [
"<Thread(inline_root_bg_thread",
"('root'",
],
'start_soon_root_bg_thread': [
"<Thread(start_soon_root_bg_thread",
"('root'",
],
}
while attach_patts:
child.sendline('c')
child.expect(PROMPT)
before = str(child.before.decode())
for key in attach_patts.copy():
if key in before:
expected_patts: str = attach_patts.pop(key)
assert_before(
child,
[_pause_msg] + expected_patts
)
break
# ensure no other task/threads engaged a REPL
# at the same time as the one that was detected above.
for key, other_patts in attach_patts.items():
assert not in_prompt_msg(
before,
other_patts,
)
if ctlc:
do_ctlc(child)
child.sendline('c')
child.expect(pexpect.EOF)
def test_post_mortem_api( def test_post_mortem_api(
spawn, spawn,
ctlc: bool, ctlc: bool,
@ -985,7 +1258,7 @@ def test_post_mortem_api(
# ) # )
child.sendline('c') child.sendline('c')
child.expect(EOF) child.expect(pexpect.EOF)
def test_shield_pause( def test_shield_pause(
@ -1060,26 +1333,9 @@ def test_shield_pause(
] ]
) )
child.sendline('c') child.sendline('c')
child.expect(EOF) child.expect(pexpect.EOF)
# TODO: better error for "non-ideal" usage from the root actor.
# -[ ] if called from an async scope emit a message that suggests
# using `await tractor.pause()` instead since it's less overhead
# (in terms of `greenback` and/or extra threads) and if it's from
# a sync scope suggest that usage must first call
# `ensure_portal()` in the (eventual parent) async calling scope?
def test_sync_pause_from_bg_task_in_root_actor_():
'''
When used from the root actor, normally we can only implicitly
support `.pause_from_sync()` from the main-parent-task (that
opens the runtime via `open_root_actor()`) since `greenback`
requires a `.ensure_portal()` call per `trio.Task` where it is
used.
'''
...
# TODO: needs ANSI code stripping tho, see `assert_before()` # above! # TODO: needs ANSI code stripping tho, see `assert_before()` # above!
def test_correct_frames_below_hidden(): def test_correct_frames_below_hidden():
''' '''

View File

@ -181,9 +181,7 @@ async def spawn_and_check_registry(
try: try:
async with tractor.open_nursery() as n: async with tractor.open_nursery() as n:
async with trio.open_nursery( async with trio.open_nursery() as trion:
strict_exception_groups=False,
) as trion:
portals = {} portals = {}
for i in range(3): for i in range(3):
@ -318,9 +316,7 @@ async def close_chans_before_nursery(
async with portal2.open_stream_from( async with portal2.open_stream_from(
stream_forever stream_forever
) as agen2: ) as agen2:
async with trio.open_nursery( async with trio.open_nursery() as n:
strict_exception_groups=False,
) as n:
n.start_soon(streamer, agen1) n.start_soon(streamer, agen1)
n.start_soon(cancel, use_signal, .5) n.start_soon(cancel, use_signal, .5)
try: try:

View File

@ -19,7 +19,7 @@ from tractor._testing import (
@pytest.fixture @pytest.fixture
def run_example_in_subproc( def run_example_in_subproc(
loglevel: str, loglevel: str,
testdir: pytest.Pytester, testdir: pytest.Testdir,
reg_addr: tuple[str, int], reg_addr: tuple[str, int],
): ):
@ -81,36 +81,28 @@ def run_example_in_subproc(
# walk yields: (dirpath, dirnames, filenames) # walk yields: (dirpath, dirnames, filenames)
[ [
(p[0], f) (p[0], f) for p in os.walk(examples_dir()) for f in p[2]
for p in os.walk(examples_dir())
for f in p[2]
if ( if '__' not in f
'__' not in f and f[0] != '_'
and f[0] != '_' and 'debugging' not in p[0]
and 'debugging' not in p[0] and 'integration' not in p[0]
and 'integration' not in p[0] and 'advanced_faults' not in p[0]
and 'advanced_faults' not in p[0] and 'multihost' not in p[0]
and 'multihost' not in p[0]
)
], ],
ids=lambda t: t[1], ids=lambda t: t[1],
) )
def test_example( def test_example(run_example_in_subproc, example_script):
run_example_in_subproc, """Load and run scripts from this repo's ``examples/`` dir as a user
example_script,
):
'''
Load and run scripts from this repo's ``examples/`` dir as a user
would copy and pasing them into their editor. would copy and pasing them into their editor.
On windows a little more "finessing" is done to make On windows a little more "finessing" is done to make
``multiprocessing`` play nice: we copy the ``__main__.py`` into the ``multiprocessing`` play nice: we copy the ``__main__.py`` into the
test directory and invoke the script as a module with ``python -m test directory and invoke the script as a module with ``python -m
test_example``. test_example``.
"""
''' ex_file = os.path.join(*example_script)
ex_file: str = os.path.join(*example_script)
if 'rpc_bidir_streaming' in ex_file and sys.version_info < (3, 9): if 'rpc_bidir_streaming' in ex_file and sys.version_info < (3, 9):
pytest.skip("2-way streaming example requires py3.9 async with syntax") pytest.skip("2-way streaming example requires py3.9 async with syntax")
@ -136,8 +128,7 @@ def test_example(
# shouldn't eventually once we figure out what's # shouldn't eventually once we figure out what's
# a better way to be explicit about aio side # a better way to be explicit about aio side
# cancels? # cancels?
and and 'asyncio.exceptions.CancelledError' not in last_error
'asyncio.exceptions.CancelledError' not in last_error
): ):
raise Exception(errmsg) raise Exception(errmsg)

View File

@ -1,946 +0,0 @@
'''
Low-level functional audits for our
"capability based messaging"-spec feats.
B~)
'''
from contextlib import (
contextmanager as cm,
# nullcontext,
)
import importlib
from typing import (
Any,
Type,
Union,
)
from msgspec import (
# structs,
# msgpack,
Raw,
# Struct,
ValidationError,
)
import pytest
import trio
import tractor
from tractor import (
Actor,
# _state,
MsgTypeError,
Context,
)
from tractor.msg import (
_codec,
_ctxvar_MsgCodec,
_exts,
NamespacePath,
MsgCodec,
MsgDec,
mk_codec,
mk_dec,
apply_codec,
current_codec,
)
from tractor.msg.types import (
log,
Started,
# _payload_msgs,
# PayloadMsg,
# mk_msg_spec,
)
from tractor.msg._ops import (
limit_plds,
)
def enc_nsp(obj: Any) -> Any:
actor: Actor = tractor.current_actor(
err_on_no_runtime=False,
)
uid: tuple[str, str]|None = None if not actor else actor.uid
print(f'{uid} ENC HOOK')
match obj:
# case NamespacePath()|str():
case NamespacePath():
encoded: str = str(obj)
print(
f'----- ENCODING `NamespacePath` as `str` ------\n'
f'|_obj:{type(obj)!r} = {obj!r}\n'
f'|_encoded: str = {encoded!r}\n'
)
# if type(obj) != NamespacePath:
# breakpoint()
return encoded
case _:
logmsg: str = (
f'{uid}\n'
'FAILED ENCODE\n'
f'obj-> `{obj}: {type(obj)}`\n'
)
raise NotImplementedError(logmsg)
def dec_nsp(
obj_type: Type,
obj: Any,
) -> Any:
# breakpoint()
actor: Actor = tractor.current_actor(
err_on_no_runtime=False,
)
uid: tuple[str, str]|None = None if not actor else actor.uid
print(
f'{uid}\n'
'CUSTOM DECODE\n'
f'type-arg-> {obj_type}\n'
f'obj-arg-> `{obj}`: {type(obj)}\n'
)
nsp = None
# XXX, never happens right?
if obj_type is Raw:
breakpoint()
if (
obj_type is NamespacePath
and isinstance(obj, str)
and ':' in obj
):
nsp = NamespacePath(obj)
# TODO: we could built a generic handler using
# JUST matching the obj_type part?
# nsp = obj_type(obj)
if nsp:
print(f'Returning NSP instance: {nsp}')
return nsp
logmsg: str = (
f'{uid}\n'
'FAILED DECODE\n'
f'type-> {obj_type}\n'
f'obj-arg-> `{obj}`: {type(obj)}\n\n'
f'current codec:\n'
f'{current_codec()}\n'
)
# TODO: figure out the ignore subsys for this!
# -[ ] option whether to defense-relay backc the msg
# inside an `Invalid`/`Ignore`
# -[ ] how to make this handling pluggable such that a
# `Channel`/`MsgTransport` can intercept and process
# back msgs either via exception handling or some other
# signal?
log.warning(logmsg)
# NOTE: this delivers the invalid
# value up to `msgspec`'s decoding
# machinery for error raising.
return obj
# raise NotImplementedError(logmsg)
def ex_func(*args):
'''
A mod level func we can ref and load via our `NamespacePath`
python-object pointer `str` subtype.
'''
print(f'ex_func({args})')
@pytest.mark.parametrize(
'add_codec_hooks',
[
True,
False,
],
ids=['use_codec_hooks', 'no_codec_hooks'],
)
def test_custom_extension_types(
debug_mode: bool,
add_codec_hooks: bool
):
'''
Verify that a `MsgCodec` (used for encoding all outbound IPC msgs
and decoding all inbound `PayloadMsg`s) and a paired `MsgDec`
(used for decoding the `PayloadMsg.pld: Raw` received within a given
task's ipc `Context` scope) can both send and receive "extension types"
as supported via custom converter hooks passed to `msgspec`.
'''
nsp_pld_dec: MsgDec = mk_dec(
spec=None, # ONLY support the ext type
dec_hook=dec_nsp if add_codec_hooks else None,
ext_types=[NamespacePath],
)
nsp_codec: MsgCodec = mk_codec(
# ipc_pld_spec=Raw, # default!
# NOTE XXX: the encode hook MUST be used no matter what since
# our `NamespacePath` is not any of a `Any` native type nor
# a `msgspec.Struct` subtype - so `msgspec` has no way to know
# how to encode it unless we provide the custom hook.
#
# AGAIN that is, regardless of whether we spec an
# `Any`-decoded-pld the enc has no knowledge (by default)
# how to enc `NamespacePath` (nsp), so we add a custom
# hook to do that ALWAYS.
enc_hook=enc_nsp if add_codec_hooks else None,
# XXX NOTE: pretty sure this is mutex with the `type=` to
# `Decoder`? so it won't work in tandem with the
# `ipc_pld_spec` passed above?
ext_types=[NamespacePath],
# TODO? is it useful to have the `.pld` decoded *prior* to
# the `PldRx`?? like perf or mem related?
# ext_dec=nsp_pld_dec,
)
if add_codec_hooks:
assert nsp_codec.dec.dec_hook is None
# TODO? if we pass `ext_dec` above?
# assert nsp_codec.dec.dec_hook is dec_nsp
assert nsp_codec.enc.enc_hook is enc_nsp
nsp = NamespacePath.from_ref(ex_func)
try:
nsp_bytes: bytes = nsp_codec.encode(nsp)
nsp_rt_sin_msg = nsp_pld_dec.decode(nsp_bytes)
nsp_rt_sin_msg.load_ref() is ex_func
except TypeError:
if not add_codec_hooks:
pass
try:
msg_bytes: bytes = nsp_codec.encode(
Started(
cid='cid',
pld=nsp,
)
)
# since the ext-type obj should also be set as the msg.pld
assert nsp_bytes in msg_bytes
started_rt: Started = nsp_codec.decode(msg_bytes)
pld: Raw = started_rt.pld
assert isinstance(pld, Raw)
nsp_rt: NamespacePath = nsp_pld_dec.decode(pld)
assert isinstance(nsp_rt, NamespacePath)
# in obj comparison terms they should be the same
assert nsp_rt == nsp
# ensure we've decoded to ext type!
assert nsp_rt.load_ref() is ex_func
except TypeError:
if not add_codec_hooks:
pass
@tractor.context
async def sleep_forever_in_sub(
ctx: Context,
) -> None:
await trio.sleep_forever()
def mk_custom_codec(
add_hooks: bool,
) -> tuple[
MsgCodec, # encode to send
MsgDec, # pld receive-n-decode
]:
'''
Create custom `msgpack` enc/dec-hooks and set a `Decoder`
which only loads `pld_spec` (like `NamespacePath`) types.
'''
# XXX NOTE XXX: despite defining `NamespacePath` as a type
# field on our `PayloadMsg.pld`, we still need a enc/dec_hook() pair
# to cast to/from that type on the wire. See the docs:
# https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types
# if pld_spec is Any:
# pld_spec = Raw
nsp_codec: MsgCodec = mk_codec(
# ipc_pld_spec=Raw, # default!
# NOTE XXX: the encode hook MUST be used no matter what since
# our `NamespacePath` is not any of a `Any` native type nor
# a `msgspec.Struct` subtype - so `msgspec` has no way to know
# how to encode it unless we provide the custom hook.
#
# AGAIN that is, regardless of whether we spec an
# `Any`-decoded-pld the enc has no knowledge (by default)
# how to enc `NamespacePath` (nsp), so we add a custom
# hook to do that ALWAYS.
enc_hook=enc_nsp if add_hooks else None,
# XXX NOTE: pretty sure this is mutex with the `type=` to
# `Decoder`? so it won't work in tandem with the
# `ipc_pld_spec` passed above?
ext_types=[NamespacePath],
)
# dec_hook=dec_nsp if add_hooks else None,
return nsp_codec
@pytest.mark.parametrize(
'limit_plds_args',
[
(
{'dec_hook': None, 'ext_types': None},
None,
),
(
{'dec_hook': dec_nsp, 'ext_types': None},
TypeError,
),
(
{'dec_hook': dec_nsp, 'ext_types': [NamespacePath]},
None,
),
(
{'dec_hook': dec_nsp, 'ext_types': [NamespacePath|None]},
None,
),
],
ids=[
'no_hook_no_ext_types',
'only_hook',
'hook_and_ext_types',
'hook_and_ext_types_w_null',
]
)
def test_pld_limiting_usage(
limit_plds_args: tuple[dict, Exception|None],
):
'''
Verify `dec_hook()` and `ext_types` need to either both be
provided or we raise a explanator type-error.
'''
kwargs, maybe_err = limit_plds_args
async def main():
async with tractor.open_nursery() as an: # just to open runtime
# XXX SHOULD NEVER WORK outside an ipc ctx scope!
try:
with limit_plds(**kwargs):
pass
except RuntimeError:
pass
p: tractor.Portal = await an.start_actor(
'sub',
enable_modules=[__name__],
)
async with (
p.open_context(
sleep_forever_in_sub
) as (ctx, first),
):
try:
with limit_plds(**kwargs):
pass
except maybe_err as exc:
assert type(exc) is maybe_err
pass
def chk_codec_applied(
expect_codec: MsgCodec|None,
enter_value: MsgCodec|None = None,
) -> MsgCodec:
'''
buncha sanity checks ensuring that the IPC channel's
context-vars are set to the expected codec and that are
ctx-var wrapper APIs match the same.
'''
# TODO: play with tricyle again, bc this is supposed to work
# the way we want?
#
# TreeVar
# task: trio.Task = trio.lowlevel.current_task()
# curr_codec = _ctxvar_MsgCodec.get_in(task)
# ContextVar
# task_ctx: Context = task.context
# assert _ctxvar_MsgCodec in task_ctx
# curr_codec: MsgCodec = task.context[_ctxvar_MsgCodec]
if expect_codec is None:
assert enter_value is None
return
# NOTE: currently we use this!
# RunVar
curr_codec: MsgCodec = current_codec()
last_read_codec = _ctxvar_MsgCodec.get()
# assert curr_codec is last_read_codec
assert (
(same_codec := expect_codec) is
# returned from `mk_codec()`
# yielded value from `apply_codec()`
# read from current task's `contextvars.Context`
curr_codec is
last_read_codec
# the default `msgspec` settings
is not _codec._def_msgspec_codec
is not _codec._def_tractor_codec
)
if enter_value:
assert enter_value is same_codec
@tractor.context
async def send_back_values(
ctx: Context,
rent_pld_spec_type_strs: list[str],
add_hooks: bool,
) -> None:
'''
Setup up a custom codec to load instances of `NamespacePath`
and ensure we can round trip a func ref with our parent.
'''
uid: tuple = tractor.current_actor().uid
# init state in sub-actor should be default
chk_codec_applied(
expect_codec=_codec._def_tractor_codec,
)
# load pld spec from input str
rent_pld_spec = _exts.dec_type_union(
rent_pld_spec_type_strs,
mods=[
importlib.import_module(__name__),
],
)
rent_pld_spec_types: set[Type] = _codec.unpack_spec_types(
rent_pld_spec,
)
# ONLY add ext-hooks if the rent specified a non-std type!
add_hooks: bool = (
NamespacePath in rent_pld_spec_types
and
add_hooks
)
# same as on parent side config.
nsp_codec: MsgCodec|None = None
if add_hooks:
nsp_codec = mk_codec(
enc_hook=enc_nsp,
ext_types=[NamespacePath],
)
with (
maybe_apply_codec(nsp_codec) as codec,
limit_plds(
rent_pld_spec,
dec_hook=dec_nsp if add_hooks else None,
ext_types=[NamespacePath] if add_hooks else None,
) as pld_dec,
):
# ?XXX? SHOULD WE NOT be swapping the global codec since it
# breaks `Context.started()` roundtripping checks??
chk_codec_applied(
expect_codec=nsp_codec,
enter_value=codec,
)
# ?TODO, mismatch case(s)?
#
# ensure pld spec matches on both sides
ctx_pld_dec: MsgDec = ctx._pld_rx._pld_dec
assert pld_dec is ctx_pld_dec
child_pld_spec: Type = pld_dec.spec
child_pld_spec_types: set[Type] = _codec.unpack_spec_types(
child_pld_spec,
)
assert (
child_pld_spec_types.issuperset(
rent_pld_spec_types
)
)
# ?TODO, try loop for each of the types in pld-superset?
#
# for send_value in [
# nsp,
# str(nsp),
# None,
# ]:
nsp = NamespacePath.from_ref(ex_func)
try:
print(
f'{uid}: attempting to `.started({nsp})`\n'
f'\n'
f'rent_pld_spec: {rent_pld_spec}\n'
f'child_pld_spec: {child_pld_spec}\n'
f'codec: {codec}\n'
)
# await tractor.pause()
await ctx.started(nsp)
except tractor.MsgTypeError as _mte:
mte = _mte
# false -ve case
if add_hooks:
raise RuntimeError(
f'EXPECTED to `.started()` value given spec ??\n\n'
f'child_pld_spec -> {child_pld_spec}\n'
f'value = {nsp}: {type(nsp)}\n'
)
# true -ve case
raise mte
# TODO: maybe we should add our own wrapper error so as to
# be interchange-lib agnostic?
# -[ ] the error type is wtv is raised from the hook so we
# could also require a type-class of errors for
# indicating whether the hook-failure can be handled by
# a nasty-dialog-unprot sub-sys?
except TypeError as typerr:
# false -ve
if add_hooks:
raise RuntimeError('Should have been able to send `nsp`??')
# true -ve
print('Failed to send `nsp` due to no ext hooks set!')
raise typerr
# now try sending a set of valid and invalid plds to ensure
# the pld spec is respected.
sent: list[Any] = []
async with ctx.open_stream() as ipc:
print(
f'{uid}: streaming all pld types to rent..'
)
# for send_value, expect_send in iter_send_val_items:
for send_value in [
nsp,
str(nsp),
None,
]:
send_type: Type = type(send_value)
print(
f'{uid}: SENDING NEXT pld\n'
f'send_type: {send_type}\n'
f'send_value: {send_value}\n'
)
try:
await ipc.send(send_value)
sent.append(send_value)
except ValidationError as valerr:
print(f'{uid} FAILED TO SEND {send_value}!')
# false -ve
if add_hooks:
raise RuntimeError(
f'EXPECTED to roundtrip value given spec:\n'
f'rent_pld_spec -> {rent_pld_spec}\n'
f'child_pld_spec -> {child_pld_spec}\n'
f'value = {send_value}: {send_type}\n'
)
# true -ve
raise valerr
# continue
else:
print(
f'{uid}: finished sending all values\n'
'Should be exiting stream block!\n'
)
print(f'{uid}: exited streaming block!')
@cm
def maybe_apply_codec(codec: MsgCodec|None) -> MsgCodec|None:
if codec is None:
yield None
return
with apply_codec(codec) as codec:
yield codec
@pytest.mark.parametrize(
'pld_spec',
[
Any,
NamespacePath,
NamespacePath|None, # the "maybe" spec Bo
],
ids=[
'any_type',
'only_nsp_ext',
'maybe_nsp_ext',
]
)
@pytest.mark.parametrize(
'add_hooks',
[
True,
False,
],
ids=[
'use_codec_hooks',
'no_codec_hooks',
],
)
def test_ext_types_over_ipc(
debug_mode: bool,
pld_spec: Union[Type],
add_hooks: bool,
):
'''
Ensure we can support extension types coverted using
`enc/dec_hook()`s passed to the `.msg.limit_plds()` API
and that sane errors happen when we try do the same without
the codec hooks.
'''
pld_types: set[Type] = _codec.unpack_spec_types(pld_spec)
async def main():
# sanity check the default pld-spec beforehand
chk_codec_applied(
expect_codec=_codec._def_tractor_codec,
)
# extension type we want to send as msg payload
nsp = NamespacePath.from_ref(ex_func)
# ^NOTE, 2 cases:
# - codec hooks noto added -> decode nsp as `str`
# - codec with hooks -> decode nsp as `NamespacePath`
nsp_codec: MsgCodec|None = None
if (
NamespacePath in pld_types
and
add_hooks
):
nsp_codec = mk_codec(
enc_hook=enc_nsp,
ext_types=[NamespacePath],
)
async with tractor.open_nursery(
debug_mode=debug_mode,
) as an:
p: tractor.Portal = await an.start_actor(
'sub',
enable_modules=[__name__],
)
with (
maybe_apply_codec(nsp_codec) as codec,
):
chk_codec_applied(
expect_codec=nsp_codec,
enter_value=codec,
)
rent_pld_spec_type_strs: list[str] = _exts.enc_type_union(pld_spec)
# XXX should raise an mte (`MsgTypeError`)
# when `add_hooks == False` bc the input
# `expect_ipc_send` kwarg has a nsp which can't be
# serialized!
#
# TODO:can we ensure this happens from the
# `Return`-side (aka the sub) as well?
try:
ctx: tractor.Context
ipc: tractor.MsgStream
async with (
# XXX should raise an mte (`MsgTypeError`)
# when `add_hooks == False`..
p.open_context(
send_back_values,
# expect_debug=debug_mode,
rent_pld_spec_type_strs=rent_pld_spec_type_strs,
add_hooks=add_hooks,
# expect_ipc_send=expect_ipc_send,
) as (ctx, first),
ctx.open_stream() as ipc,
):
with (
limit_plds(
pld_spec,
dec_hook=dec_nsp if add_hooks else None,
ext_types=[NamespacePath] if add_hooks else None,
) as pld_dec,
):
ctx_pld_dec: MsgDec = ctx._pld_rx._pld_dec
assert pld_dec is ctx_pld_dec
# if (
# not add_hooks
# and
# NamespacePath in
# ):
# pytest.fail('ctx should fail to open without custom enc_hook!?')
await ipc.send(nsp)
nsp_rt = await ipc.receive()
assert nsp_rt == nsp
assert nsp_rt.load_ref() is ex_func
# this test passes bc we can go no further!
except MsgTypeError as mte:
# if not add_hooks:
# # teardown nursery
# await p.cancel_actor()
# return
raise mte
await p.cancel_actor()
if (
NamespacePath in pld_types
and
add_hooks
):
trio.run(main)
else:
with pytest.raises(
expected_exception=tractor.RemoteActorError,
) as excinfo:
trio.run(main)
exc = excinfo.value
# bc `.started(nsp: NamespacePath)` will raise
assert exc.boxed_type is TypeError
# def chk_pld_type(
# payload_spec: Type[Struct]|Any,
# pld: Any,
# expect_roundtrip: bool|None = None,
# ) -> bool:
# pld_val_type: Type = type(pld)
# # TODO: verify that the overridden subtypes
# # DO NOT have modified type-annots from original!
# # 'Start', .pld: FuncSpec
# # 'StartAck', .pld: IpcCtxSpec
# # 'Stop', .pld: UNSEt
# # 'Error', .pld: ErrorData
# codec: MsgCodec = mk_codec(
# # NOTE: this ONLY accepts `PayloadMsg.pld` fields of a specified
# # type union.
# ipc_pld_spec=payload_spec,
# )
# # make a one-off dec to compare with our `MsgCodec` instance
# # which does the below `mk_msg_spec()` call internally
# ipc_msg_spec: Union[Type[Struct]]
# msg_types: list[PayloadMsg[payload_spec]]
# (
# ipc_msg_spec,
# msg_types,
# ) = mk_msg_spec(
# payload_type_union=payload_spec,
# )
# _enc = msgpack.Encoder()
# _dec = msgpack.Decoder(
# type=ipc_msg_spec or Any, # like `PayloadMsg[Any]`
# )
# assert (
# payload_spec
# ==
# codec.pld_spec
# )
# # assert codec.dec == dec
# #
# # ^-XXX-^ not sure why these aren't "equal" but when cast
# # to `str` they seem to match ?? .. kk
# assert (
# str(ipc_msg_spec)
# ==
# str(codec.msg_spec)
# ==
# str(_dec.type)
# ==
# str(codec.dec.type)
# )
# # verify the boxed-type for all variable payload-type msgs.
# if not msg_types:
# breakpoint()
# roundtrip: bool|None = None
# pld_spec_msg_names: list[str] = [
# td.__name__ for td in _payload_msgs
# ]
# for typedef in msg_types:
# skip_runtime_msg: bool = typedef.__name__ not in pld_spec_msg_names
# if skip_runtime_msg:
# continue
# pld_field = structs.fields(typedef)[1]
# assert pld_field.type is payload_spec # TODO-^ does this need to work to get all subtypes to adhere?
# kwargs: dict[str, Any] = {
# 'cid': '666',
# 'pld': pld,
# }
# enc_msg: PayloadMsg = typedef(**kwargs)
# _wire_bytes: bytes = _enc.encode(enc_msg)
# wire_bytes: bytes = codec.enc.encode(enc_msg)
# assert _wire_bytes == wire_bytes
# ve: ValidationError|None = None
# try:
# dec_msg = codec.dec.decode(wire_bytes)
# _dec_msg = _dec.decode(wire_bytes)
# # decoded msg and thus payload should be exactly same!
# assert (roundtrip := (
# _dec_msg
# ==
# dec_msg
# ==
# enc_msg
# ))
# if (
# expect_roundtrip is not None
# and expect_roundtrip != roundtrip
# ):
# breakpoint()
# assert (
# pld
# ==
# dec_msg.pld
# ==
# enc_msg.pld
# )
# # assert (roundtrip := (_dec_msg == enc_msg))
# except ValidationError as _ve:
# ve = _ve
# roundtrip: bool = False
# if pld_val_type is payload_spec:
# raise ValueError(
# 'Got `ValidationError` despite type-var match!?\n'
# f'pld_val_type: {pld_val_type}\n'
# f'payload_type: {payload_spec}\n'
# ) from ve
# else:
# # ow we good cuz the pld spec mismatched.
# print(
# 'Got expected `ValidationError` since,\n'
# f'{pld_val_type} is not {payload_spec}\n'
# )
# else:
# if (
# payload_spec is not Any
# and
# pld_val_type is not payload_spec
# ):
# raise ValueError(
# 'DID NOT `ValidationError` despite expected type match!?\n'
# f'pld_val_type: {pld_val_type}\n'
# f'payload_type: {payload_spec}\n'
# )
# # full code decode should always be attempted!
# if roundtrip is None:
# breakpoint()
# return roundtrip
# ?TODO? maybe remove since covered in the newer `test_pldrx_limiting`
# via end-2-end testing of all this?
# -[ ] IOW do we really NEED this lowlevel unit testing?
#
# def test_limit_msgspec(
# debug_mode: bool,
# ):
# '''
# Internals unit testing to verify that type-limiting an IPC ctx's
# msg spec with `Pldrx.limit_plds()` results in various
# encapsulated `msgspec` object settings and state.
# '''
# async def main():
# async with tractor.open_root_actor(
# debug_mode=debug_mode,
# ):
# # ensure we can round-trip a boxing `PayloadMsg`
# assert chk_pld_type(
# payload_spec=Any,
# pld=None,
# expect_roundtrip=True,
# )
# # verify that a mis-typed payload value won't decode
# assert not chk_pld_type(
# payload_spec=int,
# pld='doggy',
# )
# # parametrize the boxed `.pld` type as a custom-struct
# # and ensure that parametrization propagates
# # to all payload-msg-spec-able subtypes!
# class CustomPayload(Struct):
# name: str
# value: Any
# assert not chk_pld_type(
# payload_spec=CustomPayload,
# pld='doggy',
# )
# assert chk_pld_type(
# payload_spec=CustomPayload,
# pld=CustomPayload(name='doggy', value='urmom')
# )
# # yah, we can `.pause_from_sync()` now!
# # breakpoint()
# trio.run(main)

File diff suppressed because it is too large Load Diff

View File

@ -170,7 +170,7 @@ def test_do_not_swallow_error_before_started_by_remote_contextcancelled(
trio.run(main) trio.run(main)
rae = excinfo.value rae = excinfo.value
assert rae.boxed_type is TypeError assert rae.boxed_type == TypeError
@tractor.context @tractor.context

View File

@ -1,248 +0,0 @@
'''
Special attention cases for using "infect `asyncio`" mode from a root
actor; i.e. not using a std `trio.run()` bootstrap.
'''
import asyncio
from functools import partial
import pytest
import trio
import tractor
from tractor import (
to_asyncio,
)
from tests.test_infected_asyncio import (
aio_echo_server,
)
@pytest.mark.parametrize(
'raise_error_mid_stream',
[
False,
Exception,
KeyboardInterrupt,
],
ids='raise_error={}'.format,
)
def test_infected_root_actor(
raise_error_mid_stream: bool|Exception,
# conftest wide
loglevel: str,
debug_mode: bool,
):
'''
Verify you can run the `tractor` runtime with `Actor.is_infected_aio() == True`
in the root actor.
'''
async def _trio_main():
with trio.fail_after(2 if not debug_mode else 999):
first: str
chan: to_asyncio.LinkedTaskChannel
async with (
tractor.open_root_actor(
debug_mode=debug_mode,
loglevel=loglevel,
),
to_asyncio.open_channel_from(
aio_echo_server,
) as (first, chan),
):
assert first == 'start'
for i in range(1000):
await chan.send(i)
out = await chan.receive()
assert out == i
print(f'asyncio echoing {i}')
if (
raise_error_mid_stream
and
i == 500
):
raise raise_error_mid_stream
if out is None:
try:
out = await chan.receive()
except trio.EndOfChannel:
break
else:
raise RuntimeError(
'aio channel never stopped?'
)
if raise_error_mid_stream:
with pytest.raises(raise_error_mid_stream):
tractor.to_asyncio.run_as_asyncio_guest(
trio_main=_trio_main,
)
else:
tractor.to_asyncio.run_as_asyncio_guest(
trio_main=_trio_main,
)
async def sync_and_err(
# just signature placeholders for compat with
# ``to_asyncio.open_channel_from()``
to_trio: trio.MemorySendChannel,
from_trio: asyncio.Queue,
ev: asyncio.Event,
):
if to_trio:
to_trio.send_nowait('start')
await ev.wait()
raise RuntimeError('asyncio-side')
@pytest.mark.parametrize(
'aio_err_trigger',
[
'before_start_point',
'after_trio_task_starts',
'after_start_point',
],
ids='aio_err_triggered={}'.format
)
def test_trio_prestarted_task_bubbles(
aio_err_trigger: str,
# conftest wide
loglevel: str,
debug_mode: bool,
):
async def pre_started_err(
raise_err: bool = False,
pre_sleep: float|None = None,
aio_trigger: asyncio.Event|None = None,
task_status=trio.TASK_STATUS_IGNORED,
):
'''
Maybe pre-started error then sleep.
'''
if pre_sleep is not None:
print(f'Sleeping from trio for {pre_sleep!r}s !')
await trio.sleep(pre_sleep)
# signal aio-task to raise JUST AFTER this task
# starts but has not yet `.started()`
if aio_trigger:
print('Signalling aio-task to raise from `trio`!!')
aio_trigger.set()
if raise_err:
print('Raising from trio!')
raise TypeError('trio-side')
task_status.started()
await trio.sleep_forever()
async def _trio_main():
# with trio.fail_after(2):
with trio.fail_after(999):
first: str
chan: to_asyncio.LinkedTaskChannel
aio_ev = asyncio.Event()
async with (
tractor.open_root_actor(
debug_mode=False,
loglevel=loglevel,
),
):
# TODO, tests for this with 3.13 egs?
# from tractor.devx import open_crash_handler
# with open_crash_handler():
async with (
# where we'll start a sub-task that errors BEFORE
# calling `.started()` such that the error should
# bubble before the guest run terminates!
trio.open_nursery() as tn,
# THEN start an infect task which should error just
# after the trio-side's task does.
to_asyncio.open_channel_from(
partial(
sync_and_err,
ev=aio_ev,
)
) as (first, chan),
):
for i in range(5):
pre_sleep: float|None = None
last_iter: bool = (i == 4)
# TODO, missing cases?
# -[ ] error as well on
# 'after_start_point' case as well for
# another case?
raise_err: bool = False
if last_iter:
raise_err: bool = True
# trigger aio task to error on next loop
# tick/checkpoint
if aio_err_trigger == 'before_start_point':
aio_ev.set()
pre_sleep: float = 0
await tn.start(
pre_started_err,
raise_err,
pre_sleep,
(aio_ev if (
aio_err_trigger == 'after_trio_task_starts'
and
last_iter
) else None
),
)
if (
aio_err_trigger == 'after_start_point'
and
last_iter
):
aio_ev.set()
with pytest.raises(
expected_exception=ExceptionGroup,
) as excinfo:
tractor.to_asyncio.run_as_asyncio_guest(
trio_main=_trio_main,
)
eg = excinfo.value
rte_eg, rest_eg = eg.split(RuntimeError)
# ensure the trio-task's error bubbled despite the aio-side
# having (maybe) errored first.
if aio_err_trigger in (
'after_trio_task_starts',
'after_start_point',
):
assert len(errs := rest_eg.exceptions) == 1
typerr = errs[0]
assert (
type(typerr) is TypeError
and
'trio-side' in typerr.args
)
# when aio errors BEFORE (last) trio task is scheduled, we should
# never see anythinb but the aio-side.
else:
assert len(rtes := rte_eg.exceptions) == 1
assert 'asyncio-side' in rtes[0].args[0]

View File

@ -2,9 +2,7 @@
Broadcast channels for fan-out to local tasks. Broadcast channels for fan-out to local tasks.
""" """
from contextlib import ( from contextlib import asynccontextmanager
asynccontextmanager as acm,
)
from functools import partial from functools import partial
from itertools import cycle from itertools import cycle
import time import time
@ -17,7 +15,6 @@ import tractor
from tractor.trionics import ( from tractor.trionics import (
broadcast_receiver, broadcast_receiver,
Lagged, Lagged,
collapse_eg,
) )
@ -65,7 +62,7 @@ async def ensure_sequence(
break break
@acm @asynccontextmanager
async def open_sequence_streamer( async def open_sequence_streamer(
sequence: list[int], sequence: list[int],
@ -77,9 +74,9 @@ async def open_sequence_streamer(
async with tractor.open_nursery( async with tractor.open_nursery(
arbiter_addr=reg_addr, arbiter_addr=reg_addr,
start_method=start_method, start_method=start_method,
) as an: ) as tn:
portal = await an.start_actor( portal = await tn.start_actor(
'sequence_echoer', 'sequence_echoer',
enable_modules=[__name__], enable_modules=[__name__],
) )
@ -158,12 +155,9 @@ def test_consumer_and_parent_maybe_lag(
) as stream: ) as stream:
try: try:
async with ( async with trio.open_nursery() as n:
collapse_eg(),
trio.open_nursery() as tn,
):
tn.start_soon( n.start_soon(
ensure_sequence, ensure_sequence,
stream, stream,
sequence.copy(), sequence.copy(),
@ -236,8 +230,8 @@ def test_faster_task_to_recv_is_cancelled_by_slower(
) as stream: ) as stream:
async with trio.open_nursery() as tn: async with trio.open_nursery() as n:
tn.start_soon( n.start_soon(
ensure_sequence, ensure_sequence,
stream, stream,
sequence.copy(), sequence.copy(),
@ -259,7 +253,7 @@ def test_faster_task_to_recv_is_cancelled_by_slower(
continue continue
print('cancelling faster subtask') print('cancelling faster subtask')
tn.cancel_scope.cancel() n.cancel_scope.cancel()
try: try:
value = await stream.receive() value = await stream.receive()
@ -277,7 +271,7 @@ def test_faster_task_to_recv_is_cancelled_by_slower(
# the faster subtask was cancelled # the faster subtask was cancelled
break break
# await tractor.pause() # await tractor.breakpoint()
# await stream.receive() # await stream.receive()
print(f'final value: {value}') print(f'final value: {value}')
@ -377,13 +371,13 @@ def test_ensure_slow_consumers_lag_out(
f'on {lags}:{value}') f'on {lags}:{value}')
return return
async with trio.open_nursery() as tn: async with trio.open_nursery() as nursery:
for i in range(1, num_laggers): for i in range(1, num_laggers):
task_name = f'sub_{i}' task_name = f'sub_{i}'
laggers[task_name] = 0 laggers[task_name] = 0
tn.start_soon( nursery.start_soon(
partial( partial(
sub_and_print, sub_and_print,
delay=i*0.001, delay=i*0.001,
@ -503,7 +497,6 @@ def test_no_raise_on_lag():
# internals when the no raise flag is set. # internals when the no raise flag is set.
loglevel='warning', loglevel='warning',
), ),
collapse_eg(),
trio.open_nursery() as n, trio.open_nursery() as n,
): ):
n.start_soon(slow) n.start_soon(slow)

View File

@ -3,10 +3,6 @@ Reminders for oddities in `trio` that we need to stay aware of and/or
want to see changed. want to see changed.
''' '''
from contextlib import (
asynccontextmanager as acm,
)
import pytest import pytest
import trio import trio
from trio import TaskStatus from trio import TaskStatus
@ -64,9 +60,7 @@ def test_stashed_child_nursery(use_start_soon):
async def main(): async def main():
async with ( async with (
trio.open_nursery( trio.open_nursery() as pn,
strict_exception_groups=False,
) as pn,
): ):
cn = await pn.start(mk_child_nursery) cn = await pn.start(mk_child_nursery)
assert cn assert cn
@ -86,118 +80,3 @@ def test_stashed_child_nursery(use_start_soon):
with pytest.raises(NameError): with pytest.raises(NameError):
trio.run(main) trio.run(main)
@pytest.mark.parametrize(
('unmask_from_canc', 'canc_from_finally'),
[
(True, False),
(True, True),
pytest.param(False, True,
marks=pytest.mark.xfail(reason="never raises!")
),
],
# TODO, ask ronny how to impl this .. XD
# ids='unmask_from_canc={0}, canc_from_finally={1}',#.format,
)
def test_acm_embedded_nursery_propagates_enter_err(
canc_from_finally: bool,
unmask_from_canc: bool,
debug_mode: bool,
):
'''
Demo how a masking `trio.Cancelled` could be handled by unmasking from the
`.__context__` field when a user (by accident) re-raises from a `finally:`.
'''
import tractor
@acm
async def maybe_raise_from_masking_exc(
tn: trio.Nursery,
unmask_from: BaseException|None = trio.Cancelled
# TODO, maybe offer a collection?
# unmask_from: set[BaseException] = {
# trio.Cancelled,
# },
):
if not unmask_from:
yield
return
try:
yield
except* unmask_from as be_eg:
# TODO, if we offer `unmask_from: set`
# for masker_exc_type in unmask_from:
matches, rest = be_eg.split(unmask_from)
if not matches:
raise
for exc_match in be_eg.exceptions:
if (
(exc_ctx := exc_match.__context__)
and
type(exc_ctx) not in {
# trio.Cancelled, # always by default?
unmask_from,
}
):
exc_ctx.add_note(
f'\n'
f'WARNING: the above error was masked by a {unmask_from!r} !?!\n'
f'Are you always cancelling? Say from a `finally:` ?\n\n'
f'{tn!r}'
)
raise exc_ctx from exc_match
@acm
async def wraps_tn_that_always_cancels():
async with (
trio.open_nursery() as tn,
maybe_raise_from_masking_exc(
tn=tn,
unmask_from=(
trio.Cancelled
if unmask_from_canc
else None
),
)
):
try:
yield tn
finally:
if canc_from_finally:
tn.cancel_scope.cancel()
await trio.lowlevel.checkpoint()
async def _main():
with tractor.devx.maybe_open_crash_handler(
pdb=debug_mode,
) as bxerr:
assert not bxerr.value
async with (
wraps_tn_that_always_cancels() as tn,
):
assert not tn.cancel_scope.cancel_called
assert 0
assert (
(err := bxerr.value)
and
type(err) is AssertionError
)
with pytest.raises(ExceptionGroup) as excinfo:
trio.run(_main)
eg: ExceptionGroup = excinfo.value
assert_eg, rest_eg = eg.split(AssertionError)
assert len(assert_eg.exceptions) == 1

View File

@ -44,7 +44,6 @@ from ._state import (
current_actor as current_actor, current_actor as current_actor,
is_root_process as is_root_process, is_root_process as is_root_process,
current_ipc_ctx as current_ipc_ctx, current_ipc_ctx as current_ipc_ctx,
debug_mode as debug_mode
) )
from ._exceptions import ( from ._exceptions import (
ContextCancelled as ContextCancelled, ContextCancelled as ContextCancelled,
@ -67,4 +66,3 @@ from ._root import (
from ._ipc import Channel as Channel from ._ipc import Channel as Channel
from ._portal import Portal as Portal from ._portal import Portal as Portal
from ._runtime import Actor as Actor from ._runtime import Actor as Actor
# from . import hilevel as hilevel

View File

@ -19,13 +19,10 @@ Actor cluster helpers.
''' '''
from __future__ import annotations from __future__ import annotations
from contextlib import (
asynccontextmanager as acm, from contextlib import asynccontextmanager as acm
)
from multiprocessing import cpu_count from multiprocessing import cpu_count
from typing import ( from typing import AsyncGenerator, Optional
AsyncGenerator,
)
import trio import trio
import tractor import tractor

View File

@ -47,9 +47,6 @@ from functools import partial
import inspect import inspect
from pprint import pformat from pprint import pformat
import textwrap import textwrap
from types import (
UnionType,
)
from typing import ( from typing import (
Any, Any,
AsyncGenerator, AsyncGenerator,
@ -82,7 +79,6 @@ from .msg import (
MsgType, MsgType,
NamespacePath, NamespacePath,
PayloadT, PayloadT,
Return,
Started, Started,
Stop, Stop,
Yield, Yield,
@ -246,13 +242,11 @@ class Context:
# a drain loop? # a drain loop?
# _res_scope: trio.CancelScope|None = None # _res_scope: trio.CancelScope|None = None
_outcome_msg: Return|Error|ContextCancelled = Unresolved
# on a clean exit there should be a final value # on a clean exit there should be a final value
# delivered from the far end "callee" task, so # delivered from the far end "callee" task, so
# this value is only set on one side. # this value is only set on one side.
# _result: Any | int = None # _result: Any | int = None
_result: PayloadT|Unresolved = Unresolved _result: Any|Unresolved = Unresolved
# if the local "caller" task errors this value is always set # if the local "caller" task errors this value is always set
# to the error that was captured in the # to the error that was captured in the
@ -956,7 +950,7 @@ class Context:
# f'Context.cancel() => {self.chan.uid}\n' # f'Context.cancel() => {self.chan.uid}\n'
f'c)=> {self.chan.uid}\n' f'c)=> {self.chan.uid}\n'
# f'{self.chan.uid}\n' # f'{self.chan.uid}\n'
f' |_ @{self.dst_maddr}\n' f' |_ @{self.dst_maddr}\n'
f' >> {self.repr_rpc}\n' f' >> {self.repr_rpc}\n'
# f' >> {self._nsf}() -> {codec}[dict]:\n\n' # f' >> {self._nsf}() -> {codec}[dict]:\n\n'
# TODO: pull msg-type from spec re #320 # TODO: pull msg-type from spec re #320
@ -1009,8 +1003,7 @@ class Context:
) )
else: else:
log.cancel( log.cancel(
f'Timed out on cancel request of remote task?\n' 'Timed out on cancel request of remote task?\n'
f'\n'
f'{reminfo}' f'{reminfo}'
) )
@ -1202,11 +1195,9 @@ class Context:
''' '''
__tracebackhide__: bool = hide_tb __tracebackhide__: bool = hide_tb
if not self._portal: assert self._portal, (
raise RuntimeError( '`Context.wait_for_result()` can not be called from callee side!'
'Invalid usage of `Context.wait_for_result()`!\n' )
'Not valid on child-side IPC ctx!\n'
)
if self._final_result_is_set(): if self._final_result_is_set():
return self._result return self._result
@ -1227,8 +1218,6 @@ class Context:
# since every message should be delivered via the normal # since every message should be delivered via the normal
# `._deliver_msg()` route which will appropriately set # `._deliver_msg()` route which will appropriately set
# any `.maybe_error`. # any `.maybe_error`.
outcome_msg: Return|Error|ContextCancelled
drained_msgs: list[MsgType]
( (
outcome_msg, outcome_msg,
drained_msgs, drained_msgs,
@ -1236,19 +1225,11 @@ class Context:
ctx=self, ctx=self,
hide_tb=hide_tb, hide_tb=hide_tb,
) )
drained_status: str = ( drained_status: str = (
'Ctx drained to final outcome msg\n\n' 'Ctx drained to final outcome msg\n\n'
f'{outcome_msg}\n' f'{outcome_msg}\n'
) )
# ?XXX, should already be set in `._deliver_msg()` right?
if self._outcome_msg is not Unresolved:
# from .devx import _debug
# await _debug.pause()
assert self._outcome_msg is outcome_msg
else:
self._outcome_msg = outcome_msg
if drained_msgs: if drained_msgs:
drained_status += ( drained_status += (
'\n' '\n'
@ -1579,12 +1560,12 @@ class Context:
strict_pld_parity=strict_pld_parity, strict_pld_parity=strict_pld_parity,
hide_tb=hide_tb, hide_tb=hide_tb,
) )
except BaseException as _bexc: except BaseException as err:
err = _bexc
if not isinstance(err, MsgTypeError): if not isinstance(err, MsgTypeError):
__tracebackhide__: bool = False __tracebackhide__: bool = False
raise err raise
# TODO: maybe a flag to by-pass encode op if already done # TODO: maybe a flag to by-pass encode op if already done
# here in caller? # here in caller?
@ -1722,28 +1703,15 @@ class Context:
# TODO: expose as mod func instead! # TODO: expose as mod func instead!
structfmt = pretty_struct.Struct.pformat structfmt = pretty_struct.Struct.pformat
if self._in_overrun: if self._in_overrun:
report: str = ( log.warning(
f'Queueing OVERRUN msg on caller task:\n\n'
f'{flow_body}' f'{flow_body}'
f'{structfmt(msg)}\n' f'{structfmt(msg)}\n'
) )
over_q: deque = self._overflow_q
self._overflow_q.append(msg) self._overflow_q.append(msg)
if len(over_q) == over_q.maxlen:
report = (
'FAILED to queue OVERRUN msg, OVERAN the OVERRUN QUEUE !!\n\n'
+ report
)
# log.error(report)
log.debug(report)
else:
report = (
'Queueing OVERRUN msg on caller task:\n\n'
+ report
)
log.debug(report)
# XXX NOTE XXX # XXX NOTE XXX
# overrun is the ONLY case where returning early is fine! # overrun is the ONLY case where returning early is fine!
return False return False
@ -1756,6 +1724,7 @@ class Context:
f'{structfmt(msg)}\n' f'{structfmt(msg)}\n'
) )
# NOTE: if an error is deteced we should always still # NOTE: if an error is deteced we should always still
# send it through the feeder-mem-chan and expect # send it through the feeder-mem-chan and expect
# it to be raised by any context (stream) consumer # it to be raised by any context (stream) consumer
@ -1767,21 +1736,6 @@ class Context:
# normally the task that should get cancelled/error # normally the task that should get cancelled/error
# from some remote fault! # from some remote fault!
send_chan.send_nowait(msg) send_chan.send_nowait(msg)
match msg:
case Stop():
if (stream := self._stream):
stream._stop_msg = msg
case Return():
if not self._outcome_msg:
log.warning(
f'Setting final outcome msg AFTER '
f'`._rx_chan.send()`??\n'
f'\n'
f'{msg}'
)
self._outcome_msg = msg
return True return True
except trio.BrokenResourceError: except trio.BrokenResourceError:
@ -2015,10 +1969,7 @@ async def open_context_from_portal(
ctxc_from_callee: ContextCancelled|None = None ctxc_from_callee: ContextCancelled|None = None
try: try:
async with ( async with (
trio.open_nursery( trio.open_nursery() as tn,
strict_exception_groups=False,
) as tn,
msgops.maybe_limit_plds( msgops.maybe_limit_plds(
ctx=ctx, ctx=ctx,
spec=ctx_meta.get('pld_spec'), spec=ctx_meta.get('pld_spec'),
@ -2038,7 +1989,7 @@ async def open_context_from_portal(
# the dialog, the `Error` msg should be raised from the `msg` # the dialog, the `Error` msg should be raised from the `msg`
# handling block below. # handling block below.
try: try:
started_msg, first = await ctx._pld_rx.recv_msg( started_msg, first = await ctx._pld_rx.recv_msg_w_pld(
ipc=ctx, ipc=ctx,
expect_msg=Started, expect_msg=Started,
passthrough_non_pld_msgs=False, passthrough_non_pld_msgs=False,
@ -2403,8 +2354,7 @@ async def open_context_from_portal(
# displaying `ContextCancelled` traces where the # displaying `ContextCancelled` traces where the
# cause of crash/exit IS due to something in # cause of crash/exit IS due to something in
# user/app code on either end of the context. # user/app code on either end of the context.
and and not rxchan._closed
not rxchan._closed
): ):
# XXX NOTE XXX: and again as per above, we mask any # XXX NOTE XXX: and again as per above, we mask any
# `trio.Cancelled` raised here so as to NOT mask # `trio.Cancelled` raised here so as to NOT mask
@ -2463,7 +2413,6 @@ async def open_context_from_portal(
# FINALLY, remove the context from runtime tracking and # FINALLY, remove the context from runtime tracking and
# exit! # exit!
log.runtime( log.runtime(
# log.cancel(
f'De-allocating IPC ctx opened with {ctx.side!r} peer \n' f'De-allocating IPC ctx opened with {ctx.side!r} peer \n'
f'uid: {uid}\n' f'uid: {uid}\n'
f'cid: {ctx.cid}\n' f'cid: {ctx.cid}\n'
@ -2519,6 +2468,7 @@ def mk_context(
_caller_info=caller_info, _caller_info=caller_info,
**kwargs, **kwargs,
) )
pld_rx._ctx = ctx
ctx._result = Unresolved ctx._result = Unresolved
return ctx return ctx
@ -2581,14 +2531,7 @@ def context(
name: str name: str
param: Type param: Type
for name, param in annots.items(): for name, param in annots.items():
if ( if param is Context:
param is Context
or (
isinstance(param, UnionType)
and
Context in param.__args__
)
):
ctx_var_name: str = name ctx_var_name: str = name
break break
else: else:

View File

@ -20,7 +20,6 @@ Sub-process entry points.
""" """
from __future__ import annotations from __future__ import annotations
from functools import partial from functools import partial
import multiprocessing as mp
import os import os
import textwrap import textwrap
from typing import ( from typing import (
@ -65,22 +64,20 @@ def _mp_main(
''' '''
actor._forkserver_info = forkserver_info actor._forkserver_info = forkserver_info
from ._spawn import try_set_start_method from ._spawn import try_set_start_method
spawn_ctx: mp.context.BaseContext = try_set_start_method(start_method) spawn_ctx = try_set_start_method(start_method)
assert spawn_ctx
if actor.loglevel is not None: if actor.loglevel is not None:
log.info( log.info(
f'Setting loglevel for {actor.uid} to {actor.loglevel}' f"Setting loglevel for {actor.uid} to {actor.loglevel}")
)
get_console_log(actor.loglevel) get_console_log(actor.loglevel)
# TODO: use scops headers like for `trio` below! assert spawn_ctx
# (well after we libify it maybe..)
log.info( log.info(
f'Started new {spawn_ctx.current_process()} for {actor.uid}' f"Started new {spawn_ctx.current_process()} for {actor.uid}")
# f"parent_addr is {parent_addr}"
) _state._current_actor = actor
_state._current_actor: Actor = actor
log.debug(f"parent_addr is {parent_addr}")
trio_main = partial( trio_main = partial(
async_main, async_main,
actor=actor, actor=actor,
@ -97,9 +94,7 @@ def _mp_main(
pass # handle it the same way trio does? pass # handle it the same way trio does?
finally: finally:
log.info( log.info(f"Subactor {actor.uid} terminated")
f'`mp`-subactor {actor.uid} exited'
)
# TODO: move this func to some kinda `.devx._conc_lang.py` eventually # TODO: move this func to some kinda `.devx._conc_lang.py` eventually
@ -238,7 +233,7 @@ def _trio_main(
nest_from_op( nest_from_op(
input_op='>(', # see syntax ideas above input_op='>(', # see syntax ideas above
tree_str=actor_info, tree_str=actor_info,
back_from_op=2, # since "complete" back_from_op=1,
) )
) )
logmeth = log.info logmeth = log.info

View File

@ -22,7 +22,6 @@ from __future__ import annotations
import builtins import builtins
import importlib import importlib
from pprint import pformat from pprint import pformat
from pdb import bdb
import sys import sys
from types import ( from types import (
TracebackType, TracebackType,
@ -83,48 +82,6 @@ class InternalError(RuntimeError):
''' '''
class AsyncioCancelled(Exception):
'''
Asyncio cancelled translation (non-base) error
for use with the ``to_asyncio`` module
to be raised in the ``trio`` side task
NOTE: this should NOT inherit from `asyncio.CancelledError` or
tests should break!
'''
class AsyncioTaskExited(Exception):
'''
asyncio.Task "exited" translation error for use with the
`to_asyncio` APIs to be raised in the `trio` side task indicating
on `.run_task()`/`.open_channel_from()` exit that the aio side
exited early/silently.
'''
class TrioCancelled(Exception):
'''
Trio cancelled translation (non-base) error
for use with the `to_asyncio` module
to be raised in the `asyncio.Task` to indicate
that the `trio` side raised `Cancelled` or an error.
'''
class TrioTaskExited(Exception):
'''
The `trio`-side task exited without explicitly cancelling the
`asyncio.Task` peer.
This is very similar to how `trio.ClosedResource` acts as
a "clean shutdown" signal to the consumer side of a mem-chan,
https://trio.readthedocs.io/en/stable/reference-core.html#clean-shutdown-with-channels
'''
# NOTE: more or less should be close to these: # NOTE: more or less should be close to these:
# 'boxed_type', # 'boxed_type',
@ -170,8 +127,8 @@ _body_fields: list[str] = list(
def get_err_type(type_name: str) -> BaseException|None: def get_err_type(type_name: str) -> BaseException|None:
''' '''
Look up an exception type by name from the set of locally known Look up an exception type by name from the set of locally
namespaces: known namespaces:
- `builtins` - `builtins`
- `tractor._exceptions` - `tractor._exceptions`
@ -182,7 +139,6 @@ def get_err_type(type_name: str) -> BaseException|None:
builtins, builtins,
_this_mod, _this_mod,
trio, trio,
bdb,
]: ]:
if type_ref := getattr( if type_ref := getattr(
ns, ns,
@ -402,13 +358,6 @@ class RemoteActorError(Exception):
self._ipc_msg.src_type_str self._ipc_msg.src_type_str
) )
if not self._src_type:
raise TypeError(
f'Failed to lookup src error type with '
f'`tractor._exceptions.get_err_type()` :\n'
f'{self.src_type_str}'
)
return self._src_type return self._src_type
@property @property
@ -417,9 +366,6 @@ class RemoteActorError(Exception):
String-name of the (last hop's) boxed error type. String-name of the (last hop's) boxed error type.
''' '''
# TODO, maybe support also serializing the
# `ExceptionGroup.exeptions: list[BaseException]` set under
# certain conditions?
bt: Type[BaseException] = self.boxed_type bt: Type[BaseException] = self.boxed_type
if bt: if bt:
return str(bt.__name__) return str(bt.__name__)
@ -432,13 +378,9 @@ class RemoteActorError(Exception):
Error type boxed by last actor IPC hop. Error type boxed by last actor IPC hop.
''' '''
if ( if self._boxed_type is None:
self._boxed_type is None
and
(ipc_msg := self._ipc_msg)
):
self._boxed_type = get_err_type( self._boxed_type = get_err_type(
ipc_msg.boxed_type_str self._ipc_msg.boxed_type_str
) )
return self._boxed_type return self._boxed_type
@ -667,7 +609,6 @@ class RemoteActorError(Exception):
# just after <Type( # just after <Type(
# |___ .. # |___ ..
tb_body_indent=1, tb_body_indent=1,
boxer_header=self.relay_uid,
) )
tail = '' tail = ''
@ -710,10 +651,16 @@ class RemoteActorError(Exception):
failing actor's remote env. failing actor's remote env.
''' '''
src_type_ref: Type[BaseException] = self.src_type
if not src_type_ref:
raise TypeError(
'Failed to lookup src error type:\n'
f'{self.src_type_str}'
)
# TODO: better tb insertion and all the fancier dunder # TODO: better tb insertion and all the fancier dunder
# metadata stuff as per `.__context__` etc. and friends: # metadata stuff as per `.__context__` etc. and friends:
# https://github.com/python-trio/trio/issues/611 # https://github.com/python-trio/trio/issues/611
src_type_ref: Type[BaseException] = self.src_type
return src_type_ref(self.tb_str) return src_type_ref(self.tb_str)
# TODO: local recontruction of nested inception for a given # TODO: local recontruction of nested inception for a given
@ -839,11 +786,8 @@ class MsgTypeError(
''' '''
if ( if (
(_bad_msg := self.msgdata.get('_bad_msg')) (_bad_msg := self.msgdata.get('_bad_msg'))
and ( and
isinstance(_bad_msg, PayloadMsg) isinstance(_bad_msg, PayloadMsg)
or
isinstance(_bad_msg, msgtypes.Start)
)
): ):
return _bad_msg return _bad_msg
@ -1029,6 +973,15 @@ class NoRuntime(RuntimeError):
"The root actor has not been initialized yet" "The root actor has not been initialized yet"
class AsyncioCancelled(Exception):
'''
Asyncio cancelled translation (non-base) error
for use with the ``to_asyncio`` module
to be raised in the ``trio`` side task
'''
class MessagingError(Exception): class MessagingError(Exception):
''' '''
IPC related msg (typing), transaction (ordering) or dialog IPC related msg (typing), transaction (ordering) or dialog
@ -1036,6 +989,7 @@ class MessagingError(Exception):
''' '''
def pack_error( def pack_error(
exc: BaseException|RemoteActorError, exc: BaseException|RemoteActorError,
@ -1147,8 +1101,6 @@ def unpack_error(
which is the responsibilitiy of the caller. which is the responsibilitiy of the caller.
''' '''
# XXX, apparently we pass all sorts of msgs here?
# kinda odd but seems like maybe they shouldn't be?
if not isinstance(msg, Error): if not isinstance(msg, Error):
return None return None
@ -1191,51 +1143,19 @@ def unpack_error(
def is_multi_cancelled( def is_multi_cancelled(
exc: BaseException|BaseExceptionGroup, exc: BaseException|BaseExceptionGroup
) -> bool:
ignore_nested: set[BaseException] = set(),
) -> bool|BaseExceptionGroup:
''' '''
Predicate to determine if an `BaseExceptionGroup` only contains Predicate to determine if a possible ``BaseExceptionGroup`` contains
some (maybe nested) set of sub-grouped exceptions (like only only ``trio.Cancelled`` sub-exceptions (and is likely the result of
`trio.Cancelled`s which get swallowed silently by default) and is cancelling a collection of subtasks.
thus the result of "gracefully cancelling" a collection of
sub-tasks (or other conc primitives) and receiving a "cancelled
ACK" from each after termination.
Docs:
----
- https://docs.python.org/3/library/exceptions.html#exception-groups
- https://docs.python.org/3/library/exceptions.html#BaseExceptionGroup.subgroup
''' '''
if (
not ignore_nested
or
trio.Cancelled in ignore_nested
# XXX always count-in `trio`'s native signal
):
ignore_nested.update({trio.Cancelled})
if isinstance(exc, BaseExceptionGroup): if isinstance(exc, BaseExceptionGroup):
matched_exc: BaseExceptionGroup|None = exc.subgroup( return exc.subgroup(
tuple(ignore_nested), lambda exc: isinstance(exc, trio.Cancelled)
) is not None
# TODO, complain about why not allowed XD
# condition=tuple(ignore_nested),
)
if matched_exc is not None:
return matched_exc
# NOTE, IFF no excs types match (throughout the error-tree)
# -> return `False`, OW return the matched sub-eg.
#
# IOW, for the inverse of ^ for the purpose of
# maybe-enter-REPL--logic: "only debug when the err-tree contains
# at least one exc-type NOT in `ignore_nested`" ; i.e. the case where
# we fallthrough and return `False` here.
return False return False
@ -1455,9 +1375,7 @@ def _mk_recv_mte(
any_pld: Any = msgpack.decode(msg.pld) any_pld: Any = msgpack.decode(msg.pld)
message: str = ( message: str = (
f'invalid `{msg_type.__qualname__}` msg payload\n\n' f'invalid `{msg_type.__qualname__}` msg payload\n\n'
f'{any_pld!r}\n\n' f'value: `{any_pld!r}` does not match type-spec: '
f'has type {type(any_pld)!r}\n\n'
f'and does not match type-spec '
f'`{type(msg).__qualname__}.pld: {codec.pld_spec_str}`' f'`{type(msg).__qualname__}.pld: {codec.pld_spec_str}`'
) )
bad_msg = msg bad_msg = msg

View File

@ -255,8 +255,8 @@ class MsgpackTCPStream(MsgTransport):
raise TransportClosed( raise TransportClosed(
message=( message=(
f'IPC transport already closed by peer\n' f'IPC transport already closed by peer\n'
f'x]> {type(trans_err)}\n' f'x)> {type(trans_err)}\n'
f' |_{self}\n' f' |_{self}\n'
), ),
loglevel=loglevel, loglevel=loglevel,
) from trans_err ) from trans_err
@ -273,8 +273,8 @@ class MsgpackTCPStream(MsgTransport):
raise TransportClosed( raise TransportClosed(
message=( message=(
f'IPC transport already manually closed locally?\n' f'IPC transport already manually closed locally?\n'
f'x]> {type(closure_err)} \n' f'x)> {type(closure_err)} \n'
f' |_{self}\n' f' |_{self}\n'
), ),
loglevel='error', loglevel='error',
raise_on_report=( raise_on_report=(
@ -289,8 +289,8 @@ class MsgpackTCPStream(MsgTransport):
raise TransportClosed( raise TransportClosed(
message=( message=(
f'IPC transport already gracefully closed\n' f'IPC transport already gracefully closed\n'
f']>\n' f')>\n'
f' |_{self}\n' f'|_{self}\n'
), ),
loglevel='transport', loglevel='transport',
# cause=??? # handy or no? # cause=??? # handy or no?

View File

@ -184,7 +184,7 @@ class Portal:
( (
self._final_result_msg, self._final_result_msg,
self._final_result_pld, self._final_result_pld,
) = await self._expect_result_ctx._pld_rx.recv_msg( ) = await self._expect_result_ctx._pld_rx.recv_msg_w_pld(
ipc=self._expect_result_ctx, ipc=self._expect_result_ctx,
expect_msg=Return, expect_msg=Return,
) )
@ -533,10 +533,6 @@ async def open_portal(
async with maybe_open_nursery( async with maybe_open_nursery(
tn, tn,
shield=shield, shield=shield,
strict_exception_groups=False,
# ^XXX^ TODO? soo roll our own then ??
# -> since we kinda want the "if only one `.exception` then
# just raise that" interface?
) as tn: ) as tn:
if not channel.connected(): if not channel.connected():

View File

@ -80,7 +80,7 @@ async def open_root_actor(
# enables the multi-process debugger support # enables the multi-process debugger support
debug_mode: bool = False, debug_mode: bool = False,
maybe_enable_greenback: bool = True, # `.pause_from_sync()/breakpoint()` support maybe_enable_greenback: bool = False, # `.pause_from_sync()/breakpoint()` support
enable_stack_on_sig: bool = False, enable_stack_on_sig: bool = False,
# internal logging # internal logging
@ -95,24 +95,13 @@ async def open_root_actor(
hide_tb: bool = True, hide_tb: bool = True,
# XXX, proxied directly to `.devx._debug._maybe_enter_pm()`
# for REPL-entry logic.
debug_filter: Callable[
[BaseException|BaseExceptionGroup],
bool,
] = lambda err: not is_multi_cancelled(err),
# TODO, a way for actors to augment passing derived
# read-only state to sublayers?
# extra_rt_vars: dict|None = None,
) -> Actor: ) -> Actor:
''' '''
Runtime init entry point for ``tractor``. Runtime init entry point for ``tractor``.
''' '''
_debug.hide_runtime_frames()
__tracebackhide__: bool = hide_tb __tracebackhide__: bool = hide_tb
_debug.hide_runtime_frames()
# TODO: stick this in a `@cm` defined in `devx._debug`? # TODO: stick this in a `@cm` defined in `devx._debug`?
# #
@ -244,8 +233,14 @@ async def open_root_actor(
and and
enable_stack_on_sig enable_stack_on_sig
): ):
from .devx._stackscope import enable_stack_on_sig try:
enable_stack_on_sig() logger.info('Enabling `stackscope` traces on SIGUSR1')
from .devx import enable_stack_on_sig
enable_stack_on_sig()
except ImportError:
logger.warning(
'`stackscope` not installed for use in debug mode!'
)
# closed into below ping task-func # closed into below ping task-func
ponged_addrs: list[tuple[str, int]] = [] ponged_addrs: list[tuple[str, int]] = []
@ -341,10 +336,6 @@ async def open_root_actor(
loglevel=loglevel, loglevel=loglevel,
enable_modules=enable_modules, enable_modules=enable_modules,
) )
# XXX, in case the root actor runtime was actually run from
# `tractor.to_asyncio.run_as_asyncio_guest()` and NOt
# `.trio.run()`.
actor._infected_aio = _state._runtime_vars['_is_infected_aio']
# Start up main task set via core actor-runtime nurseries. # Start up main task set via core actor-runtime nurseries.
try: try:
@ -362,10 +353,7 @@ async def open_root_actor(
) )
# start the actor runtime in a new task # start the actor runtime in a new task
async with trio.open_nursery( async with trio.open_nursery() as nursery:
strict_exception_groups=False,
# ^XXX^ TODO? instead unpack any RAE as per "loose" style?
) as nursery:
# ``_runtime.async_main()`` creates an internal nursery # ``_runtime.async_main()`` creates an internal nursery
# and blocks here until any underlying actor(-process) # and blocks here until any underlying actor(-process)
@ -389,13 +377,6 @@ async def open_root_actor(
Exception, Exception,
BaseExceptionGroup, BaseExceptionGroup,
) as err: ) as err:
# TODO, in beginning to handle the subsubactor with
# crashed grandparent cases..
#
# was_locked: bool = await _debug.maybe_wait_for_debugger(
# child_in_debug=True,
# )
# XXX NOTE XXX see equiv note inside # XXX NOTE XXX see equiv note inside
# `._runtime.Actor._stream_handler()` where in the # `._runtime.Actor._stream_handler()` where in the
# non-root or root-that-opened-this-mahually case we # non-root or root-that-opened-this-mahually case we
@ -404,15 +385,11 @@ async def open_root_actor(
entered: bool = await _debug._maybe_enter_pm( entered: bool = await _debug._maybe_enter_pm(
err, err,
api_frame=inspect.currentframe(), api_frame=inspect.currentframe(),
debug_filter=debug_filter,
) )
if ( if (
not entered not entered
and and
not is_multi_cancelled( not is_multi_cancelled(err)
err,
)
): ):
logger.exception('Root actor crashed\n') logger.exception('Root actor crashed\n')
@ -466,19 +443,12 @@ def run_daemon(
start_method: str | None = None, start_method: str | None = None,
debug_mode: bool = False, debug_mode: bool = False,
# TODO, support `infected_aio=True` mode by,
# - calling the appropriate entrypoint-func from `.to_asyncio`
# - maybe init-ing `greenback` as done above in
# `open_root_actor()`.
**kwargs **kwargs
) -> None: ) -> None:
''' '''
Spawn a root (daemon) actor which will respond to RPC; the main Spawn daemon actor which will respond to RPC; the main task simply
task simply starts the runtime and then blocks via embedded starts the runtime and then sleeps forever.
`trio.sleep_forever()`.
This is a very minimal convenience wrapper around starting This is a very minimal convenience wrapper around starting
a "run-until-cancelled" root actor which can be started with a set a "run-until-cancelled" root actor which can be started with a set
@ -491,6 +461,7 @@ def run_daemon(
importlib.import_module(path) importlib.import_module(path)
async def _main(): async def _main():
async with open_root_actor( async with open_root_actor(
registry_addrs=registry_addrs, registry_addrs=registry_addrs,
name=name, name=name,

View File

@ -620,11 +620,7 @@ async def _invoke(
tn: trio.Nursery tn: trio.Nursery
rpc_ctx_cs: CancelScope rpc_ctx_cs: CancelScope
async with ( async with (
trio.open_nursery( trio.open_nursery() as tn,
strict_exception_groups=False,
# ^XXX^ TODO? instead unpack any RAE as per "loose" style?
) as tn,
msgops.maybe_limit_plds( msgops.maybe_limit_plds(
ctx=ctx, ctx=ctx,
spec=ctx_meta.get('pld_spec'), spec=ctx_meta.get('pld_spec'),
@ -649,10 +645,6 @@ async def _invoke(
) )
# set and shuttle final result to "parent"-side task. # set and shuttle final result to "parent"-side task.
ctx._result = res ctx._result = res
log.runtime(
f'Sending result msg and exiting {ctx.side!r}\n'
f'{return_msg}\n'
)
await chan.send(return_msg) await chan.send(return_msg)
# NOTE: this happens IFF `ctx._scope.cancel()` is # NOTE: this happens IFF `ctx._scope.cancel()` is
@ -741,8 +733,8 @@ async def _invoke(
# XXX: do we ever trigger this block any more? # XXX: do we ever trigger this block any more?
except ( except (
BaseExceptionGroup, BaseExceptionGroup,
BaseException,
trio.Cancelled, trio.Cancelled,
BaseException,
) as scope_error: ) as scope_error:
if ( if (
@ -855,8 +847,8 @@ async def try_ship_error_to_remote(
log.critical( log.critical(
'IPC transport failure -> ' 'IPC transport failure -> '
f'failed to ship error to {remote_descr}!\n\n' f'failed to ship error to {remote_descr}!\n\n'
f'{type(msg)!r}[{msg.boxed_type_str}] X=> {channel.uid}\n' f'X=> {channel.uid}\n\n'
f'\n'
# TODO: use `.msg.preetty_struct` for this! # TODO: use `.msg.preetty_struct` for this!
f'{msg}\n' f'{msg}\n'
) )

View File

@ -59,7 +59,6 @@ from types import ModuleType
import warnings import warnings
import trio import trio
from trio._core import _run as trio_runtime
from trio import ( from trio import (
CancelScope, CancelScope,
Nursery, Nursery,
@ -81,7 +80,6 @@ from ._context import (
from .log import get_logger from .log import get_logger
from ._exceptions import ( from ._exceptions import (
ContextCancelled, ContextCancelled,
InternalError,
ModuleNotExposed, ModuleNotExposed,
MsgTypeError, MsgTypeError,
unpack_error, unpack_error,
@ -100,7 +98,6 @@ from ._rpc import (
if TYPE_CHECKING: if TYPE_CHECKING:
from ._supervise import ActorNursery from ._supervise import ActorNursery
from trio._channel import MemoryChannelState
log = get_logger('tractor') log = get_logger('tractor')
@ -836,10 +833,8 @@ class Actor:
)] )]
except KeyError: except KeyError:
report: str = ( report: str = (
'Ignoring invalid IPC msg!?\n' 'Ignoring invalid IPC ctx msg!\n\n'
f'Ctx seems to not/no-longer exist??\n' f'<=? {uid}\n\n'
f'\n'
f'<=? {uid}\n'
f' |_{pretty_struct.pformat(msg)}\n' f' |_{pretty_struct.pformat(msg)}\n'
) )
match msg: match msg:
@ -901,15 +896,11 @@ class Actor:
f'peer: {chan.uid}\n' f'peer: {chan.uid}\n'
f'cid:{cid}\n' f'cid:{cid}\n'
) )
ctx._allow_overruns: bool = allow_overruns ctx._allow_overruns = allow_overruns
# adjust buffer size if specified # adjust buffer size if specified
state: MemoryChannelState = ctx._send_chan._state # type: ignore state = ctx._send_chan._state # type: ignore
if ( if msg_buffer_size and state.max_buffer_size != msg_buffer_size:
msg_buffer_size
and
state.max_buffer_size != msg_buffer_size
):
state.max_buffer_size = msg_buffer_size state.max_buffer_size = msg_buffer_size
except KeyError: except KeyError:
@ -1103,36 +1094,7 @@ class Actor:
'`tractor.pause_from_sync()` not available!' '`tractor.pause_from_sync()` not available!'
) )
# XXX ensure the "infected `asyncio` mode" setting rvs['_is_root'] = False
# passed down from our spawning parent is consistent
# with `trio`-runtime initialization:
# - during sub-proc boot, the entrypoint func
# (`._entry.<spawn_backend>_main()`) should set
# `._infected_aio = True` before calling
# `run_as_asyncio_guest()`,
# - the value of `infect_asyncio: bool = True` as
# passed to `ActorNursery.start_actor()` must be
# the same as `_runtime_vars['_is_infected_aio']`
if (
(aio_rtv := rvs['_is_infected_aio'])
!=
(aio_attr := self._infected_aio)
):
raise InternalError(
'Parent sent runtime-vars that mismatch for the '
'"infected `asyncio` mode" settings ?!?\n\n'
f'rvs["_is_infected_aio"] = {aio_rtv}\n'
f'self._infected_aio = {aio_attr}\n'
)
if aio_rtv:
assert trio_runtime.GLOBAL_RUN_CONTEXT.runner.is_guest
# ^TODO^ possibly add a `sniffio` or
# `trio` pub-API for `is_guest_mode()`?
rvs['_is_root'] = False # obvi XD
# update process-wide globals
_state._runtime_vars.update(rvs) _state._runtime_vars.update(rvs)
# XXX: ``msgspec`` doesn't support serializing tuples # XXX: ``msgspec`` doesn't support serializing tuples
@ -1285,8 +1247,7 @@ class Actor:
msg: str = ( msg: str = (
f'Actor-runtime cancel request from {requester_type}\n\n' f'Actor-runtime cancel request from {requester_type}\n\n'
f'<=c) {requesting_uid}\n' f'<=c) {requesting_uid}\n'
f' |_{self}\n' f' |_{self}\n'
f'\n'
) )
# TODO: what happens here when we self-cancel tho? # TODO: what happens here when we self-cancel tho?
@ -1306,15 +1267,13 @@ class Actor:
lock_req_ctx.has_outcome lock_req_ctx.has_outcome
): ):
msg += ( msg += (
f'\n' '-> Cancelling active debugger request..\n'
f'-> Cancelling active debugger request..\n'
f'|_{_debug.Lock.repr()}\n\n' f'|_{_debug.Lock.repr()}\n\n'
f'|_{lock_req_ctx}\n\n' f'|_{lock_req_ctx}\n\n'
) )
# lock_req_ctx._scope.cancel() # lock_req_ctx._scope.cancel()
# TODO: wrap this in a method-API.. # TODO: wrap this in a method-API..
debug_req.req_cs.cancel() debug_req.req_cs.cancel()
# if lock_req_ctx:
# self-cancel **all** ongoing RPC tasks # self-cancel **all** ongoing RPC tasks
await self.cancel_rpc_tasks( await self.cancel_rpc_tasks(
@ -1723,15 +1682,11 @@ async def async_main(
# parent is kept alive as a resilient service until # parent is kept alive as a resilient service until
# cancellation steps have (mostly) occurred in # cancellation steps have (mostly) occurred in
# a deterministic way. # a deterministic way.
async with trio.open_nursery( async with trio.open_nursery() as root_nursery:
strict_exception_groups=False,
) as root_nursery:
actor._root_n = root_nursery actor._root_n = root_nursery
assert actor._root_n assert actor._root_n
async with trio.open_nursery( async with trio.open_nursery() as service_nursery:
strict_exception_groups=False,
) as service_nursery:
# This nursery is used to handle all inbound # This nursery is used to handle all inbound
# connections to us such that if the TCP server # connections to us such that if the TCP server
# is killed, connections can continue to process # is killed, connections can continue to process

View File

@ -327,10 +327,9 @@ async def soft_kill(
uid: tuple[str, str] = portal.channel.uid uid: tuple[str, str] = portal.channel.uid
try: try:
log.cancel( log.cancel(
f'Soft killing sub-actor via portal request\n' 'Soft killing sub-actor via portal request\n'
f'\n' f'c)> {portal.chan.uid}\n'
f'(c=> {portal.chan.uid}\n' f' |_{proc}\n'
f' |_{proc}\n'
) )
# wait on sub-proc to signal termination # wait on sub-proc to signal termination
await wait_func(proc) await wait_func(proc)

View File

@ -44,8 +44,6 @@ _runtime_vars: dict[str, Any] = {
'_root_mailbox': (None, None), '_root_mailbox': (None, None),
'_registry_addrs': [], '_registry_addrs': [],
'_is_infected_aio': False,
# for `tractor.pause_from_sync()` & `breakpoint()` support # for `tractor.pause_from_sync()` & `breakpoint()` support
'use_greenback': False, 'use_greenback': False,
} }
@ -72,8 +70,7 @@ def current_actor(
''' '''
if ( if (
err_on_no_runtime err_on_no_runtime
and and _current_actor is None
_current_actor is None
): ):
msg: str = 'No local actor has been initialized yet?\n' msg: str = 'No local actor has been initialized yet?\n'
from ._exceptions import NoRuntime from ._exceptions import NoRuntime
@ -108,7 +105,6 @@ def is_main_process() -> bool:
return mp.current_process().name == 'MainProcess' return mp.current_process().name == 'MainProcess'
# TODO, more verby name?
def debug_mode() -> bool: def debug_mode() -> bool:
''' '''
Bool determining if "debug mode" is on which enables Bool determining if "debug mode" is on which enables

View File

@ -45,11 +45,9 @@ from .trionics import (
BroadcastReceiver, BroadcastReceiver,
) )
from tractor.msg import ( from tractor.msg import (
Error, # Return,
Return, # Stop,
Stop,
MsgType, MsgType,
PayloadT,
Yield, Yield,
) )
@ -72,7 +70,8 @@ class MsgStream(trio.abc.Channel):
A bidirectional message stream for receiving logically sequenced A bidirectional message stream for receiving logically sequenced
values over an inter-actor IPC `Channel`. values over an inter-actor IPC `Channel`.
This is the type returned to a local task which entered either
`Portal.open_stream_from()` or `Context.open_stream()`.
Termination rules: Termination rules:
@ -95,9 +94,6 @@ class MsgStream(trio.abc.Channel):
self._rx_chan = rx_chan self._rx_chan = rx_chan
self._broadcaster = _broadcaster self._broadcaster = _broadcaster
# any actual IPC msg which is effectively an `EndOfStream`
self._stop_msg: bool|Stop = False
# flag to denote end of stream # flag to denote end of stream
self._eoc: bool|trio.EndOfChannel = False self._eoc: bool|trio.EndOfChannel = False
self._closed: bool|trio.ClosedResourceError = False self._closed: bool|trio.ClosedResourceError = False
@ -129,67 +125,16 @@ class MsgStream(trio.abc.Channel):
def receive_nowait( def receive_nowait(
self, self,
expect_msg: MsgType = Yield, expect_msg: MsgType = Yield,
) -> PayloadT: ):
ctx: Context = self._ctx ctx: Context = self._ctx
( return ctx._pld_rx.recv_pld_nowait(
msg,
pld,
) = ctx._pld_rx.recv_msg_nowait(
ipc=self, ipc=self,
expect_msg=expect_msg, expect_msg=expect_msg,
) )
# ?TODO, maybe factor this into a hyper-common `unwrap_pld()`
#
match msg:
# XXX, these never seems to ever hit? cool?
case Stop():
log.cancel(
f'Msg-stream was ended via stop msg\n'
f'{msg}'
)
case Error():
log.error(
f'Msg-stream was ended via error msg\n'
f'{msg}'
)
# XXX NOTE, always set any final result on the ctx to
# avoid teardown race conditions where previously this msg
# would be consumed silently (by `.aclose()` doing its
# own "msg drain loop" but WITHOUT those `drained: lists[MsgType]`
# being post-close-processed!
#
# !!TODO, see the equiv todo-comment in `.receive()`
# around the `if drained:` where we should prolly
# ACTUALLY be doing this post-close processing??
#
case Return(pld=pld):
log.warning(
f'Msg-stream final result msg for IPC ctx?\n'
f'{msg}'
)
# XXX TODO, this **should be covered** by higher
# scoped runtime-side method calls such as
# `Context._deliver_msg()`, so you should never
# really see the warning above or else something
# racy/out-of-order is likely going on between
# actor-runtime-side push tasks and the user-app-side
# consume tasks!
# -[ ] figure out that set of race cases and fix!
# -[ ] possibly return the `msg` given an input
# arg-flag is set so we can process the `Return`
# from the `.aclose()` caller?
#
# breakpoint() # to debug this RACE CASE!
ctx._result = pld
ctx._outcome_msg = msg
return pld
async def receive( async def receive(
self, self,
hide_tb: bool = False, hide_tb: bool = False,
): ):
''' '''
@ -209,7 +154,7 @@ class MsgStream(trio.abc.Channel):
# except trio.EndOfChannel: # except trio.EndOfChannel:
# raise StopAsyncIteration # raise StopAsyncIteration
# #
# see `.aclose()` for notes on the old behaviour prior to # see ``.aclose()`` for notes on the old behaviour prior to
# introducing this # introducing this
if self._eoc: if self._eoc:
raise self._eoc raise self._eoc
@ -220,11 +165,7 @@ class MsgStream(trio.abc.Channel):
src_err: Exception|None = None # orig tb src_err: Exception|None = None # orig tb
try: try:
ctx: Context = self._ctx ctx: Context = self._ctx
pld = await ctx._pld_rx.recv_pld( return await ctx._pld_rx.recv_pld(ipc=self)
ipc=self,
expect_msg=Yield,
)
return pld
# XXX: the stream terminates on either of: # XXX: the stream terminates on either of:
# - `self._rx_chan.receive()` raising after manual closure # - `self._rx_chan.receive()` raising after manual closure
@ -233,7 +174,7 @@ class MsgStream(trio.abc.Channel):
# - via a `Stop`-msg received from remote peer task. # - via a `Stop`-msg received from remote peer task.
# NOTE # NOTE
# |_ previously this was triggered by calling # |_ previously this was triggered by calling
# `._rx_chan.aclose()` on the send side of the channel # ``._rx_chan.aclose()`` on the send side of the channel
# inside `Actor._deliver_ctx_payload()`, but now the 'stop' # inside `Actor._deliver_ctx_payload()`, but now the 'stop'
# message handling gets delegated to `PldRFx.recv_pld()` # message handling gets delegated to `PldRFx.recv_pld()`
# internals. # internals.
@ -257,14 +198,11 @@ class MsgStream(trio.abc.Channel):
# terminated and signal this local iterator to stop # terminated and signal this local iterator to stop
drained: list[Exception|dict] = await self.aclose() drained: list[Exception|dict] = await self.aclose()
if drained: if drained:
# ^^^^^^^^TODO? pass these to the `._ctx._drained_msgs: # ?TODO? pass these to the `._ctx._drained_msgs: deque`
# deque` and then iterate them as part of any # and then iterate them as part of any `.wait_for_result()` call?
# `.wait_for_result()` call? #
# # from .devx import pause
# -[ ] move the match-case processing from # await pause()
# `.receive_nowait()` instead to right here, use it from
# a for msg in drained:` post-proc loop?
#
log.warning( log.warning(
'Drained context msgs during closure\n\n' 'Drained context msgs during closure\n\n'
f'{drained}' f'{drained}'
@ -327,6 +265,9 @@ class MsgStream(trio.abc.Channel):
- more or less we try to maintain adherance to trio's `.aclose()` semantics: - more or less we try to maintain adherance to trio's `.aclose()` semantics:
https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose
''' '''
# rx_chan = self._rx_chan
# XXX NOTE XXX # XXX NOTE XXX
# it's SUPER IMPORTANT that we ensure we don't DOUBLE # it's SUPER IMPORTANT that we ensure we don't DOUBLE
# DRAIN msgs on closure so avoid getting stuck handing on # DRAIN msgs on closure so avoid getting stuck handing on
@ -338,16 +279,15 @@ class MsgStream(trio.abc.Channel):
# this stream has already been closed so silently succeed as # this stream has already been closed so silently succeed as
# per ``trio.AsyncResource`` semantics. # per ``trio.AsyncResource`` semantics.
# https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose # https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose
# import tractor
# await tractor.pause()
return [] return []
ctx: Context = self._ctx ctx: Context = self._ctx
drained: list[Exception|dict] = [] drained: list[Exception|dict] = []
while not drained: while not drained:
try: try:
maybe_final_msg: Yield|Return = self.receive_nowait( maybe_final_msg = self.receive_nowait(
expect_msg=Yield|Return, # allow_msgs=[Yield, Return],
expect_msg=Yield,
) )
if maybe_final_msg: if maybe_final_msg:
log.debug( log.debug(
@ -432,30 +372,18 @@ class MsgStream(trio.abc.Channel):
# await rx_chan.aclose() # await rx_chan.aclose()
if not self._eoc: if not self._eoc:
this_side: str = self._ctx.side
peer_side: str = self._ctx.peer_side
message: str = ( message: str = (
f'Stream self-closed by {this_side!r}-side before EoC from {peer_side!r}\n' f'Stream self-closed by {self._ctx.side!r}-side before EoC\n'
# } bc a stream is a "scope"/msging-phase inside an IPC # } bc a stream is a "scope"/msging-phase inside an IPC
f'x}}>\n' f'x}}>\n'
f' |_{self}\n' f'|_{self}\n'
) )
log.cancel(message) log.cancel(message)
self._eoc = trio.EndOfChannel(message) self._eoc = trio.EndOfChannel(message)
if (
(rx_chan := self._rx_chan)
and
(stats := rx_chan.statistics()).tasks_waiting_receive
):
log.cancel(
f'Msg-stream is closing but there is still reader tasks,\n'
f'{stats}\n'
)
# ?XXX WAIT, why do we not close the local mem chan `._rx_chan` XXX? # ?XXX WAIT, why do we not close the local mem chan `._rx_chan` XXX?
# => NO, DEFINITELY NOT! <= # => NO, DEFINITELY NOT! <=
# if we're a bi-dir `MsgStream` BECAUSE this same # if we're a bi-dir ``MsgStream`` BECAUSE this same
# core-msg-loop mem recv-chan is used to deliver the # core-msg-loop mem recv-chan is used to deliver the
# potential final result from the surrounding inter-actor # potential final result from the surrounding inter-actor
# `Context` so we don't want to close it until that # `Context` so we don't want to close it until that

View File

@ -158,7 +158,6 @@ class ActorNursery:
# configure and pass runtime state # configure and pass runtime state
_rtv = _state._runtime_vars.copy() _rtv = _state._runtime_vars.copy()
_rtv['_is_root'] = False _rtv['_is_root'] = False
_rtv['_is_infected_aio'] = infect_asyncio
# allow setting debug policy per actor # allow setting debug policy per actor
if debug_mode is not None: if debug_mode is not None:
@ -395,23 +394,17 @@ async def _open_and_supervise_one_cancels_all_nursery(
# `ActorNursery.start_actor()`). # `ActorNursery.start_actor()`).
# errors from this daemon actor nursery bubble up to caller # errors from this daemon actor nursery bubble up to caller
async with trio.open_nursery( async with trio.open_nursery() as da_nursery:
strict_exception_groups=False,
# ^XXX^ TODO? instead unpack any RAE as per "loose" style?
) as da_nursery:
try: try:
# This is the inner level "run in actor" nursery. It is # This is the inner level "run in actor" nursery. It is
# awaited first since actors spawned in this way (using # awaited first since actors spawned in this way (using
# `ActorNusery.run_in_actor()`) are expected to only # ``ActorNusery.run_in_actor()``) are expected to only
# return a single result and then complete (i.e. be canclled # return a single result and then complete (i.e. be canclled
# gracefully). Errors collected from these actors are # gracefully). Errors collected from these actors are
# immediately raised for handling by a supervisor strategy. # immediately raised for handling by a supervisor strategy.
# As such if the strategy propagates any error(s) upwards # As such if the strategy propagates any error(s) upwards
# the above "daemon actor" nursery will be notified. # the above "daemon actor" nursery will be notified.
async with trio.open_nursery( async with trio.open_nursery() as ria_nursery:
strict_exception_groups=False,
# ^XXX^ TODO? instead unpack any RAE as per "loose" style?
) as ria_nursery:
an = ActorNursery( an = ActorNursery(
actor, actor,
@ -478,8 +471,8 @@ async def _open_and_supervise_one_cancels_all_nursery(
ContextCancelled, ContextCancelled,
}: }:
log.cancel( log.cancel(
'Actor-nursery caught remote cancellation\n' 'Actor-nursery caught remote cancellation\n\n'
'\n'
f'{inner_err.tb_str}' f'{inner_err.tb_str}'
) )
else: else:
@ -571,9 +564,7 @@ async def _open_and_supervise_one_cancels_all_nursery(
@acm @acm
# @api_frame # @api_frame
async def open_nursery( async def open_nursery(
hide_tb: bool = True,
**kwargs, **kwargs,
# ^TODO, paramspec for `open_root_actor()`
) -> typing.AsyncGenerator[ActorNursery, None]: ) -> typing.AsyncGenerator[ActorNursery, None]:
''' '''
@ -591,7 +582,7 @@ async def open_nursery(
which cancellation scopes correspond to each spawned subactor set. which cancellation scopes correspond to each spawned subactor set.
''' '''
__tracebackhide__: bool = hide_tb __tracebackhide__: bool = True
implicit_runtime: bool = False implicit_runtime: bool = False
actor: Actor = current_actor(err_on_no_runtime=False) actor: Actor = current_actor(err_on_no_runtime=False)
an: ActorNursery|None = None an: ActorNursery|None = None
@ -607,10 +598,7 @@ async def open_nursery(
# mark us for teardown on exit # mark us for teardown on exit
implicit_runtime: bool = True implicit_runtime: bool = True
async with open_root_actor( async with open_root_actor(**kwargs) as actor:
hide_tb=hide_tb,
**kwargs,
) as actor:
assert actor is current_actor() assert actor is current_actor()
try: try:
@ -648,10 +636,8 @@ async def open_nursery(
# show frame on any internal runtime-scope error # show frame on any internal runtime-scope error
if ( if (
an an
and and not an.cancelled
not an.cancelled and an._scope_error
and
an._scope_error
): ):
__tracebackhide__: bool = False __tracebackhide__: bool = False

View File

@ -19,16 +19,10 @@ Various helpers/utils for auditing your `tractor` app and/or the
core runtime. core runtime.
''' '''
from contextlib import ( from contextlib import asynccontextmanager as acm
asynccontextmanager as acm,
)
import os
import pathlib import pathlib
import tractor import tractor
from tractor.devx._debug import (
BoxedMaybeException,
)
from .pytest import ( from .pytest import (
tractor_test as tractor_test tractor_test as tractor_test
) )
@ -60,35 +54,6 @@ def examples_dir() -> pathlib.Path:
return repodir() / 'examples' return repodir() / 'examples'
def mk_cmd(
ex_name: str,
exs_subpath: str = 'debugging',
) -> str:
'''
Generate a shell command suitable to pass to `pexpect.spawn()`
which runs the script as a python program's entrypoint.
In particular ensure we disable the new tb coloring via unsetting
`$PYTHON_COLORS` so that `pexpect` can pattern match without
color-escape-codes.
'''
script_path: pathlib.Path = (
examples_dir()
/ exs_subpath
/ f'{ex_name}.py'
)
py_cmd: str = ' '.join([
'python',
str(script_path)
])
# XXX, required for py 3.13+
# https://docs.python.org/3/using/cmdline.html#using-on-controlling-color
# https://docs.python.org/3/using/cmdline.html#envvar-PYTHON_COLORS
os.environ['PYTHON_COLORS'] = '0'
return py_cmd
@acm @acm
async def expect_ctxc( async def expect_ctxc(
yay: bool, yay: bool,
@ -101,13 +66,12 @@ async def expect_ctxc(
''' '''
if yay: if yay:
try: try:
yield (maybe_exc := BoxedMaybeException()) yield
raise RuntimeError('Never raised ctxc?') raise RuntimeError('Never raised ctxc?')
except tractor.ContextCancelled as ctxc: except tractor.ContextCancelled:
maybe_exc.value = ctxc
if reraise: if reraise:
raise raise
else: else:
return return
else: else:
yield (maybe_exc := BoxedMaybeException()) yield

View File

@ -26,7 +26,7 @@ from ._debug import (
breakpoint as breakpoint, breakpoint as breakpoint,
pause as pause, pause as pause,
pause_from_sync as pause_from_sync, pause_from_sync as pause_from_sync,
sigint_shield as sigint_shield, shield_sigint_handler as shield_sigint_handler,
open_crash_handler as open_crash_handler, open_crash_handler as open_crash_handler,
maybe_open_crash_handler as maybe_open_crash_handler, maybe_open_crash_handler as maybe_open_crash_handler,
maybe_init_greenback as maybe_init_greenback, maybe_init_greenback as maybe_init_greenback,

File diff suppressed because it is too large Load Diff

View File

@ -234,7 +234,7 @@ def find_caller_info(
_frame2callerinfo_cache: dict[FrameType, CallerInfo] = {} _frame2callerinfo_cache: dict[FrameType, CallerInfo] = {}
# TODO: -[x] move all this into new `.devx._frame_stack`! # TODO: -[x] move all this into new `.devx._code`!
# -[ ] consider rename to _callstack? # -[ ] consider rename to _callstack?
# -[ ] prolly create a `@runtime_api` dec? # -[ ] prolly create a `@runtime_api` dec?
# |_ @api_frame seems better? # |_ @api_frame seems better?
@ -286,18 +286,3 @@ def api_frame(
wrapped._call_infos: dict[FrameType, CallerInfo] = _frame2callerinfo_cache wrapped._call_infos: dict[FrameType, CallerInfo] = _frame2callerinfo_cache
wrapped.__api_func__: bool = True wrapped.__api_func__: bool = True
return wrapper(wrapped) return wrapper(wrapped)
# TODO: something like this instead of the adhoc frame-unhiding
# blocks all over the runtime!! XD
# -[ ] ideally we can expect a certain error (set) and if something
# else is raised then all frames below the wrapped one will be
# un-hidden via `__tracebackhide__: bool = False`.
# |_ might need to dynamically mutate the code objs like
# `pdbp.hideframe()` does?
# -[ ] use this as a `@acm` decorator as introed in 3.10?
# @acm
# async def unhide_frame_when_not(
# error_set: set[BaseException],
# ) -> TracebackType:
# ...

View File

@ -24,32 +24,19 @@ disjoint, parallel executing tasks in separate actors.
''' '''
from __future__ import annotations from __future__ import annotations
# from functools import partial
from threading import (
current_thread,
Thread,
RLock,
)
import multiprocessing as mp import multiprocessing as mp
from signal import ( from signal import (
signal, signal,
getsignal,
SIGUSR1, SIGUSR1,
SIGINT,
)
# import traceback
from types import ModuleType
from typing import (
Callable,
TYPE_CHECKING,
) )
import traceback
from typing import TYPE_CHECKING
import trio import trio
from tractor import ( from tractor import (
_state, _state,
log as logmod, log as logmod,
) )
from tractor.devx import _debug
log = logmod.get_logger(__name__) log = logmod.get_logger(__name__)
@ -64,68 +51,26 @@ if TYPE_CHECKING:
@trio.lowlevel.disable_ki_protection @trio.lowlevel.disable_ki_protection
def dump_task_tree() -> None: def dump_task_tree() -> None:
'''
Do a classic `stackscope.extract()` task-tree dump to console at
`.devx()` level.
'''
import stackscope import stackscope
from tractor.log import get_console_log
tree_str: str = str( tree_str: str = str(
stackscope.extract( stackscope.extract(
trio.lowlevel.current_root_task(), trio.lowlevel.current_root_task(),
recurse_child_tasks=True recurse_child_tasks=True
) )
) )
log = get_console_log(
name=__name__,
level='cancel',
)
actor: Actor = _state.current_actor() actor: Actor = _state.current_actor()
thr: Thread = current_thread()
current_sigint_handler: Callable = getsignal(SIGINT)
if (
current_sigint_handler
is not
_debug.DebugStatus._trio_handler
):
sigint_handler_report: str = (
'The default `trio` SIGINT handler was replaced?!'
)
else:
sigint_handler_report: str = (
'The default `trio` SIGINT handler is in use?!'
)
# sclang symbology
# |_<object>
# |_(Task/Thread/Process/Actor
# |_{Supervisor/Scope
# |_[Storage/Memory/IPC-Stream/Data-Struct
log.devx( log.devx(
f'Dumping `stackscope` tree for actor\n' f'Dumping `stackscope` tree for actor\n'
f'(>: {actor.uid!r}\n' f'{actor.name}: {actor}\n'
f' |_{mp.current_process()}\n' f' |_{mp.current_process()}\n\n'
f' |_{thr}\n' f'{tree_str}\n'
f' |_{actor}\n'
f'\n'
f'{sigint_handler_report}\n'
f'signal.getsignal(SIGINT) -> {current_sigint_handler!r}\n'
# f'\n'
# start-of-trace-tree delimiter (mostly for testing)
# f'------ {actor.uid!r} ------\n'
f'\n'
f'------ start-of-{actor.uid!r} ------\n'
f'|\n'
f'{tree_str}'
# end-of-trace-tree delimiter (mostly for testing)
f'|\n'
f'|_____ end-of-{actor.uid!r} ______\n'
) )
# TODO: can remove this right?
# -[ ] was original code from author
#
# print(
# 'DUMPING FROM PRINT\n'
# +
# content
# )
# import logging # import logging
# try: # try:
# with open("/dev/tty", "w") as tty: # with open("/dev/tty", "w") as tty:
@ -135,130 +80,58 @@ def dump_task_tree() -> None:
# "task_tree" # "task_tree"
# ).exception("Error printing task tree") # ).exception("Error printing task tree")
_handler_lock = RLock()
_tree_dumped: bool = False
def signal_handler(
def dump_tree_on_sig(
sig: int, sig: int,
frame: object, frame: object,
relay_to_subs: bool = True, relay_to_subs: bool = True,
) -> None: ) -> None:
global _tree_dumped, _handler_lock try:
with _handler_lock: trio.lowlevel.current_trio_token(
# if _tree_dumped: ).run_sync_soon(dump_task_tree)
# log.warning( except RuntimeError:
# 'Already dumped for this actor...??' # not in async context -- print a normal traceback
# ) traceback.print_stack()
# return
_tree_dumped = True
# actor: Actor = _state.current_actor()
log.devx(
'Trying to dump `stackscope` tree..\n'
)
try:
dump_task_tree()
# await actor._service_n.start_soon(
# partial(
# trio.to_thread.run_sync,
# dump_task_tree,
# )
# )
# trio.lowlevel.current_trio_token().run_sync_soon(
# dump_task_tree
# )
except RuntimeError:
log.exception(
'Failed to dump `stackscope` tree..\n'
)
# not in async context -- print a normal traceback
# traceback.print_stack()
raise
except BaseException:
log.exception(
'Failed to dump `stackscope` tree..\n'
)
raise
# log.devx(
# 'Supposedly we dumped just fine..?'
# )
if not relay_to_subs: if not relay_to_subs:
return return
an: ActorNursery an: ActorNursery
for an in _state.current_actor()._actoruid2nursery.values(): for an in _state.current_actor()._actoruid2nursery.values():
subproc: ProcessType subproc: ProcessType
subactor: Actor subactor: Actor
for subactor, subproc, _ in an._children.values(): for subactor, subproc, _ in an._children.values():
log.warning( log.devx(
f'Relaying `SIGUSR1`[{sig}] to sub-actor\n' f'Relaying `SIGUSR1`[{sig}] to sub-actor\n'
f'{subactor}\n' f'{subactor}\n'
f' |_{subproc}\n' f' |_{subproc}\n'
) )
# bc of course stdlib can't have a std API.. XD if isinstance(subproc, trio.Process):
match subproc: subproc.send_signal(sig)
case trio.Process():
subproc.send_signal(sig)
case mp.Process(): elif isinstance(subproc, mp.Process):
subproc._send_signal(sig) subproc._send_signal(sig)
def enable_stack_on_sig( def enable_stack_on_sig(
sig: int = SIGUSR1, sig: int = SIGUSR1
) -> ModuleType: ) -> None:
''' '''
Enable `stackscope` tracing on reception of a signal; by Enable `stackscope` tracing on reception of a signal; by
default this is SIGUSR1. default this is SIGUSR1.
HOT TIP: a task/ctx-tree dump can be triggered from a shell with
fancy cmds.
For ex. from `bash` using `pgrep` and cmd-sustitution
(https://www.gnu.org/software/bash/manual/bash.html#Command-Substitution)
you could use:
>> kill -SIGUSR1 $(pgrep -f <part-of-cmd: str>)
OR without a sub-shell,
>> pkill --signal SIGUSR1 -f <part-of-cmd: str>
''' '''
try:
import stackscope
except ImportError:
log.warning(
'`stackscope` not installed for use in debug mode!'
)
return None
handler: Callable|int = getsignal(sig)
if handler is dump_tree_on_sig:
log.devx(
'A `SIGUSR1` handler already exists?\n'
f'|_ {handler!r}\n'
)
return
signal( signal(
sig, sig,
dump_tree_on_sig, signal_handler,
) )
log.devx( # NOTE: not the above can be triggered from
'Enabling trace-trees on `SIGUSR1` ' # a (xonsh) shell using:
'since `stackscope` is installed @ \n' # kill -SIGUSR1 @$(pgrep -f '<cmd>')
f'{stackscope!r}\n\n' #
f'With `SIGUSR1` handler\n' # for example if you were looking to trace a `pytest` run
f'|_{dump_tree_on_sig}\n' # kill -SIGUSR1 @$(pgrep -f 'pytest')
)
return stackscope

View File

@ -53,7 +53,6 @@ def pformat_boxed_tb(
tb_box_indent: int|None = None, tb_box_indent: int|None = None,
tb_body_indent: int = 1, tb_body_indent: int = 1,
boxer_header: str = '-'
) -> str: ) -> str:
''' '''
@ -89,10 +88,10 @@ def pformat_boxed_tb(
tb_box: str = ( tb_box: str = (
f'|\n' f'|\n'
f' ------ {boxer_header} ------\n' f' ------ - ------\n'
f'{tb_body}' f'{tb_body}'
f' ------ {boxer_header}- ------\n' f' ------ - ------\n'
f'_|' f'_|\n'
) )
tb_box_indent: str = ( tb_box_indent: str = (
tb_box_indent tb_box_indent

View File

@ -258,28 +258,20 @@ class ActorContextInfo(Mapping):
def get_logger( def get_logger(
name: str|None = None,
name: str | None = None,
_root_name: str = _proj_name, _root_name: str = _proj_name,
logger: Logger|None = None,
# TODO, using `.config.dictConfig()` api?
# -[ ] SO answer with docs links
# |_https://stackoverflow.com/questions/7507825/where-is-a-complete-example-of-logging-config-dictconfig
# |_https://docs.python.org/3/library/logging.config.html#configuration-dictionary-schema
subsys_spec: str|None = None,
) -> StackLevelAdapter: ) -> StackLevelAdapter:
'''Return the package log or a sub-logger for ``name`` if provided. '''Return the package log or a sub-logger for ``name`` if provided.
''' '''
log: Logger log: Logger
log = rlog = logger or logging.getLogger(_root_name) log = rlog = logging.getLogger(_root_name)
if ( if (
name name
and and name != _proj_name
name != _proj_name
): ):
# NOTE: for handling for modules that use ``get_logger(__name__)`` # NOTE: for handling for modules that use ``get_logger(__name__)``
@ -291,7 +283,7 @@ def get_logger(
# since in python the {filename} is always this same # since in python the {filename} is always this same
# module-file. # module-file.
sub_name: None|str = None sub_name: None | str = None
rname, _, sub_name = name.partition('.') rname, _, sub_name = name.partition('.')
pkgpath, _, modfilename = sub_name.rpartition('.') pkgpath, _, modfilename = sub_name.rpartition('.')
@ -314,10 +306,7 @@ def get_logger(
# add our actor-task aware adapter which will dynamically look up # add our actor-task aware adapter which will dynamically look up
# the actor and task names at each log emit # the actor and task names at each log emit
logger = StackLevelAdapter( logger = StackLevelAdapter(log, ActorContextInfo())
log,
ActorContextInfo(),
)
# additional levels # additional levels
for name, val in CUSTOM_LEVELS.items(): for name, val in CUSTOM_LEVELS.items():
@ -330,25 +319,15 @@ def get_logger(
def get_console_log( def get_console_log(
level: str|None = None, level: str | None = None,
logger: Logger|None = None,
**kwargs, **kwargs,
) -> LoggerAdapter: ) -> LoggerAdapter:
''' '''Get the package logger and enable a handler which writes to stderr.
Get a `tractor`-style logging instance: a `Logger` wrapped in
a `StackLevelAdapter` which injects various concurrency-primitive
(process, thread, task) fields and enables a `StreamHandler` that
writes on stderr using `colorlog` formatting.
Yeah yeah, i know we can use `logging.config.dictConfig()`. You do it.
Yeah yeah, i know we can use ``DictConfig``. You do it.
''' '''
log = get_logger( log = get_logger(**kwargs) # our root logger
logger=logger, logger = log.logger
**kwargs
) # set a root logger
logger: Logger = log.logger
if not level: if not level:
return log return log
@ -367,13 +346,9 @@ def get_console_log(
None, None,
) )
): ):
fmt = LOG_FORMAT
# if logger:
# fmt = None
handler = StreamHandler() handler = StreamHandler()
formatter = colorlog.ColoredFormatter( formatter = colorlog.ColoredFormatter(
fmt=fmt, LOG_FORMAT,
datefmt=DATE_FORMAT, datefmt=DATE_FORMAT,
log_colors=STD_PALETTE, log_colors=STD_PALETTE,
secondary_log_colors=BOLD_PALETTE, secondary_log_colors=BOLD_PALETTE,
@ -390,7 +365,7 @@ def get_loglevel() -> str:
# global module logger for tractor itself # global module logger for tractor itself
log: StackLevelAdapter = get_logger('tractor') log = get_logger('tractor')
def at_least_level( def at_least_level(

View File

@ -33,7 +33,6 @@ from ._codec import (
apply_codec as apply_codec, apply_codec as apply_codec,
mk_codec as mk_codec, mk_codec as mk_codec,
mk_dec as mk_dec,
MsgCodec as MsgCodec, MsgCodec as MsgCodec,
MsgDec as MsgDec, MsgDec as MsgDec,
current_codec as current_codec, current_codec as current_codec,

View File

@ -41,10 +41,8 @@ import textwrap
from typing import ( from typing import (
Any, Any,
Callable, Callable,
Protocol,
Type, Type,
TYPE_CHECKING, TYPE_CHECKING,
TypeVar,
Union, Union,
) )
from types import ModuleType from types import ModuleType
@ -61,7 +59,6 @@ from tractor.msg.pretty_struct import Struct
from tractor.msg.types import ( from tractor.msg.types import (
mk_msg_spec, mk_msg_spec,
MsgType, MsgType,
PayloadMsg,
) )
from tractor.log import get_logger from tractor.log import get_logger
@ -81,7 +78,6 @@ class MsgDec(Struct):
''' '''
_dec: msgpack.Decoder _dec: msgpack.Decoder
# _ext_types_box: Struct|None = None
@property @property
def dec(self) -> msgpack.Decoder: def dec(self) -> msgpack.Decoder:
@ -181,126 +177,19 @@ class MsgDec(Struct):
def mk_dec( def mk_dec(
spec: Union[Type[Struct]]|Type|None, spec: Union[Type[Struct]]|Any = Any,
# NOTE, required for ad-hoc type extensions to the underlying
# serialization proto (which is default `msgpack`),
# https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types
dec_hook: Callable|None = None, dec_hook: Callable|None = None,
ext_types: list[Type]|None = None,
) -> MsgDec: ) -> MsgDec:
'''
Create an IPC msg decoder, a slightly higher level wrapper around
a `msgspec.msgpack.Decoder` which provides,
- easier introspection of the underlying type spec via
the `.spec` and `.spec_str` attrs,
- `.hook` access to the `Decoder.dec_hook()`,
- automatic custom extension-types decode support when
`dec_hook()` is provided such that any `PayloadMsg.pld` tagged
as a type from from `ext_types` (presuming the `MsgCodec.encode()` also used
a `.enc_hook()`) is processed and constructed by a `PldRx` implicitily.
NOTE, as mentioned a `MsgDec` is normally used for `PayloadMsg.pld: PayloadT` field
decoding inside an IPC-ctx-oriented `PldRx`.
'''
if (
spec is None
and
ext_types is None
):
raise TypeError(
f'MIssing type-`spec` for msg decoder!\n'
f'\n'
f'`spec=None` is **only** permitted is if custom extension types '
f'are provided via `ext_types`, meaning it must be non-`None`.\n'
f'\n'
f'In this case it is presumed that only the `ext_types`, '
f'which much be handled by a paired `dec_hook()`, '
f'will be permitted within the payload type-`spec`!\n'
f'\n'
f'spec = {spec!r}\n'
f'dec_hook = {dec_hook!r}\n'
f'ext_types = {ext_types!r}\n'
)
if dec_hook:
if ext_types is None:
raise TypeError(
f'If extending the serializable types with a custom decode hook (`dec_hook()`), '
f'you must also provide the expected type set that the hook will handle '
f'via a `ext_types: Union[Type]|None = None` argument!\n'
f'\n'
f'dec_hook = {dec_hook!r}\n'
f'ext_types = {ext_types!r}\n'
)
# XXX, i *thought* we would require a boxing struct as per docs,
# https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types
# |_ see comment,
# > Note that typed deserialization is required for
# > successful roundtripping here, so we pass `MyMessage` to
# > `Decoder`.
#
# BUT, turns out as long as you spec a union with `Raw` it
# will work? kk B)
#
# maybe_box_struct = mk_boxed_ext_struct(ext_types)
spec = Raw | Union[*ext_types]
return MsgDec( return MsgDec(
_dec=msgpack.Decoder( _dec=msgpack.Decoder(
type=spec, # like `MsgType[Any]` type=spec, # like `MsgType[Any]`
dec_hook=dec_hook, dec_hook=dec_hook,
), )
) )
# TODO? remove since didn't end up needing this?
def mk_boxed_ext_struct(
ext_types: list[Type],
) -> Struct:
# NOTE, originally was to wrap non-msgpack-supported "extension
# types" in a field-typed boxing struct, see notes around the
# `dec_hook()` branch in `mk_dec()`.
ext_types_union = Union[*ext_types]
repr_ext_types_union: str = (
str(ext_types_union)
or
"|".join(ext_types)
)
BoxedExtType = msgspec.defstruct(
f'BoxedExts[{repr_ext_types_union}]',
fields=[
('boxed', ext_types_union),
],
)
return BoxedExtType
def unpack_spec_types(
spec: Union[Type]|Type,
) -> set[Type]:
'''
Given an input type-`spec`, either a lone type
or a `Union` of types (like `str|int|MyThing`),
return a set of individual types.
When `spec` is not a type-union returns `{spec,}`.
'''
spec_subtypes: set[Union[Type]] = set(
getattr(
spec,
'__args__',
{spec,},
)
)
return spec_subtypes
def mk_msgspec_table( def mk_msgspec_table(
dec: msgpack.Decoder, dec: msgpack.Decoder,
msg: MsgType|None = None, msg: MsgType|None = None,
@ -338,13 +227,6 @@ def pformat_msgspec(
join_char: str = '\n', join_char: str = '\n',
) -> str: ) -> str:
'''
Pretty `str` format the `msgspec.msgpack.Decoder.type` attribute
for display in (console) log messages as a nice (maybe multiline)
presentation of all supported `Struct`s (subtypes) available for
typed decoding.
'''
dec: msgpack.Decoder = getattr(codec, 'dec', codec) dec: msgpack.Decoder = getattr(codec, 'dec', codec)
return join_char.join( return join_char.join(
mk_msgspec_table( mk_msgspec_table(
@ -378,8 +260,6 @@ class MsgCodec(Struct):
_dec: msgpack.Decoder _dec: msgpack.Decoder
_pld_spec: Type[Struct]|Raw|Any _pld_spec: Type[Struct]|Raw|Any
# _ext_types_box: Struct|None = None
def __repr__(self) -> str: def __repr__(self) -> str:
speclines: str = textwrap.indent( speclines: str = textwrap.indent(
pformat_msgspec(codec=self), pformat_msgspec(codec=self),
@ -446,15 +326,12 @@ class MsgCodec(Struct):
def encode( def encode(
self, self,
py_obj: Any|PayloadMsg, py_obj: Any,
use_buf: bool = False, use_buf: bool = False,
# ^-XXX-^ uhh why am i getting this? # ^-XXX-^ uhh why am i getting this?
# |_BufferError: Existing exports of data: object cannot be re-sized # |_BufferError: Existing exports of data: object cannot be re-sized
as_ext_type: bool = False,
hide_tb: bool = True,
) -> bytes: ) -> bytes:
''' '''
Encode input python objects to `msgpack` bytes for Encode input python objects to `msgpack` bytes for
@ -464,46 +341,11 @@ class MsgCodec(Struct):
https://jcristharif.com/msgspec/perf-tips.html#reusing-an-output-buffer https://jcristharif.com/msgspec/perf-tips.html#reusing-an-output-buffer
''' '''
__tracebackhide__: bool = hide_tb
if use_buf: if use_buf:
self._enc.encode_into(py_obj, self._buf) self._enc.encode_into(py_obj, self._buf)
return self._buf return self._buf
else:
return self._enc.encode(py_obj) return self._enc.encode(py_obj)
# try:
# return self._enc.encode(py_obj)
# except TypeError as typerr:
# typerr.add_note(
# '|_src error from `msgspec`'
# # f'|_{self._enc.encode!r}'
# )
# raise typerr
# TODO! REMOVE once i'm confident we won't ever need it!
#
# box: Struct = self._ext_types_box
# if (
# as_ext_type
# or
# (
# # XXX NOTE, auto-detect if the input type
# box
# and
# (ext_types := unpack_spec_types(
# spec=box.__annotations__['boxed'])
# )
# )
# ):
# match py_obj:
# # case PayloadMsg(pld=pld) if (
# # type(pld) in ext_types
# # ):
# # py_obj.pld = box(boxed=py_obj)
# # breakpoint()
# case _ if (
# type(py_obj) in ext_types
# ):
# py_obj = box(boxed=py_obj)
@property @property
def dec(self) -> msgpack.Decoder: def dec(self) -> msgpack.Decoder:
@ -523,30 +365,21 @@ class MsgCodec(Struct):
return self._dec.decode(msg) return self._dec.decode(msg)
# ?TODO? time to remove this finally? # [x] TODO: a sub-decoder system as well? => No!
#
# -[x] TODO: a sub-decoder system as well?
# => No! already re-architected to include a "payload-receiver"
# now found in `._ops`.
# #
# -[x] do we still want to try and support the sub-decoder with # -[x] do we still want to try and support the sub-decoder with
# `.Raw` technique in the case that the `Generic` approach gives # `.Raw` technique in the case that the `Generic` approach gives
# future grief? # future grief?
# => well YES but NO, since we went with the `PldRx` approach # => NO, since we went with the `PldRx` approach instead B)
# instead!
# #
# IF however you want to see the code that was staged for this # IF however you want to see the code that was staged for this
# from wayyy back, see the pure removal commit. # from wayyy back, see the pure removal commit.
def mk_codec( def mk_codec(
ipc_pld_spec: Union[Type[Struct]]|Any|Raw = Raw, # struct type unions set for `Decoder`
# tagged-struct-types-union set for `Decoder`ing of payloads, as # https://jcristharif.com/msgspec/structs.html#tagged-unions
# per https://jcristharif.com/msgspec/structs.html#tagged-unions. ipc_pld_spec: Union[Type[Struct]]|Any = Any,
# NOTE that the default `Raw` here **is very intentional** since
# the `PldRx._pld_dec: MsgDec` is responsible for per ipc-ctx-task
# decoding of msg-specs defined by the user as part of **their**
# `tractor` "app's" type-limited IPC msg-spec.
# TODO: offering a per-msg(-field) type-spec such that # TODO: offering a per-msg(-field) type-spec such that
# the fields can be dynamically NOT decoded and left as `Raw` # the fields can be dynamically NOT decoded and left as `Raw`
@ -559,18 +392,13 @@ def mk_codec(
libname: str = 'msgspec', libname: str = 'msgspec',
# settings for encoding-to-send extension-types, # proxy as `Struct(**kwargs)` for ad-hoc type extensions
# https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types # https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types
# dec_hook: Callable|None = None, # ------ - ------
dec_hook: Callable|None = None,
enc_hook: Callable|None = None, enc_hook: Callable|None = None,
ext_types: list[Type]|None = None, # ------ - ------
# optionally provided msg-decoder from which we pull its,
# |_.dec_hook()
# |_.type
ext_dec: MsgDec|None = None
# #
# ?TODO? other params we might want to support
# Encoder: # Encoder:
# write_buffer_size=write_buffer_size, # write_buffer_size=write_buffer_size,
# #
@ -584,44 +412,26 @@ def mk_codec(
`msgspec` ;). `msgspec` ;).
''' '''
pld_spec = ipc_pld_spec # (manually) generate a msg-payload-spec for all relevant
if enc_hook: # god-boxing-msg subtypes, parameterizing the `PayloadMsg.pld: PayloadT`
if not ext_types: # for the decoder such that all sub-type msgs in our SCIPP
raise TypeError( # will automatically decode to a type-"limited" payload (`Struct`)
f'If extending the serializable types with a custom encode hook (`enc_hook()`), ' # object (set).
f'you must also provide the expected type set that the hook will handle '
f'via a `ext_types: Union[Type]|None = None` argument!\n'
f'\n'
f'enc_hook = {enc_hook!r}\n'
f'ext_types = {ext_types!r}\n'
)
dec_hook: Callable|None = None
if ext_dec:
dec: msgspec.Decoder = ext_dec.dec
dec_hook = dec.dec_hook
pld_spec |= dec.type
if ext_types:
pld_spec |= Union[*ext_types]
# (manually) generate a msg-spec (how appropes) for all relevant
# payload-boxing-struct-msg-types, parameterizing the
# `PayloadMsg.pld: PayloadT` for the decoder such that all msgs
# in our SC-RPC-protocol will automatically decode to
# a type-"limited" payload (`Struct`) object (set).
( (
ipc_msg_spec, ipc_msg_spec,
msg_types, msg_types,
) = mk_msg_spec( ) = mk_msg_spec(
payload_type_union=pld_spec, payload_type_union=ipc_pld_spec,
) )
assert len(ipc_msg_spec.__args__) == len(msg_types)
assert ipc_msg_spec
msg_spec_types: set[Type] = unpack_spec_types(ipc_msg_spec) # TODO: use this shim instead?
assert ( # bc.. unification, err somethin?
len(ipc_msg_spec.__args__) == len(msg_types) # dec: MsgDec = mk_dec(
and # spec=ipc_msg_spec,
len(msg_spec_types) == len(msg_types) # dec_hook=dec_hook,
) # )
dec = msgpack.Decoder( dec = msgpack.Decoder(
type=ipc_msg_spec, type=ipc_msg_spec,
@ -630,29 +440,22 @@ def mk_codec(
enc = msgpack.Encoder( enc = msgpack.Encoder(
enc_hook=enc_hook, enc_hook=enc_hook,
) )
codec = MsgCodec( codec = MsgCodec(
_enc=enc, _enc=enc,
_dec=dec, _dec=dec,
_pld_spec=pld_spec, _pld_spec=ipc_pld_spec,
) )
# sanity on expected backend support # sanity on expected backend support
assert codec.lib.__name__ == libname assert codec.lib.__name__ == libname
return codec return codec
# instance of the default `msgspec.msgpack` codec settings, i.e. # instance of the default `msgspec.msgpack` codec settings, i.e.
# no custom structs, hooks or other special types. # no custom structs, hooks or other special types.
# _def_msgspec_codec: MsgCodec = mk_codec(ipc_pld_spec=Any)
# XXX NOTE XXX, this will break our `Context.start()` call!
#
# * by default we roundtrip the started pld-`value` and if you apply
# this codec (globally anyway with `apply_codec()`) then the
# `roundtripped` value will include a non-`.pld: Raw` which will
# then type-error on the consequent `._ops.validte_payload_msg()`..
#
_def_msgspec_codec: MsgCodec = mk_codec(
ipc_pld_spec=Any,
)
# The built-in IPC `Msg` spec. # The built-in IPC `Msg` spec.
# Our composing "shuttle" protocol which allows `tractor`-app code # Our composing "shuttle" protocol which allows `tractor`-app code
@ -660,13 +463,13 @@ _def_msgspec_codec: MsgCodec = mk_codec(
# https://jcristharif.com/msgspec/supported-types.html # https://jcristharif.com/msgspec/supported-types.html
# #
_def_tractor_codec: MsgCodec = mk_codec( _def_tractor_codec: MsgCodec = mk_codec(
ipc_pld_spec=Raw, # XXX should be default righ!? # TODO: use this for debug mode locking prot?
# ipc_pld_spec=Any,
ipc_pld_spec=Raw,
) )
# TODO: IDEALLY provides for per-`trio.Task` specificity of the
# -[x] TODO, IDEALLY provides for per-`trio.Task` specificity of the
# IPC msging codec used by the transport layer when doing # IPC msging codec used by the transport layer when doing
# `Channel.send()/.recv()` of wire data. # `Channel.send()/.recv()` of wire data.
# => impled as our `PldRx` which is `Context` scoped B)
# ContextVar-TODO: DIDN'T WORK, kept resetting in every new task to default!? # ContextVar-TODO: DIDN'T WORK, kept resetting in every new task to default!?
# _ctxvar_MsgCodec: ContextVar[MsgCodec] = ContextVar( # _ctxvar_MsgCodec: ContextVar[MsgCodec] = ContextVar(
@ -743,6 +546,17 @@ def apply_codec(
) )
token: Token = var.set(codec) token: Token = var.set(codec)
# ?TODO? for TreeVar approach which copies from the
# cancel-scope of the prior value, NOT the prior task
# See the docs:
# - https://tricycle.readthedocs.io/en/latest/reference.html#tree-variables
# - https://github.com/oremanj/tricycle/blob/master/tricycle/_tests/test_tree_var.py
# ^- see docs for @cm `.being()` API
# with _ctxvar_MsgCodec.being(codec):
# new = _ctxvar_MsgCodec.get()
# assert new is codec
# yield codec
try: try:
yield var.get() yield var.get()
finally: finally:
@ -753,19 +567,6 @@ def apply_codec(
) )
assert var.get() is orig assert var.get() is orig
# ?TODO? for TreeVar approach which copies from the
# cancel-scope of the prior value, NOT the prior task
#
# See the docs:
# - https://tricycle.readthedocs.io/en/latest/reference.html#tree-variables
# - https://github.com/oremanj/tricycle/blob/master/tricycle/_tests/test_tree_var.py
# ^- see docs for @cm `.being()` API
#
# with _ctxvar_MsgCodec.being(codec):
# new = _ctxvar_MsgCodec.get()
# assert new is codec
# yield codec
def current_codec() -> MsgCodec: def current_codec() -> MsgCodec:
''' '''
@ -785,7 +586,6 @@ def limit_msg_spec(
# -> related to the `MsgCodec._payload_decs` stuff above.. # -> related to the `MsgCodec._payload_decs` stuff above..
# tagged_structs: list[Struct]|None = None, # tagged_structs: list[Struct]|None = None,
hide_tb: bool = True,
**codec_kwargs, **codec_kwargs,
) -> MsgCodec: ) -> MsgCodec:
@ -796,7 +596,7 @@ def limit_msg_spec(
for all IPC contexts in use by the current `trio.Task`. for all IPC contexts in use by the current `trio.Task`.
''' '''
__tracebackhide__: bool = hide_tb __tracebackhide__: bool = True
curr_codec: MsgCodec = current_codec() curr_codec: MsgCodec = current_codec()
msgspec_codec: MsgCodec = mk_codec( msgspec_codec: MsgCodec = mk_codec(
ipc_pld_spec=payload_spec, ipc_pld_spec=payload_spec,
@ -830,57 +630,31 @@ def limit_msg_spec(
# # import pdbp; pdbp.set_trace() # # import pdbp; pdbp.set_trace()
# assert ext_codec.pld_spec == extended_spec # assert ext_codec.pld_spec == extended_spec
# yield ext_codec # yield ext_codec
# TODO: make something similar to this inside `._codec` such that
# user can just pass a type table of some sort?
# -[ ] we would need to decode all msgs to `pretty_struct.Struct`
# and then call `.to_dict()` on them?
# -[x] we're going to need to re-impl all the stuff changed in the
# runtime port such that it can handle dicts or `Msg`s?
# #
# ^-TODO-^ is it impossible to make something like this orr!? # def mk_dict_msg_codec_hooks() -> tuple[Callable, Callable]:
# '''
# TODO: make an auto-custom hook generator from a set of input custom # Deliver a `enc_hook()`/`dec_hook()` pair which does
# types? # manual convertion from our above native `Msg` set
# -[ ] below is a proto design using a `TypeCodec` idea? # to `dict` equivalent (wire msgs) in order to keep legacy compat
# with the original runtime implementation.
# #
# type var for the expected interchange-lib's # Note: this is is/was primarly used while moving the core
# IPC-transport type when not available as a built-in # runtime over to using native `Msg`-struct types wherein we
# serialization output. # start with the send side emitting without loading
WireT = TypeVar('WireT') # a typed-decoder and then later flipping the switch over to
# load to the native struct types once all runtime usage has
# been adjusted appropriately.
# TODO: some kinda (decorator) API for built-in subtypes #
# that builds this implicitly by inspecting the `mro()`? # '''
class TypeCodec(Protocol): # return (
''' # # enc_to_dict,
A per-custom-type wire-transport serialization translator # dec_from_dict,
description type. # )
'''
src_type: Type
wire_type: WireT
def encode(obj: Type) -> WireT:
...
def decode(
obj_type: Type[WireT],
obj: WireT,
) -> Type:
...
class MsgpackTypeCodec(TypeCodec):
...
def mk_codec_hooks(
type_codecs: list[TypeCodec],
) -> tuple[Callable, Callable]:
'''
Deliver a `enc_hook()`/`dec_hook()` pair which handle
manual convertion from an input `Type` set such that whenever
the `TypeCodec.filter()` predicate matches the
`TypeCodec.decode()` is called on the input native object by
the `dec_hook()` and whenever the
`isiinstance(obj, TypeCodec.type)` matches against an
`enc_hook(obj=obj)` the return value is taken from a
`TypeCodec.encode(obj)` callback.
'''
...

View File

@ -1,94 +0,0 @@
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
Type-extension-utils for codec-ing (python) objects not
covered by the `msgspec.msgpack` protocol.
See the various API docs from `msgspec`.
extending from native types,
- https://jcristharif.com/msgspec/extending.html#mapping-to-from-native-types
converters,
- https://jcristharif.com/msgspec/converters.html
- https://jcristharif.com/msgspec/api.html#msgspec.convert
`Raw` fields,
- https://jcristharif.com/msgspec/api.html#raw
- support for `.convert()` and `Raw`,
|_ https://jcristharif.com/msgspec/changelog.html
'''
from types import (
ModuleType,
)
import typing
from typing import (
Type,
Union,
)
def dec_type_union(
type_names: list[str],
mods: list[ModuleType] = []
) -> Type|Union[Type]:
'''
Look up types by name, compile into a list and then create and
return a `typing.Union` from the full set.
'''
# import importlib
types: list[Type] = []
for type_name in type_names:
for mod in [
typing,
# importlib.import_module(__name__),
] + mods:
if type_ref := getattr(
mod,
type_name,
False,
):
types.append(type_ref)
# special case handling only..
# ipc_pld_spec: Union[Type] = eval(
# pld_spec_str,
# {}, # globals
# {'typing': typing}, # locals
# )
return Union[*types]
def enc_type_union(
union_or_type: Union[Type]|Type,
) -> list[str]:
'''
Encode a type-union or single type to a list of type-name-strings
ready for IPC interchange.
'''
type_strs: list[str] = []
for typ in getattr(
union_or_type,
'__args__',
{union_or_type,},
):
type_strs.append(typ.__qualname__)
return type_strs

View File

@ -50,9 +50,7 @@ from tractor._exceptions import (
_mk_recv_mte, _mk_recv_mte,
pack_error, pack_error,
) )
from tractor._state import ( from tractor._state import current_ipc_ctx
current_ipc_ctx,
)
from ._codec import ( from ._codec import (
mk_dec, mk_dec,
MsgDec, MsgDec,
@ -80,7 +78,7 @@ if TYPE_CHECKING:
log = get_logger(__name__) log = get_logger(__name__)
_def_any_pldec: MsgDec[Any] = mk_dec(spec=Any) _def_any_pldec: MsgDec[Any] = mk_dec()
class PldRx(Struct): class PldRx(Struct):
@ -110,11 +108,33 @@ class PldRx(Struct):
# TODO: better to bind it here? # TODO: better to bind it here?
# _rx_mc: trio.MemoryReceiveChannel # _rx_mc: trio.MemoryReceiveChannel
_pld_dec: MsgDec _pld_dec: MsgDec
_ctx: Context|None = None
_ipc: Context|MsgStream|None = None
@property @property
def pld_dec(self) -> MsgDec: def pld_dec(self) -> MsgDec:
return self._pld_dec return self._pld_dec
# TODO: a better name?
# -[ ] when would this be used as it avoids needingn to pass the
# ipc prim to every method
@cm
def wraps_ipc(
self,
ipc_prim: Context|MsgStream,
) -> PldRx:
'''
Apply this payload receiver to an IPC primitive type, one
of `Context` or `MsgStream`.
'''
self._ipc = ipc_prim
try:
yield self
finally:
self._ipc = None
@cm @cm
def limit_plds( def limit_plds(
self, self,
@ -128,10 +148,6 @@ class PldRx(Struct):
exit. exit.
''' '''
# TODO, ensure we pull the current `MsgCodec`'s custom
# dec/enc_hook settings as well ?
# -[ ] see `._codec.mk_codec()` inputs
#
orig_dec: MsgDec = self._pld_dec orig_dec: MsgDec = self._pld_dec
limit_dec: MsgDec = mk_dec( limit_dec: MsgDec = mk_dec(
spec=spec, spec=spec,
@ -147,7 +163,7 @@ class PldRx(Struct):
def dec(self) -> msgpack.Decoder: def dec(self) -> msgpack.Decoder:
return self._pld_dec.dec return self._pld_dec.dec
def recv_msg_nowait( def recv_pld_nowait(
self, self,
# TODO: make this `MsgStream` compat as well, see above^ # TODO: make this `MsgStream` compat as well, see above^
# ipc_prim: Context|MsgStream, # ipc_prim: Context|MsgStream,
@ -158,95 +174,34 @@ class PldRx(Struct):
hide_tb: bool = False, hide_tb: bool = False,
**dec_pld_kwargs, **dec_pld_kwargs,
) -> tuple[ ) -> Any|Raw:
MsgType[PayloadT],
PayloadT,
]:
'''
Attempt to non-blocking receive a message from the `._rx_chan` and
unwrap it's payload delivering the pair to the caller.
'''
__tracebackhide__: bool = hide_tb __tracebackhide__: bool = hide_tb
msg: MsgType = ( msg: MsgType = (
ipc_msg ipc_msg
or or
# sync-rx msg from underlying IPC feeder (mem-)chan # sync-rx msg from underlying IPC feeder (mem-)chan
ipc._rx_chan.receive_nowait() ipc._rx_chan.receive_nowait()
) )
pld: PayloadT = self.decode_pld( return self.decode_pld(
msg, msg,
ipc=ipc, ipc=ipc,
expect_msg=expect_msg, expect_msg=expect_msg,
hide_tb=hide_tb, hide_tb=hide_tb,
**dec_pld_kwargs, **dec_pld_kwargs,
) )
return (
msg,
pld,
)
async def recv_msg(
self,
ipc: Context|MsgStream,
expect_msg: MsgType,
# NOTE: ONLY for handling `Stop`-msgs that arrive during
# a call to `drain_to_final_msg()` above!
passthrough_non_pld_msgs: bool = True,
hide_tb: bool = True,
**decode_pld_kwargs,
) -> tuple[MsgType, PayloadT]:
'''
Retrieve the next avail IPC msg, decode its payload, and
return the (msg, pld) pair.
'''
__tracebackhide__: bool = hide_tb
msg: MsgType = await ipc._rx_chan.receive()
match msg:
case Return()|Error():
log.runtime(
f'Rxed final outcome msg\n'
f'{msg}\n'
)
case Stop():
log.runtime(
f'Rxed stream stopped msg\n'
f'{msg}\n'
)
if passthrough_non_pld_msgs:
return msg, None
# TODO: is there some way we can inject the decoded
# payload into an existing output buffer for the original
# msg instance?
pld: PayloadT = self.decode_pld(
msg,
ipc=ipc,
expect_msg=expect_msg,
hide_tb=hide_tb,
**decode_pld_kwargs,
)
return (
msg,
pld,
)
async def recv_pld( async def recv_pld(
self, self,
ipc: Context|MsgStream, ipc: Context|MsgStream,
ipc_msg: MsgType[PayloadT]|None = None, ipc_msg: MsgType|None = None,
expect_msg: Type[MsgType]|None = None, expect_msg: Type[MsgType]|None = None,
hide_tb: bool = True, hide_tb: bool = True,
**dec_pld_kwargs, **dec_pld_kwargs,
) -> PayloadT: ) -> Any|Raw:
''' '''
Receive a `MsgType`, then decode and return its `.pld` field. Receive a `MsgType`, then decode and return its `.pld` field.
@ -258,13 +213,6 @@ class PldRx(Struct):
# async-rx msg from underlying IPC feeder (mem-)chan # async-rx msg from underlying IPC feeder (mem-)chan
await ipc._rx_chan.receive() await ipc._rx_chan.receive()
) )
if (
type(msg) is Return
):
log.info(
f'Rxed final result msg\n'
f'{msg}\n'
)
return self.decode_pld( return self.decode_pld(
msg=msg, msg=msg,
ipc=ipc, ipc=ipc,
@ -310,9 +258,6 @@ class PldRx(Struct):
f'|_pld={pld!r}\n' f'|_pld={pld!r}\n'
) )
return pld return pld
except TypeError as typerr:
__tracebackhide__: bool = False
raise typerr
# XXX pld-value type failure # XXX pld-value type failure
except ValidationError as valerr: except ValidationError as valerr:
@ -453,6 +398,45 @@ class PldRx(Struct):
__tracebackhide__: bool = False __tracebackhide__: bool = False
raise raise
dec_msg = decode_pld
async def recv_msg_w_pld(
self,
ipc: Context|MsgStream,
expect_msg: MsgType,
# NOTE: generally speaking only for handling `Stop`-msgs that
# arrive during a call to `drain_to_final_msg()` above!
passthrough_non_pld_msgs: bool = True,
hide_tb: bool = True,
**kwargs,
) -> tuple[MsgType, PayloadT]:
'''
Retrieve the next avail IPC msg, decode it's payload, and return
the pair of refs.
'''
__tracebackhide__: bool = hide_tb
msg: MsgType = await ipc._rx_chan.receive()
if passthrough_non_pld_msgs:
match msg:
case Stop():
return msg, None
# TODO: is there some way we can inject the decoded
# payload into an existing output buffer for the original
# msg instance?
pld: PayloadT = self.decode_pld(
msg,
ipc=ipc,
expect_msg=expect_msg,
hide_tb=hide_tb,
**kwargs,
)
return msg, pld
@cm @cm
def limit_plds( def limit_plds(
@ -468,16 +452,11 @@ def limit_plds(
''' '''
__tracebackhide__: bool = True __tracebackhide__: bool = True
curr_ctx: Context|None = current_ipc_ctx()
if curr_ctx is None:
raise RuntimeError(
'No IPC `Context` is active !?\n'
'Did you open `limit_plds()` from outside '
'a `Portal.open_context()` scope-block?'
)
try: try:
curr_ctx: Context = current_ipc_ctx()
rx: PldRx = curr_ctx._pld_rx rx: PldRx = curr_ctx._pld_rx
orig_pldec: MsgDec = rx.pld_dec orig_pldec: MsgDec = rx.pld_dec
with rx.limit_plds( with rx.limit_plds(
spec=spec, spec=spec,
**dec_kwargs, **dec_kwargs,
@ -487,11 +466,6 @@ def limit_plds(
f'{pldec}\n' f'{pldec}\n'
) )
yield pldec yield pldec
except BaseException:
__tracebackhide__: bool = False
raise
finally: finally:
log.runtime( log.runtime(
'Reverted to previous payload-decoder\n\n' 'Reverted to previous payload-decoder\n\n'
@ -545,8 +519,8 @@ async def maybe_limit_plds(
async def drain_to_final_msg( async def drain_to_final_msg(
ctx: Context, ctx: Context,
msg_limit: int = 6,
hide_tb: bool = True, hide_tb: bool = True,
msg_limit: int = 6,
) -> tuple[ ) -> tuple[
Return|None, Return|None,
@ -575,8 +549,8 @@ async def drain_to_final_msg(
even after ctx closure and the `.open_context()` block exit. even after ctx closure and the `.open_context()` block exit.
''' '''
__tracebackhide__: bool = hide_tb
raise_overrun: bool = not ctx._allow_overruns raise_overrun: bool = not ctx._allow_overruns
parent_never_opened_stream: bool = ctx._stream is None
# wait for a final context result by collecting (but # wait for a final context result by collecting (but
# basically ignoring) any bi-dir-stream msgs still in transit # basically ignoring) any bi-dir-stream msgs still in transit
@ -585,14 +559,13 @@ async def drain_to_final_msg(
result_msg: Return|Error|None = None result_msg: Return|Error|None = None
while not ( while not (
ctx.maybe_error ctx.maybe_error
and and not ctx._final_result_is_set()
not ctx._final_result_is_set()
): ):
try: try:
# receive all msgs, scanning for either a final result # receive all msgs, scanning for either a final result
# or error; the underlying call should never raise any # or error; the underlying call should never raise any
# remote error directly! # remote error directly!
msg, pld = await ctx._pld_rx.recv_msg( msg, pld = await ctx._pld_rx.recv_msg_w_pld(
ipc=ctx, ipc=ctx,
expect_msg=Return, expect_msg=Return,
raise_error=False, raise_error=False,
@ -639,11 +612,6 @@ async def drain_to_final_msg(
) )
__tracebackhide__: bool = False __tracebackhide__: bool = False
else:
log.cancel(
f'IPC ctx cancelled externally during result drain ?\n'
f'{ctx}'
)
# CASE 2: mask the local cancelled-error(s) # CASE 2: mask the local cancelled-error(s)
# only when we are sure the remote error is # only when we are sure the remote error is
# the source cause of this local task's # the source cause of this local task's
@ -675,24 +643,17 @@ async def drain_to_final_msg(
case Yield(): case Yield():
pre_result_drained.append(msg) pre_result_drained.append(msg)
if ( if (
not parent_never_opened_stream (ctx._stream.closed
and ( and (reason := 'stream was already closed')
(ctx._stream.closed )
and or (ctx.cancel_acked
(reason := 'stream was already closed') and (reason := 'ctx cancelled other side')
) or )
(ctx.cancel_acked or (ctx._cancel_called
and and (reason := 'ctx called `.cancel()`')
(reason := 'ctx cancelled other side') )
) or (len(pre_result_drained) > msg_limit
or (ctx._cancel_called and (reason := f'"yield" limit={msg_limit}')
and
(reason := 'ctx called `.cancel()`')
)
or (len(pre_result_drained) > msg_limit
and
(reason := f'"yield" limit={msg_limit}')
)
) )
): ):
log.cancel( log.cancel(
@ -710,7 +671,7 @@ async def drain_to_final_msg(
# drain up to the `msg_limit` hoping to get # drain up to the `msg_limit` hoping to get
# a final result or error/ctxc. # a final result or error/ctxc.
else: else:
report: str = ( log.warning(
'Ignoring "yield" msg during `ctx.result()` drain..\n' 'Ignoring "yield" msg during `ctx.result()` drain..\n'
f'<= {ctx.chan.uid}\n' f'<= {ctx.chan.uid}\n'
f' |_{ctx._nsf}()\n\n' f' |_{ctx._nsf}()\n\n'
@ -719,14 +680,6 @@ async def drain_to_final_msg(
f'{pretty_struct.pformat(msg)}\n' f'{pretty_struct.pformat(msg)}\n'
) )
if parent_never_opened_stream:
report = (
f'IPC ctx never opened stream on {ctx.side!r}-side!\n'
f'\n'
# f'{ctx}\n'
) + report
log.warning(report)
continue continue
# stream terminated, but no result yet.. # stream terminated, but no result yet..
@ -818,7 +771,6 @@ async def drain_to_final_msg(
f'{ctx.outcome}\n' f'{ctx.outcome}\n'
) )
__tracebackhide__: bool = hide_tb
return ( return (
result_msg, result_msg,
pre_result_drained, pre_result_drained,
@ -844,14 +796,8 @@ def validate_payload_msg(
__tracebackhide__: bool = hide_tb __tracebackhide__: bool = hide_tb
codec: MsgCodec = current_codec() codec: MsgCodec = current_codec()
msg_bytes: bytes = codec.encode(pld_msg) msg_bytes: bytes = codec.encode(pld_msg)
roundtripped: Started|None = None
try: try:
roundtripped: Started = codec.decode(msg_bytes) roundtripped: Started = codec.decode(msg_bytes)
except TypeError as typerr:
__tracebackhide__: bool = False
raise typerr
try:
ctx: Context = getattr(ipc, 'ctx', ipc) ctx: Context = getattr(ipc, 'ctx', ipc)
pld: PayloadT = ctx.pld_rx.decode_pld( pld: PayloadT = ctx.pld_rx.decode_pld(
msg=roundtripped, msg=roundtripped,
@ -876,11 +822,6 @@ def validate_payload_msg(
) )
raise ValidationError(complaint) raise ValidationError(complaint)
# usually due to `.decode()` input type
except TypeError as typerr:
__tracebackhide__: bool = False
raise typerr
# raise any msg type error NO MATTER WHAT! # raise any msg type error NO MATTER WHAT!
except ValidationError as verr: except ValidationError as verr:
try: try:
@ -891,13 +832,9 @@ def validate_payload_msg(
verb_header='Trying to send ', verb_header='Trying to send ',
is_invalid_payload=True, is_invalid_payload=True,
) )
except BaseException as _be: except BaseException:
if not roundtripped:
raise verr
be = _be
__tracebackhide__: bool = False __tracebackhide__: bool = False
raise be raise
if not raise_mte: if not raise_mte:
return mte return mte

View File

@ -30,9 +30,9 @@ from msgspec import (
Struct as _Struct, Struct as _Struct,
structs, structs,
) )
# from pprint import ( from pprint import (
# saferepr, saferepr,
# ) )
from tractor.log import get_logger from tractor.log import get_logger
@ -75,8 +75,8 @@ class DiffDump(UserList):
for k, left, right in self: for k, left, right in self:
repstr += ( repstr += (
f'({k},\n' f'({k},\n'
f' |_{repr(left)},\n' f'\t{repr(left)},\n'
f' |_{repr(right)},\n' f'\t{repr(right)},\n'
')\n' ')\n'
) )
repstr += ']\n' repstr += ']\n'
@ -144,22 +144,15 @@ def pformat(
field_indent=indent + field_indent, field_indent=indent + field_indent,
) )
else: else: # the `pprint` recursion-safe format:
val_str: str = repr(v)
# XXX LOL, below just seems to be f#$%in causing
# recursion errs..
#
# the `pprint` recursion-safe format:
# https://docs.python.org/3.11/library/pprint.html#pprint.saferepr # https://docs.python.org/3.11/library/pprint.html#pprint.saferepr
# try: try:
# val_str: str = saferepr(v) val_str: str = saferepr(v)
# except Exception: except Exception:
# log.exception( log.exception(
# 'Failed to `saferepr({type(struct)})` !?\n' 'Failed to `saferepr({type(struct)})` !?\n'
# ) )
# raise return _Struct.__repr__(struct)
# return _Struct.__repr__(struct)
# TODO: LOLOL use `textwrap.indent()` instead dawwwwwg! # TODO: LOLOL use `textwrap.indent()` instead dawwwwwg!
obj_str += (field_ws + f'{k}: {typ_name} = {val_str},\n') obj_str += (field_ws + f'{k}: {typ_name} = {val_str},\n')
@ -210,7 +203,12 @@ class Struct(
return sin_props return sin_props
pformat = pformat pformat = pformat
# __repr__ = pformat
# __str__ = __repr__ = pformat
# TODO: use a pprint.PrettyPrinter instance around ONLY rendering
# inside a known tty?
# def __repr__(self) -> str:
# ...
def __repr__(self) -> str: def __repr__(self) -> str:
try: try:
return pformat(self) return pformat(self)
@ -220,13 +218,6 @@ class Struct(
) )
return _Struct.__repr__(self) return _Struct.__repr__(self)
# __repr__ = pformat
# __str__ = __repr__ = pformat
# TODO: use a pprint.PrettyPrinter instance around ONLY rendering
# inside a known tty?
# def __repr__(self) -> str:
# ...
def copy( def copy(
self, self,
update: dict | None = None, update: dict | None = None,
@ -276,15 +267,13 @@ class Struct(
fi.type(getattr(self, fi.name)), fi.type(getattr(self, fi.name)),
) )
# TODO: make a mod func instead and just point to it here for
# method impl?
def __sub__( def __sub__(
self, self,
other: Struct, other: Struct,
) -> DiffDump[tuple[str, Any, Any]]: ) -> DiffDump[tuple[str, Any, Any]]:
''' '''
Compare fields/items key-wise and return a `DiffDump` Compare fields/items key-wise and return a ``DiffDump``
for easy visual REPL comparison B) for easy visual REPL comparison B)
''' '''
@ -301,42 +290,3 @@ class Struct(
)) ))
return diffs return diffs
@classmethod
def fields_diff(
cls,
other: dict|Struct,
) -> DiffDump[tuple[str, Any, Any]]:
'''
Very similar to `PrettyStruct.__sub__()` except accepts an
input `other: dict` (presumably that would normally be called
like `Struct(**other)`) which returns a `DiffDump` of the
fields of the struct and the `dict`'s fields.
'''
nullish = object()
consumed: dict = other.copy()
diffs: DiffDump[tuple[str, Any, Any]] = DiffDump()
for fi in structs.fields(cls):
field_name: str = fi.name
# ours: Any = getattr(self, field_name)
theirs: Any = consumed.pop(field_name, nullish)
if theirs is nullish:
diffs.append((
field_name,
f'{fi.type!r}',
'NOT-DEFINED in `other: dict`',
))
# when there are lingering fields in `other` that this struct
# DOES NOT define we also append those.
if consumed:
for k, v in consumed.items():
diffs.append((
k,
f'NOT-DEFINED for `{cls.__name__}`',
f'`other: dict` has value = {v!r}',
))
return diffs

View File

@ -599,15 +599,15 @@ def mk_msg_spec(
Msg[payload_type_union], Msg[payload_type_union],
Generic[PayloadT], Generic[PayloadT],
) )
# defstruct_bases: tuple = ( defstruct_bases: tuple = (
# Msg, # [payload_type_union], Msg, # [payload_type_union],
# # Generic[PayloadT], # Generic[PayloadT],
# # ^-XXX-^: not allowed? lul.. # ^-XXX-^: not allowed? lul..
# ) )
ipc_msg_types: list[Msg] = [] ipc_msg_types: list[Msg] = []
idx_msg_types: list[Msg] = [] idx_msg_types: list[Msg] = []
# defs_msg_types: list[Msg] = [] defs_msg_types: list[Msg] = []
nc_msg_types: list[Msg] = [] nc_msg_types: list[Msg] = []
for msgtype in __msg_types__: for msgtype in __msg_types__:
@ -625,7 +625,7 @@ def mk_msg_spec(
# TODO: wait why do we need the dynamic version here? # TODO: wait why do we need the dynamic version here?
# XXX ANSWER XXX -> BC INHERITANCE.. don't work w generics.. # XXX ANSWER XXX -> BC INHERITANCE.. don't work w generics..
# #
# NOTE previously bc msgtypes WERE NOT inheriting # NOTE previously bc msgtypes WERE NOT inheritting
# directly the `Generic[PayloadT]` type, the manual method # directly the `Generic[PayloadT]` type, the manual method
# of generic-paraming with `.__class_getitem__()` wasn't # of generic-paraming with `.__class_getitem__()` wasn't
# working.. # working..
@ -662,35 +662,38 @@ def mk_msg_spec(
# with `msgspec.structs.defstruct` # with `msgspec.structs.defstruct`
# XXX ALSO DOESN'T WORK # XXX ALSO DOESN'T WORK
# defstruct_msgtype = defstruct( defstruct_msgtype = defstruct(
# name=msgtype.__name__, name=msgtype.__name__,
# fields=[ fields=[
# ('cid', str), ('cid', str),
# # XXX doesn't seem to work.. # XXX doesn't seem to work..
# # ('pld', PayloadT), # ('pld', PayloadT),
('pld', payload_type_union),
],
bases=defstruct_bases,
)
defs_msg_types.append(defstruct_msgtype)
# ('pld', payload_type_union),
# ],
# bases=defstruct_bases,
# )
# defs_msg_types.append(defstruct_msgtype)
# assert index_paramed_msg_type == manual_paramed_msg_subtype # assert index_paramed_msg_type == manual_paramed_msg_subtype
# paramed_msg_type = manual_paramed_msg_subtype # paramed_msg_type = manual_paramed_msg_subtype
# ipc_payload_msgs_type_union |= index_paramed_msg_type # ipc_payload_msgs_type_union |= index_paramed_msg_type
idx_spec: Union[Type[Msg]] = Union[*idx_msg_types] idx_spec: Union[Type[Msg]] = Union[*idx_msg_types]
# def_spec: Union[Type[Msg]] = Union[*defs_msg_types] def_spec: Union[Type[Msg]] = Union[*defs_msg_types]
nc_spec: Union[Type[Msg]] = Union[*nc_msg_types] nc_spec: Union[Type[Msg]] = Union[*nc_msg_types]
specs: dict[str, Union[Type[Msg]]] = { specs: dict[str, Union[Type[Msg]]] = {
'indexed_generics': idx_spec, 'indexed_generics': idx_spec,
# 'defstruct': def_spec, 'defstruct': def_spec,
'types_new_class': nc_spec, 'types_new_class': nc_spec,
} }
msgtypes_table: dict[str, list[Msg]] = { msgtypes_table: dict[str, list[Msg]] = {
'indexed_generics': idx_msg_types, 'indexed_generics': idx_msg_types,
# 'defstruct': defs_msg_types, 'defstruct': defs_msg_types,
'types_new_class': nc_msg_types, 'types_new_class': nc_msg_types,
} }

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,3 @@ from ._broadcast import (
BroadcastReceiver as BroadcastReceiver, BroadcastReceiver as BroadcastReceiver,
Lagged as Lagged, Lagged as Lagged,
) )
from ._beg import (
collapse_eg as collapse_eg,
)

View File

@ -1,58 +0,0 @@
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
`BaseExceptionGroup` related utils and helpers pertaining to
first-class-`trio` from a historical perspective B)
'''
from contextlib import (
asynccontextmanager as acm,
)
def maybe_collapse_eg(
beg: BaseExceptionGroup,
) -> BaseException:
'''
If the input beg can collapse to a single non-eg sub-exception,
return it instead.
'''
if len(excs := beg.exceptions) == 1:
return excs[0]
return beg
@acm
async def collapse_eg():
'''
If `BaseExceptionGroup` raised in the body scope is
"collapse-able" (in the same way that
`trio.open_nursery(strict_exception_groups=False)` works) then
only raise the lone emedded non-eg in in place.
'''
try:
yield
except* BaseException as beg:
if (
exc := maybe_collapse_eg(beg)
) is not beg:
raise exc
raise beg

View File

@ -15,7 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
''' '''
`tokio` style broadcast channel. ``tokio`` style broadcast channel.
https://docs.rs/tokio/1.11.0/tokio/sync/broadcast/index.html https://docs.rs/tokio/1.11.0/tokio/sync/broadcast/index.html
''' '''
@ -382,7 +382,7 @@ class BroadcastReceiver(ReceiveChannel):
# likely it makes sense to unwind back to the # likely it makes sense to unwind back to the
# underlying? # underlying?
# import tractor # import tractor
# await tractor.pause() # await tractor.breakpoint()
log.warning( log.warning(
f'Only one sub left for {self}?\n' f'Only one sub left for {self}?\n'
'We can probably unwind from breceiver?' 'We can probably unwind from breceiver?'

View File

@ -57,8 +57,6 @@ async def maybe_open_nursery(
shield: bool = False, shield: bool = False,
lib: ModuleType = trio, lib: ModuleType = trio,
**kwargs, # proxy thru
) -> AsyncGenerator[trio.Nursery, Any]: ) -> AsyncGenerator[trio.Nursery, Any]:
''' '''
Create a new nursery if None provided. Create a new nursery if None provided.
@ -69,7 +67,7 @@ async def maybe_open_nursery(
if nursery is not None: if nursery is not None:
yield nursery yield nursery
else: else:
async with lib.open_nursery(**kwargs) as nursery: async with lib.open_nursery() as nursery:
nursery.cancel_scope.shield = shield nursery.cancel_scope.shield = shield
yield nursery yield nursery
@ -145,14 +143,9 @@ async def gather_contexts(
'Use a non-lazy iterator or sequence type intead!' 'Use a non-lazy iterator or sequence type intead!'
) )
async with trio.open_nursery( async with trio.open_nursery() as n:
strict_exception_groups=False,
# ^XXX^ TODO? soo roll our own then ??
# -> since we kinda want the "if only one `.exception` then
# just raise that" interface?
) as tn:
for mngr in mngrs: for mngr in mngrs:
tn.start_soon( n.start_soon(
_enter_and_wait, _enter_and_wait,
mngr, mngr,
unwrapped, unwrapped,

88
uv.lock
View File

@ -126,31 +126,7 @@ wheels = [
[[package]] [[package]]
name = "msgspec" name = "msgspec"
version = "0.19.0" version = "0.19.0"
source = { registry = "https://pypi.org/simple" } source = { git = "https://github.com/jcrist/msgspec.git#dd965dce22e5278d4935bea923441ecde31b5325" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/9b/95d8ce458462b8b71b8a70fa94563b2498b89933689f3a7b8911edfae3d7/msgspec-0.19.0.tar.gz", hash = "sha256:604037e7cd475345848116e89c553aa9a233259733ab51986ac924ab1b976f8e", size = 216934 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/d4/2ec2567ac30dab072cce3e91fb17803c52f0a37aab6b0c24375d2b20a581/msgspec-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa77046904db764b0462036bc63ef71f02b75b8f72e9c9dd4c447d6da1ed8f8e", size = 187939 },
{ url = "https://files.pythonhosted.org/packages/2b/c0/18226e4328897f4f19875cb62bb9259fe47e901eade9d9376ab5f251a929/msgspec-0.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:047cfa8675eb3bad68722cfe95c60e7afabf84d1bd8938979dd2b92e9e4a9551", size = 182202 },
{ url = "https://files.pythonhosted.org/packages/81/25/3a4b24d468203d8af90d1d351b77ea3cffb96b29492855cf83078f16bfe4/msgspec-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e78f46ff39a427e10b4a61614a2777ad69559cc8d603a7c05681f5a595ea98f7", size = 209029 },
{ url = "https://files.pythonhosted.org/packages/85/2e/db7e189b57901955239f7689b5dcd6ae9458637a9c66747326726c650523/msgspec-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c7adf191e4bd3be0e9231c3b6dc20cf1199ada2af523885efc2ed218eafd011", size = 210682 },
{ url = "https://files.pythonhosted.org/packages/03/97/7c8895c9074a97052d7e4a1cc1230b7b6e2ca2486714eb12c3f08bb9d284/msgspec-0.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f04cad4385e20be7c7176bb8ae3dca54a08e9756cfc97bcdb4f18560c3042063", size = 214003 },
{ url = "https://files.pythonhosted.org/packages/61/61/e892997bcaa289559b4d5869f066a8021b79f4bf8e955f831b095f47a4cd/msgspec-0.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45c8fb410670b3b7eb884d44a75589377c341ec1392b778311acdbfa55187716", size = 216833 },
{ url = "https://files.pythonhosted.org/packages/ce/3d/71b2dffd3a1c743ffe13296ff701ee503feaebc3f04d0e75613b6563c374/msgspec-0.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:70eaef4934b87193a27d802534dc466778ad8d536e296ae2f9334e182ac27b6c", size = 186184 },
{ url = "https://files.pythonhosted.org/packages/b2/5f/a70c24f075e3e7af2fae5414c7048b0e11389685b7f717bb55ba282a34a7/msgspec-0.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f98bd8962ad549c27d63845b50af3f53ec468b6318400c9f1adfe8b092d7b62f", size = 190485 },
{ url = "https://files.pythonhosted.org/packages/89/b0/1b9763938cfae12acf14b682fcf05c92855974d921a5a985ecc197d1c672/msgspec-0.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43bbb237feab761b815ed9df43b266114203f53596f9b6e6f00ebd79d178cdf2", size = 183910 },
{ url = "https://files.pythonhosted.org/packages/87/81/0c8c93f0b92c97e326b279795f9c5b956c5a97af28ca0fbb9fd86c83737a/msgspec-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cfc033c02c3e0aec52b71710d7f84cb3ca5eb407ab2ad23d75631153fdb1f12", size = 210633 },
{ url = "https://files.pythonhosted.org/packages/d0/ef/c5422ce8af73928d194a6606f8ae36e93a52fd5e8df5abd366903a5ca8da/msgspec-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d911c442571605e17658ca2b416fd8579c5050ac9adc5e00c2cb3126c97f73bc", size = 213594 },
{ url = "https://files.pythonhosted.org/packages/19/2b/4137bc2ed45660444842d042be2cf5b18aa06efd2cda107cff18253b9653/msgspec-0.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:757b501fa57e24896cf40a831442b19a864f56d253679f34f260dcb002524a6c", size = 214053 },
{ url = "https://files.pythonhosted.org/packages/9d/e6/8ad51bdc806aac1dc501e8fe43f759f9ed7284043d722b53323ea421c360/msgspec-0.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5f0f65f29b45e2816d8bded36e6b837a4bf5fb60ec4bc3c625fa2c6da4124537", size = 219081 },
{ url = "https://files.pythonhosted.org/packages/b1/ef/27dd35a7049c9a4f4211c6cd6a8c9db0a50647546f003a5867827ec45391/msgspec-0.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:067f0de1c33cfa0b6a8206562efdf6be5985b988b53dd244a8e06f993f27c8c0", size = 187467 },
{ url = "https://files.pythonhosted.org/packages/3c/cb/2842c312bbe618d8fefc8b9cedce37f773cdc8fa453306546dba2c21fd98/msgspec-0.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f12d30dd6266557aaaf0aa0f9580a9a8fbeadfa83699c487713e355ec5f0bd86", size = 190498 },
{ url = "https://files.pythonhosted.org/packages/58/95/c40b01b93465e1a5f3b6c7d91b10fb574818163740cc3acbe722d1e0e7e4/msgspec-0.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82b2c42c1b9ebc89e822e7e13bbe9d17ede0c23c187469fdd9505afd5a481314", size = 183950 },
{ url = "https://files.pythonhosted.org/packages/e8/f0/5b764e066ce9aba4b70d1db8b087ea66098c7c27d59b9dd8a3532774d48f/msgspec-0.19.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19746b50be214a54239aab822964f2ac81e38b0055cca94808359d779338c10e", size = 210647 },
{ url = "https://files.pythonhosted.org/packages/9d/87/bc14f49bc95c4cb0dd0a8c56028a67c014ee7e6818ccdce74a4862af259b/msgspec-0.19.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60ef4bdb0ec8e4ad62e5a1f95230c08efb1f64f32e6e8dd2ced685bcc73858b5", size = 213563 },
{ url = "https://files.pythonhosted.org/packages/53/2f/2b1c2b056894fbaa975f68f81e3014bb447516a8b010f1bed3fb0e016ed7/msgspec-0.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac7f7c377c122b649f7545810c6cd1b47586e3aa3059126ce3516ac7ccc6a6a9", size = 213996 },
{ url = "https://files.pythonhosted.org/packages/aa/5a/4cd408d90d1417e8d2ce6a22b98a6853c1b4d7cb7669153e4424d60087f6/msgspec-0.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5bc1472223a643f5ffb5bf46ccdede7f9795078194f14edd69e3aab7020d327", size = 219087 },
{ url = "https://files.pythonhosted.org/packages/23/d8/f15b40611c2d5753d1abb0ca0da0c75348daf1252220e5dda2867bd81062/msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f", size = 187432 },
]
[[package]] [[package]]
name = "outcome" name = "outcome"
@ -264,7 +240,7 @@ wheels = [
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.3.5" version = "8.3.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
@ -272,9 +248,9 @@ dependencies = [
{ name = "packaging" }, { name = "packaging" },
{ name = "pluggy" }, { name = "pluggy" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 },
] ]
[[package]] [[package]]
@ -338,15 +314,17 @@ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "stackscope" }, { name = "stackscope" },
{ name = "xonsh" }, { name = "xonsh" },
{ name = "xonsh-vox-tabcomplete" },
{ name = "xontrib-vox" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "colorlog", specifier = ">=6.8.2,<7" }, { name = "colorlog", specifier = ">=6.8.2,<7" },
{ name = "msgspec", specifier = ">=0.19.0" }, { name = "msgspec", git = "https://github.com/jcrist/msgspec.git" },
{ name = "pdbp", specifier = ">=1.6,<2" }, { name = "pdbp", specifier = ">=1.5.0,<2" },
{ name = "tricycle", specifier = ">=0.4.1,<0.5" }, { name = "tricycle", specifier = ">=0.4.1,<0.5" },
{ name = "trio", specifier = ">0.27" }, { name = "trio", specifier = ">=0.24,<0.25" },
{ name = "wrapt", specifier = ">=1.16.0,<2" }, { name = "wrapt", specifier = ">=1.16.0,<2" },
] ]
@ -354,11 +332,13 @@ requires-dist = [
dev = [ dev = [
{ name = "greenback", specifier = ">=1.2.1,<2" }, { name = "greenback", specifier = ">=1.2.1,<2" },
{ name = "pexpect", specifier = ">=4.9.0,<5" }, { name = "pexpect", specifier = ">=4.9.0,<5" },
{ name = "prompt-toolkit", specifier = ">=3.0.50" }, { name = "prompt-toolkit", specifier = ">=3.0.43,<4" },
{ name = "pyperclip", specifier = ">=1.9.0" }, { name = "pyperclip", specifier = ">=1.9.0" },
{ name = "pytest", specifier = ">=8.3.5" }, { name = "pytest", specifier = ">=8.2.0,<9" },
{ name = "stackscope", specifier = ">=0.2.2,<0.3" }, { name = "stackscope", specifier = ">=0.2.2,<0.3" },
{ name = "xonsh", specifier = ">=0.19.2" }, { name = "xonsh", specifier = ">=0.19.1" },
{ name = "xonsh-vox-tabcomplete", specifier = ">=0.5,<0.6" },
{ name = "xontrib-vox", specifier = ">=0.0.1,<0.0.2" },
] ]
[[package]] [[package]]
@ -375,7 +355,7 @@ wheels = [
[[package]] [[package]]
name = "trio" name = "trio"
version = "0.29.0" version = "0.24.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "attrs" }, { name = "attrs" },
@ -385,9 +365,9 @@ dependencies = [
{ name = "sniffio" }, { name = "sniffio" },
{ name = "sortedcontainers" }, { name = "sortedcontainers" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a1/47/f62e62a1a6f37909aed0bf8f5d5411e06fa03846cfcb64540cd1180ccc9f/trio-0.29.0.tar.gz", hash = "sha256:ea0d3967159fc130acb6939a0be0e558e364fee26b5deeecc893a6b08c361bdf", size = 588952 } sdist = { url = "https://files.pythonhosted.org/packages/8a/f3/07c152213222c615fe2391b8e1fea0f5af83599219050a549c20fcbd9ba2/trio-0.24.0.tar.gz", hash = "sha256:ffa09a74a6bf81b84f8613909fb0beaee84757450183a7a2e0b47b455c0cac5d", size = 545131 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/55/c4d9bea8b3d7937901958f65124123512419ab0eb73695e5f382521abbfb/trio-0.29.0-py3-none-any.whl", hash = "sha256:d8c463f1a9cc776ff63e331aba44c125f423a5a13c684307e828d930e625ba66", size = 492920 }, { url = "https://files.pythonhosted.org/packages/14/fb/9299cf74953f473a15accfdbe2c15218e766bae8c796f2567c83bae03e98/trio-0.24.0-py3-none-any.whl", hash = "sha256:c3bd3a4e3e3025cd9a2241eae75637c43fe0b9e88b4c97b9161a55b9e54cd72c", size = 460205 },
] ]
[[package]] [[package]]
@ -454,13 +434,33 @@ wheels = [
[[package]] [[package]]
name = "xonsh" name = "xonsh"
version = "0.19.2" version = "0.19.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/68/4e/56e95a5e607eb3b0da37396f87cde70588efc8ef819ab16f02d5b8378dc4/xonsh-0.19.2.tar.gz", hash = "sha256:cfdd0680d954a2c3aefd6caddcc7143a3d06aa417ed18365a08219bb71b960b0", size = 799960 } sdist = { url = "https://files.pythonhosted.org/packages/98/6e/b54a0b2685535995ee50f655103c463f9d339455c9b08c4bce3e03e7bb17/xonsh-0.19.1.tar.gz", hash = "sha256:5d3de649c909f6d14bc69232219bcbdb8152c830e91ddf17ad169c672397fb97", size = 796468 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/13/281094759df87b23b3c02dc4a16603ab08ea54d7f6acfeb69f3341137c7a/xonsh-0.19.2-py310-none-any.whl", hash = "sha256:ec7f163fd3a4943782aa34069d4e72793328c916a5975949dbec8536cbfc089b", size = 642301 }, { url = "https://files.pythonhosted.org/packages/8c/e6/db44068c5725af9678e37980ae9503165393d51b80dc8517fa4ec74af1cf/xonsh-0.19.1-py310-none-any.whl", hash = "sha256:83eb6610ed3535f8542abd80af9554fb7e2805b0b3f96e445f98d4b5cf1f7046", size = 640686 },
{ url = "https://files.pythonhosted.org/packages/29/41/a51e4c3918fe9a293b150cb949b1b8c6d45eb17dfed480dcb76ea43df4e7/xonsh-0.19.2-py311-none-any.whl", hash = "sha256:53c45f7a767901f2f518f9b8dd60fc653e0498e56e89825e1710bb0859985049", size = 642286 }, { url = "https://files.pythonhosted.org/packages/77/4e/e487e82349866b245c559433c9ba626026a2e66bd17d7f9ac1045082f146/xonsh-0.19.1-py311-none-any.whl", hash = "sha256:c176e515b0260ab803963d1f0924f1e32f1064aa6fd5d791aa0cf6cda3a924ae", size = 640680 },
{ url = "https://files.pythonhosted.org/packages/0a/93/9a77b731f492fac27c577dea2afb5a2bcc2a6a1c79be0c86c95498060270/xonsh-0.19.2-py312-none-any.whl", hash = "sha256:b24c619aa52b59eae4d35c4195dba9b19a2c548fb5c42c6f85f2b8ccb96807b5", size = 642386 }, { url = "https://files.pythonhosted.org/packages/5d/88/09060815548219b8f6953a06c247cb5c92d03cbdf7a02a980bda1b5754db/xonsh-0.19.1-py312-none-any.whl", hash = "sha256:fe1266c86b117aced3bdc4d5972420bda715864435d0bd3722d63451e8001036", size = 640604 },
{ url = "https://files.pythonhosted.org/packages/be/75/070324769c1ff88d971ce040f4f486339be98e0a365c8dd9991eb654265b/xonsh-0.19.2-py313-none-any.whl", hash = "sha256:c53ef6c19f781fbc399ed1b382b5c2aac2125010679a3b61d643978273c27df0", size = 642873 }, { url = "https://files.pythonhosted.org/packages/83/ff/7873cb8184cffeafddbf861712831c2baa2e9dbecdbfd33b1228f0db0019/xonsh-0.19.1-py313-none-any.whl", hash = "sha256:3f158b6fc0bba954e0b989004d4261bafc4bd94c68c2abd75b825da23e5a869c", size = 641166 },
{ url = "https://files.pythonhosted.org/packages/fa/cb/2c7ccec54f5b0e73fdf7650e8336582ff0347d9001c5ef8271dc00c034fe/xonsh-0.19.2-py39-none-any.whl", hash = "sha256:bcc0225dc3847f1ed2f175dac6122fbcc54cea67d9c2dc2753d9615e2a5ff284", size = 634602 }, { url = "https://files.pythonhosted.org/packages/cc/03/b9f8dd338df0a330011d104e63d4d0acd8bbbc1e990ff049487b6bdf585d/xonsh-0.19.1-py39-none-any.whl", hash = "sha256:a900a6eb87d881a7ef90b1ac8522ba3699582f0bcb1e9abd863d32f6d63faf04", size = 632912 },
]
[[package]]
name = "xonsh-vox-tabcomplete"
version = "0.5"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/fd/af0c2ee6c067c2a4dc64ec03598c94de1f6ec5984b3116af917f3add4a16/xonsh_vox_tabcomplete-0.5-py3-none-any.whl", hash = "sha256:9701b198180f167071234e77eab87b7befa97c1873b088d0b3fbbe6d6d8dcaad", size = 14381 },
]
[[package]]
name = "xontrib-vox"
version = "0.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "xonsh" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6c/ac/a5db68a1f2e4036f7ff4c8546b1cbe29edee2ff40e0ff931836745988b79/xontrib-vox-0.0.1.tar.gz", hash = "sha256:c1f0b155992b4b0ebe6dcfd651084a8707ade7372f7e456c484d2a85339d9907", size = 16504 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/58/dcdf11849c8340033da00669527ce75d8292a4e8d82605c082ed236a081a/xontrib_vox-0.0.1-py3-none-any.whl", hash = "sha256:df2bbb815832db5b04d46684f540eac967ee40ef265add2662a95d6947d04c70", size = 13467 },
] ]