pytermgui.inspector

Inspector widget and inspection utilities for (in the future) any Python objects. This module will soon see a full overhaul, so the API is likely to change.

View Source
"""
`Inspector` widget and inspection utilities for (in the future) any Python objects. This
module will soon see a full overhaul, so the API is likely to change.
"""
# TODO: This module should get a rewrite at some point:
#       - Tie into WindowManager
#       - Live object inspection
#       - Support for module inspection
#       - Handle long items

from __future__ import annotations

from typing import Optional, Any
from inspect import signature, getdoc, isclass, ismodule, Signature

from .input import getch
from .parser import markup
from .enums import HorizontalAlignment

from .context_managers import alt_buffer
from .widgets.boxes import DOUBLE_BOTTOM
from .widgets import Container, Label
from .widgets.styles import MarkupFormatter, StyleType, StyleCall
from .ansi_interface import foreground, terminal, is_interactive

__all__ = ["inspect", "Inspector"]


def create_color_style(color: int) -> StyleType:
    """Create a color style callable"""

    def color_style(_: int, item: str) -> str:
        """Simple style using foreground colors"""

        return foreground(item, color)

    return color_style


def inspect(
    target: Any,
    style: bool = True,
    show_dunder: bool = False,
    show_private: bool = False,
) -> None:
    """Inspect an object"""

    target_height = int(terminal.height * 3 / 4)
    inspector = Inspector()
    inspector.inspect(target, show_dunder=show_dunder, show_private=show_private)

    def handle_scrolling(root: Container, index: int) -> Optional[Container]:
        """Handle scrolling root to index"""

        widgets = []
        current = 0

        for label in inspector[index:]:
            if current > target_height:
                break

            widgets.append(label)
            current += label.height

        if inspector.height < target_height:
            root.height = inspector.height

        else:
            for _ in range(target_height - current):
                widgets.append(Label())

        root.set_widgets(widgets)

        return root

    root = Container(width=terminal.width)
    root.height = target_height
    root += Label()

    if style:
        builtin_style = StyleCall(inspector, inspector.styles["builtin"])
        DOUBLE_BOTTOM.set_chars_of(root)

        corners = root.chars["corner"]
        assert isinstance(corners, list)
        corners[1] = " Inspecting: " + builtin_style(str(target)) + " " + corners[1]
        root.set_char("corner", corners)

        root.set_style("corner", lambda _, item: item)

    handle_scrolling(root, 0)
    scroll = 0

    with alt_buffer(cursor=False):
        root.center()
        root.print()

        while True:
            previous = scroll

            try:
                key = getch()
            except KeyboardInterrupt:
                break

            if inspector.height > target_height:
                if key == "j":
                    scroll += 1

                elif key == "k":
                    scroll -= 1

                scroll = max(0, scroll)

                if not handle_scrolling(root, scroll):
                    scroll = previous

            root.center()
            root.print()

    print("Inspection complete!")
    if is_interactive():
        print(
            markup.parse(
                "\n[210 bold]Note: [/]"
                + "The Python interactive shell doesn't support hiding input characters,"
                + " so the inspect() experience is not ideal.\n"
                + "Consider using [249]`ptg --inspect`[/fg] instead."
            )
        )


