From 502c7a1dc6cd3047fe5bfd41558a1b5e32b38b1a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 12 Jun 2025 23:16:29 -0400 Subject: [PATCH] Move `.is_multi_cancelled()` to `.trioniics._beg` Since it's for beg filtering, the current impl should be renamed anyway; it's not just for filtering cancelled excs. Deats, - added a real doc string, links to official eg docs and fixed the return typing. - adjust all internal imports to match. --- tests/test_context_stream_semantics.py | 2 +- tractor/_exceptions.py | 49 -------------------- tractor/_root.py | 4 +- tractor/_supervise.py | 4 +- tractor/devx/debug/_post_mortem.py | 2 +- tractor/to_asyncio.py | 4 +- tractor/trionics/__init__.py | 1 + tractor/trionics/_beg.py | 64 ++++++++++++++++++++++++++ tractor/trionics/_mngrs.py | 34 ++++++++------ 9 files changed, 97 insertions(+), 67 deletions(-) diff --git a/tests/test_context_stream_semantics.py b/tests/test_context_stream_semantics.py index 14cb9cc6..4c347e91 100644 --- a/tests/test_context_stream_semantics.py +++ b/tests/test_context_stream_semantics.py @@ -252,7 +252,7 @@ def test_simple_context( pass except BaseExceptionGroup as beg: # XXX: on windows it seems we may have to expect the group error - from tractor._exceptions import is_multi_cancelled + from tractor.trionics import is_multi_cancelled assert is_multi_cancelled(beg) else: trio.run(main) diff --git a/tractor/_exceptions.py b/tractor/_exceptions.py index 3561c7c6..418accc3 100644 --- a/tractor/_exceptions.py +++ b/tractor/_exceptions.py @@ -1246,55 +1246,6 @@ def unpack_error( return exc -def is_multi_cancelled( - exc: BaseException|BaseExceptionGroup, - - ignore_nested: set[BaseException] = set(), - -) -> bool|BaseExceptionGroup: - ''' - Predicate to determine if an `BaseExceptionGroup` only contains - some (maybe nested) set of sub-grouped exceptions (like only - `trio.Cancelled`s which get swallowed silently by default) and is - 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): - matched_exc: BaseExceptionGroup|None = exc.subgroup( - tuple(ignore_nested), - - # 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 - - def _raise_from_unexpected_msg( ctx: Context, msg: MsgType, diff --git a/tractor/_root.py b/tractor/_root.py index 14903a66..4202fbd8 100644 --- a/tractor/_root.py +++ b/tractor/_root.py @@ -61,9 +61,11 @@ from ._addr import ( mk_uuid, wrap_address, ) +from .trionics import ( + is_multi_cancelled, +) from ._exceptions import ( RuntimeFailure, - is_multi_cancelled, ) diff --git a/tractor/_supervise.py b/tractor/_supervise.py index 0a0463dc..e1775292 100644 --- a/tractor/_supervise.py +++ b/tractor/_supervise.py @@ -40,8 +40,10 @@ from ._state import current_actor, is_main_process from .log import get_logger, get_loglevel from ._runtime import Actor from ._portal import Portal -from ._exceptions import ( +from .trionics import ( is_multi_cancelled, +) +from ._exceptions import ( ContextCancelled, ) from ._root import ( diff --git a/tractor/devx/debug/_post_mortem.py b/tractor/devx/debug/_post_mortem.py index ce4931cf..a5d6cc9e 100644 --- a/tractor/devx/debug/_post_mortem.py +++ b/tractor/devx/debug/_post_mortem.py @@ -59,7 +59,7 @@ from tractor._state import ( debug_mode, ) from tractor.log import get_logger -from tractor._exceptions import ( +from tractor.trionics import ( is_multi_cancelled, ) from ._trace import ( diff --git a/tractor/to_asyncio.py b/tractor/to_asyncio.py index 04635c5b..63c20bc1 100644 --- a/tractor/to_asyncio.py +++ b/tractor/to_asyncio.py @@ -38,7 +38,6 @@ from typing import ( import tractor from tractor._exceptions import ( InternalError, - is_multi_cancelled, TrioTaskExited, TrioCancelled, AsyncioTaskExited, @@ -59,6 +58,9 @@ from tractor.log import ( # from tractor.msg import ( # pretty_struct, # ) +from tractor.trionics import ( + is_multi_cancelled, +) from tractor.trionics._broadcast import ( broadcast_receiver, BroadcastReceiver, diff --git a/tractor/trionics/__init__.py b/tractor/trionics/__init__.py index 42f675b2..bcd355fa 100644 --- a/tractor/trionics/__init__.py +++ b/tractor/trionics/__init__.py @@ -32,4 +32,5 @@ from ._broadcast import ( from ._beg import ( collapse_eg as collapse_eg, maybe_collapse_eg as maybe_collapse_eg, + is_multi_cancelled as is_multi_cancelled, ) diff --git a/tractor/trionics/_beg.py b/tractor/trionics/_beg.py index 843b9f70..ad10f3bf 100644 --- a/tractor/trionics/_beg.py +++ b/tractor/trionics/_beg.py @@ -22,6 +22,11 @@ first-class-`trio` from a historical perspective B) from contextlib import ( asynccontextmanager as acm, ) +from typing import ( + Literal, +) + +import trio def maybe_collapse_eg( @@ -56,3 +61,62 @@ async def collapse_eg(): raise exc raise beg + + +def is_multi_cancelled( + beg: BaseException|BaseExceptionGroup, + + ignore_nested: set[BaseException] = set(), + +) -> Literal[False]|BaseExceptionGroup: + ''' + Predicate to determine if an `BaseExceptionGroup` only contains + some (maybe nested) set of sub-grouped exceptions (like only + `trio.Cancelled`s which get swallowed silently by default) and is + 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 not in ignore_nested + # XXX always count-in `trio`'s native signal + ): + ignore_nested.update({trio.Cancelled}) + + if isinstance(beg, BaseExceptionGroup): + # https://docs.python.org/3/library/exceptions.html#BaseExceptionGroup.subgroup + # |_ "The condition can be an exception type or tuple of + # exception types, in which case each exception is checked + # for a match using the same check that is used in an + # except clause. The condition can also be a callable + # (other than a type object) that accepts an exception as + # its single argument and returns true for the exceptions + # that should be in the subgroup." + matched_exc: BaseExceptionGroup|None = beg.subgroup( + tuple(ignore_nested), + + # ??TODO, complain about why not allowed to use + # named arg style calling??? + # XD .. wtf? + # 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 diff --git a/tractor/trionics/_mngrs.py b/tractor/trionics/_mngrs.py index 9a5ed156..23e9d6a7 100644 --- a/tractor/trionics/_mngrs.py +++ b/tractor/trionics/_mngrs.py @@ -40,6 +40,8 @@ from typing import ( import trio from tractor._state import current_actor from tractor.log import get_logger +# from ._beg import collapse_eg + if TYPE_CHECKING: from tractor import ActorNursery @@ -111,17 +113,19 @@ async def gather_contexts( None, ]: ''' - Concurrently enter a sequence of async context managers (acms), - each from a separate `trio` task and deliver the unwrapped - `yield`-ed values in the same order once all managers have entered. + Concurrently enter a sequence of async context managers (`acm`s), + each scheduled in a separate `trio.Task` and deliver their + unwrapped `yield`-ed values in the same order once all `@acm`s + in every task have entered. - On exit, all acms are subsequently and concurrently exited. + On exit, all `acm`s are subsequently and concurrently exited with + **no order guarantees**. This function is somewhat similar to a batch of non-blocking calls to `contextlib.AsyncExitStack.enter_async_context()` (inside a loop) *in combo with* a `asyncio.gather()` to get the `.__aenter__()`-ed values, except the managers are both - concurrently entered and exited and *cancellation just works*(R). + concurrently entered and exited and *cancellation-just-works™*. ''' seed: int = id(mngrs) @@ -141,16 +145,20 @@ async def gather_contexts( if not mngrs: raise ValueError( '`.trionics.gather_contexts()` input mngrs is empty?\n' + '\n' 'Did try to use inline generator syntax?\n' - 'Use a non-lazy iterator or sequence type intead!' + 'Use a non-lazy iterator or sequence-type intead!\n' ) - async with trio.open_nursery( - 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: + async with ( + # collapse_eg(), + trio.open_nursery( + # 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: tn.start_soon( _enter_and_wait, @@ -167,7 +175,7 @@ async def gather_contexts( try: yield tuple(unwrapped.values()) finally: - # NOTE: this is ABSOLUTELY REQUIRED to avoid + # XXX NOTE: this is ABSOLUTELY REQUIRED to avoid # the following wacky bug: # parent_exit.set()