Source code for logwrap.repr_utils

#    Copyright 2016 - 2022 Alexey Stepanov aka penguinolog

#    Copyright 2016 Mirantis, Inc.

#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at

#         http://www.apache.org/licenses/LICENSE-2.0

#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

"""repr_utils module.

This is no reason to import this submodule directly, all required methods is
available from the main module.
"""

from __future__ import annotations

# Standard Library
import abc
import collections
import types
from inspect import Parameter
from inspect import Signature
from inspect import signature
from typing import TYPE_CHECKING
from typing import Any
from typing import ForwardRef
from typing import NoReturn
from typing import Protocol
from typing import get_type_hints
from typing import runtime_checkable

if TYPE_CHECKING:
    # Standard Library
    import dataclasses
    from collections.abc import Callable
    from collections.abc import Iterable

    # External Dependencies
    from rich.repr import Result as RichReprResult


__all__ = ("PrettyFormat", "PrettyRepr", "PrettyStr", "pretty_repr", "pretty_str")

_SIMPLE_MAGIC_ATTRIBUTES = ("__repr__", "__str__")


@runtime_checkable
class _AttributeHolderProto(Protocol):
    __slots__ = ()

    def _get_kwargs(self) -> list[tuple[str, Any]]:
        """Protocol stub."""

    def _get_args(self) -> list[str]:
        """Protocol stub."""


@runtime_checkable
class _NamedTupleProto(Protocol):
    __slots__ = ()

    def _asdict(self) -> dict[str, Any]:
        """Protocol stub."""

    def __getnewargs__(self) -> tuple[Any, ...]:
        """Protocol stub."""

    def _replace(self, /, **kwds: dict[str, Any]) -> _NamedTupleProto:
        """Protocol stub."""

    @classmethod
    def _make(cls, iterable: Iterable[Any]) -> _NamedTupleProto:
        """Protocol stub."""


@runtime_checkable
class _DataClassProto(Protocol):
    __slots__ = ()

    __dataclass_params__: dataclasses._DataclassParams  # type: ignore[name-defined]
    __dataclass_fields__: dict[str, dataclasses.Field[Any]] = {}  # noqa: RUF012


@runtime_checkable
class _RichReprProto(Protocol):
    """Protocol for type checking."""

    def __rich_repr__(self) -> RichReprResult:
        """Protocol stub."""


def _known_callable(item: Any) -> bool:
    """Check for possibility to parse callable.

    :param item:  item to check for repr() way
    :type item: Any
    :return: item is callable and should be processed not using repr
    :rtype: bool
    """
    return isinstance(item, (types.FunctionType, types.MethodType))


def _simple(item: Any) -> bool:
    """Check for nested iterations: True, if not.

    :param item: item to check for repr() way
    :type item: Any
    :return: use repr() iver item by default
    :rtype: bool
    """
    return not any(
        (
            isinstance(item, data_type)
            and all(
                getattr(type(item), attribute) is getattr(data_type, attribute)
                for attribute in _SIMPLE_MAGIC_ATTRIBUTES
            )
        )
        for data_type in (list, set, tuple, dict, frozenset, collections.deque)
    )


