Compare commits

..

33 Commits

Author SHA1 Message Date
Tyler Goodlet 2c1c89b2f4 Mask not-yet-existing `.devx.pformat` import 2025-03-21 00:15:37 -04:00
Tyler Goodlet 77c51446c0 Handle cpython builds with `libedit` for `readline`
Since `uv`'s cpython distributions are built this way `pdbp`'s tab
completion was breaking (as was vi-mode). This adds a new
`.devx._enable_readline_feats()` import hook which checks for the
appropriate library and applies settings accordingly.
2025-03-21 00:15:37 -04:00
Tyler Goodlet 3ef78bc0c1 Add in some dev deps for @goodboy
Namely since i use `xonsh` for a main shell, this includes adding it as
well as related tooling. Obvi bump the `uv.lock`.

Some other stuff retained from `poetry` days,
- add usage-comments around various (optional) deps.
- add toml section separator lines.
- go with 2-space indent.
- add comment on `trio>0.27` needed for py3.13+
2025-03-21 00:15:37 -04:00
Tyler Goodlet 75ed175452 Disable invalid line in `ruff` config? 2025-03-21 00:15:37 -04:00
Tyler Goodlet 0dacf4be7c Add a `ruff.toml` with ignore set taken from old `pyproject.toml` content 2025-03-21 00:15:37 -04:00
Guillermo Rodriguez c0e60d9072 Migrate to uv using "uvx migrate-to-uv", use msgspec from git due to python 3.13 compat 2025-03-21 00:15:37 -04:00
Tyler Goodlet 863751b47b Add `enable_stack_on_sig: bool` for `stackscope` toggle 2025-03-20 23:22:45 -04:00
Tyler Goodlet 46c8dbef1f Bleh, make `log.devx()` level less then cancel but > `.runtime()` 2025-03-20 23:22:45 -04:00
Tyler Goodlet e7dbb52b34 Tweaks to debugger examples
Light stuff like comments, typing, and a couple API usage updates.
2025-03-20 23:22:45 -04:00
Tyler Goodlet d044629cce Woops, make `log.devx()` level less `.error()` 2025-03-20 23:22:45 -04:00
Tyler Goodlet 8832cdfe0d Make `log.devx()` level below `.pdb()`
Kinda like a "runtime"-y level for `.pdb()` (which is more or less like
an `.info()` for our debugger subsys) which can be used to report
internals info for those hacking on `.devx` tools.

Also, inject only the *last* 6 digits of the `id(Task)` in
`pformat_task_uid()` output by default.
2025-03-20 23:22:45 -04:00
Tyler Goodlet f6fc43d58d Include truncated `id(trio.Task)` for task info in log header 2025-03-20 23:22:45 -04:00
Tyler Goodlet cdc513f25d Add a `.log.at_least_level()` predicate 2025-03-20 23:22:45 -04:00
Tyler Goodlet 9eaee7a060 Woops, make `log.devx()` level 600 2025-03-20 23:22:45 -04:00
Tyler Goodlet 63c087f08d Use `log.devx()` for `stackscope` messages 2025-03-20 23:22:45 -04:00
Tyler Goodlet d5f80365b5 Add a `log.devx()` level 2025-03-20 23:22:45 -04:00
Tyler Goodlet d20f711fb0 Tweak `breakpoint()` usage error message 2025-03-20 23:22:45 -04:00
Tyler Goodlet 21509791e3 Start a `devx._code` mod
Starting with a little sub-sys for tracing caller frames by marking them
with a dunder var (`__runtimeframe__` by default) and then scanning for
that frame such that code that is *calling* our APIs can be reported
easily in logging / tracing output.

New APIs:
- `find_caller_info()` which does the scan and delivers a,
- `CallerInfo` which (attempts) to expose both the runtime frame-info
  and frame of the caller func along with `NamespacePath` properties.

Probably going to re-implement the dunder var bit as a decorator later
so we can bind in the literal func-object ref instead of trying to look
it up with `get_class_from_frame()`, since it's kinda hacky/non-general
and def doesn't work for closure funcs..
2025-03-20 23:22:45 -04:00
Tyler Goodlet ce6974690b Relay `SIGUSR1` to subactors for `stackscope` tracing
Since obvi we don't want to just only see the trace in the root most of
the time ;)