class Inspector(Container):
    """A Container subclass that allows inspection of any Python object"""

    styles = {
        **Container.styles,
        **{
            "builtin": MarkupFormatter("[208]{item}"),
            "declaration": MarkupFormatter("[9 bold]{item}"),
            "name": MarkupFormatter("[114]{item}"),
            "string": lambda depth, item: foreground(item, 142),
        },
    }

    _inspectable = ["__init__", "inspect"]

    def __init__(self, **container_args: Any) -> None:
        """Initialize object and inspect something"""

        super().__init__(**container_args)

        self.styles = type(self).styles.copy()

    @staticmethod
    def _get_signature(target: Any) -> Optional[Signature]:
        """Get signature of target, return None if exception occurs"""

        try:
            return signature(target)

        except (TypeError, ValueError):
            return None

    def _get_docstring(self, target: Any) -> list[str]:
        """Get docstring of target"""

        string_style = self._get_style("string")

        doc = getdoc(target)
        if doc is None:
            return []

        doc = '"""' + doc + '"""'

        lines = []
        for line in doc.splitlines():
            lines.append(string_style(line))

        return lines

    def _get_definition(self, target: Any) -> str:
        """Get definition (def fun(...) / class Cls(...)) of an object"""

        name_style = self._get_style("name")
        declaration_style = self._get_style("declaration")

        elements = [declaration_style("class" if isclass(target) else "def")]

        if hasattr(target, "__name__"):
            obj_name = name_style(getattr(target, "__name__"))

        else:
            obj_name = name_style(type(target).__name__)

        obj_name += "("

        sig = self._get_signature(target)

        if isclass(target) or sig is None:
            parameters = ["..."]

        else:
            parameters = []
            for name, parameter in sig.parameters.items():
                param_str = name

                if name == "self" or parameter.annotation is Signature.empty:
                    parameters.append(param_str)
                    continue

                param_str += ": "
                param_str += self._style_annotation(parameter.annotation)

                if parameter.default is not Signature.empty:
                    param_str += " = "
                    param_str += str(parameter.default)

                parameters.append(param_str)

        obj_name += ", ".join(parameters) + ")"

        if not isclass(target):
            if sig is not None and sig.return_annotation is not Signature.empty:
                obj_name += " -> "
                obj_name += self._style_annotation(sig.return_annotation)

        obj_name += ":"
        elements.append(obj_name)

        return " ".join(elements)

    def _style_annotation(self, annotation: Any) -> str:
        """Style an annotation property"""

        builtin_style = self._get_style("builtin")
        if isinstance(annotation, type):
            return builtin_style(annotation.__name__.replace("[", r"\["))

        return builtin_style(str(annotation.replace("[", r"\[")))

    def inspect(
        self,
        target: Any,
        keep_elements: bool = False,
        show_dunder: bool = False,
        show_private: bool = False,
        _padding: int = 0,
    ) -> Container:
        """Inspect any Python element"""

        if ismodule(target):
            raise NotImplementedError("Modules are not inspectable yet.")

        if not keep_elements:
            self._widgets = []

        definition = Label(
            self._get_definition(target),
            padding=_padding,
            parent_align=HorizontalAlignment.LEFT,
        )
        definition.set_style("value", lambda _, item: item)

        # it keeps the same type
        self._add_widget(definition)

        for line in self._get_docstring(target):
            doc = Label(
                line, padding=_padding + 4, parent_align=HorizontalAlignment.LEFT
            )
            doc.set_style("value", lambda _, item: item)
            self._add_widget(doc)

        if isclass(target):
            self._add_widget(Label())

            if hasattr(target, "_inspectable"):
                functions = [
                    getattr(target, name) for name in getattr(target, "_inspectable")
                ]

            else:
                functions = []
                for name in dir(target):
                    value = getattr(target, name)

                    value_name = getattr(value, "__name__", None)

                    if value_name:
                        if (
                            not show_dunder
                            and value_name.startswith("__")
                            and value_name.endswith("__")
                            and not value_name == "__init__"
                        ):
                            continue

                        if (
                            not show_private
                            and value_name.startswith("_")
                            and not value_name.endswith("__")
                        ):
                            continue

                    if callable(value) and not isinstance(value, type):
                        functions.append(value)

            for function in functions:
                self.inspect(function, keep_elements=True, _padding=_padding + 4)
                self._add_widget(Label())

        return self