class ReprParameter:
    """Parameter wrapper wor repr and str operations over signature."""

    __slots__ = ("_value", "_parameter")

    POSITIONAL_ONLY = Parameter.POSITIONAL_ONLY
    POSITIONAL_OR_KEYWORD = Parameter.POSITIONAL_OR_KEYWORD
    VAR_POSITIONAL = Parameter.VAR_POSITIONAL
    KEYWORD_ONLY = Parameter.KEYWORD_ONLY
    VAR_KEYWORD = Parameter.VAR_KEYWORD

    empty = Parameter.empty

    def __init__(self, parameter: Parameter, value: Any = Parameter.empty) -> None:
        """Parameter-like object store for repr and str tasks.

        :param parameter: parameter from signature
        :type parameter: Parameter
        :param value: default value override
        :type value: Any
        """
        self._parameter: Parameter = parameter
        self._value: Any = value if value is not parameter.empty else parameter.default

    @property
    def parameter(self) -> Parameter:
        """Parameter object.

        :return: original Parameter object
        :rtype: Parameter
        """
        return self._parameter

    @property
    def name(self) -> None | str:
        """Parameter name.

        :return: parameter name. For `*args` and `**kwargs` add corresponding prefixes
        :rtype: None | str
        """
        if self.kind == Parameter.VAR_POSITIONAL:
            return "*" + self.parameter.name
        if self.kind == Parameter.VAR_KEYWORD:
            return "**" + self.parameter.name
        return self.parameter.name

    @property
    def value(self) -> Any:
        """Parameter value to log.

        :return: If function is bound to class -> value is class instance else default value.
        :rtype: Any
        """
        return self._value

    @property
    def annotation(self) -> Parameter.empty | str:  # type: ignore[valid-type]
        """Parameter annotation.

        :return: parameter annotation from signature
        :rtype: Parameter.empty | str
        """
        return self.parameter.annotation  # type: ignore[no-any-return]

    @property
    def kind(self) -> int:
        """Parameter kind.

        :return: parameter kind from Parameter
        :rtype: int
        """
        # noinspection PyTypeChecker
        return self.parameter.kind

    def __hash__(self) -> NoReturn:  # pylint: disable=invalid-hash-returned
        """Block hashing.

        :raises TypeError: Not hashable.
        """
        msg = f"not hashable type: '{self.__class__.__name__}'"
        raise TypeError(msg)

    def __repr__(self) -> str:
        """Debug purposes.

        :return: parameter repr for debug purposes
        :rtype: str
        """
        return f'<{self.__class__.__name__} "{self}">'


def _prepare_repr(func: types.FunctionType | types.MethodType) -> list[ReprParameter]:
    """Get arguments lists with defaults.

    :param func: Callable object to process
    :type func: types.FunctionType | types.MethodType
    :return: repr of callable parameter from signature
    :rtype: list[ReprParameter]
    """
    ismethod: bool = isinstance(func, types.MethodType)
    self_processed: bool = False
    result: list[ReprParameter] = []
    if not ismethod:
        real_func: Callable[..., Any] = func
    else:
        real_func = func.__func__  # type: ignore[union-attr]

    for param in signature(real_func).parameters.values():
        if not self_processed and ismethod and func.__self__ is not None:  # type: ignore[union-attr]
            result.append(ReprParameter(param, value=func.__self__))  # type: ignore[union-attr]
            self_processed = True
        else:
            result.append(ReprParameter(param))

    return result


