# -*- coding: utf-8 -*-
"""Object type tree analyzer. The GUI is handled by a separate module."""
import dataclasses
import enum
import functools
import json
import re
from collections.abc import Callable
from typing import Any, Type
try:
from viewer import tree_viewer
except (ModuleNotFoundError, ImportError):
from .viewer import tree_viewer
_DEFAULT_MAX_LINES = 1000
_DEFAULT_MAX_SEARCH = 100_000
_DEFAULT_MAX_DEPTH = 20
_DEFAULT_MAX_BRANCHES = float('inf')
# Pre-compiled format for _KeyType.INDEX and _KeyType.SLICE
_RANGE_REGEX = re.compile(r'^\[(\d+)(?::(\d+))?]$')
[docs]@functools.total_ordering
class KeyType(enum.Enum):
"""Node key types.
Each key will be displayed differently based on their type.
"""
NONE = 0, 'None'
"""For the root node, which has no key."""
ATTR = 1, 'Attribute'
"""For object attributes (starts with a dot).
Examples: .attr, ._protected, .__special__.
"""
MAP = 2, 'Mapping'
"""For dict-like Mapping keys.
Examples: ['key'], [datetime.date(1970, 1, 1)].
"""
INDEX = 3, 'Sequence'
"""For array-like Sequence indices or slices.
The indices must be consecutive integers.
Examples: [2], [4:7], [:3], [:].
"""
SET = 4, 'Collection'
"""For set-like Collections, which have item counters, but no key.
Example: (×3).
The path to the item will be set to .copy().pop() for convenience,
even though not all Collections accept these methods.
"""
[docs] @classmethod
def path(cls, key_type: 'KeyType', value: Any = None) -> str:
"""Key path from the parent object to the current one."""
match key_type, value:
case cls.NONE, None:
return ''
case cls.NONE, str(x):
return x
case cls.ATTR, _:
return f'.{value!s}'
case cls.MAP, _:
return f'[{value!r}]'
case cls.SET, int():
return '.copy().pop()'
case cls.INDEX, int(x):
return f'[{x}]'
case cls.INDEX, None:
return '[:]'
case cls.INDEX, (int(x), int(y)):
if x + 1 == y:
return f'[{x}]'
if x == 0:
return f'[:{y}]'
return f'[{x}:{y}]'
raise TypeError(f"Invalid key type '{key_type}' or value '{value}'")
[docs] @classmethod
def str(cls, key_type: 'KeyType', value: Any = None) -> str:
"""Key label to be displayed in the tree view."""
match key_type, value:
case cls.NONE, None:
return ''
case cls.SET, int(x):
return '(×{:d}) '.format(x)
case _:
return f'{cls.path(key_type, value)}: '
def __lt__(self, other: 'KeyType'):
"""For sorting. Respect the declaration order."""
if not isinstance(other, type(self)):
return TypeError
return self.value < other.value
@functools.total_ordering
class _NodeKey:
"""Node key for string representation and for sorting."""
def __init__(self, key_type: KeyType, value: Any = None):
self._str: str = KeyType.str(key_type, value)
self._path: str = KeyType.path(key_type, value)
self._type: KeyType = key_type
self._counter: int = 1
if key_type == KeyType.SET:
if not isinstance(value, int):
raise TypeError
self._counter = value
self._slice: tuple[int, int] | None = None
if key_type == KeyType.INDEX:
if isinstance(value, int):
self._slice = value, value + 1
else:
self._slice = value
# Hash is unique for Sets
self._hash: int = hash((
type(self),
self._type,
self._slice,
id(self)*(self._type == KeyType.SET)
))
@property
def path(self) -> str:
return self._path
@property
def type(self) -> KeyType:
return self._type
@property
def counter(self) -> int:
return self._counter
@property
def slice(self) -> tuple[int, int] | None:
return self._slice
def reset_counter(self):
self._counter = 1
self._str = KeyType.str(self.type, self._counter)
def increment_counter(self):
self._counter += 1
self._str = KeyType.str(self.type, self._counter)
def __str__(self) -> str:
return self._str
def __repr__(self) -> str:
return f'{type(self).__name__}({self})'
def __eq__(self, other: object) -> bool:
if not isinstance(other, type(self)):
raise NotImplementedError
if self._type == other._type == KeyType.SET:
return False
return self._str == other._str
def __lt__(self, other: object) -> bool:
if not isinstance(other, type(self)):
raise NotImplementedError
if self._str == other._str:
return False
if self._slice is not None:
if other._slice is None:
return self._type < other._type
return self._slice < other._slice
if self._type == other._type:
if self._type == KeyType.SET:
if self._counter != other._counter:
return self._counter < other._counter
return self._hash < other._hash
return self._str < other._str
return self._type < other._type
def __hash__(self) -> int:
return self._hash
def get_itself(var: Any) -> Any:
"""Do nothing, return itself."""
return var
def get_type_name(var: Any) -> str:
"""Return the input's type name."""
return str(type(var).__name__)
def getattr_maker(attr: str) -> Callable[[Any], Any]:
"""Build a partial `getattr` for a fixed attribute."""
def attr_get(var: Any, attr_copy: str = attr) -> Any:
return getattr(var, attr_copy)
return attr_get
[docs]@dataclasses.dataclass(slots=True, frozen=True)
class Template:
"""Default template for configuration properties of :class:`Tree`."""
items_lookup: Callable[[Any], Any] = get_itself
type_name_lookup: Callable[[Any], str] = get_type_name
value_lookup: Callable[[Any], Any] = get_itself
sort_keys: bool = True
show_lengths: bool = True
include_attributes: bool = True
include_dir: bool = False
include_protected: bool = False
include_special: bool = False
max_search: float = _DEFAULT_MAX_SEARCH
max_depth: float = _DEFAULT_MAX_DEPTH
max_branches: float = _DEFAULT_MAX_BRANCHES
[docs]@dataclasses.dataclass(slots=True, frozen=True)
class DOM(Template):
"""Template for generating a tree view of DOM etree objects."""
items_lookup: Callable[[Any], Any] = getattr_maker('childNodes')
type_name_lookup: Callable[[Any], str] = getattr_maker('nodeName')
value_lookup: Callable[[Any], Any] = getattr_maker('text')
[docs]@dataclasses.dataclass(slots=True, frozen=True)
class HTML(Template):
"""Template for generating a tree view of HTML etree objects."""
type_name_lookup: Callable[[Any], str] = getattr_maker('tag')
value_lookup: Callable[[Any], Any] = getattr_maker('text')
[docs]@dataclasses.dataclass(slots=True, frozen=True)
class XML(Template):
"""Template for generating a tree view of XML etree objects."""
type_name_lookup: Callable[[Any], str] = getattr_maker('tag')
value_lookup: Callable[[Any], Any] = getattr_maker('text')
class _MaxSearchError(Exception):
"""Reached maximum number of nodes to be searched."""
pass
class _NodeInfo:
"""Non-recursive tree node info."""
def __init__(self, obj: Any, node_key: _NodeKey, config: Template,
nodes_searched: int, ancestors_ids: set[int], depth: int):
self.key: _NodeKey = node_key
self.path: str = node_key.path
self.config: Template = config
self.nodes_searched: int = nodes_searched
self.maxed_search: bool = False
# For displaying an ellipsis indicating possible inner content
self.maxed_depth: bool = depth >= config.max_depth
is_infinite_recursion: bool = id(obj) in ancestors_ids
self.type_name: str
try:
self.type_name = self.config.type_name_lookup(obj)
except AttributeError:
self.type_name = '?'
original_var: Any = obj
obj = self.config.items_lookup(obj)
# These refer to the contents of Maps or Sequences
self.items_key_type: KeyType = KeyType.NONE
self.items_len: int | None = None
self.update_items_info(obj, original_var)
self.branches: dict[_NodeKey, Any] = {}
if not is_infinite_recursion:
try:
self.add_branches(obj)
except _MaxSearchError:
pass
self.var_repr: str = f'<{self.type_name}>'
if is_infinite_recursion:
self.var_repr = f'<...> {self.var_repr}'
if self.config.show_lengths and self.items_len is not None:
self.var_repr = f'{self.var_repr}[{self.items_len}]'
def update_items_info(self, var: Any, original_var: Any):
"""Check which kind of iterable var is, if any, and update.
Update `self.items_key_type` and `self.items_len` if needed.
"""
# Check for Collections
try:
var, size = self.get_var_len(var, original_var)
# If TypeError, var is not a Collection of items
# Also ignore possible attribute lookup error
except (TypeError, AttributeError):
return
# Check for Mappings
if self.is_mapping(var):
self.items_key_type = KeyType.MAP
self.items_len = size
return
# Check for Sequences
if self.is_sequence(var):
self.items_key_type = KeyType.INDEX
self.items_len = size
return
# Check for set-like Collections
if self.is_collection(var):
self.items_key_type = KeyType.SET
self.items_len = size
def get_var_len(self, var: Any, original_var: Any) -> tuple[Any, int]:
"""Check for contents in `var`.
If no content is found, use the value lookup for tree leaves.
Return the value and the number of items it contains.
Raise :exc:`TypeError` if not a Collection.
"""
size: int = len(var) # Raises TypeError if not a Collection
if not size:
var = self.config.value_lookup(original_var)
# value_lookup might return itself and var might be empty
size = len(var)
if isinstance(var, str | bytes | bytearray):
raise TypeError
return var, size
@staticmethod
def is_mapping(var: Any) -> bool:
try:
# Since Maps are usually also Sequences, the priority is
# to access the Map items. But if the keys do not match
# their Sequence values, the priority inverts.
if (all(key1 == key2 for key1, key2 in zip(var, var.keys()))
and all(var[key] == value for key, value in var.items())
and len(var) == len(var.keys()) == len(var.items())):
return True
except (AttributeError, TypeError, KeyError):
pass
return False
@staticmethod
def is_sequence(var: Any) -> bool:
try:
if all(var[index] == value for index, value in enumerate(var)):
return True
except (KeyError, TypeError):
pass
return False
@staticmethod
def is_collection(var: Any) -> bool:
try:
next(iter(var))
# Unknown type: has len, but is not an iterable. Ignore contents.
except (TypeError, KeyError):
return False
# Is an empty Collection
except StopIteration:
pass
return True
def add_branches(self, var: Any):
if self.config.include_attributes and hasattr(var, '__dict__'):
for key, value in vars(var).items():
if self.include_attr(key):
self.add_branch(KeyType.ATTR, key, value)
if self.config.include_dir:
for key in dir(var):
if self.include_attr(key):
try:
value = getattr(var, key)
except AttributeError:
continue
self.add_branch(KeyType.ATTR, key, value)
match self.items_key_type:
case KeyType.MAP:
for key, value in var.items():
self.add_branch(KeyType.MAP, key, value)
case KeyType.INDEX:
for index, value in enumerate(var):
self.add_branch(KeyType.INDEX, index, value)
case KeyType.SET:
for value in var:
self.add_branch(KeyType.SET, 1, value)
# Success -- do not display ellipsis indicating max depth exceeded
self.maxed_depth = False
def add_branch(self, key_type: KeyType, key: Any, value: Any):
if self.nodes_searched >= self.config.max_search:
self.maxed_search = True
if not self.branches: # empty
# For displaying an ellipsis indicating possible inner content
self.maxed_depth = True
raise _MaxSearchError
if self.maxed_depth:
raise _MaxSearchError
node_key = _NodeKey(key_type, key)
self.branches[node_key] = value
self.nodes_searched += 1
def include_attr(self, key: str) -> bool:
if key.startswith('__') and key.endswith('__'):
return self.config.include_special
if key.startswith('_'):
return self.config.include_protected
return True
def __lt__(self, other):
if self.key.type == other.key.type == KeyType.SET:
if self.key.counter != other.key.counter:
return self.key.counter < other.key.counter
return self.path < other.path
return self.key < other.key
def __str__(self) -> str:
return f'{self.key}{self.var_repr}'
def __repr__(self) -> str:
return f'{type(self).__name__}({self})'
class _InfoTree:
"""A recursive tree builder of :class:`_NodeInfo`."""
def __init__(self, obj: Any, node_key: _NodeKey, config: Template,
nodes_searched: int, ancestors_ids: set[int], depth: int = 0):
self.config: Template = config
self.ancestors_ids: set[int] = ancestors_ids.copy()
self.depth: int = depth
self.node_info: _NodeInfo = _NodeInfo(
obj, node_key, config, nodes_searched, ancestors_ids, self.depth
)
self.nodes_searched: int = self.node_info.nodes_searched
self.ancestors_ids.add(id(obj))
self.is_complete: bool = not self.node_info.branches
self.maxed_depth: bool = (bool(self.depth >= config.max_depth
and self.node_info.branches)
or self.node_info.maxed_depth)
self.has_maxed_depth: bool = self.maxed_depth
self.branches: list[_InfoTree] = []
self.updated: bool = False
def update(self):
"""Recursively call to update the deepest :class:`_InfoTree`."""
if self.is_complete:
return
elif self.updated:
for info_tree in self.branches:
info_tree.nodes_searched = self.nodes_searched
info_tree.update()
self._update_info_tree(info_tree)
else:
self.updated = True
for key, var in self.node_info.branches.items():
info_tree: _InfoTree = _InfoTree(
var, key, self.config, self.nodes_searched,
self.ancestors_ids, self.depth + 1,
)
self.branches.append(info_tree)
self._update_info_tree(info_tree)
self.is_complete = all(info_tree.is_complete
for info_tree in self.branches)
def _update_info_tree(self, info_tree: '_InfoTree'):
if info_tree.has_maxed_depth:
self.has_maxed_depth = True
if self.nodes_searched == info_tree.nodes_searched:
info_tree.is_complete = True
self.nodes_searched = info_tree.nodes_searched
class _SubtreeCreator:
"""Create a :class:`Subtree` instance.
Used for pre-computing and analysing the branches.
Needed because Subtree inherits from tuple, which is immutable, and
the creation process is too complex to be done inside
:meth:`_SubtreeCreator.__new__`.
"""
def __init__(self, cls: Type['Subtree'], info_tree: _InfoTree):
self.config: Template = info_tree.config
info: _NodeInfo = info_tree.node_info
node_key: _NodeKey = info.key
self.all_branches: tuple[Subtree, ...] = tuple(
Subtree(sub_info_tree) for sub_info_tree in info_tree.branches
)
self.group_branches()
max_branches: int = int(min(float(len(self.all_branches)),
self.config.max_branches))
overflowed: bool = info.maxed_search or (len(self.all_branches)
> self.config.max_branches)
self.subtree: Subtree = tuple.__new__(
cls, self.all_branches[:max_branches]
)
self.subtree._node_key = node_key
self.subtree._config = self.config
self.subtree._info = info
self.subtree._label = str(info)
self.subtree._overflowed = overflowed
self.subtree._maxed_depth = info.maxed_depth
@staticmethod
def group_to_map(v: list[set[int]]) -> dict[tuple[int, int], int]:
"""Convert groups of index mappings to consecutive range maps.
Argument `v` is a list of indices grouped in sets that map to
the same structure in a Sequence tree. Their positions indicate
where they map to. Example:
`branches = [A, B, A, A, C, A, B]`
`unique_branches = [A, B, C]`
`v = [{0, 2, 3, 5}, {1, 6}, {4}]`
means that `A` shows in indices `v[0] = {0, 2, 3, 5}`, `B` shows
in indices `v[1] = {1, 6}`, and `C` shows in `v[2] = {4}`.
The return value is a dict of sequential ranges of indices
as keys and their mapping to unique_branches. In the previous
case it will return `{(0, 1): 0, (1, 2): 1, (2, 4): 0,
(4, 5): 2, (5, 6): 0, (6, 7): 1}`.
"""
if not v:
return {}
u: dict[tuple[int, int], int] = {}
for k, s in enumerate(v):
if not s:
continue
sv: list[int] = list(sorted(s))
su: list[tuple[int, int]] = [(sv[0], sv[0] + 1)]
for x in sv[1:]: # type: int
if x == su[-1][1]:
su[-1] = (su[-1][0], x + 1)
else:
su.append((x, x + 1))
for t in su: # type: tuple[int, int]
u[t] = k
# noinspection PyTypeChecker
return dict(sorted(u.items()))
# noinspection PyProtectedMember
def group_branches(self):
"""Group equivalent branches (with the same type structure)."""
if not self.all_branches:
return
# Group unique consecutive Sequence branches and
# unique Collection branches
unique_index_branches: list[Subtree] = []
index_branch_groups: list[list[Subtree]] = []
index_key_groups: list[set[int]] = []
unique_set_branches: list[Subtree] = []
added_branches: list[Subtree] = []
for branch in self.all_branches:
# Process Sequences
if branch._node_key.type == KeyType.INDEX:
range_key = range(*branch._node_key.slice)
try:
index = unique_index_branches.index(branch)
except ValueError:
unique_index_branches.append(branch)
index_branch_groups.append([branch])
index_key_groups.append(set(range_key))
else:
index_branch_groups[index].append(branch)
index_key_groups[index].update(range_key)
# Process Collections
elif branch._node_key.type == KeyType.SET:
try:
index = unique_set_branches.index(branch)
except ValueError:
branch._node_key.reset_counter()
unique_set_branches.append(branch)
else:
unique_set_branches[index]._node_key.increment_counter()
# Do not group other types
else:
added_branches.append(branch)
# Update node keys
unique_index_branches = self.update_index_keys(index_branch_groups,
index_key_groups)
for branch in unique_set_branches:
branch._update_key(branch._node_key)
# Join and sort
self.all_branches = tuple(
added_branches + unique_index_branches + unique_set_branches
)
self.sort_branches()
# noinspection PyProtectedMember
def update_index_keys(self, branch_groups: list[list['Subtree']],
key_groups: list[set[int]]) -> list['Subtree']:
updated_branches: list[Subtree] = []
for _range, index in self.group_to_map(key_groups).items():
branch = branch_groups[index].pop()
if self.config.show_lengths:
branch._update_key(_NodeKey(KeyType.INDEX, _range))
else:
branch._update_key(_NodeKey(KeyType.INDEX, None))
updated_branches.append(branch)
return list(sorted(updated_branches, key=lambda x: x.key))
# noinspection PyProtectedMember
def sort_branches(self):
self.all_branches = tuple(sorted(
self.all_branches,
key=((lambda x: x._info) if self.config.sort_keys
else lambda x: x._node_key._type)
))
[docs]@functools.total_ordering
class Subtree(tuple):
"""A recursive object tree structure."""
# Initialized by _SubtreeCreator
_node_key: _NodeKey
_config: Template
_info: _NodeInfo
_label: str
_overflowed: bool
_maxed_depth: bool
# Initialized by __init__
_nodes: int
_hash: int
_path: str
_is_expandable: bool
def __new__(cls, info_tree: _InfoTree):
"""Create an immutable nested subtree."""
return _SubtreeCreator(cls, info_tree).subtree
[docs] def __init__(self, *_args, **_kwargs):
"""Initialize the remaining subtree attributes."""
self._update_paths()
node: Subtree
self._nodes = len(self) + sum(node._nodes for node in self)
# Hash is unique if overflowed or max depth reached because
# the tree is incomplete and equality between incomplete trees
# cannot be established.
self._hash = hash((
self._info.var_repr,
self._info.key.type,
id(self)*(self._overflowed or self._maxed_depth),
tuple(map(hash, self)),
))
self._path = self._node_key.path
self._update_paths()
self._is_expandable = bool(self or self._overflowed
or self._maxed_depth)
@property
def key(self) -> str:
"""Key path from previous node to the current one."""
return self._node_key.path
@property
def key_type(self) -> KeyType:
"""Return the key type used.
Can be a Sequence index, a Mapping key, an attribute, a Set
(empty key), or none (also empty).
"""
return self._node_key.type
@property
def path(self) -> str:
"""Return the key path from the root node to the current one."""
return self._path
@property
def type(self) -> str:
"""Return the type name of the object the node corresponds to."""
return self._info.type_name
@property
def label(self) -> str:
"""Return the full text displayed for the node."""
return self._label
@property
def nodes(self) -> int:
"""Return the number of inner nodes indexed."""
return self._nodes
@property
def config(self) -> Template:
"""Return the settings used for generating the tree."""
return self._config
@property
def is_expandable(self) -> bool:
"""Return a flag for whether the node has inner content.
If true, the node will show as expandable in the tree view.
"""
return self._is_expandable
@property
def maxed_depth(self) -> bool:
"""Return a flag for maximum depth reached.
Only evaluate to `True` if the object the node refers to has
inner content that was not indexed.
"""
return self._maxed_depth
@property
def overflowed(self) -> bool:
"""Return a flag for maximum branch exceeded.
If `True`, the node has more branches than indexed.
Might be from reaching either `max_search` or `max_branches`.
"""
return self._overflowed
def _update_key(self, new_key: _NodeKey):
self._node_key = new_key
self._info.key = new_key
self._label = str(self._info)
self._update_paths()
def _update_paths(self, parent_path: str = ''):
if self._node_key.type == KeyType.SET:
self._path = f'{parent_path}.copy().pop()'
else:
self._path = f'{parent_path}{self._node_key.path}'
for branch in self: # type: Subtree
branch._update_paths(self._path)
def _get_tree_lines(self, max_lines: float,
root_pad: str = '',
branch_pad: str = '') -> list[str]:
lines: list[str] = [f'{root_pad}{self._label}']
if self._maxed_depth and self._is_expandable:
lines.append(f'{branch_pad}└── ...')
return lines
if not self: # empty
return lines
end: int = len(self)
if not self._overflowed:
end -= 1
for branch in self[:end]: # type: Subtree
lines.extend(branch._get_tree_lines(
max_lines=max_lines,
root_pad=f'{branch_pad}├── ',
branch_pad=f'{branch_pad}│ ',
))
if self._overflowed:
lines.append(f'{branch_pad}...')
else:
last_branch: Subtree = self[-1]
lines.extend(last_branch._get_tree_lines(
max_lines=max_lines,
root_pad=f'{branch_pad}└── ',
branch_pad=f'{branch_pad} ',
))
if len(lines) > max_lines:
del lines[int(max_lines) - 1:]
lines.append(' ...')
return lines
[docs] def to_dict(self, max_lines: float) -> str | dict[str, str | dict]:
"""Create a nested dict representing the type tree structure."""
if not self: # empty
return str(self._info)
branch: Subtree
return {
str(branch._info): branch.to_dict(max_lines)
for branch in self
}
def __eq__(self, other: object) -> bool:
"""Compare two subtrees.
Subtrees containing the same type structure are equivalent,
except for set-like nodes.
"""
if not isinstance(other, type(self)):
raise NotImplementedError
if self._hash != other._hash:
return False
if self._info.var_repr != other._info.var_repr:
return False
if self._info.key.type != other._info.key.type:
return False
if self._overflowed or self._maxed_depth:
return False
return super().__eq__(other)
def __lt__(self, other: object) -> bool:
"""Compare two subtrees for sorting."""
if not isinstance(other, type(self)):
raise NotImplementedError
if self == other:
return False
if self._info.key.type != other._info.key.type:
if self._info.key.type is None:
return True
if other._info.key.type is None:
return False
return self._info.key.type < other._info.key.type
if self._info.var_repr != other._info.var_repr:
return self._info.var_repr < other._info.var_repr
if len(self) != len(other):
return len(self) < len(other)
for x, y in zip(self, other):
if x != y:
return x < y
return self._hash < other._hash
def __str__(self) -> str:
"""Return the string representation of the subtree."""
return f'{self._info!s}{{...}}'
def __repr__(self) -> str:
"""Return the repr of the subtree."""
return f'{type(self).__name__}({self!s})'
def __hash__(self) -> int:
"""Return the hash of the subtree.
Equivalent (equal) subtrees always evaluate to the same hash.
"""
return self._hash
[docs]class Tree(Subtree):
"""Root node of the type tree representation of a Python object.
Root-only (:class:`Tree`) attributes:
:param depth: Maximum depth reached
:type depth: int
:param searches: Total number of nodes searches
:type searches: int
Inherited (:class:`Subtree`) attributes:
:param key: Key path from previous node to the current one
:type key: str
:param key_type: The key type used. Type `help(KeyType)` for more
info
:type key_type: KeyType
:param path: The key path from the root node to the current one
:type path: str
:param type: The type name of the object the node corresponds to
:type type: str
:param label: The full text displayed for the node
:type label: str
:param nodes: The number of inner nodes indexed
:type nodes: int
:param config: The settings used for generating the tree
:type config: Template
:param is_expandable: Flag for whether the node has inner content.
If `True`, the node will show as expandable in the tree view
:type is_expandable: bool
:param maxed_depth: Flag for maximum depth reached. If `True`, the
object the node refers to has inner content that was not indexed
:type maxed_depth: bool
:param overflowed: Flag indicating maximum branch exceeded. If
`True`, the node has more branches than indexed. Might be from
reaching either `max_search` or `max_branches`
:type overflowed: bool
"""
_depth: int
_searches: int
max_lines: float
def __new__(cls, obj: Any, *, key_text: str | None = None,
max_lines: float = _DEFAULT_MAX_LINES,
template: Type[Template] = Template, **kwargs):
"""Use breadth-first search to construct a new instance."""
# Since Tree inherits from tuple, which is immutable, the
# instance size must be pre-computed before the instance
# initialization. This process is done recursively with a
# breadth-first algorithm.
# noinspection PyArgumentList
config: Template = template(**kwargs)
info_tree: _InfoTree = _InfoTree(
obj, _NodeKey(KeyType.NONE, key_text), config,
nodes_searched=0, ancestors_ids=set(), depth=0
)
depth: int = 0
while not info_tree.is_complete:
info_tree.update()
depth += 1
tree: Tree = super().__new__(cls, info_tree)
tree._depth = depth
tree._searches = info_tree.nodes_searched
tree.max_lines = max_lines
return tree
[docs] def __init__(self, obj: Any, **kwargs):
"""Build a recursive object tree structure.
:param obj: Any Python object to be analysed
:type obj: Any
:param key_text: Placeholder text for the root key node.
Defaults to None
:type key_text: str, optional
:param template: A configuration template for common object
types. Currently supported: `Template` (default), `DOM`,
`HTML`, and `XML`
:param items_lookup: Function used to access the node's content.
Defaults to `lambda var: var`
:type items_lookup: Callable[[Any], Any], optional
:param type_name_lookup: Function used to get the type name.
Defaults to `lambda var: type(var).__name__`
:type type_name_lookup: Callable[[Any], Any], optional
:param value_lookup: Function used to get the value when the
node's content is empty (tree leaves). Defaults to
`lambda var: var`
:type value_lookup: Callable[[Any], Any], optional
:param sort_keys: Flag for sorting keys alphabetically. Defaults
to `True`
:type sort_keys: bool, optional
:param show_lengths: Flag for displaying sizes of iterables.
This affects how subtrees are grouped together, since
Sequences of different sizes but same content types are
considered equivalent. Defaults to `True`
:type show_lengths: bool, optional
:param include_attributes: Flag for including the mutable
attributes returned by `vars()`. Defaults to `True`
:type include_attributes: bool, optional
:param include_dir: Flag for including the attributes returned
by `dir()`, except the protected (`_protected`) and special
(`__special__`) ones. Defaults to `False`
:type include_dir: bool, optional
:param include_protected: Flag for including the protected
(`_protected`) attributes. Defaults to `False`
:type include_protected: bool, optional
:param include_special`: Flag for including the special
(`__special__`) attributes. Defaults to `False`
:type include_special: bool, optional
:param max_lines: Maximum number of lines to be printed For the
GUI, it is the maximum number of rows to be displayed, not
including the extra ellipsis at the end. Can be disabled by
setting it to infinity (`float('inf')` or `math.inf`).
Defaults to 1000
:type max_lines: float, optional
:param max_search: Maximum number of nodes searched. Defaults to
100,000
:type max_search: float, optional
:param max_depth: Maximum search depth. Defaults to 20
:type max_depth: float, optional
:param max_branches: Maximum number of branches displayed on
each node. This only applies after grouping. Defaults to
infinity
:type max_branches: float, optional
"""
super().__init__(obj, **kwargs)
@property
def depth(self) -> int:
"""Maximum depth reached."""
return self._depth
@property
def searches(self) -> int:
"""Total number of nodes searched."""
return self._searches
[docs] def to_dict(self, max_lines: float | None = None
) -> str | dict[str, str | dict]:
"""Return a nested `dict` representation of the type tree."""
if max_lines is None:
max_lines = self.max_lines
return super().to_dict(max_lines)
[docs] def to_json(self, *args, max_lines: float | None = None, **kwargs) -> str:
"""Return a JSON representation of the type tree."""
if max_lines is None:
max_lines = self.max_lines
return json.dumps(self.to_dict(max_lines), *args, **kwargs)
[docs] def save_as_json(self, file_path: str, *args,
max_lines: float | None = None,
encoding: str = 'utf-8',
ensure_ascii: bool = False,
indent: int = 4,
**kwargs):
"""Save a JSON representation of the type tree to a file."""
if max_lines is None:
max_lines = self.max_lines
with open(file_path, 'w', encoding=encoding) as file:
json.dump(self.to_dict(max_lines), file, *args,
ensure_ascii=ensure_ascii,
indent=indent,
**kwargs)
[docs] def save_as_text(self, file_path: str, encoding: str = 'utf-8', **kwargs):
"""Save the tree view as a text file."""
with open(file_path, 'w', encoding=encoding) as f:
f.write(self.to_string(**kwargs))
[docs] def to_string(self, max_lines: float | None = None,
verbose: bool = False) -> str:
"""Get a tree view of the object's type structure as a string.
:param max_lines: Maximum number of lines to be printed. Can be
disabled by setting it to infinity
:type max_lines: float, optional
:param verbose: Flag for printing extra information before
printing the tree view. Defaults to False
:type verbose: bool, optional
"""
lines: list[str] = []
if verbose:
lines.append('Nodes (indexed/searched): '
f'{self.nodes}/{self.searches}')
lines.append(f'Maximum depth: {self.depth}')
if max_lines is None:
max_lines = self.max_lines
lines.extend(self._get_tree_lines(max_lines, ' ', ' '))
return '\n'.join(lines)
[docs] def print(self, max_lines: float | None = None, verbose: bool = False):
"""Print a tree view of the object's type structure.
:param max_lines: Maximum number of lines to be printed. Can be
disabled by setting it to infinity
:type max_lines: float, optional
:param verbose: Flag for printing extra information before
printing the tree view. Defaults to False
:type verbose: bool, optional
"""
print(self.to_string(max_lines=max_lines, verbose=verbose))
[docs] def view(self, spawn_thread: bool = True, spawn_process: bool = False,
max_lines: float | None = None):
"""Show a tree view of the object's type structure in a GUI.
:param spawn_thread: Run the GUI in a separate thread
:type spawn_thread: bool
:param spawn_process: Run the GUI in a separate process
:type spawn_process: bool
:param max_lines: Maximum number of rows to be displayed, not
including the extra ellipsis at the end. Can be disabled by
setting it to infinity
"""
if max_lines is None:
max_lines = self.max_lines
tree_viewer(self, max_lines,
spawn_thread=spawn_thread,
spawn_process=spawn_process)
[docs]def print_tree(obj: Any, *, max_lines: float | None = None,
verbose: bool = False, **kwargs):
"""Print a tree view of the object's type structure.
:param obj: Any Python object to be analysed
:type obj: Any
:param max_lines: Maximum number of lines to be printed. Can be
disabled by setting it to infinity
:type max_lines: float, optional
:param verbose: Flag for printing extra information before printing
the tree view. Defaults to False
:type verbose: bool, optional
:param kwargs: Same as :class:`Tree`. Type `help(Tree.__init__)` for
the full list
"""
Tree(obj, **kwargs).print(max_lines=max_lines, verbose=verbose)
[docs]def view_tree(obj: Any, *,
spawn_thread: bool = True,
spawn_process: bool = False,
**kwargs):
"""Show a tree view of the object's type structure in a GUI.
:param obj: Any Python object to be analysed
:type obj: Any
:param spawn_thread: Run the GUI in a separate thread
:type spawn_thread: bool
:param spawn_process: Run the GUI in a separate process
:type spawn_process: bool
:param kwargs: Same as :class:`Tree`. Type `help(Tree.__init__)` for
the full list
"""
Tree(obj, **kwargs).view(spawn_thread=spawn_thread,
spawn_process=spawn_process)