#   def inspect( target: Any, style: bool = True, show_dunder: bool = False, show_private: bool = False ) -> None:
View Source
def inspect(
    target: Any,
    style: bool = True,
    show_dunder: bool = False,
    show_private: bool = False,
) -> None:
    """Inspect an object"""

    target_height = int(terminal.height * 3 / 4)
    inspector = Inspector()
    inspector.inspect(target, show_dunder=show_dunder, show_private=show_private)

    def handle_scrolling(root: Container, index: int) -> Optional[Container]:
        """Handle scrolling root to index"""

        widgets = []
        current = 0

        for label in inspector[index:]:
            if current > target_height:
                break

            widgets.append(label)
            current += label.height

        if inspector.height < target_height:
            root.height = inspector.height

        else:
            for _ in range(target_height - current):
                widgets.append(Label())

        root.set_widgets(widgets)

        return root

    root = Container(width=terminal.width)
    root.height = target_height
    root += Label()

    if style:
        builtin_style = StyleCall(inspector, inspector.styles["builtin"])
        DOUBLE_BOTTOM.set_chars_of(root)

        corners = root.chars["corner"]
        assert isinstance(corners, list)
        corners[1] = " Inspecting: " + builtin_style(str(target)) + " " + corners[1]
        root.set_char("corner", corners)

        root.set_style("corner", lambda _, item: item)

    handle_scrolling(root, 0)
    scroll = 0

    with alt_buffer(cursor=False):
        root.center()
        root.print()

        while True:
            previous = scroll

            try:
                key = getch()
            except KeyboardInterrupt:
                break

            if inspector.height > target_height:
                if key == "j":
                    scroll += 1

                elif key == "k":
                    scroll -= 1

                scroll = max(0, scroll)

                if not handle_scrolling(root, scroll):
                    scroll = previous

            root.center()
            root.print()

    print("Inspection complete!")
    if is_interactive():
        print(
            markup.parse(
                "\n[210 bold]Note: [/]"
                + "The Python interactive shell doesn't support hiding input characters,"
                + " so the inspect() experience is not ideal.\n"
                + "Consider using [249]`ptg --inspect`[/fg] instead."
            )
        )

Inspect an object

View Source
class Inspector(Container):
    """A Container subclass that allows inspection of any Python object"""

    styles = {
        **Container.styles,
        **{
            "builtin": MarkupFormatter("[208]{item}"),
            "declaration": MarkupFormatter("[9 bold]{item}"),
            "name": MarkupFormatter("[114]{item}"),
            "string": lambda depth, item: foreground(item, 142),
        },
    }

    _inspectable = ["__init__", "inspect"]

    def __init__(self, **container_args: Any) -> None:
        """Initialize object and inspect something"""

        super().__init__(**container_args)

        self.styles = type(self).styles.copy()

    @staticmethod
    def _get_signature(target: Any) -> Optional[Signature]:
        """Get signature of target, return None if exception occurs"""

        try:
            return signature(target)

        except (TypeError, ValueError):
            return None

    def _get_docstring(self, target: Any) -> list[str]:
        """Get docstring of target"""

        string_style = self._get_style("string")

        doc = getdoc(target)
        if doc is None:
            return []

        doc = '"""' + doc + '"""'

        lines = []
        for line in doc.splitlines():
            lines.append(string_style(line))

        return lines

    def _get_definition(self, target: Any) -> str:
        """Get definition (def fun(...) / class Cls(...)) of an object"""

        name_style = self._get_style("name")
        declaration_style = self._get_style("declaration")

        elements = [declaration_style("class" if isclass(target) else "def")]

        if hasattr(target, "__name__"):
            obj_name = name_style(getattr(target, "__name__"))

        else:
            obj_name = name_style(type(target).__name__)

        obj_name += "("

        sig = self._get_signature(target)

        if isclass(target) or sig is None:
            parameters = ["..."]

        else:
            parameters = []
            for name, parameter in sig.parameters.items():
                param_str = name

                if name == "self" or parameter.annotation is Signature.empty:
                    parameters.append(param_str)
                    continue

                param_str += ": "
                param_str += self._style_annotation(parameter.annotation)

                if parameter.default is not Signature.empty:
                    param_str += " = "
                    param_str += str(parameter.default)

                parameters.append(param_str)

        obj_name += ", ".join(parameters) + ")"

        if not isclass(target):
            if sig is not None and sig.return_annotation is not Signature.empty:
                obj_name += " -> "
                obj_name += self._style_annotation(sig.return_annotation)

        obj_name += ":"
        elements.append(obj_name)

        return " ".join(elements)

    def _style_annotation(self, annotation: Any) -> str:
        """Style an annotation property"""

        builtin_style = self._get_style("builtin")
        if isinstance(annotation, type):
            return builtin_style(annotation.__name__.replace("[", r"\["))

        return builtin_style(str(annotation.replace("[", r"\[")))

    def inspect(
        self,
        target: Any,
        keep_elements: bool = False,
        show_dunder: bool = False,
        show_private: bool = False,
        _padding: int = 0,
    ) -> Container:
        """Inspect any Python element"""

        if ismodule(target):
            raise NotImplementedError("Modules are not inspectable yet.")

        if not keep_elements:
            self._widgets = []

        definition = Label(
            self._get_definition(target),
            padding=_padding,
            parent_align=HorizontalAlignment.LEFT,
        )
        definition.set_style("value", lambda _, item: item)

        # it keeps the same type
        self._add_widget(definition)

        for line in self._get_docstring(target):
            doc = Label(
                line, padding=_padding + 4, parent_align=HorizontalAlignment.LEFT
            )
            doc.set_style("value", lambda _, item: item)
            self._add_widget(doc)

        if isclass(target):
            self._add_widget(Label())

            if hasattr(target, "_inspectable"):
                functions = [
                    getattr(target, name) for name in getattr(target, "_inspectable")
                ]

            else:
                functions = []
                for name in dir(target):
                    value = getattr(target, name)

                    value_name = getattr(value, "__name__", None)

                    if value_name:
                        if (
                            not show_dunder
                            and value_name.startswith("__")
                            and value_name.endswith("__")
                            and not value_name == "__init__"
                        ):
                            continue

                        if (
                            not show_private
                            and value_name.startswith("_")
                            and not value_name.endswith("__")
                        ):
                            continue

                    if callable(value) and not isinstance(value, type):
                        functions.append(value)

            for function in functions:
                self.inspect(function, keep_elements=True, _padding=_padding + 4)
                self._add_widget(Label())

        return self

