examples: add bar_chart demos and outputs

This commit is contained in:
Daeseok Bae 2025-11-24 10:28:06 +09:00
parent a6cd41a7cb
commit 99e9fa65a8
8 changed files with 643 additions and 0 deletions

34
demo_after.py Normal file
View file

@ -0,0 +1,34 @@
"""Demo: behavior AFTER adding explicit markup validation.
This script validates the same broken markup string using
`rich.markup_validator.MarkupValidator`. If validation fails a
`rich.errors.MarkupError` is caught and a clear alert is printed.
Run with: python demo_after.py
"""
from rich.console import Console
from rich.markup_validator import MarkupValidator
from rich.errors import MarkupError
broken_text = "[bold]Hello[/dim]"
def main() -> None:
console = Console()
validator = MarkupValidator()
try:
# Validate markup first. This will raise MarkupError for mismatches.
validator.validate(broken_text)
except MarkupError as exc:
console.print("[bold red]🚨 Validation Failed![/]")
console.print(f"[red]{exc}[/]")
else:
# Only print if validation passes
console.print("-- Validation passed; now printing --")
console.print(broken_text)
if __name__ == "__main__":
main()

30
demo_before.py Normal file
View file

@ -0,0 +1,30 @@
"""Demo: behavior BEFORE adding explicit markup validation.
This script prints a broken markup string directly with `rich.Console.print`.
It demonstrates the "silent failure" behavior where mismatched tags do not
raise an exception and the renderer may ignore or render them literally.
Run with: python demo_before.py
"""
from rich.console import Console
broken_text = "[bold]Hello[/dim]"
def main() -> None:
console = Console()
# Intentionally printing a string with mismatched tags WITHOUT validation.
# In this demo we do not use MarkupValidator, so Console.print will not raise
# an exception; it will either ignore the bad closing tag or render it
# depending on the environment. This illustrates a silent failure.
console.print("-- Printing without validation (silent failure expected) --")
console.print(broken_text)
# Also print a plain notification so the behavior is explicit when run
print("Printed without validation — no exception thrown (silent failure).")
if __name__ == "__main__":
main()

12
demo_file_explorer.py Normal file
View file

@ -0,0 +1,12 @@
from rich.console import Console
from rich.panel import Panel
from rich.file_explorer import FileExplorer # 방금 만든 모듈
console = Console()
# 1. 현재 디렉토리(.) 탐색
console.print(Panel(FileExplorer("."), title="📂 현재 프로젝트 구조", border_style="blue"))
# 2. 특정 폴더 무시하고 탐색 (예: __pycache__, .git)
# ignore_list = [".git", "__pycache__", ".vscode"]
# console.print(Panel(FileExplorer(".", ignore=ignore_list), title="Clean View", border_style="green"))

0
examples-bar_output.txt Normal file
View file

105
examples/bar_chart.py Normal file
View file