Currently the sig keeps firing twice in the root though, and i'm not
sure why yet..
2025-03-20 23:22:45 -04:00
Tyler Goodlet 972325a28d Add defaul rtv for `use_greeback: bool = False` 2025-03-20 23:22:45 -04:00
Tyler Goodlet b4f890bd58 Flip to `.pause()` in subactor bp example 2025-03-20 23:22:45 -04:00
Tyler Goodlet e2fa5a4d05 Add `maybe_enable_greenback: bool` flag to `open_root_actor()` 2025-03-20 23:22:45 -04:00
Tyler Goodlet 2f4c019f39 Hide `._entry`/`._child` frames, tweak some more type annots 2025-03-20 23:22:45 -04:00
Tyler Goodlet 2b1dbcb541 TO-CHERRY: Error on `breakpoint()` without `debug_mode=True`?
Not sure if this is a good tactic (yet) but it at least covers us from
getting user's confused by `breakpoint()` usage causing REPL clobbering.
Always set an explicit rte raising breakpoint hook such that the user
realizes they can't use `.pause_from_sync()` without enabling debug
mode.

** CHERRY-PICK into `pause_from_sync_w_greenback` branch! **
2025-03-20 23:22:45 -04:00
Tyler Goodlet 49ebdc2e6a Oof, fix walrus assign causes name-error edge case
Only warn log on a non-`trio` async lib when in the main thread to
avoid a name error when in the non-`asyncio` non-main-thread case.

=> To cherry into the `.pause_from_sync()` feature branch.
2025-03-20 23:22:45 -04:00
Tyler Goodlet daf37ed24c Provision for infected-`asyncio` debug mode support
It's **almost** there, we're just missing the final translation code to
get from an `asyncio` side task to be able to call
`.devx._debug..wait_for_parent_stdin_hijack()` to do root actor TTY
locking. Then we just need to ensure internals also do the right thing
with `greenback()` for equivalent sync `breakpoint()` style pause
points.

Since i'm deferring this until later, tossing in some xfail tests to
`test_infected_asyncio` with TODOs for the needed implementation as well
as eventual test org.

By "provision" it means we add:
- `greenback` init block to `_run_asyncio_task()` when debug mode is
  enabled (but which will currently rte when `asyncio` is detected)
  using `.bestow_portal()` around the `asyncio.Task`.
- a call to `_debug.maybe_init_greenback()` in the `run_as_asyncio_guest()`
  guest-mode entry point.
- as part of `._debug.Lock.is_main_trio_thread()` whenever the async-lib
  is not 'trio' error lock the backend name (which is obvi `'asyncio'`
  in this use case).
2025-03-20 22:37:51 -04:00
Tyler Goodlet 0701874033 Drop extra newline from log msg 2025-03-20 22:37:51 -04:00
Tyler Goodlet 4621c8c1b9 Change all `| None` -> `|None` in `._runtime` 2025-03-20 22:37:51 -04:00
Tyler Goodlet a69f1a61a5 Add todo-notes for hiding `@acm` frames
In the particular case of the `Portal.open_context().__aexit__()` frame,
due to usage of `contextlib.asynccontextmanager`, we can't easily hook
into monkeypatching a `__tracebackhide__` set nor catch-n-reraise around
the block exit without defining our own `.__aexit__()` impl. Thus, it's
prolly most sane to do something with an override of
`contextlib._AsyncGeneratorContextManager` or the public exposed
`AsyncContextDecorator` (which uses the former internally right?).

