Source code for Karana.KUtils.visjs._python_reference

# Copyright (c) 2024-2026 Karana Dynamics Pty Ltd. All rights reserved.
#
# NOTICE TO USER:
#
# This source code and/or documentation (the "Licensed Materials") is
# the confidential and proprietary information of Karana Dynamics Inc.
# Use of these Licensed Materials is governed by the terms and conditions
# of a separate software license agreement between Karana Dynamics and the
# Licensee ("License Agreement"). Unless expressly permitted under that
# agreement, any reproduction, modification, distribution, or disclosure
# of the Licensed Materials, in whole or in part, to any third party
# without the prior written consent of Karana Dynamics is strictly prohibited.
#
# THE LICENSED MATERIALS ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
# KARANA DYNAMICS DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
# BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, NON-INFRINGEMENT, AND
# FITNESS FOR A PARTICULAR PURPOSE.
#
# IN NO EVENT SHALL KARANA DYNAMICS BE LIABLE FOR ANY DAMAGES WHATSOEVER,
# INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, OR USE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGES, WHETHER IN CONTRACT, TORT,
# OR OTHERWISE ARISING OUT OF OR IN CONNECTION WITH THE LICENSED MATERIALS.
#
# U.S. Government End Users: The Licensed Materials are a "commercial item"
# as defined at 48 C.F.R. 2.101, and are provided to the U.S. Government
# only as a commercial end item under the terms of this license.
#
# Any use of the Licensed Materials in individual or commercial software must
# include, in the user documentation and internal source code comments,
# this Notice, Disclaimer, and U.S. Government Use Provision.

"""Tools for viewing Python object referrers with vis-network.js."""

from collections import deque
from collections.abc import Callable
import gc
import types
import weakref

import Karana.Core as kc

from ._datatypes import (
    ArrowOptions,
    ArrowStyle,
    Edge,
    EdgeColorOptions,
    EdgeOptions,
    NetworkGraph,
    NetworkOptions,
    Node,
    NodeColorOptions,
    NodeFontOptions,
    NodeOptions,
    SmoothOptions,
)
from ._server import GraphServer

__all__ = ["PythonReferenceGraphServer"]


_TARGET_COLOR = "#FFB4A2"
_DIRECT_REFERRER_COLOR = "#90CAF9"
_INDIRECT_REFERRER_COLOR = "#DDEAF7"
_COLLECTED_COLOR = "#BDBDBD"
_EDGE_COLOR = "#607D8B"
_HIGHLIGHT_COLOR = "#FF00FF"
_MAX_TEXT_LENGTH = 160


def _safeRepr(obj: object, max_length: int = _MAX_TEXT_LENGTH) -> str:
    """Return a bounded representation that tolerates broken repr methods."""
    try:
        text = repr(obj)
    except Exception as error:
        text = f"<repr failed: {type(error).__name__}: {error}>"
    if len(text) > max_length:
        return f"{text[: max_length - 3]}..."
    return text


def _qualifiedTypeName(obj: object) -> str:
    """Return the object's module-qualified type name."""
    object_type = type(obj)
    return f"{object_type.__module__}.{object_type.__qualname__}"


def _objectLabel(obj: object, *, target_label: str | None = None) -> str:
    """Create a compact node label for an arbitrary Python object."""
    if target_label is not None:
        label = target_label
    elif isinstance(obj, kc.Base):
        label = f"{obj.typeString()}\n{obj.name()}"
    elif isinstance(obj, types.FrameType):
        label = f"frame\n{obj.f_code.co_name}"
    elif isinstance(obj, types.ModuleType):
        label = f"module\n{obj.__name__}"
    elif isinstance(obj, dict):
        namespace = obj.get("__name__")
        if isinstance(namespace, str) and "__builtins__" in obj:
            label = f"namespace\n{namespace}"
        else:
            label = f"dict ({len(obj)} items)"
    elif isinstance(obj, (list, tuple, set, frozenset, deque)):
        label = f"{type(obj).__name__} ({len(obj)} items)"
    else:
        label = type(obj).__name__
    return label


def _objectTitle(obj: object) -> str:
    """Create detailed tooltip text without retaining the object."""
    lines = [
        _qualifiedTypeName(obj),
        f"python_id=0x{id(obj):x}",
    ]
    if isinstance(obj, kc.Base):
        lines.extend(
            (
                f"kdflex_id={obj.id()}",
                f"name={obj.name()}",
            )
        )
    if isinstance(obj, types.FrameType):
        lines.extend(
            (
                f"function={obj.f_code.co_name}",
                f"file={obj.f_code.co_filename}:{obj.f_lineno}",
            )
        )
    else:
        lines.append(f"repr={_safeRepr(obj)}")
    return "\n".join(lines)


