Files
2026-07-13 13:17:40 +08:00

484 lines
16 KiB
Python

from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
import numpy as np
import ray
from ray.data._internal.logical.interfaces import SourceOperator
from ray.data.block import Block, BlockAccessor, BlockMetadata
from ray.exceptions import RayError
from ray.types import ObjectRef
if TYPE_CHECKING:
from ray.data._internal.logical.interfaces.operator import Operator
from ray.data.dataset import Dataset, Schema
_DATASET_REPR_ELLIPSIS = "…" # Ellipsis marker for truncated cells/rows.
_DATASET_REPR_MAX_ROWS = 10 # Total preview row budget when materialized.
_DATASET_REPR_HEAD_ROWS = 5 # Number of head rows to show before the gap.
_DATASET_REPR_MAX_COLUMN_WIDTH = 40 # Max width per column cell in the table.
_DATASET_REPR_GET_TIMEOUT_S = 30.0 # Timeout for fetching preview blocks.
__all__ = [
"build_dataset_ascii_repr",
"build_dataset_summary_repr",
]
def build_dataset_ascii_repr(
dataset: "Dataset",
schema: "Schema",
is_materialized: bool,
) -> str:
"""Render the dataset as a multi-line tabular string."""
columns = list(schema.names)
if not columns:
return build_dataset_summary_repr(dataset)
num_rows = dataset._meta_count()
head_rows: List[List[str]] = []
tail_rows: List[List[str]] = []
if is_materialized:
try:
head_data, tail_data, _ = _collect_materialized_rows_for_repr(
dataset, num_rows
)
head_rows = _format_rows_for_repr(head_data, columns)
tail_rows = _format_rows_for_repr(tail_data, columns)
except RayError:
head_rows = []
tail_rows = []
return _build_dataset_ascii_repr_from_rows(
schema=schema,
num_rows=num_rows,
dataset_name=dataset.name,
is_materialized=is_materialized,
head_rows=head_rows,
tail_rows=tail_rows,
)
def _build_dataset_ascii_repr_from_rows(
*,
schema: "Schema",
num_rows: Optional[int],
dataset_name: Optional[str],
is_materialized: bool,
head_rows: List[List[str]],
tail_rows: List[List[str]],
) -> str:
"""Render the dataset repr given schema metadata and preview rows."""
columns = list(schema.names)
num_cols = len(columns)
shape_line = f"shape: ({num_rows if num_rows is not None else '?'}, {num_cols})"
# Build header rows from schema.
dtype_strings = [_repr_format_dtype(t) for t in schema.types]
column_headers = [
_truncate_to_cell_width(str(col), _DATASET_REPR_MAX_COLUMN_WIDTH)
for col in columns
]
dtype_headers = [
_truncate_to_cell_width(dtype, _DATASET_REPR_MAX_COLUMN_WIDTH)
for dtype in dtype_strings
]
separator_row = ["---"] * len(columns)
# Assemble rows, including an ellipsis gap if needed.
show_gap = bool(head_rows) and bool(tail_rows)
display_rows: List[List[str]] = []
display_rows.extend(head_rows)
if show_gap:
display_rows.append([_DATASET_REPR_ELLIPSIS] * len(columns))
display_rows.extend(tail_rows)
# Render the table with computed column widths.
column_widths = _compute_column_widths(
column_headers, dtype_headers, separator_row, display_rows
)
table_lines = _render_table_lines(
column_headers,
dtype_headers,
separator_row,
display_rows,
column_widths,
)
# Append a summary line describing row coverage.
num_rows_shown = len(head_rows) + len(tail_rows)
summary_line = (
f"(Showing {num_rows_shown} of {num_rows} rows)"
if is_materialized
else "(Dataset isn't materialized)"
)
if is_materialized and num_rows is None:
summary_line = f"(Showing {num_rows_shown} of ? rows)"
components = []
if dataset_name is not None:
components.append(f"name: {dataset_name}")
components.extend([shape_line, "\n".join(table_lines), summary_line])
return "\n".join(components)
def _repr_format_dtype(dtype: object) -> str:
"""Format a dtype into a compact string for the schema row.
Dtypes may come from PyArrow, pandas/NumPy, or be plain Python types.
"""
if isinstance(dtype, type):
return dtype.__name__
name = getattr(dtype, "name", None)
if isinstance(name, str):
return name
return str(dtype)
def _collect_materialized_rows_for_repr(
dataset: "Dataset",
num_rows: Optional[int],
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], bool]:
"""Collect head/tail rows for preview and whether to show a gap row."""
block_entries: List[Tuple[ObjectRef, BlockMetadata]] = []
for ref_bundle in dataset.iter_internal_ref_bundles():
block_entries.extend(zip(ref_bundle.block_refs, ref_bundle.metadata))
if not block_entries:
return [], [], False
# Compute how many head/tail rows to show within the preview budget.
head_row_limit, tail_row_limit = _determine_preview_row_targets(num_rows)
block_cache: Dict[ObjectRef, Block] = {}
def _resolve_block(block_ref: ObjectRef) -> Block:
if block_ref not in block_cache:
block_cache[block_ref] = ray.get(
block_ref, timeout=_DATASET_REPR_GET_TIMEOUT_S
)
return block_cache[block_ref]
head_rows: List[Dict[str, Any]] = []
head_remaining = head_row_limit
for block_ref, _ in block_entries:
if head_remaining <= 0:
break
block = _resolve_block(block_ref)
accessor = BlockAccessor.for_block(block)
for row in accessor.iter_rows(public_row_format=True):
head_rows.append(row)
head_remaining -= 1
if head_remaining <= 0:
break
tail_rows: List[Dict[str, Any]] = []
tail_remaining = tail_row_limit
tail_parts: List[List[Dict[str, Any]]] = []
if tail_remaining > 0:
for block_ref, metadata in reversed(block_entries):
if tail_remaining <= 0:
break
block = _resolve_block(block_ref)
accessor = BlockAccessor.for_block(block)
total_rows = metadata.num_rows
if total_rows is None:
total_rows = accessor.num_rows()
if total_rows == 0:
continue
start = max(0, total_rows - tail_remaining)
sliced_block = accessor.slice(start, total_rows, copy=False)
slice_accessor = BlockAccessor.for_block(sliced_block)
block_rows = list(slice_accessor.iter_rows(public_row_format=True))
tail_parts.append(block_rows)
tail_remaining -= len(block_rows)
if tail_remaining <= 0:
break
for part in reversed(tail_parts):
tail_rows.extend(part)
show_gap = bool(head_rows) and bool(tail_rows)
return head_rows, tail_rows, show_gap
def _determine_preview_row_targets(num_rows: Optional[int]) -> Tuple[int, int]:
"""Compute how many head and tail rows to preview."""
max_rows = _DATASET_REPR_MAX_ROWS
if num_rows is None or num_rows <= max_rows:
head = num_rows if num_rows is not None else max_rows
return head, 0
head = min(_DATASET_REPR_HEAD_ROWS, max_rows)
tail = max_rows - head
return head, tail
def _format_rows_for_repr(
rows: List[Dict[str, Any]],
column_names: List[str],
) -> List[List[str]]:
"""Format row dicts into string cell rows for table rendering."""
formatted_rows: List[List[str]] = []
for row in rows:
formatted_row = []
for column in column_names:
value = row.get(column)
formatted_value = _format_value(value)
formatted_row.append(
_truncate_to_cell_width(formatted_value, _DATASET_REPR_MAX_COLUMN_WIDTH)
)
formatted_rows.append(formatted_row)
return formatted_rows
def _format_value(value: Any) -> str:
if isinstance(value, np.generic):
value = value.item()
return str(value).replace("\n", " ").replace("\r", " ")
def _truncate_to_cell_width(value: str, max_width: int) -> str:
"""Truncate a single cell to the configured max width."""
if max_width is None:
return value
if max_width <= 0:
return _DATASET_REPR_ELLIPSIS if value else ""
if len(value) <= max_width:
return value
if max_width == 1:
return _DATASET_REPR_ELLIPSIS
return value[: max_width - 1] + _DATASET_REPR_ELLIPSIS
def _compute_column_widths(
headers: List[str],
dtype_headers: List[str],
separator_row: List[str],
data_rows: List[List[str]],
) -> List[int]:
"""Compute per-column widths for table rendering."""
column_widths: List[int] = []
for idx in range(len(headers)):
widths = [
len(headers[idx]),
len(dtype_headers[idx]),
len(separator_row[idx]),
]
for row in data_rows:
widths.append(len(row[idx]))
column_widths.append(max(widths))
return column_widths
def _render_table_lines(
headers: List[str],
dtype_headers: List[str],
separator_row: List[str],
data_rows: List[List[str]],
column_widths: List[int],
) -> List[str]:
"""Render the full table (borders, headers, data) as lines."""
lines: List[str] = []
top = _render_border("╭", "┬", "╮", "─", column_widths)
header_row = _render_row(headers, column_widths)
separator_line = _render_row(separator_row, column_widths)
dtype_row = _render_row(dtype_headers, column_widths)
lines.extend([top, header_row, separator_line, dtype_row])
if data_rows:
middle = _render_border("╞", "╪", "╡", "═", column_widths)
lines.append(middle)
for row in data_rows:
lines.append(_render_row(row, column_widths))
bottom = _render_border("╰", "┴", "╯", "─", column_widths)
lines.append(bottom)
return lines
def _render_border(
left: str, middle: str, right: str, fill: str, column_widths: List[int]
) -> str:
"""Render a table border line given column widths."""
segments = [fill * (width + 2) for width in column_widths]
return f"{left}{middle.join(segments)}{right}"
def _render_row(values: List[str], column_widths: List[int]) -> str:
"""Render a single table row with padding."""
cells = []
for idx, value in enumerate(values):
padded = value.ljust(column_widths[idx])
cells.append(f" {padded} ")
return f"│{'┆'.join(cells)}│"
def _format_operator_dag(
op: "Operator",
curr_str: str = "",
depth: int = 0,
including_source: bool = True,
show_op_repr: bool = False,
) -> Tuple[str, int]:
"""Traverse (DFS) the Plan DAG and
return a string representation of the operators."""
if not including_source and isinstance(op, SourceOperator):
return curr_str, depth
curr_max_depth = depth
# For logical plan, only show the operator name like "Aggregate".
# But for physical plan, show the operator class name as well like
# "AllToAllOperator[Aggregate]".
op_str = repr(op) if show_op_repr else op.name
if depth == 0:
curr_str += f"{op_str}\n"
else:
trailing_space = " " * ((depth - 1) * 3)
curr_str += f"{trailing_space}+- {op_str}\n"
for input in op.input_dependencies:
curr_str, input_max_depth = _format_operator_dag(
input, curr_str, depth + 1, including_source, show_op_repr
)
curr_max_depth = max(curr_max_depth, input_max_depth)
return curr_str, curr_max_depth
def build_dataset_summary_repr(dataset: "Dataset") -> str:
"""Create a cosmetic string representation of a dataset.
This is used for Dataset.__repr__ when no tabular preview is available.
Must be very cheap — never forces execution.
"""
from ray.data.dataset import MaterializedDataset
dataset_cls = type(dataset)
logical_plan = dataset._logical_plan
dataset_name = dataset._dataset_name
plan_str = ""
plan_max_depth = 0
if not dataset._has_computed_output():
plan_str, plan_max_depth = _format_operator_dag(
logical_plan.dag, including_source=False
)
schema = dataset._base_schema(fetch_if_missing=False)
count = dataset._cache.get_num_rows(logical_plan.dag)
if schema is None or count is None:
has_n_ary_operator = False
dag = logical_plan.dag
while not isinstance(dag, SourceOperator):
if len(dag.input_dependencies) > 1:
has_n_ary_operator = True
break
dag = dag.input_dependencies[0]
# TODO(@bveeramani): Handle schemas for n-ary operators like `Union`.
if not has_n_ary_operator:
assert isinstance(dag, SourceOperator), dag
# We infer from logical plan's dag directly as we know that
# we don't have any cached values, so inferring is the only
# option left.
if schema is None:
schema = dag.infer_schema()
if count is None:
count = dag.infer_metadata().num_rows
if schema is None:
schema_str = "Unknown schema"
elif isinstance(schema, type):
schema_str = str(schema)
else:
schema_str = []
for n, t in zip(schema.names, schema.types):
if hasattr(t, "__name__"):
t = t.__name__
schema_str.append(f"{n}: {t}")
schema_str = ", ".join(schema_str)
schema_str = "{" + schema_str + "}"
if count is None:
count = "?"
num_blocks = None
if dataset_cls == MaterializedDataset:
num_blocks = logical_plan.initial_num_blocks()
assert num_blocks is not None
name_str = "name={}, ".format(dataset_name) if dataset_name is not None else ""
num_blocks_str = f"num_blocks={num_blocks}, " if num_blocks else ""
dataset_str = "{}({}{}num_rows={}, schema={})".format(
dataset_cls.__name__,
name_str,
num_blocks_str,
count,
schema_str,
)
# If the resulting string representation fits in one line, use it directly.
SCHEMA_LINE_CHAR_LIMIT = 80
MIN_FIELD_LENGTH = 10
INDENT_STR = " " * 3
trailing_space = INDENT_STR * plan_max_depth
if len(dataset_str) > SCHEMA_LINE_CHAR_LIMIT:
# If the resulting string representation exceeds the line char limit,
# first try breaking up each `Dataset` parameter into its own line
# and check if each line fits within the line limit. We check the
# `schema` param's length, since this is likely the longest string.
schema_str_on_new_line = f"{trailing_space}{INDENT_STR}schema={schema_str}"
if len(schema_str_on_new_line) > SCHEMA_LINE_CHAR_LIMIT:
# If the schema cannot fit on a single line, break up each field
# into its own line.
schema_str = []
for n, t in zip(schema.names, schema.types):
if hasattr(t, "__name__"):
t = t.__name__
col_str = f"{trailing_space}{INDENT_STR * 2}{n}: {t}"
# If the field line exceeds the char limit, abbreviate
# the field name to fit while maintaining the full type
if len(col_str) > SCHEMA_LINE_CHAR_LIMIT:
shortened_suffix = f"...: {str(t)}"
# Show at least 10 characters of the field name, even if
# we have already hit the line limit with the type.
chars_left_for_col_name = max(
SCHEMA_LINE_CHAR_LIMIT - len(shortened_suffix),
MIN_FIELD_LENGTH,
)
col_str = f"{col_str[:chars_left_for_col_name]}{shortened_suffix}"
schema_str.append(col_str)
schema_str = ",\n".join(schema_str)
schema_str = "{\n" + schema_str + f"\n{trailing_space}{INDENT_STR}" + "}"
name_str = (
f"\n{trailing_space}{INDENT_STR}name={dataset_name},"
if dataset_name is not None
else ""
)
num_blocks_str = (
f"\n{trailing_space}{INDENT_STR}num_blocks={num_blocks},"
if num_blocks
else ""
)
dataset_str = (
f"{dataset_cls.__name__}("
f"{name_str}"
f"{num_blocks_str}"
f"\n{trailing_space}{INDENT_STR}num_rows={count},"
f"\n{trailing_space}{INDENT_STR}schema={schema_str}"
f"\n{trailing_space})"
)
if plan_max_depth == 0:
plan_str += dataset_str
else:
plan_str += f"{INDENT_STR * (plan_max_depth - 1)}+- {dataset_str}"
return plan_str