Rework and first draft of `.devx._frame_stack.py`

Proto-ing a little suite of call-stack-frame annotation-for-scanning
sub-systems for the purposes of both,
- the `.devx._debug`er and its
  traceback and frame introspection needs when entering the REPL,
- detailed trace-style logging such that we can explicitly report
  on "which and where" `tractor`'s APIs are used in the "app" code.

Deats:
- change mod name obvi from `._code` and adjust client mod imports.
- using `wrapt` (for perf) implement a `@api_frame` annot decorator
  which both stashes per-call-stack-frame instances of `CallerInfo` in
  a table and marks the function such that API endpoints can be easily
  found via runtime stack scanning despite any internal impl changes.
- add a global `_frame2callerinfo_cache: dict[FrameType, CallerInfo]`
  table for providing the per func-frame info caching.
- Re-implement `CallerInfo` to require less (types of) inputs:
  |_ `_api_func: Callable`, a ref to the (singleton) func def.
  |_ `_api_frame: FrameType` taken from the `@api_frame` marked `tractor`-API
     func's runtime call-stack, from which we can determine the
     app code's `.caller_frame`.
  |_`_caller_frames_up: int|None` allowing the specific `@api_frame` to
    determine "how many frames up" the application / calling code is.
  And, a better set of derived attrs:
  |_`caller_frame: FrameType` which finds and caches the API-eps calling
    frame.
  |_`caller_frame: FrameType` which finds and caches the API-eps calling
- add a new attempt at "getting a method ref from its runtime frame"
  with `get_ns_and_func_from_frame()` using a heuristic that the
  `CodeType.co_qualname: str` should have a "." in it for methods.
  - main issue is still that the func-ref lookup will require searching
    for the method's instance type by name, and that name isn't
    guaranteed to be defined in any particular ns..
   |_rn we try to read it from the `FrameType.f_locals` but that is
     going to obvi fail any time the method is called in a module where
     it's type is not also defined/imported.
  - returns both the ns and the func ref FYI.
runtime_to_msgspec
Tyler Goodlet 2024-05-08 14:24:25 -04:00
parent 343b7c9712
commit 6690968236
2 changed files with 121 additions and 112 deletions

View File

