# Copyright (c) 2024 Harim Kang
# SPDX-License-Identifier: MIT
from abc import ABC, abstractmethod
from io import StringIO
from pathlib import Path
from typing import Any, Protocol
from rich.console import Console
from rich.tree import Tree
from .types import ConfigDiff
[docs]
class ConfigData(Protocol):
"""Protocol for configuration data.
This protocol defines the required interface for configuration data objects
that can be visualized by the TreeVisualizer.
Attributes:
data: Dictionary containing the configuration data
source: Optional path to the source configuration file
"""
@property
def data(self) -> dict[str, Any]: ...
@property
def source(self) -> Path | None: ...
[docs]
class BaseVisualizer(ABC):
"""Base class for configuration visualizers.
This abstract class defines the interface that all visualizer implementations must follow.
It provides common functionality for displaying configuration data in different formats.
"""
[docs]
@abstractmethod
def visualize(self, data: Any) -> None:
"""Display data in the console.
Args:
data: The data to visualize. The specific type depends on the visualizer implementation.
"""
pass
[docs]
@abstractmethod
def to_string(self, data: Any) -> str:
"""Convert visualization to string.
Args:
data: The data to convert to string representation
Returns:
String representation of the visualized data
"""
pass
def _create_console(self, string_io: StringIO | None = None) -> Console:
"""Create a console instance for output.
Args:
string_io: Optional StringIO object to write output to instead of terminal
Returns:
Console instance configured for either terminal or string output
"""
return Console(file=string_io, force_terminal=False if string_io else True)
[docs]
class TreeVisualizer(BaseVisualizer):
"""Visualizer for configuration data in tree structure.
This visualizer displays configuration data as a colored tree structure,
with different colors for each level of nesting and different data types.
"""
[docs]
def __init__(self) -> None:
"""Initialize the TreeVisualizer with a predefined set of colors for different tree levels."""
self.level_colors = [
"turquoise2",
"orchid2",
"medium_spring_green",
"tan",
"deep_sky_blue3",
]
def _build_tree(self, tree: Tree, data: dict[str, Any], depth: int = 0) -> None:
"""Helper function to recursively build a rich.tree.Tree object.
Args:
tree: The Tree object to build upon
data: Dictionary containing configuration data
depth: Current depth in the tree hierarchy (used for color selection)
"""
color = self.level_colors[depth % len(self.level_colors)]
for key, value in data.items():
if isinstance(value, dict):
# Apply level-specific color for dictionary nodes
branch = tree.add(f"[bold {color}]{key}[/]")
self._build_tree(branch, value, depth + 1)
elif isinstance(value, list):
# Apply level-specific color for list nodes
branch = tree.add(f"[bold {color}]{key}[/]")
for i, item in enumerate(value):
if isinstance(item, dict):
item_branch = branch.add(f"[{color}][{i}][/]")
self._build_tree(item_branch, item, depth + 1)
else:
# Display list items in gray
formatted_value = self._format_value(item)
branch.add(f"[{color}][{i}][/]: {formatted_value}")
else:
# Apply level color for keys and type-specific color for values
formatted_value = self._format_value(value)
tree.add(f"[{color}]{key}[/]: {formatted_value}")
def _format_value(self, value: Any) -> str:
"""Format value with appropriate color based on its type.
Args:
value: The value to format
Returns:
A string with rich markup for colored formatting based on the value type:
- Numbers (int/float): white
- Strings: yellow with quotes
- None: dimmed "null"
- Booleans: blue lowercase
- Other types: red
"""
if isinstance(value, (int, float)):
return f"[white]{value}[/]"
elif isinstance(value, str):
return f'[yellow]"{value}"[/]'
elif value is None:
return "[dim]null[/]"
elif isinstance(value, bool):
return f"[blue]{str(value).lower()}[/]"
else:
return f"[red]{value}[/]"
def _create_tree(self, source: Path | None) -> Tree:
"""Create a new Tree object with the given source as root.
Args:
source: Optional source path to display as root node
Returns:
A new Tree instance with formatted root node and black guide lines
"""
return Tree(
f"[bold white]{source if source else 'Config'}[/]",
guide_style="bright_black",
)
[docs]
def visualize(self, config: ConfigData) -> None:
"""Display the configuration data as a tree in the console.
Args:
config: Configuration data object implementing the ConfigData protocol
"""
console = self._create_console()
tree = self._create_tree(config.source)
self._build_tree(tree, config.data)
console.print(tree)
[docs]
def to_string(self, config: ConfigData) -> str:
"""Convert the configuration tree visualization to a string.
Args:
config: Configuration data object implementing the ConfigData protocol
Returns:
String representation of the configuration tree visualization
"""
string_io = StringIO()
console = Console(file=string_io, force_terminal=False)
tree = self._create_tree(config.source)
self._build_tree(tree, config.data)
console.print(tree)
return string_io.getvalue()
[docs]
class DiffVisualizer(BaseVisualizer):
"""Visualizer for configuration differences.
This visualizer displays the differences between two configurations,
highlighting added, removed, and modified values in different colors.
"""
def _build_diff_tree(self, tree: Tree, data: dict[str, Any], style: str) -> None:
"""Helper function to recursively build a diff tree with specified style.
Args:
tree: The Tree object to build upon
data: Dictionary containing diff data
style: Color style to apply to the nodes (e.g., "green" for additions)
"""
for key, value in data.items():
if isinstance(value, dict):
branch = tree.add(f"[{style}]{key}[/]")
self._build_diff_tree(branch, value, style)
elif isinstance(value, list):
branch = tree.add(f"[{style}]{key}[/]")
for i, item in enumerate(value):
if isinstance(item, dict):
item_branch = branch.add(f"[{style}][{i}][/]")
self._build_diff_tree(item_branch, item, style)
else:
branch.add(f"[{style}][{i}]: {self._format_value(item)}[/]")
else:
tree.add(f"[{style}]{key}: {self._format_value(value)}[/]")
def _format_value(self, value: Any) -> str:
"""Format value with appropriate color based on its type."""
if isinstance(value, (int, float)):
return str(value)
elif isinstance(value, str):
return f'"{value}"'
elif value is None:
return "null"
elif isinstance(value, bool):
return str(value).lower()
else:
return str(value)
def _build_modified_tree(self, tree: Tree, data: dict[str, tuple[Any, Any]]) -> None:
"""Helper function to build a tree for modified values.
Args:
tree: The Tree object to build upon
data: Dictionary containing pairs of old and new values
where key maps to tuple of (old_value, new_value)
"""
for key, (old, new) in data.items():
branch = tree.add(f"[yellow]{key}[/]")
branch.add(f"[red]- {old}[/]")
branch.add(f"[green]+ {new}[/]")
[docs]
def visualize(self, diff: ConfigDiff) -> None:
"""Display the configuration differences in the console.
Args:
diff: ConfigDiff object containing added, removed, modified, and unchanged items
"""
console = self._create_console()
tree = Tree("Configuration Differences")
self._build_diff_sections(tree, diff)
console.print(tree)
[docs]
def to_string(self, diff: ConfigDiff) -> str:
"""Convert the configuration differences visualization to a string.
Args:
diff: ConfigDiff object containing added, removed, modified, and unchanged items
Returns:
String representation of the configuration differences visualization
"""
string_io = StringIO()
console = self._create_console(string_io)
tree = Tree("Configuration Differences")
self._build_diff_sections(tree, diff)
console.print(tree)
return string_io.getvalue()
def _build_diff_sections(self, tree: Tree, diff: ConfigDiff) -> None:
"""Build all sections of the diff tree.
Creates separate sections for added (green), removed (red), and modified (yellow) items.
Each section is color-coded and contains the respective changes.
Args:
tree: The Tree object to build upon
diff: ConfigDiff object containing the differences to visualize
"""
if diff.added:
added = tree.add("[green]Added[/]")
self._build_diff_tree(added, diff.added, "green")
if diff.removed:
removed = tree.add("[red]Removed[/]")
self._build_diff_tree(removed, diff.removed, "red")
if diff.modified:
modified = tree.add("[yellow]Modified[/]")
self._build_modified_tree(modified, diff.modified)
# TODO: How to visualize unchanged?
# if diff.unchanged:
# unchanged = tree.add("[bright_black]Unchanged[/]")
# self._build_diff_tree(unchanged, diff.unchanged, "bright_black")