Also fixup some old `._invoke` mod paths in comments and just show
`str(eoc)` in `.open_stream().__aexit__()` terminated-by-EoC log msg
since the `repr()` form won't pprint the IPC msg nicely..
2025-03-20 22:37:51 -04:00
Tyler Goodlet 0c9e1be883 Tweak main thread predicate to ensure `trio.run()`
Change the name to `Lock.is_main_trio_thread()` indicating that when
`True` the thread is both the main one **and** the one that called
`trio.run()`. Add a todo for just copying the
`trio._util.is_main_thread()` impl (since it's private / may change) and
some brief notes about potential usage of
`trio.from_thread.check_cancelled()` to detect non-`.to_thread` thread
spawns.
2025-03-20 22:37:51 -04:00
Tyler Goodlet 8731ab3134 Refine and test `tractor.pause_from_sync()`
Now supports use from any `trio` task, any sync thread started with
`trio.to_thread.run_sync()` AND also via `breakpoint()` builtin API!
The only bit missing now is support for `asyncio` tasks when in infected
mode.. Bo

`greenback` setup/API adjustments:
- move `._rpc.maybe_import_gb()` to -> `devx._debug` and factor out the cached
  import checking into a sync func whilst placing the async `.ensure_portal()`
  bootstrapping into a new async `maybe_init_greenback()`.
- use the new init-er func inside `open_root_actor()` with the output
  predicating whether we override the `breakpoint()` hook.

core `devx._debug` implementation deatz:
- make `mk_mpdb()` only return the `pdp.Pdb` subtype instance since
  the sigint unshielding func is now accessible from the `Lock`
  singleton from anywhere.

- add non-main thread support (at least for `trio.to_thread` use cases)
  to our `Lock` with a new `.is_trio_thread()` predicate that delegates
  directly to `trio`'s internal version.

- do `Lock.is_trio_thread()` checks inside any methods which require
  special provisions when invoked from a non-main `trio` thread:
  - `.[un]shield_sigint()` methods since `signal.signal` usage is only
    allowed from cpython's main thread.
  - `.release()` since `trio.StrictFIFOLock` can only be called from
    a `trio` task.

- rework `.pause_from_sync()` itself to directly call `._set_trace()`
  and don't bother with `greenback._await()` when we're already calling
  it from a `.to_thread.run_sync()` thread, oh and try to use the
  thread/task name when setting `Lock.local_task_in_debug`.

- make it an RTE for now if you try to use `.pause_from_sync()` from any
  infected-`asyncio` task, but support is (hopefully) coming soon!

For testing we add a new `test_debugger.py::test_pause_from_sync()`
which includes a ctrl-c parametrization around the
`examples/debugging/sync_bp.py` script which includes all currently
supported/working usages:
- `tractor.pause_from_sync()`.
- via `breakpoint()` overload.
- from a `trio.to_thread.run_sync()` spawn.
2025-03-20 22:37:51 -04:00
Tyler Goodlet b38ff36e04 First draft workin minus non-main-thread usage! 2025-03-20 22:37:51 -04:00
goodboy 819889702f
Merge pull request #373 from goodboy/remote_inceptions
Remote inceptions: improved `RemoteActorError` boxing of inter-actor exceptions
2025-03-20 22:37:00 -04:00
18 changed files with 410 additions and 61 deletions

View File

@ -4,9 +4,15 @@ import trio
async def breakpoint_forever(): async def breakpoint_forever():
"Indefinitely re-enter debugger in child actor." "Indefinitely re-enter debugger in child actor."
try:
while True: while True:
yield 'yo' yield 'yo'
await tractor.breakpoint() await tractor.breakpoint()
except BaseException:
tractor.log.get_console_log().exception(
'Cancelled while trying to enter pause point!'
)
raise
async def name_error(): async def name_error():
@ -19,7 +25,7 @@ async def main():
""" """
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
loglevel='error', loglevel='cancel',
) 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

@ -45,6 +45,7 @@ async def spawn_until(depth=0):
) )
# TODO: notes on the new boxed-relayed errors through proxy actors
async def main(): async def main():
"""The main ``tractor`` routine. """The main ``tractor`` routine.

View File

@ -38,6 +38,7 @@ async def main():
""" """
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
# loglevel='runtime',
) as n: ) as n:
# Spawn both actors, don't bother with collecting results # Spawn both actors, don't bother with collecting results

View File

@ -23,5 +23,6 @@ async def main():
n.start_soon(debug_actor.run, die) n.start_soon(debug_actor.run, die)
n.start_soon(crash_boi.run, die) n.start_soon(crash_boi.run, die)
if __name__ == '__main__': if __name__ == '__main__':
trio.run(main) trio.run(main)

View File

@ -2,10 +2,13 @@ import trio
import tractor import tractor
async def main(): async def main(
registry_addrs: tuple[str, int]|None = None
):
async with tractor.open_root_actor( async with tractor.open_root_actor(
debug_mode=True, debug_mode=True,
# loglevel='runtime',
): ):
while True: while True:
await tractor.breakpoint() await tractor.breakpoint()

View File