[docs]class PrettyFormat(metaclass=abc.ABCMeta): """Pretty Formatter. Designed for usage as __repr__ and __str__ replacement on complex objects """ __slots__ = ("__max_indent", "__indent_step")
[docs] def __init__(self, max_indent: int = 20, indent_step: int = 4) -> None: """Pretty Formatter. :param max_indent: maximal indent before classic repr() call :type max_indent: int :param indent_step: step for the next indentation level :type indent_step: int """ self.__max_indent: int = max_indent self.__indent_step: int = indent_step
@property def max_indent(self) -> int: """Max indent getter. :return: maximal indent before switch to normal repr :rtype: int """ return self.__max_indent @property def indent_step(self) -> int: """Indent step getter. :return: indent step for nested definitions :rtype: int """ return self.__indent_step
[docs] def next_indent(self, indent: int, multiplier: int = 1) -> int: """Next indentation value. :param indent: current indentation value :type indent: int :param multiplier: step multiplier :type multiplier: int :return: next indentation value :rtype: int """ return indent + multiplier * self.indent_step
def _repr_callable( self, src: types.FunctionType | types.MethodType, indent: int = 0, ) -> str: """Repr callable object (function or method). :param src: Callable to process :type src: types.FunctionType | types.MethodType :param indent: start indentation :type indent: int :return: Repr of function or method with signature. :rtype: str """ param_repr: list[str] = [] next_indent = self.next_indent(indent) prefix: str = "\n" + " " * next_indent for param in _prepare_repr(src): param_repr.append(f"{prefix}{param.name}") annotation_exist = param.annotation is not param.empty # type: ignore[comparison-overlap] if annotation_exist: param_repr.append(f": {getattr(param.annotation, '__name__', param.annotation)!s}") if param.value is not param.empty: if annotation_exist: param_repr.append(" = ") else: param_repr.append("=") param_repr.append(self.process_element(src=param.value, indent=next_indent, no_indent_start=True)) param_repr.append(",") if param_repr: param_repr.append("\n") param_repr.append(" " * indent) param_str = "".join(param_repr) sig: Signature = signature(src) if sig.return_annotation is Parameter.empty: annotation: str = "" elif sig.return_annotation is type(None): # Python 3.10 special case annotation = " -> None" else: annotation = f" -> {getattr(sig.return_annotation, '__name__', sig.return_annotation)!s}" return ( f"{'':<{indent}}" f"<{src.__class__.__name__} {src.__module__}.{src.__name__} with interface ({param_str}){annotation}>" ) def _repr_attribute_holder( self, src: _AttributeHolderProto, indent: int = 0, no_indent_start: bool = False, ) -> str: """Repr attribute holder object (like argparse objects). :param src: attribute holder object to process :type src: _AttributeHolderProto :param indent: start indentation :type indent: int :param no_indent_start: do not indent open bracket and simple parameters :type no_indent_start: bool :return: Repr of attribute holder object. :rtype: str """ param_repr: list[str] = [] star_args: dict[str, Any] = {} next_indent = self.next_indent(indent) prefix: str = "\n" + " " * next_indent for arg in src._get_args(): # pylint: disable=protected-access repr_val = self.process_element(arg, indent=next_indent) param_repr.append(f"{prefix}{repr_val},") for name, value in src._get_kwargs(): # pylint: disable=protected-access if name.isidentifier(): repr_val = self.process_element(value, indent=next_indent, no_indent_start=True) param_repr.append(f"{prefix}{name}={repr_val},") else: star_args[name] = value if star_args: repr_val = self.process_element(star_args, indent=next_indent, no_indent_start=True) param_repr.append(f"{prefix}**{repr_val},") if param_repr: param_repr.append("\n") param_repr.append(" " * indent) param_str = "".join(param_repr) return f"{'':<{indent if not no_indent_start else 0}}{src.__module__}.{src.__class__.__name__}({param_str})" def _repr_named_tuple( self, src: _NamedTupleProto, indent: int = 0, no_indent_start: bool = False, ) -> str: """Repr named tuple. :param src: named tuple object to process :type src: _NamedTupleProto :param indent: start indentation :type indent: int :param no_indent_start: do not indent open bracket and simple parameters :type no_indent_start: bool :return: Repr of named tuple object. :rtype: str """ param_repr: list[str] = [] # noinspection PyBroadException try: args_annotations: dict[str, Any] = get_type_hints(type(src)) except BaseException: # NOSONAR args_annotations = {} next_indent = self.next_indent(indent) prefix: str = "\n" + " " * next_indent for arg_name, value in src._asdict().items(): repr_val = self.process_element(value, indent=next_indent, no_indent_start=True) param_repr.append(f"{prefix}{arg_name}={repr_val},") if arg_name in args_annotations and not isinstance(getattr(args_annotations, arg_name, None), ForwardRef): annotation = getattr(args_annotations[arg_name], "__name__", args_annotations[arg_name]) param_repr.append(f" # type: {annotation!s}") if param_repr: param_repr.append("\n") param_repr.append(" " * indent) param_str = "".join(param_repr) return f"{'':<{indent if not no_indent_start else 0}}{src.__module__}.{src.__class__.__name__}({param_str})" def _repr_dataclass( self, src: _DataClassProto, indent: int = 0, no_indent_start: bool = False, ) -> str: """Repr dataclass. :param src: dataclass object to process :type src: _DataClassProto :param indent: start indentation :type indent: int :param no_indent_start: do not indent open bracket and simple parameters :type no_indent_start: bool :return: Repr of dataclass. :rtype: str """ param_repr: list[str] = [] next_indent = self.next_indent(indent) prefix: str = "\n" + " " * next_indent for arg_name, field in src.__dataclass_fields__.items(): if not field.repr: continue repr_val = self.process_element(getattr(src, arg_name), indent=next_indent, no_indent_start=True) comment: list[str] = [] if field.type: if isinstance(field.type, str): comment.append(f"type: {field.type}") else: comment.append(f"type: {field.type.__name__}") if getattr(field, "kw_only", False): # python 3.10+ comment.append("kw_only") if comment: comment_str = " # " + " # ".join(comment) else: comment_str = "" param_repr.append(f"{prefix}{arg_name}={repr_val},{comment_str}") if param_repr: param_repr.append("\n") param_repr.append(" " * indent) param_str = "".join(param_repr) return f"{'':<{indent if not no_indent_start else 0}}{src.__module__}.{src.__class__.__name__}({param_str})" @abc.abstractmethod def _repr_simple( self, src: Any, indent: int = 0, no_indent_start: bool = False, ) -> str: """Repr object without iteration. :param src: Source object :type src: Any :param indent: start indentation :type indent: int :param no_indent_start: ignore indent :type no_indent_start: bool :return: simple repr() over object :rtype: str """ @abc.abstractmethod def _repr_dict_items( self, src: dict[Any, Any], indent: int = 0, ) -> str: """Repr dict items. :param src: object to process :type src: dict[Any, Any] :param indent: start indentation :type indent: int :return: repr of key/value pairs from dict :rtype: str """ @staticmethod @abc.abstractmethod def _repr_iterable_item( obj_type: str, prefix: str, indent: int, no_indent_start: bool, result: str, suffix: str, ) -> str: """Repr iterable item. :param obj_type: Object type :type obj_type: str :param prefix: prefix :type prefix: str :param indent: start indentation :type indent: int :param no_indent_start: do not indent open bracket and simple parameters :type no_indent_start: bool :param result: result of pre-formatting :type result: str :param suffix: suffix :type suffix: str :return: formatted repr of "result" with prefix and suffix to explain type. :rtype: str """ def _repr_iterable_items( self, src: Iterable[Any], indent: int = 0, ) -> str: """Repr iterable items (not designed for dicts). :param src: object to process :type src: Iterable[Any] :param indent: start indentation :type indent: int :return: repr of elements in iterable item :rtype: str """ next_indent: int = self.next_indent(indent) buf: list[str] = [] for elem in src: buf.append("\n") buf.append(self.process_element(src=elem, indent=next_indent)) buf.append(",") return "".join(buf) def _repr_rich( self, src: _RichReprProto, indent: int = 0, no_indent_start: bool = False, ) -> str: """Repr of objects with rich defined repr. :param src: object to process :type src: Any :param indent: start indentation :type indent: int :param no_indent_start: do not indent open bracket and simple parameters :type no_indent_start: bool :return: formatted string :rtype: str """ param_repr: list[str] = [] next_indent = self.next_indent(indent) prefix: str = "\n" + " " * next_indent for arg in src.__rich_repr__(): if isinstance(arg, tuple): if len(arg) == 1: arg_name = None default = () value = arg[0] else: arg_name, value, *default = arg # type: ignore[assignment] repr_val = self.process_element(value, indent=next_indent, no_indent_start=True) if arg_name is None: param_repr.append(f"{prefix}{repr_val},") else: if default and default[0] == value: # standard behavior for rich continue param_repr.append(f"{prefix}{arg_name}={repr_val},") else: repr_val = self.process_element(arg, indent=next_indent, no_indent_start=True) param_repr.append(f"{prefix}{repr_val},") if param_repr: param_repr.append("\n") param_repr.append(" " * indent) param_str = "".join(param_repr) return f"{'':<{indent if not no_indent_start else 0}}{src.__module__}.{src.__class__.__name__}({param_str})" @property @abc.abstractmethod def _magic_method_name(self) -> str: """Magic method name. :return: magic method name to lookup in processing objects :rtype: str """
[docs] def process_element( self, src: Any, indent: int = 0, no_indent_start: bool = False, ) -> str: """Make human readable representation of object. :param src: object to process :type src: Any :param indent: start indentation :type indent: int :param no_indent_start: do not indent open bracket and simple parameters :type no_indent_start: bool :return: formatted string :rtype: str """ if hasattr(src, self._magic_method_name): result = getattr(src, self._magic_method_name)(self, indent=indent, no_indent_start=no_indent_start) return result # type: ignore[no-any-return] if isinstance(src, _RichReprProto): return self._repr_rich(src=src, indent=indent) if _known_callable(src): return self._repr_callable(src=src, indent=indent) if isinstance(src, _AttributeHolderProto): return self._repr_attribute_holder(src=src, indent=indent, no_indent_start=no_indent_start) if isinstance(src, tuple) and isinstance(src, _NamedTupleProto): return self._repr_named_tuple(src=src, indent=indent, no_indent_start=no_indent_start) if isinstance(src, _DataClassProto) and not isinstance(src, type) and src.__dataclass_params__.repr: return self._repr_dataclass(src=src, indent=indent, no_indent_start=no_indent_start) if _simple(src) or indent >= self.max_indent or not src: return self._repr_simple(src=src, indent=indent, no_indent_start=no_indent_start) if isinstance(src, dict): prefix, suffix = "{", "}" result = self._repr_dict_items(src=src, indent=indent) elif isinstance(src, collections.deque): result = self._repr_iterable_items(src=src, indent=self.next_indent(indent)) prefix, suffix = "(", ")" else: if isinstance(src, list): prefix, suffix = "[", "]" elif isinstance(src, tuple): prefix, suffix = "(", ")" elif isinstance(src, (set, frozenset)): prefix, suffix = "{", "}" else: prefix, suffix = "", "" result = self._repr_iterable_items(src=src, indent=indent) if isinstance(src, collections.deque): next_indent = self.next_indent(indent) return ( f"{'':<{indent if not no_indent_start else 0}}" f"{src.__class__.__name__}(\n" f"{'':<{next_indent}}{prefix}{result}\n" f"{'':<{next_indent}}{suffix},\n" f"{'':<{self.next_indent(indent)}}maxlen={src.maxlen},\n" f"{'':<{indent}})" ) if type(src) in (list, tuple, set, dict): return f"{'':<{indent if not no_indent_start else 0}}{prefix}{result}\n{'':<{indent}}{suffix}" return self._repr_iterable_item( obj_type=src.__class__.__name__, prefix=prefix, indent=indent, no_indent_start=no_indent_start, result=result, suffix=suffix, )
[docs] def __call__( self, src: Any, indent: int = 0, no_indent_start: bool = False, ) -> str: """Make human-readable representation of object. The main entry point. :param src: object to process :type src: Any :param indent: start indentation :type indent: int :param no_indent_start: do not indent open bracket and simple parameters :type no_indent_start: bool :return: formatted string :rtype: str """ result = self.process_element(src, indent=indent, no_indent_start=no_indent_start) return result
[docs]class PrettyRepr(PrettyFormat): """Pretty repr. Designed for usage as __repr__ replacement on complex objects """ __slots__ = () @property def _magic_method_name(self) -> str: """Magic method name. :return: magic method name to lookup in processing objects :rtype: str """ return "__pretty_repr__" def _repr_simple( self, src: Any, indent: int = 0, no_indent_start: bool = False, ) -> str: """Repr object without iteration. :param src: Source object :type src: Any :param indent: start indentation :type indent: int :param no_indent_start: ignore indent :type no_indent_start: bool :return: simple repr() over object, except strings (add prefix) and set (uniform py2/py3) :rtype: str """ return f"{'':<{0 if no_indent_start else indent}}{src!r}" def _repr_dict_items( self, src: dict[Any, Any], indent: int = 0, ) -> str: """Repr dict items. :param src: object to process :type src: dict[Any, Any] :param indent: start indentation :type indent: int :return: repr of key/value pairs from dict :rtype: str """ max_len: int = max(len(repr(key)) for key in src) if src else 0 next_indent: int = self.next_indent(indent) prefix: str = "\n" + " " * next_indent buf: list[str] = [] for key, val in src.items(): buf.append(prefix) buf.append(f"{key!r:{max_len}}: ") buf.append(self.process_element(val, indent=next_indent, no_indent_start=True)) buf.append(",") return "".join(buf) @staticmethod def _repr_iterable_item( obj_type: str, prefix: str, indent: int, no_indent_start: bool, result: str, suffix: str, ) -> str: """Repr iterable item. :param obj_type: Object type :type obj_type: str :param prefix: prefix :type prefix: str :param indent: start indentation :type indent: int :param no_indent_start: do not indent open bracket and simple parameters :type no_indent_start: bool :param result: result of pre-formatting :type result: str :param suffix: suffix :type suffix: str :return: formatted repr of "result" with prefix and suffix to explain type. :rtype: str """ return f"{'':<{indent if not no_indent_start else 0}}{obj_type}({prefix}{result}\n{'':<{indent}}{suffix})"
[docs]class PrettyStr(PrettyFormat): """Pretty str. Designed for usage as __str__ replacement on complex objects """ __slots__ = () @property def _magic_method_name(self) -> str: """Magic method name. :rtype: str """ return "__pretty_str__" @staticmethod def _strings_str( indent: int, val: bytes | str, ) -> str: """Custom str for strings and binary strings. :param indent: result indent :type indent: int :param val: value for repr :type val: bytes | str :return: indented string as `str` :rtype: str """ if isinstance(val, bytes): string: str = val.decode(encoding="utf-8", errors="backslashreplace") else: string = val return f"{'':<{indent}}{string}" def _repr_simple( self, src: Any, indent: int = 0, no_indent_start: bool = False, ) -> str: """Repr object without iteration. :param src: Source object :type src: Any :param indent: start indentation :type indent: int :param no_indent_start: ignore indent :type no_indent_start: bool :return: simple repr() over object, except strings (decode) and set (uniform py2/py3) :rtype: str """ indent = 0 if no_indent_start else indent if isinstance(src, (bytes, str)): return self._strings_str(indent=indent, val=src) return f"{'':<{indent}}{src!s}" def _repr_dict_items( self, src: dict[Any, Any], indent: int = 0, ) -> str: """Repr dict items. :param src: object to process :type src: dict[Any, Any] :param indent: start indentation :type indent: int :return: repr of key/value pairs from dict :rtype: str """ max_len = max(len(str(key)) for key in src) if src else 0 next_indent: int = self.next_indent(indent) prefix: str = "\n" + " " * next_indent buf: list[str] = [] for key, val in src.items(): buf.append(prefix) buf.append(f"{key!s:{max_len}}: ") buf.append(self.process_element(val, indent=next_indent, no_indent_start=True)) buf.append(",") return "".join(buf) @staticmethod def _repr_iterable_item( obj_type: str, prefix: str, indent: int, no_indent_start: bool, result: str, suffix: str, ) -> str: """Repr iterable item. :param obj_type: Object type :type obj_type: str :param prefix: prefix :type prefix: str :param indent: start indentation :type indent: int :param no_indent_start: do not indent open bracket and simple parameters :type no_indent_start: bool :param result: result of pre-formatting :type result: str :param suffix: suffix :type suffix: str :return: formatted repr of "result" with prefix and suffix to explain type. :rtype: str """ return f"{'':<{indent if not no_indent_start else 0}}{prefix}{result}\n{'':<{indent}}{suffix}"
[docs]def pretty_repr( src: Any, indent: int = 0, no_indent_start: bool = False, max_indent: int = 20, indent_step: int = 4, ) -> str: """Make human readable repr of object. :param src: object to process :type src: Any :param indent: start indentation, all next levels is +indent_step :type indent: int :param no_indent_start: do not indent open bracket and simple parameters :type no_indent_start: bool :param max_indent: maximal indent before classic repr() call :type max_indent: int :param indent_step: step for the next indentation level :type indent_step: int :return: formatted string :rtype: str """ return PrettyRepr(max_indent=max_indent, indent_step=indent_step)( src=src, indent=indent, no_indent_start=no_indent_start, )
[docs]def pretty_str( src: Any, indent: int = 0, no_indent_start: bool = False, max_indent: int = 20, indent_step: int = 4, ) -> str: """Make human readable str of object. :param src: object to process :type src: Any :param indent: start indentation, all next levels is +indent_step :type indent: int :param no_indent_start: do not indent open bracket and simple parameters :type no_indent_start: bool :param max_indent: maximal indent before classic repr() call :type max_indent: int :param indent_step: step for the next indentation level :type indent_step: int :return: formatted string """ return PrettyStr(max_indent=max_indent, indent_step=indent_step)( src=src, indent=indent, no_indent_start=no_indent_start, )