@ -0,0 +1,105 @@
"""
Example of using BarChart to display data.
Run this example with:
python examples/bar_chart.py
"""
from rich import print
from rich.bar_chart import BarChart
from rich.panel import Panel
# Example 1: Using a dictionary
print("\n[bold]Example 1: Sales Data (Dictionary)[/bold]")
sales_data = {
"Q1": 45000,
"Q2": 52000,
"Q3": 48000,
"Q4": 61000,
}
chart1 = BarChart(sales_data, width=50)
print(chart1)
# Example 2: Using a list of tuples
print("\n[bold]Example 2: Population by City (List of Tuples)[/bold]")
population_data = [
("Seoul", 9.7),
("Busan", 3.4),
("Incheon", 2.9),
("Daegu", 2.4),
("Daejeon", 1.5),
]
chart2 = BarChart(population_data, width=50, max_value=10.0)
print(chart2)
# Example 3: Using custom styles
print("\n[bold]Example 3: Custom Colors[/bold]")
chart3 = BarChart(
{"Apples": 30, "Oranges": 25, "Bananas": 20, "Grapes": 15},
width=50,
bar_styles=["red", "yellow", "green", "magenta"],
label_style="bold",
value_style="dim",
)
print(chart3)
# Example 4: Without values
print("\n[bold]Example 4: Without Value Labels[/bold]")
chart4 = BarChart(
{"Jan": 100, "Feb": 150, "Mar": 120, "Apr": 180},
width=50,
show_values=False,
style="cyan",
)
print(chart4)
# Example 5: In a Panel
print("\n[bold]Example 5: Chart in a Panel[/bold]")
chart5 = BarChart(
{"Python": 85, "JavaScript": 75, "Java": 70, "C++": 65},
width=50,
bar_styles=["blue", "yellow", "red", "green"],
)
panel = Panel(chart5, title="Programming Language Popularity", border_style="blue")
print(panel)
print("\n[bold]Example 6: Vertical Bar Chart[/bold]")
vertical = BarChart(
{"Jan": 3, "Feb": 7, "Mar": 5, "Apr": 9},
orientation="vertical",
chart_height=8,
bar_width=2,
bar_styles=["cyan", "magenta", "yellow", "green"],
label_style="bold",
)
print(vertical)
print("\n[bold]Example 7: Grouped Horizontal Bars[/bold]")
grouped_quarters = {
"Q1": {"North": 32, "South": 28, "East": 25},
"Q2": {"North": 38, "South": 30, "East": 29},
"Q3": {"North": 34, "South": 35, "East": 30},
"Q4": {"North": 40, "South": 37, "East": 33},
}
chart7 = BarChart(
grouped_quarters,
width=70,
show_values=True,
bar_width=2,
group_styles=["red", "green", "cyan"],
label_style="bold",
value_style="dim",
)
print(chart7)
print("\n[bold]Example 8: Grouped Vertical Bars[/bold]")
chart8 = BarChart(
grouped_quarters,
orientation="vertical",
chart_height=12,
bar_width=2,
group_gap=2,
group_styles=["red", "green", "cyan"],
label_style="bold",
)
print(chart8)

10
markup-before-fix.txt Normal file
View file

@ -0,0 +1,10 @@
ERROR: file or directory not found: tests/test_markup_validator.py
============================= test session starts =============================
platform win32 -- Python 3.11.9, pytest-9.0.1, pluggy-1.6.0
rootdir: C:\Users\BAE\rich-1
configfile: pyproject.toml
plugins: anyio-4.10.0
collected 0 items
============================ no tests ran in 0.01s ============================

0
rich-bar_output.txt Normal file
View file

452
rich/bar_chart.py Normal file
View file