@ -3,17 +3,20 @@ import tractor
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 trio.sleep(0.1) await trio.sleep(0.1)
await tractor.breakpoint() await tractor.pause()
async def main(): async def main():
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
loglevel='cancel',
) as n: ) as n:
portal = await n.run_in_actor( portal = await n.run_in_actor(

View File

@ -3,16 +3,26 @@ import tractor
async def name_error(): async def name_error():
getattr(doggypants) getattr(doggypants) # noqa (on purpose)
async def main(): async def main():
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
) as n: # loglevel='transport',
) as an:
portal = await n.run_in_actor(name_error) # TODO: ideally the REPL arrives at this frame in the parent,
await portal.result() # ABOVE the @api_frame of `Portal.run_in_actor()` (which
# should eventually not even be a portal method ... XD)
# await tractor.pause()
p: tractor.Portal = await an.run_in_actor(name_error)
# with this style, should raise on this line
await p.result()
# with this alt style should raise at `open_nusery()`
# return await p.result()
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -7,7 +7,7 @@ def sync_pause(
error: bool = False, error: bool = False,
): ):
if use_builtin: if use_builtin:
breakpoint() breakpoint(hide_tb=False)
else: else:
tractor.pause_from_sync() tractor.pause_from_sync()
@ -20,18 +20,20 @@ def sync_pause(
async def start_n_sync_pause( async def start_n_sync_pause(
ctx: tractor.Context, ctx: tractor.Context,
): ):
# sync to requesting peer actor: tractor.Actor = tractor.current_actor()
# sync to parent-side task
await ctx.started() await ctx.started()
actor: tractor.Actor = tractor.current_actor()
print(f'entering SYNC PAUSE in {actor.uid}') print(f'entering SYNC PAUSE in {actor.uid}')
sync_pause() sync_pause()
print(f'back from SYNC PAUSE in {actor.uid}') print(f'back from SYNC PAUSE in {actor.uid}')
async def main() -> None: async def main() -> None:
async with tractor.open_nursery( async with tractor.open_nursery(
# NOTE: required for pausing from sync funcs
maybe_enable_greenback=True,
debug_mode=True, debug_mode=True,
) as an: ) as an:

View File

@ -36,6 +36,7 @@ def parse_ipaddr(arg):
if __name__ == "__main__": if __name__ == "__main__":
__tracebackhide__: bool = True
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--uid", type=parse_uid) parser.add_argument("--uid", type=parse_uid)

View File

@ -106,6 +106,7 @@ def _trio_main(
Entry point for a `trio_run_in_process` subactor. Entry point for a `trio_run_in_process` subactor.
''' '''
__tracebackhide__: bool = True
_state._current_actor = actor _state._current_actor = actor
trio_main = partial( trio_main = partial(
async_main, async_main,

View File

@ -22,9 +22,10 @@ from contextlib import asynccontextmanager
from functools import partial from functools import partial
import importlib import importlib
import logging import logging
import os
import signal import signal
import sys import sys
import os from typing import Callable
import warnings import warnings
@ -78,6 +79,8 @@ 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 = False, # `.pause_from_sync()/breakpoint()` support
enable_stack_on_sig: bool = False,
# internal logging # internal logging
loglevel: str|None = None, loglevel: str|None = None,
@ -99,19 +102,36 @@ async def open_root_actor(
# Override the global debugger hook to make it play nice with # Override the global debugger hook to make it play nice with
# ``trio``, see much discussion in: # ``trio``, see much discussion in:
# https://github.com/python-trio/trio/issues/1155#issuecomment-742964018 # https://github.com/python-trio/trio/issues/1155#issuecomment-742964018
if ( builtin_bp_handler: Callable = sys.breakpointhook
await _debug.maybe_init_greenback(
raise_not_found=False,
)
):
builtin_bp_handler = sys.breakpointhook
orig_bp_path: str|None = os.environ.get( orig_bp_path: str|None = os.environ.get(
'PYTHONBREAKPOINT', 'PYTHONBREAKPOINT',
None, None,
) )
if (
debug_mode
and maybe_enable_greenback
and await _debug.maybe_init_greenback(
raise_not_found=False,
)
):
os.environ['PYTHONBREAKPOINT'] = ( os.environ['PYTHONBREAKPOINT'] = (
'tractor.devx._debug.pause_from_sync' 'tractor.devx._debug.pause_from_sync'
) )
else:
# TODO: disable `breakpoint()` by default (without
# `greenback`) since it will break any multi-actor
# usage by a clobbered TTY's stdstreams!
def block_bps(*args, **kwargs):
raise RuntimeError(
'Trying to use `breakpoint()` eh?\n'
'Welp, `tractor` blocks `breakpoint()` built-in calls by default!\n'
'If you need to use it please install `greenback` and set '
'`debug_mode=True` when opening the runtime '
'(either via `.open_nursery()` or `open_root_actor()`)\n'
)
sys.breakpointhook = block_bps
# os.environ['PYTHONBREAKPOINT'] = None
# attempt to retreive ``trio``'s sigint handler and stash it # attempt to retreive ``trio``'s sigint handler and stash it
# on our debugger lock state. # on our debugger lock state.
@ -191,7 +211,11 @@ async def open_root_actor(
assert _log assert _log
# TODO: factor this into `.devx._stackscope`!! # TODO: factor this into `.devx._stackscope`!!
if debug_mode: if (
debug_mode
and
enable_stack_on_sig
):
try: try:
logger.info('Enabling `stackscope` traces on SIGUSR1') logger.info('Enabling `stackscope` traces on SIGUSR1')
from .devx import enable_stack_on_sig from .devx import enable_stack_on_sig
@ -368,6 +392,8 @@ async def open_root_actor(
_state._last_actor_terminated = actor _state._last_actor_terminated = actor
# restore built-in `breakpoint()` hook state # restore built-in `breakpoint()` hook state
if debug_mode:
if builtin_bp_handler is not None:
sys.breakpointhook = builtin_bp_handler sys.breakpointhook = builtin_bp_handler
if orig_bp_path is not None: if orig_bp_path is not None:
os.environ['PYTHONBREAKPOINT'] = orig_bp_path os.environ['PYTHONBREAKPOINT'] = orig_bp_path

View File

@ -503,7 +503,7 @@ async def trio_proc(
}) })
# track subactor in current nursery # track subactor in current nursery
curr_actor = current_actor() curr_actor: Actor = current_actor()
curr_actor._actoruid2nursery[subactor.uid] = actor_nursery curr_actor._actoruid2nursery[subactor.uid] = actor_nursery
# resume caller at next checkpoint now that child is up # resume caller at next checkpoint now that child is up

View File

@ -30,11 +30,16 @@ if TYPE_CHECKING:
_current_actor: Actor|None = None # type: ignore # noqa _current_actor: Actor|None = None # type: ignore # noqa
_last_actor_terminated: Actor|None = None _last_actor_terminated: Actor|None = None
# TODO: mk this a `msgspec.Struct`!
_runtime_vars: dict[str, Any] = { _runtime_vars: dict[str, Any] = {
'_debug_mode': False, '_debug_mode': False,
'_is_root': False, '_is_root': False,
'_root_mailbox': (None, None), '_root_mailbox': (None, None),
'_registry_addrs': [], '_registry_addrs': [],
# for `breakpoint()` support
'use_greenback': False,
} }

View File

@ -0,0 +1,177 @@
# 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/>.
'''
Tools for code-object annotation, introspection and mutation
as it pertains to improving the grok-ability of our runtime!
'''
from __future__ import annotations
import inspect
# import msgspec
# from pprint import pformat
from types import (
FrameType,
FunctionType,
MethodType,
# CodeType,
)
from typing import (
# Any,
Callable,
# TYPE_CHECKING,
Type,
)
from tractor.msg import (
pretty_struct,
NamespacePath,
)
# TODO: yeah, i don't love this and we should prolly just
# write a decorator that actually keeps a stupid ref to the func
# obj..
def get_class_from_frame(fr: FrameType) -> (
FunctionType
|MethodType
):
'''
Attempt to get the function (or method) reference
from a given `FrameType`.
Verbatim from an SO:
https://stackoverflow.com/a/2220759
'''
args, _, _, value_dict = inspect.getargvalues(fr)
# we check the first parameter for the frame function is
# named 'self'
if (
len(args)
and
# TODO: other cases for `@classmethod` etc..?)
args[0] == 'self'
):
# in that case, 'self' will be referenced in value_dict
instance: object = value_dict.get('self')
if instance:
# return its class
return getattr(
instance,
'__class__',
None,
)
# return None otherwise
return None
def func_ref_from_frame(
frame: FrameType,
) -> Callable:
func_name: str = frame.f_code.co_name
try:
return frame.f_globals[func_name]
except KeyError:
cls: Type|None = get_class_from_frame(frame)
if cls:
return getattr(
cls,
func_name,
)
# TODO: move all this into new `.devx._code`!
# -[ ] prolly create a `@runtime_api` dec?
# -[ ] ^- make it capture and/or accept buncha optional
# meta-data like a fancier version of `@pdbp.hideframe`.
#
class CallerInfo(pretty_struct.Struct):
rt_fi: inspect.FrameInfo
call_frame: FrameType
@property
def api_func_ref(self) -> Callable|None:
return func_ref_from_frame(self.rt_fi.frame)
@property
def api_nsp(self) -> NamespacePath|None:
func: FunctionType = self.api_func_ref
if func:
return NamespacePath.from_ref(func)
return '<unknown>'
@property
def caller_func_ref(self) -> Callable|None:
return func_ref_from_frame(self.call_frame)
@property
def caller_nsp(self) -> NamespacePath|None:
func: FunctionType = self.caller_func_ref
if func:
return NamespacePath.from_ref(func)
return '<unknown>'
def find_caller_info(
dunder_var: str = '__runtimeframe__',
iframes:int = 1,
check_frame_depth: bool = True,
) -> CallerInfo|None:
'''
Scan up the callstack for a frame with a `dunder_var: str` variable
and return the `iframes` frames above it.
By default we scan for a `__runtimeframe__` scope var which
denotes a `tractor` API above which (one frame up) is "user
app code" which "called into" the `tractor` method or func.
TODO: ex with `Portal.open_context()`
'''
# TODO: use this instead?
# https://docs.python.org/3/library/inspect.html#inspect.getouterframes
frames: list[inspect.FrameInfo] = inspect.stack()
for fi in frames:
assert (
fi.function
==
fi.frame.f_code.co_name
)
this_frame: FrameType = fi.frame
dunder_val: int|None = this_frame.f_locals.get(dunder_var)
if dunder_val:
go_up_iframes: int = (
dunder_val # could be 0 or `True` i guess?
or
iframes
)
rt_frame: FrameType = fi.frame
call_frame = rt_frame
for i in range(go_up_iframes):
call_frame = call_frame.f_back
return CallerInfo(
rt_fi=fi,
call_frame=call_frame,
)
return None

View File

@ -190,11 +190,14 @@ class Lock:
is_trio_main = ( is_trio_main = (
# TODO: since this is private, @oremanj says # TODO: since this is private, @oremanj says
# we should just copy the impl for now.. # we should just copy the impl for now..
trio._util.is_main_thread() (is_main_thread := trio._util.is_main_thread())
and and
(async_lib := sniffio.current_async_library()) == 'trio' (async_lib := sniffio.current_async_library()) == 'trio'
) )
if not is_trio_main: if (
not is_trio_main
and is_main_thread
):
log.warning( log.warning(
f'Current async-lib detected by `sniffio`: {async_lib}\n' f'Current async-lib detected by `sniffio`: {async_lib}\n'
) )

View File

@ -23,12 +23,31 @@ into each ``trio.Nursery`` except it links the lifetimes of memory space
disjoint, parallel executing tasks in separate actors. disjoint, parallel executing tasks in separate actors.
''' '''
from __future__ import annotations
import multiprocessing as mp
from signal import ( from signal import (
signal, signal,
SIGUSR1, SIGUSR1,
) )
import traceback
from typing import TYPE_CHECKING
import trio import trio
from tractor import (
_state,
log as logmod,
)
log = logmod.get_logger(__name__)
if TYPE_CHECKING:
from tractor._spawn import ProcessType
from tractor import (
Actor,
ActorNursery,
)
@trio.lowlevel.disable_ki_protection @trio.lowlevel.disable_ki_protection
def dump_task_tree() -> None: def dump_task_tree() -> None:
@ -41,9 +60,15 @@ def dump_task_tree() -> None:
recurse_child_tasks=True recurse_child_tasks=True
) )
) )
log = get_console_log('cancel') log = get_console_log(
log.pdb( name=__name__,
f'Dumping `stackscope` tree:\n\n' level='cancel',
)
actor: Actor = _state.current_actor()
log.devx(
f'Dumping `stackscope` tree for actor\n'
f'{actor.name}: {actor}\n'
f' |_{mp.current_process()}\n\n'
f'{tree_str}\n' f'{tree_str}\n'
) )
# import logging # import logging
@ -56,8 +81,13 @@ def dump_task_tree() -> None:
# ).exception("Error printing task tree") # ).exception("Error printing task tree")
def signal_handler(sig: int, frame: object) -> None: def signal_handler(
import traceback sig: int,
frame: object,
relay_to_subs: bool = True,
) -> None:
try: try:
trio.lowlevel.current_trio_token( trio.lowlevel.current_trio_token(
).run_sync_soon(dump_task_tree) ).run_sync_soon(dump_task_tree)
@ -65,6 +95,26 @@ def signal_handler(sig: int, frame: object) -> None:
# not in async context -- print a normal traceback # not in async context -- print a normal traceback
traceback.print_stack() traceback.print_stack()
if not relay_to_subs:
return
an: ActorNursery
for an in _state.current_actor()._actoruid2nursery.values():
subproc: ProcessType
subactor: Actor
for subactor, subproc, _ in an._children.values():
log.devx(
f'Relaying `SIGUSR1`[{sig}] to sub-actor\n'
f'{subactor}\n'
f' |_{subproc}\n'
)
if isinstance(subproc, trio.Process):
subproc.send_signal(sig)
elif isinstance(subproc, mp.Process):
subproc._send_signal(sig)
def enable_stack_on_sig( def enable_stack_on_sig(
@ -82,3 +132,6 @@ def enable_stack_on_sig(
# NOTE: not the above can be triggered from # NOTE: not the above can be triggered from
# a (xonsh) shell using: # a (xonsh) shell using:
# kill -SIGUSR1 @$(pgrep -f '<cmd>') # kill -SIGUSR1 @$(pgrep -f '<cmd>')
#
# for example if you were looking to trace a `pytest` run
# kill -SIGUSR1 @$(pgrep -f 'pytest')

View File

@ -21,6 +21,11 @@ Log like a forester!
from collections.abc import Mapping from collections.abc import Mapping
import sys import sys
import logging import logging
from logging import (
LoggerAdapter,
Logger,
StreamHandler,
)
import colorlog # type: ignore import colorlog # type: ignore
import trio import trio
@ -48,20 +53,19 @@ LOG_FORMAT = (
DATE_FORMAT = '%b %d %H:%M:%S' DATE_FORMAT = '%b %d %H:%M:%S'
LEVELS: dict[str, int] = { # FYI, ERROR is 40
CUSTOM_LEVELS: dict[str, int] = {
'TRANSPORT': 5, 'TRANSPORT': 5,
'RUNTIME': 15, 'RUNTIME': 15,
'CANCEL': 16, 'DEVX': 17,
'CANCEL': 18,
'PDB': 500, 'PDB': 500,
} }
# _custom_levels: set[str] = {
# lvlname.lower for lvlname in LEVELS.keys()
# }
STD_PALETTE = { STD_PALETTE = {
'CRITICAL': 'red', 'CRITICAL': 'red',
'ERROR': 'red', 'ERROR': 'red',
'PDB': 'white', 'PDB': 'white',
'DEVX': 'cyan',
'WARNING': 'yellow', 'WARNING': 'yellow',
'INFO': 'green', 'INFO': 'green',
'CANCEL': 'yellow', 'CANCEL': 'yellow',
@ -78,7 +82,7 @@ BOLD_PALETTE = {
# TODO: this isn't showing the correct '{filename}' # TODO: this isn't showing the correct '{filename}'
# as it did before.. # as it did before..
class StackLevelAdapter(logging.LoggerAdapter): class StackLevelAdapter(LoggerAdapter):
def transport( def transport(
self, self,
@ -86,7 +90,8 @@ class StackLevelAdapter(logging.LoggerAdapter):
) -> None: ) -> None:
''' '''
IPC level msg-ing. IPC transport level msg IO; generally anything below
`._ipc.Channel` and friends.
''' '''
return self.log(5, msg) return self.log(5, msg)
@ -102,11 +107,11 @@ class StackLevelAdapter(logging.LoggerAdapter):
msg: str, msg: str,
) -> None: ) -> None:
''' '''
Cancellation logging, mostly for runtime reporting. Cancellation sequencing, mostly for runtime reporting.
''' '''
return self.log( return self.log(
level=16, level=22,
msg=msg, msg=msg,
# stacklevel=4, # stacklevel=4,
) )
@ -116,11 +121,21 @@ class StackLevelAdapter(logging.LoggerAdapter):
msg: str, msg: str,
) -> None: ) -> None:
''' '''
Debugger logging. `pdb`-REPL (debugger) related statuses.
''' '''
return self.log(500, msg) return self.log(500, msg)
def devx(
self,
msg: str,
) -> None:
'''
"Developer experience" sub-sys statuses.
'''
return self.log(17, msg)
def log( def log(
self, self,
level, level,
@ -136,8 +151,7 @@ class StackLevelAdapter(logging.LoggerAdapter):
if self.isEnabledFor(level): if self.isEnabledFor(level):
stacklevel: int = 3 stacklevel: int = 3
if ( if (
level in LEVELS.values() level in CUSTOM_LEVELS.values()
# or level in _custom_levels
): ):
stacklevel: int = 4 stacklevel: int = 4
@ -184,8 +198,30 @@ class StackLevelAdapter(logging.LoggerAdapter):
) )
# TODO IDEAs:
# -[ ] move to `.devx.pformat`?
# -[ ] do per task-name and actor-name color coding
# -[ ] unique color per task-id and actor-uuid
def pformat_task_uid(
id_part: str = 'tail'
):
'''
Return `str`-ified unique for a `trio.Task` via a combo of its
`.name: str` and `id()` truncated output.
'''
task: trio.Task = trio.lowlevel.current_task()
tid: str = str(id(task))
if id_part == 'tail':
tid_part: str = tid[-6:]
else:
tid_part: str = tid[:6]
return f'{task.name}[{tid_part}]'
_conc_name_getters = { _conc_name_getters = {
'task': lambda: trio.lowlevel.current_task().name, 'task': pformat_task_uid,
'actor': lambda: current_actor(), 'actor': lambda: current_actor(),
'actor_name': lambda: current_actor().name, 'actor_name': lambda: current_actor().name,
'actor_uid': lambda: current_actor().uid[1][:6], 'actor_uid': lambda: current_actor().uid[1][:6],
@ -193,7 +229,10 @@ _conc_name_getters = {
class ActorContextInfo(Mapping): class ActorContextInfo(Mapping):
"Dyanmic lookup for local actor and task names" '''
Dyanmic lookup for local actor and task names.
'''
_context_keys = ( _context_keys = (
'task', 'task',
'actor', 'actor',
@ -224,6 +263,7 @@ def get_logger(
'''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 = rlog = logging.getLogger(_root_name) log = rlog = logging.getLogger(_root_name)
if ( if (
@ -266,7 +306,7 @@ def get_logger(
logger = StackLevelAdapter(log, ActorContextInfo()) logger = StackLevelAdapter(log, ActorContextInfo())
# additional levels # additional levels
for name, val in LEVELS.items(): for name, val in CUSTOM_LEVELS.items():
logging.addLevelName(val, name) logging.addLevelName(val, name)
# ensure customs levels exist as methods # ensure customs levels exist as methods
@ -278,7 +318,7 @@ def get_logger(
def get_console_log( def get_console_log(
level: str | None = None, level: str | None = None,
**kwargs, **kwargs,
) -> logging.LoggerAdapter: ) -> LoggerAdapter:
'''Get the package logger and enable a handler which writes to stderr. '''Get the package logger and enable a handler which writes to stderr.
Yeah yeah, i know we can use ``DictConfig``. You do it. Yeah yeah, i know we can use ``DictConfig``. You do it.
@ -303,7 +343,7 @@ def get_console_log(
None, None,
) )
): ):
handler = logging.StreamHandler() handler = StreamHandler()
formatter = colorlog.ColoredFormatter( formatter = colorlog.ColoredFormatter(
LOG_FORMAT, LOG_FORMAT,
datefmt=DATE_FORMAT, datefmt=DATE_FORMAT,
@ -323,3 +363,19 @@ def get_loglevel() -> str:
# global module logger for tractor itself # global module logger for tractor itself
log = get_logger('tractor') log = get_logger('tractor')
def at_least_level(
log: Logger|LoggerAdapter,
level: int|str,
) -> bool:
'''
Predicate to test if a given level is active.
'''
if isinstance(level, str):
level: int = CUSTOM_LEVELS[level.upper()]
if log.getEffectiveLevel() <= level:
return True
return False