# 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 . ''' Prettified version of `msgspec.Struct` for easier console grokin. ''' from __future__ import annotations from collections import UserList import textwrap from typing import ( Any, Iterator, ) from msgspec import ( msgpack, Struct as _Struct, structs, ) # from pprint import ( # saferepr, # ) from tractor.log import get_logger log = get_logger() # TODO: auto-gen type sig for input func both for # type-msgs and logging of RPC tasks? # taken and modified from: # https://stackoverflow.com/a/57110117 # import inspect # from typing import List # def my_function(input_1: str, input_2: int) -> list[int]: # pass # def types_of(func): # specs = inspect.getfullargspec(func) # return_type = specs.annotations['return'] # input_types = [t.__name__ for s, t in specs.annotations.items() if s != 'return'] # return f'{func.__name__}({": ".join(input_types)}) -> {return_type}' # types_of(my_function) class DiffDump(UserList): ''' Very simple list delegator that repr() dumps (presumed) tuple elements of the form `tuple[str, Any, Any]` in a nice multi-line readable form for analyzing `Struct` diffs. ''' def __repr__(self) -> str: if not len(self): return super().__repr__() # format by displaying item pair's ``repr()`` on multiple, # indented lines such that they are more easily visually # comparable when printed to console when printed to # console. repstr: str = '[\n' for k, left, right in self: repstr += ( f'({k},\n' f' |_{repr(left)},\n' f' |_{repr(right)},\n' ')\n' ) repstr += ']\n' return repstr def iter_fields(struct: Struct) -> Iterator[ tuple[ structs.FieldIinfo, str, Any, ] ]: ''' Iterate over all non-@property fields of this struct. ''' fi: structs.FieldInfo for fi in structs.fields(struct): key: str = fi.name val: Any = getattr(struct, key) yield ( fi, key, val, ) def iter_struct_ppfmt_lines( struct: Struct, field_indent: int = 0, ) -> Iterator[tuple[str, str]]: fi: structs.FieldInfo k: str v: Any for fi, k, v in iter_fields(struct): # TODO: how can we prefer `Literal['option1', 'option2, # ..]` over .__name__ == `Literal` but still get only the # latter for simple types like `str | int | None` etc..? ft: type = fi.type typ_name: str = getattr( ft, '__name__', str(ft) ).replace(' ', '') if k[0] == '_': continue # recurse to get sub-struct's `.pformat()` output Bo elif isinstance(v, Struct): yield from iter_struct_ppfmt_lines( struct=v, field_indent=field_indent+field_indent, ) else: # top-level field 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 # try: # val_str: str = saferepr(v) # except Exception: # log.exception( # 'Failed to `saferepr({type(struct)})` !?\n' # ) # raise # return _Struct.__repr__(struct) yield ( ' '*field_indent, # indented ws prefix f'{k}: {typ_name} = {val_str},', # field's repr line content ) def pformat( struct: Struct, field_indent: int = 2, indent: int = 0, ) -> str: ''' Recursion-safe `pprint.pformat()` style formatting of a `msgspec.Struct` for sane reading by a human using a REPL. ''' obj_str: str = '' # accumulator for prefix, field_repr, in iter_struct_ppfmt_lines( struct, field_indent=field_indent, ): obj_str += f'{prefix}{field_repr}\n' # global whitespace indent ws: str = ' '*indent if indent: obj_str: str = textwrap.indent( text=obj_str, prefix=ws, ) # qtn: str = ws + struct.__class__.__qualname__ qtn: str = struct.__class__.__qualname__ return ( f'{qtn}(\n' f'{obj_str}' f'{ws})' ) class Struct( _Struct, # https://jcristharif.com/msgspec/structs.html#tagged-unions # tag='pikerstruct', # tag=True, ): ''' A "human friendlier" (aka repl buddy) struct subtype. ''' def to_dict( self, include_non_members: bool = True, ) -> dict: ''' Like it sounds.. direct delegation to: https://jcristharif.com/msgspec/api.html#msgspec.structs.asdict BUT, by default we pop all non-member (aka not defined as struct fields) fields by default. ''' asdict: dict = structs.asdict(self) if include_non_members: return asdict # only return a dict of the struct members # which were provided as input, NOT anything # added as type-defined `@property` methods! sin_props: dict = {} fi: structs.FieldInfo for fi, k, v in iter_fields(self): sin_props[k] = asdict[k] return sin_props pformat = pformat def __repr__(self) -> str: try: return pformat(self) except Exception: log.exception( f'Failed to `pformat({type(self)})` !?\n' ) 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( self, update: dict | None = None, ) -> Struct: ''' Validate-typecast all self defined fields, return a copy of us with all such fields. NOTE: This is kinda like the default behaviour in `pydantic.BaseModel` except a copy of the object is returned making it compat with `frozen=True`. ''' if update: for k, v in update.items(): setattr(self, k, v) # NOTE: roundtrip serialize to validate # - enode to msgpack binary format, # - decode that back to a struct. return msgpack.Decoder(type=type(self)).decode( msgpack.Encoder().encode(self) ) def typecast( self, # TODO: allow only casting a named subset? # fields: set[str] | None = None, ) -> None: ''' Cast all fields using their declared type annotations (kinda like what `pydantic` does by default). NOTE: this of course won't work on frozen types, use ``.copy()`` above in such cases. ''' # https://jcristharif.com/msgspec/api.html#msgspec.structs.fields fi: structs.FieldInfo for fi in structs.fields(self): setattr( 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__( self, other: Struct, ) -> DiffDump[tuple[str, Any, Any]]: ''' Compare fields/items key-wise and return a `DiffDump` for easy visual REPL comparison B) ''' diffs: DiffDump[tuple[str, Any, Any]] = DiffDump() for fi in structs.fields(self): attr_name: str = fi.name ours: Any = getattr(self, attr_name) theirs: Any = getattr(other, attr_name) if ours != theirs: diffs.append(( attr_name, ours, theirs, )) 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