def _nodeFor(
    obj: object,
    *,
    depth: int,
    max_depth: int,
    target_label: str | None = None,
) -> Node:
    """Create a node without storing the referenced Python object."""
    if depth == 0:
        color = _TARGET_COLOR
        shape = "diamond"
    elif depth == 1:
        color = _DIRECT_REFERRER_COLOR
        shape = "box"
    else:
        color = _INDIRECT_REFERRER_COLOR
        shape = "box"
    return Node(
        id=id(obj),
        label=_objectLabel(obj, target_label=target_label),
        title=_objectTitle(obj),
        options=NodeOptions(
            shape=shape,
            level=max_depth - depth,
            borderWidth=2,
            color=NodeColorOptions(
                background=color,
                border=color,
                highlight={"border": _HIGHLIGHT_COLOR, "background": color},
            ),
            font=NodeFontOptions(color="#FFFFFF", size=12, face="Arial"),
        ),
    )


def _matchingNames(mapping: dict, referent: object) -> list[str]:
    """Return bounded key names whose values are the referent."""
    names = []
    for key, value in mapping.items():
        if value is referent:
            names.append(_safeRepr(key, max_length=50))
            if len(names) == 4:
                break
    return names


def _relationLabel(owner: object, referent: object) -> str:
    """Describe how an owner directly references the referent."""
    if isinstance(owner, dict):
        names = _matchingNames(owner, referent)
        return ", ".join(names) if names else "value"
    if isinstance(owner, (list, tuple, deque)):
        indices = [str(index) for index, value in enumerate(owner) if value is referent][:4]
        return f"index {', '.join(indices)}" if indices else "element"
    if isinstance(owner, (set, frozenset)):
        return "member"
    if isinstance(owner, types.FrameType):
        names = _matchingNames(owner.f_locals, referent)
        return ", ".join(names) if names else "local"
    if isinstance(owner, types.CellType):
        return "closure cell"
    try:
        attributes = _matchingNames(vars(owner), referent)
    except TypeError:
        attributes = []
    return ", ".join(attributes) if attributes else "reference"


def _isInternalReferrer(obj: object, ignored_ids: set[int]) -> bool:
    """Exclude traversal artifacts and this module's own execution frames."""
    if id(obj) in ignored_ids:
        return True
    if isinstance(obj, types.FrameType):
        return obj.f_globals.get("__name__") == __name__
    if isinstance(obj, dict):
        return obj.get("__name__") == __name__
    return False


def _isTerminalOwner(obj: object) -> bool:
    """Avoid expanding interpreter roots that produce low-value fan-out."""
    if isinstance(obj, (types.FrameType, types.ModuleType)):
        return True
    return isinstance(obj, dict) and isinstance(obj.get("__name__"), str) and "__builtins__" in obj


def _targetHandle(target: object) -> Callable[[], object | None]:
    """Create a non-owning target handle appropriate for the object."""
    if isinstance(target, kc.Base):
        return kc.CppWeakRefBase(target)
    try:
        return weakref.ref(target)
    except TypeError as error:
        raise TypeError(
            "PythonReferenceGraphServer requires a weak-referenceable target. "
            "kdFlex Base objects and ordinary user-defined class instances are "
            "supported; built-in containers such as list and dict are not."
        ) from error


# The graph server stores traversal limits and a non-owning target handle.
class PythonReferenceGraphServer(GraphServer):
    """GraphServer showing which Python objects retain a target object.

    Every edge points from an owner to the object it directly references.
    Traversal proceeds away from the target through ``gc.get_referrers``.
    """

    def __init__(
        self,
        target: object,
        *,
        port: int = 0,
        title: str = "Python reference owners",
        target_label: str | None = None,
        max_depth: int = 2,
        max_nodes: int = 100,
        network_options: NetworkOptions | None = None,
    ):
        """Create and bind a PythonReferenceGraphServer.

        Parameters
        ----------
        target : object
            Weak-referenceable object whose Python referrers should be shown.
            kdFlex Base objects use ``CppWeakRefBase`` automatically.
        port : int
            Port to bind. Port 0 selects an arbitrary free port.
        title : str
            Graph title.
        target_label : str | None
            Optional display label for the target node.
        max_depth : int
            Maximum number of referrer links to traverse from the target.
        max_nodes : int
            Maximum number of nodes, including the target.
        network_options : NetworkOptions | None
            visjs network options. Default options are used when omitted.
        """
        if max_depth < 0:
            raise ValueError("max_depth must be greater than or equal to zero")
        if max_nodes < 1:
            raise ValueError("max_nodes must be greater than or equal to one")

        self._target_ref = _targetHandle(target)
        self._target_id = id(target)
        self._title = title
        self._target_label = target_label
        self._max_depth = max_depth
        self._max_nodes = max_nodes
        self._network_options = NetworkOptions() if network_options is None else network_options
        self.nodes: dict[int | str, Node] = {}
        self.edges: dict[str, Edge] = {}
        self.truncated = False
        self.graph = NetworkGraph()
        self.initial_graph_pending = True

        # Do not call gc.get_referrers() while target is still present in this
        # constructor's temporary call arguments. The first client connection
        # performs the full traversal after those objects have been released.
        self.nodes = {
            id(target): _nodeFor(
                target,
                depth=0,
                max_depth=self._max_depth,
                target_label=self._target_label,
            )
        }
        self._updateGraph()
        super().__init__(graph=self.graph, port=port, buttons=[])

    def _ensureInitialGraph(self):
        """Build the first full graph after constructor temporaries are gone."""
        if not self.initial_graph_pending:
            return
        self._buildGraph()
        self.initial_graph_pending = False