@ -94,7 +94,7 @@ if TYPE_CHECKING:
from ._portal import Portal from ._portal import Portal
from ._runtime import Actor from ._runtime import Actor
from ._ipc import MsgTransport from ._ipc import MsgTransport
from .devx._code import ( from .devx._frame_stack import (
CallerInfo, CallerInfo,
) )
@ -2513,7 +2513,7 @@ def mk_context(
send_chan, recv_chan = trio.open_memory_channel(msg_buffer_size) send_chan, recv_chan = trio.open_memory_channel(msg_buffer_size)
# TODO: only scan caller-info if log level so high! # TODO: only scan caller-info if log level so high!
from .devx._code import find_caller_info from .devx._frame_stack import find_caller_info
caller_info: CallerInfo|None = find_caller_info() caller_info: CallerInfo|None = find_caller_info()
# TODO: when/how do we apply `.limit_plds()` from here? # TODO: when/how do we apply `.limit_plds()` from here?

View File

@ -20,11 +20,8 @@ as it pertains to improving the grok-ability of our runtime!
''' '''
from __future__ import annotations from __future__ import annotations
from functools import partial
import inspect import inspect
# import msgspec
# from pprint import pformat
import textwrap
import traceback
from types import ( from types import (
FrameType, FrameType,
FunctionType, FunctionType,
@ -32,9 +29,8 @@ from types import (
# CodeType, # CodeType,
) )
from typing import ( from typing import (
# Any, Any,
Callable, Callable,
# TYPE_CHECKING,
Type, Type,
) )
@ -42,6 +38,7 @@ from tractor.msg import (
pretty_struct, pretty_struct,
NamespacePath, NamespacePath,
) )
import wrapt
# TODO: yeah, i don't love this and we should prolly just # TODO: yeah, i don't love this and we should prolly just
@ -83,6 +80,31 @@ def get_class_from_frame(fr: FrameType) -> (
return None return None
def get_ns_and_func_from_frame(
frame: FrameType,
) -> Callable:
'''
Return the corresponding function object reference from
a `FrameType`, and return it and it's parent namespace `dict`.
'''
ns: dict[str, Any]
# for a method, go up a frame and lookup the name in locals()
if '.' in (qualname := frame.f_code.co_qualname):
cls_name, _, func_name = qualname.partition('.')
ns = frame.f_back.f_locals[cls_name].__dict__
else:
func_name: str = frame.f_code.co_name
ns = frame.f_globals
return (
ns,
ns[func_name],
)
def func_ref_from_frame( def func_ref_from_frame(
frame: FrameType, frame: FrameType,
) -> Callable: ) -> Callable:
@ -98,34 +120,63 @@ def func_ref_from_frame(
) )
# 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): class CallerInfo(pretty_struct.Struct):
rt_fi: inspect.FrameInfo # https://docs.python.org/dev/reference/datamodel.html#frame-objects
call_frame: FrameType # https://docs.python.org/dev/library/inspect.html#the-interpreter-stack
_api_frame: FrameType
@property @property
def api_func_ref(self) -> Callable|None: def api_frame(self) -> FrameType:
return func_ref_from_frame(self.rt_fi.frame) try:
self._api_frame.clear()
except RuntimeError:
# log.warning(
print(
f'Frame {self._api_frame} for {self.api_func} is still active!'
)
return self._api_frame
_api_func: Callable
@property
def api_func(self) -> Callable:
return self._api_func
_caller_frames_up: int|None = 1
_caller_frame: FrameType|None = None # cached after first stack scan
@property @property
def api_nsp(self) -> NamespacePath|None: def api_nsp(self) -> NamespacePath|None:
func: FunctionType = self.api_func_ref func: FunctionType = self.api_func
if func: if func:
return NamespacePath.from_ref(func) return NamespacePath.from_ref(func)
return '<unknown>' return '<unknown>'
@property @property
def caller_func_ref(self) -> Callable|None: def caller_frame(self) -> FrameType:
return func_ref_from_frame(self.call_frame)
# if not already cached, scan up stack explicitly by
# configured count.
if not self._caller_frame:
if self._caller_frames_up:
for _ in range(self._caller_frames_up):
caller_frame: FrameType|None = self.api_frame.f_back
if not caller_frame:
raise ValueError(
'No frame exists {self._caller_frames_up} up from\n'
f'{self.api_frame} @ {self.api_nsp}\n'
)
self._caller_frame = caller_frame
return self._caller_frame
@property @property
def caller_nsp(self) -> NamespacePath|None: def caller_nsp(self) -> NamespacePath|None:
func: FunctionType = self.caller_func_ref func: FunctionType = self.api_func
if func: if func:
return NamespacePath.from_ref(func) return NamespacePath.from_ref(func)
@ -172,108 +223,66 @@ def find_caller_info(
call_frame = call_frame.f_back call_frame = call_frame.f_back
return CallerInfo( return CallerInfo(
rt_fi=fi, _api_frame=rt_frame,
call_frame=call_frame, _api_func=func_ref_from_frame(rt_frame),
_caller_frames_up=go_up_iframes,
) )
return None return None
def pformat_boxed_tb( _frame2callerinfo_cache: dict[FrameType, CallerInfo] = {}
tb_str: str,
fields_str: str|None = None,
field_prefix: str = ' |_',
tb_box_indent: int|None = None,
tb_body_indent: int = 1,
) -> str: # TODO: -[x] move all this into new `.devx._code`!
''' # -[ ] consider rename to _callstack?
Create a "boxed" looking traceback string. # -[ ] prolly create a `@runtime_api` dec?
# |_ @api_frame seems better?
# -[ ] ^- make it capture and/or accept buncha optional
# meta-data like a fancier version of `@pdbp.hideframe`.
#
def api_frame(
wrapped: Callable|None = None,
*,
caller_frames_up: int = 1,
Useful for emphasizing traceback text content as being an ) -> Callable:
embedded attribute of some other object (like
a `RemoteActorError` or other boxing remote error shuttle
container).
Any other parent/container "fields" can be passed in the # handle the decorator called WITHOUT () case,
`fields_str` input along with other prefix/indent settings. # i.e. just @api_frame, NOT @api_frame(extra=<blah>)
if wrapped is None:
return partial(
api_frame,
caller_frames_up=caller_frames_up,
)
''' @wrapt.decorator
if ( async def wrapper(
fields_str wrapped: Callable,
and instance: object,
field_prefix args: tuple,
kwargs: dict,
): ):
fields: str = textwrap.indent( # maybe cache the API frame for this call
fields_str, global _frame2callerinfo_cache
prefix=field_prefix, this_frame: FrameType = inspect.currentframe()
) api_frame: FrameType = this_frame.f_back
else:
fields = fields_str or ''
tb_body = tb_str if not _frame2callerinfo_cache.get(api_frame):
if tb_body_indent: _frame2callerinfo_cache[api_frame] = CallerInfo(
tb_body: str = textwrap.indent( _api_frame=api_frame,
tb_str, _api_func=wrapped,
prefix=tb_body_indent * ' ', _caller_frames_up=caller_frames_up,
) )
tb_box: str = ( return wrapped(*args, **kwargs)
# orig # annotate the function as a "api function", meaning it is
# f' |\n' # a function for which the function above it in the call stack should be
# f' ------ - ------\n\n' # non-`tractor` code aka "user code".
# f'{tb_str}\n' #
# f' ------ - ------\n' # in the global frame cache for easy lookup from a given
# f' _|\n' # func-instance
wrapped._call_infos: dict[FrameType, CallerInfo] = _frame2callerinfo_cache
f'|\n' wrapped.__api_func__: bool = True
f' ------ - ------\n\n' return wrapper(wrapped)
# f'{tb_str}\n'
f'{tb_body}'
f' ------ - ------\n'
f'_|\n'
)
tb_box_indent: str = (
tb_box_indent
or
1
# (len(field_prefix))
# ? ^-TODO-^ ? if you wanted another indent level
)
if tb_box_indent > 0:
tb_box: str = textwrap.indent(
tb_box,
prefix=tb_box_indent * ' ',
)
return (
fields
+
tb_box
)
def pformat_caller_frame(
stack_limit: int = 1,
box_tb: bool = True,
) -> str:
'''
Capture and return the traceback text content from
`stack_limit` call frames up.
'''
tb_str: str = (
'\n'.join(
traceback.format_stack(limit=stack_limit)
)
)
if box_tb:
tb_str: str = pformat_boxed_tb(
tb_str=tb_str,
field_prefix=' ',
indent='',
)
return tb_str