@ -0,0 +1,452 @@
"""Bar chart renderable for Rich."""
from collections.abc import Mapping, Sequence
from typing import Dict, List, Optional, Tuple, Union
from .bar import END_BLOCK_ELEMENTS, FULL_BLOCK
from .console import Console, ConsoleOptions, RenderResult
from .jupyter import JupyterMixin
from .measure import Measurement
from .segment import Segment
from .style import Style, StyleType
DEFAULT_COLORS = [
"blue",
"green",
"yellow",
"magenta",
"cyan",
"red",
"bright_blue",
"bright_green",
]
class BarChart(JupyterMixin):
"""Render a flexible bar chart in the terminal.
Args:
data (Union[Dict[str, float], List[Tuple[str, float]], Sequence[float]]):
Data to display. Can be:
- Dict mapping labels to values
- Dict mapping labels to dicts of grouped values
- List of (label, value) tuples
- Sequence of values (labels will be auto-generated)
width (int, optional): Width of the chart, or None for maximum width. Defaults to None.
max_value (float, optional): Maximum value for scaling. If None, uses max value from data. Defaults to None.
show_values (bool, optional): Show values on bars (horizontal only). Defaults to True.
bar_width (int, optional): Width of each bar in characters. Defaults to 1.
orientation (str, optional): Either ``"horizontal"`` or ``"vertical"``. Defaults to ``"horizontal"``.
chart_height (int, optional): Height (in rows) of a vertical chart. Defaults to 10 if not given.
group_labels (Sequence[str], optional): Optional ordering of grouped series labels.
group_styles (Sequence[StyleType], optional): Styles for grouped series (cycled if fewer than groups).
group_gap (int, optional): Number of spaces between grouped bars. Defaults to 1.
style (StyleType, optional): Default style for bars. Defaults to None.
bar_styles (Sequence[StyleType], optional): Styles for each single bar (cycled). Defaults to None.
label_style (StyleType, optional): Style for labels. Defaults to None.
value_style (StyleType, optional): Style for values. Defaults to None.
"""
def __init__(
self,
data: Union["Dict[str, float]", List[Tuple[str, float]], Sequence[float]],
*,
width: Optional[int] = None,
max_value: Optional[float] = None,
show_values: bool = True,
bar_width: int = 1,
orientation: str = "horizontal",
chart_height: Optional[int] = None,
group_labels: Optional[Sequence[str]] = None,
group_styles: Optional[Sequence[StyleType]] = None,
group_gap: int = 1,
style: Optional[StyleType] = None,
bar_styles: Optional[Sequence[StyleType]] = None,
label_style: Optional[StyleType] = None,
value_style: Optional[StyleType] = None,
):
items = self._coerce_items(data)
if not items:
raise ValueError("Data cannot be empty")
self.width = width
self.show_values = show_values
self.bar_width = max(1, bar_width)
self.orientation = orientation
self.chart_height = chart_height
self.style = style
self.bar_styles = list(bar_styles) if bar_styles else None
self.label_style = label_style
self.value_style = value_style
self.group_gap = max(0, group_gap)
self._explicit_group_labels = list(group_labels) if group_labels else None
self.group_styles = list(group_styles) if group_styles else None
self.grouped = False
self.group_labels: List[str] = []
self.simple_rows: List[Tuple[str, float]] = []
self.group_rows: List[Tuple[str, List[float]]] = []
self._normalize_items(items)
self._set_max_value(max_value)
# --------------------------------------------------------------------- #
# Initialization helpers
# --------------------------------------------------------------------- #
def _coerce_items(
self,
data: Union["Dict[str, float]", List[Tuple[str, float]], Sequence[float]],
) -> List[Tuple[Union[str, int], Union[float, Mapping[str, float]]]]:
if isinstance(data, Mapping):
return list(data.items())
if isinstance(data, Sequence):
if not data:
return []
first = data[0]
if isinstance(first, tuple) and len(first) == 2:
return list(data) # type: ignore[list-item]
return [(index, value) for index, value in enumerate(data)]
raise TypeError("BarChart data must be a mapping or sequence")
def _normalize_items(
self,
items: List[Tuple[Union[str, int], Union[float, Mapping[str, float]]]],
) -> None:
first_value = items[0][1]
if isinstance(first_value, Mapping):
self.grouped = True
self._normalize_grouped_items(items) # type: ignore[arg-type]
else:
self.simple_rows = [
(str(label), float(value)) for label, value in items # type: ignore[arg-type]
]
def _normalize_grouped_items(
self,
items: List[Tuple[Union[str, int], Mapping[str, float]]],
) -> None:
groups: List[str] = []
seen = set()
if self._explicit_group_labels:
groups.extend(str(name) for name in self._explicit_group_labels)
seen.update(groups)
normalized_rows: List[Tuple[str, Dict[str, float]]] = []
for label, mapping in items:
label_str = str(label)
row_dict: Dict[str, float] = {}
for key, value in mapping.items():
key_str = str(key)
row_dict[key_str] = float(value)
if not self._explicit_group_labels and key_str not in seen:
groups.append(key_str)
seen.add(key_str)
normalized_rows.append((label_str, row_dict))
if not groups:
raise ValueError("Grouped data requires at least one series")
self.group_labels = groups
for label, mapping in normalized_rows:
row_values = [mapping.get(group, 0.0) for group in self.group_labels]
self.group_rows.append((label, row_values))
def _set_max_value(self, max_value: Optional[float]) -> None:
values: List[float]
if self.grouped:
values = [
value
for _, row in self.group_rows
for value in row
]
else:
values = [value for _, value in self.simple_rows]
if not values:
values = [0.0]
computed_max = max(values)
if computed_max <= 0:
computed_max = 1.0
self.max_value = max_value if max_value is not None else computed_max
if self.max_value <= 0:
self.max_value = 1.0
# --------------------------------------------------------------------- #
# Rendering helpers
# --------------------------------------------------------------------- #
def _get_label_style(self) -> Optional[Style]:
return Style.parse(str(self.label_style)) if self.label_style else None
def _get_value_style(self) -> Optional[Style]:
return Style.parse(str(self.value_style)) if self.value_style else None
def _get_bar_style(self, index: int) -> Style:
if self.bar_styles:
raw_style = self.bar_styles[index % len(self.bar_styles)]
elif self.style:
raw_style = self.style
else:
raw_style = DEFAULT_COLORS[index % len(DEFAULT_COLORS)]
return Style.parse(str(raw_style))
def _get_group_style(self, index: int) -> Style:
if self.group_styles:
raw_style = self.group_styles[index % len(self.group_styles)]
elif self.bar_styles:
raw_style = self.bar_styles[index % len(self.bar_styles)]
elif self.style:
raw_style = self.style
else:
raw_style = DEFAULT_COLORS[index % len(DEFAULT_COLORS)]
return Style.parse(str(raw_style))
def _bar_length(self, value: float, bar_area_width: int) -> int:
if self.max_value <= 0:
return 0
return int((value / self.max_value) * bar_area_width)
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
if self.orientation == "vertical":
yield from self._render_vertical(console, options)
else:
yield from self._render_horizontal(console, options)
# ------------------------------------------------------------------ #
# Horizontal rendering
# ------------------------------------------------------------------ #
def _render_horizontal(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
if self.grouped:
yield from self._render_horizontal_grouped(console, options)
return
available_width = min(
self.width if self.width is not None else options.max_width,
options.max_width,
)
max_label_len = max(len(label) for label, _ in self.simple_rows)
label_width = max_label_len + 2
bar_area_width = available_width - label_width
if self.show_values:
bar_area_width -= 12
if bar_area_width < 1:
bar_area_width = 1
label_style = self._get_label_style()
value_style = self._get_value_style()
for idx, (label, value) in enumerate(self.simple_rows):
bar_style = self._get_bar_style(idx)
bar_length = self._bar_length(value, bar_area_width)
bar_text = self._make_horizontal_bar(bar_length)
value_text = ""
if self.show_values:
value_text = f" {value:.2f}"
label_padding = " " * (label_width - len(label) - 1)
line_segments: List[Segment] = [
Segment(label_padding),
Segment(label, label_style),
Segment(" "),
Segment(bar_text, bar_style),
]
if value_text:
line_segments.append(Segment(value_text, value_style))
line_segments.append(Segment.line())
yield from line_segments
def _render_horizontal_grouped(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
available_width = min(
self.width if self.width is not None else options.max_width,
options.max_width,
)
max_category_len = max(len(label) for label, _ in self.group_rows)
label_width = max_category_len + 1
max_group_len = max(len(name) for name in self.group_labels)
group_width = max_group_len + 1
bar_area_width = available_width - label_width - group_width - 2
if self.show_values:
bar_area_width -= 12
if bar_area_width < 1:
bar_area_width = 1
label_style = self._get_label_style()
value_style = self._get_value_style()
for label, values in self.group_rows:
for group_index, value in enumerate(values):
bar_length = self._bar_length(value, bar_area_width)
bar_style = self._get_group_style(group_index)
category_column = label if group_index == 0 else ""
category_column = category_column.ljust(label_width)
group_name = self.group_labels[group_index]
group_column = group_name.ljust(group_width)
line_segments: List[Segment] = [
Segment(category_column, label_style if category_column.strip() else None),
Segment(" "),
Segment(group_column, label_style),
Segment(" "),
Segment(self._make_horizontal_bar(bar_length), bar_style),
]
if self.show_values:
line_segments.append(Segment(f" {value:.2f}", value_style))
line_segments.append(Segment.line())
yield from line_segments
# Blank line between categories for readability
yield Segment.line()
yield from self._render_group_legend()
def _make_horizontal_bar(self, bar_length: int) -> str:
full_blocks = bar_length // self.bar_width
remainder = bar_length % self.bar_width
bar_chars = FULL_BLOCK * full_blocks
if remainder > 0 and remainder < len(END_BLOCK_ELEMENTS):
bar_chars += END_BLOCK_ELEMENTS[remainder]
return bar_chars or ""
# ------------------------------------------------------------------ #
# Vertical rendering
# ------------------------------------------------------------------ #
def _render_vertical(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
if self.grouped:
yield from self._render_vertical_grouped(console, options)
return
chart_height = self._get_chart_height(options)
label_style = self._get_label_style()
bar_heights = [
self._bar_length(value, chart_height) for _, value in self.simple_rows
]
bar_styles = [self._get_bar_style(idx) for idx in range(len(bar_heights))]
gap = 1
for row in range(chart_height, 0, -1):
segments: List[Segment] = []
for idx, height in enumerate(bar_heights):
if height >= row:
segments.append(Segment(FULL_BLOCK * self.bar_width, bar_styles[idx]))
else:
segments.append(Segment(" " * self.bar_width))
if idx != len(bar_heights) - 1:
segments.append(Segment(" " * gap))
segments.append(Segment.line())
yield from segments
label_segments: List[Segment] = []
for idx, (label, _value) in enumerate(self.simple_rows):
label_str = self._fit_label(label)
label_segments.append(Segment(label_str, label_style))
if idx != len(self.simple_rows) - 1:
label_segments.append(Segment(" " * gap))
label_segments.append(Segment.line())
yield from label_segments
def _render_vertical_grouped(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
chart_height = self._get_chart_height(options)
label_style = self._get_label_style()
group_count = len(self.group_labels)
group_gap = self.group_gap
category_gap = group_gap + 1
heights: List[List[int]] = []
for label, values in self.group_rows:
heights.append(
[self._bar_length(value, chart_height) for value in values]
)
for row in range(chart_height, 0, -1):
segments: List[Segment] = []
for cat_idx, category_heights in enumerate(heights):
for group_index, bar_height in enumerate(category_heights):
if bar_height >= row:
style = self._get_group_style(group_index)
segments.append(Segment(FULL_BLOCK * self.bar_width, style))
else:
segments.append(Segment(" " * self.bar_width))
if group_index != group_count - 1:
segments.append(Segment(" " * group_gap))
if cat_idx != len(heights) - 1:
segments.append(Segment(" " * category_gap))
segments.append(Segment.line())
yield from segments
category_width = group_count * self.bar_width + (group_count - 1) * group_gap
label_segments: List[Segment] = []
for cat_idx, (label, _values) in enumerate(self.group_rows):
label_text = self._center_text(label, category_width)
label_segments.append(Segment(label_text, label_style))
if cat_idx != len(self.group_rows) - 1:
label_segments.append(Segment(" " * category_gap))
label_segments.append(Segment.line())
yield from label_segments
yield from self._render_group_legend()
def _render_group_legend(self) -> RenderResult:
if not self.grouped or not self.group_labels:
return
segments: List[Segment] = []
for idx, group in enumerate(self.group_labels):
style = self._get_group_style(idx)
segments.append(Segment(FULL_BLOCK * self.bar_width, style))
segments.append(Segment(f" {group} "))
segments.append(Segment.line())
yield from segments
# ------------------------------------------------------------------ #
# Utilities
# ------------------------------------------------------------------ #
def _get_chart_height(self, options: ConsoleOptions) -> int:
default_height = 10
max_height = options.height if options.height is not None else options.size.height
chart_height = self.chart_height or min(default_height, max_height)
return max(1, chart_height)
def _fit_label(self, label: str) -> str:
if len(label) > self.bar_width:
return label[: self.bar_width]
return label.ljust(self.bar_width)
def _center_text(self, text: str, width: int) -> str:
if width <= len(text):
return text[:width]
padding = width - len(text)
left = padding // 2
right = padding - left
return f"{' ' * left}{text}{' ' * right}"
def __rich_measure__(
self, console: Console, options: ConsoleOptions
) -> Measurement:
if self.width is not None:
return Measurement(self.width, self.width)
return Measurement(20, options.max_width)