[docs] def onConnect(self, client_id: int): """Build a clean initial graph before initializing the first client.""" self._ensureInitialGraph() super().onConnect(client_id)
def _buildGraph(self, target: object | None = None): """Rebuild the graph without retaining any traversed objects.""" if target is None: target = self._target_ref() if target is None: collected_node = Node( id=f"collected-{self._target_id}", label=self._target_label or "Target collected", title=f"Former python_id=0x{self._target_id:x}", options=NodeOptions( shape="diamond", color=NodeColorOptions( background=_COLLECTED_COLOR, border=_COLLECTED_COLOR, ), ), ) self.nodes = {collected_node.id: collected_node} self.edges = {} self.truncated = False self._updateGraph() return nodes: dict[int | str, Node] = { id(target): _nodeFor( target, depth=0, max_depth=self._max_depth, target_label=self._target_label, ) } edges: dict[str, Edge] = {} seen_ids = {id(target)} pending = deque([(target, 0)]) ignored_ids = { id(self), id(nodes), id(edges), id(seen_ids), id(pending), } self.truncated = False while pending: current, depth = pending.popleft() if depth >= self._max_depth: continue referrers = gc.get_referrers(current) ignored_ids.add(id(referrers)) for owner in referrers: owner_id = id(owner) if _isInternalReferrer(owner, ignored_ids): continue if owner_id not in nodes and len(nodes) >= self._max_nodes: self.truncated = True continue owner_depth = depth + 1 if owner_id not in nodes: nodes[owner_id] = _nodeFor( owner, depth=owner_depth, max_depth=self._max_depth, ) edge_id = f"pyref-{owner_id}-{id(current)}" edges[edge_id] = Edge( from_=owner_id, to=id(current), id=edge_id, title=_relationLabel(owner, current), options=EdgeOptions( label=_relationLabel(owner, current), color=EdgeColorOptions( color=_EDGE_COLOR, highlight=_HIGHLIGHT_COLOR, ), width=2, arrows=ArrowOptions( to=ArrowStyle(enabled=True, scaleFactor=1.1), ), smooth=SmoothOptions( type="cubicBezier", roundness=0.25, ), ), ) if owner_id not in seen_ids and not _isTerminalOwner(owner): seen_ids.add(owner_id) pending.append((owner, owner_depth)) del referrers self.nodes = nodes self.edges = edges self._updateGraph() def _updateGraph(self): """Update the stored NetworkGraph from the current node and edge maps.""" title = f"{self._title} (truncated)" if self.truncated else self._title self.graph = NetworkGraph( nodes=list(self.nodes.values()), edges=list(self.edges.values()), title=title, options=self._network_options, )
[docs] def refresh(self): """Rebuild and broadcast the current Python referrer graph.""" self._buildGraph() self.initial_graph_pending = False self.updateClientGraphs()
[docs] def maxDepth(self) -> int: """Return the current maximum traversal depth.""" return self._max_depth
[docs] def maxNodes(self) -> int: """Return the current maximum node count.""" return self._max_nodes
[docs] def setMaxDepth(self, max_depth: int): """Set the maximum traversal depth, rebuild, and broadcast the graph.""" if max_depth < 0: raise ValueError("max_depth must be greater than or equal to zero") self._max_depth = max_depth self.refresh()
[docs] def setMaxNodes(self, max_nodes: int): """Set the maximum node count, rebuild, and broadcast the graph.""" if max_nodes < 1: raise ValueError("max_nodes must be greater than or equal to one") self._max_nodes = max_nodes self.refresh()