A Container subclass that allows inspection of any Python object

#   Inspector(**container_args: Any)
View Source
    def __init__(self, **container_args: Any) -> None:
        """Initialize object and inspect something"""

        super().__init__(**container_args)

        self.styles = type(self).styles.copy()

Initialize object and inspect something

#   styles: dict[str, typing.Callable[[int, str], str]] = {'border': <function <lambda>>, 'corner': <function <lambda>>, 'fill': <function <lambda>>, 'builtin': MarkupFormatter(markup='[208]{item}', ensure_reset=True, ensure_strip=False), 'declaration': MarkupFormatter(markup='[9 bold]{item}', ensure_reset=True, ensure_strip=False), 'name': MarkupFormatter(markup='[114]{item}', ensure_reset=True, ensure_strip=False), 'string': <function Inspector.<lambda>>}

Default styles for this class

#   def inspect( self, target: Any, keep_elements: bool = False, show_dunder: bool = False, show_private: bool = False, _padding: int = 0 ) -> pytermgui.widgets.layouts.Container:
View Source
    def inspect(
        self,
        target: Any,
        keep_elements: bool = False,
        show_dunder: bool = False,
        show_private: bool = False,
        _padding: int = 0,
    ) -> Container:
        """Inspect any Python element"""

        if ismodule(target):
            raise NotImplementedError("Modules are not inspectable yet.")

        if not keep_elements:
            self._widgets = []

        definition = Label(
            self._get_definition(target),
            padding=_padding,
            parent_align=HorizontalAlignment.LEFT,
        )
        definition.set_style("value", lambda _, item: item)

        # it keeps the same type
        self._add_widget(definition)

        for line in self._get_docstring(target):
            doc = Label(
                line, padding=_padding + 4, parent_align=HorizontalAlignment.LEFT
            )
            doc.set_style("value", lambda _, item: item)
            self._add_widget(doc)

        if isclass(target):
            self._add_widget(Label())

            if hasattr(target, "_inspectable"):
                functions = [
                    getattr(target, name) for name in getattr(target, "_inspectable")
                ]

            else:
                functions = []
                for name in dir(target):
                    value = getattr(target, name)

                    value_name = getattr(value, "__name__", None)

                    if value_name:
                        if (
                            not show_dunder
                            and value_name.startswith("__")
                            and value_name.endswith("__")
                            and not value_name == "__init__"
                        ):
                            continue

                        if (
                            not show_private
                            and value_name.startswith("_")
                            and not value_name.endswith("__")
                        ):
                            continue

                    if callable(value) and not isinstance(value, type):
                        functions.append(value)

            for function in functions:
                self.inspect(function, keep_elements=True, _padding=_padding + 4)
                self._add_widget(Label())

        return self

Inspect any Python element