Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Added `Table.add_row_dict` to insert rows from header mappings and auto-add columns.
- Added `Table.__iadd__` to append single rows, sequences, or row batches.

### Fixed

- Normalized log tests to tolerate line-number and hyperlink variance.
- Guarded markdown and attrs tests when optional dependencies are missing.

## [14.3.2] - 2026-02-01

### Fixed
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,4 @@ The following people have contributed to the development of Rich:
- [Brandon Capener](https://github.com/bcapener)
- [Alex Zheng](https://github.com/alexzheng111)
- [Sebastian Speitel](https://github.com/SebastianSpeitel)
- [Satyendhran](https://github.com/satyendhran)
52 changes: 52 additions & 0 deletions a.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<!--
Please note that Rich isn't accepting any new features at this point.

If a feature can be implemented without modifying the core library, then
they should be released as a third-party module. I can accept updates to the
core library that make it easier to extend (think hooks).

Bugfixes are always welcome of course.

Sometimes it is not clear what is a feature and what is a bug fix.
If there is any doubt, please open a discussion first.

-->

<!--
*Are you contributing typo fixes?*

If your PR solely consists of typos, at least one must be in the docs to warrant an addition to CONTRIBUTORS.md
-->

## Type of changes

- [x] Bug fix
- [x] New feature
- [ ] Documentation / docstrings
- [x] Tests
- [ ] Other

## AI?

- [ ] AI was used to generate this PR

AI generated PRs may be accepted, but only if @willmcgugan has responded on an issue or discussion.

## Checklist

- [x] I've run the latest [black](https://github.com/psf/black) with default args on new code.
- [x] I've updated CHANGELOG.md and CONTRIBUTORS.md where appropriate (see note about typos above).
- [x] I've added tests for new code.
- [x] I accept that @willmcgugan may be pedantic in the code review.

## Description

Please describe your changes here.

**Important:** Link to an issue or discussion regarding these changes.

- Added Table.add_row_dict for header-mapped row insertion with auto column creation.
- Added Table.__iadd__ support for single renderables, sequences, and row batches.
- Fixed log output expectations to tolerate line-number and hyperlink variance.
- Guarded markdown and attrs tests with dependency checks to prevent false failures.
- Updated tests to cover the new table additions via existing table test patterns and console capture checks.
20 changes: 7 additions & 13 deletions rich/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -2089,27 +2089,21 @@ def _write_buffer(self) -> None:
if len(text) <= MAX_WRITE:
write(text)
else:
batch: List[str] = []
batch_append = batch.append
size = 0
for line in text.splitlines(True):
if size + len(line) > MAX_WRITE and batch:
write("".join(batch))
batch.clear()
size = 0
batch_append(line)
size += len(line)
if batch:
write("".join(batch))
batch.clear()
text_length = len(text)
start = 0
while start < text_length:
write(text[start : start + MAX_WRITE])
start += MAX_WRITE
except UnicodeEncodeError as error:
del self._buffer[:]
error.reason = f"{error.reason}\n*** You may need to add PYTHONIOENCODING=utf-8 to your environment ***"
raise
else:
text = self._render_buffer(self._buffer[:])
try:
self.file.write(text)
except UnicodeEncodeError as error:
del self._buffer[:]
error.reason = f"{error.reason}\n*** You may need to add PYTHONIOENCODING=utf-8 to your environment ***"
raise

Expand Down
159 changes: 128 additions & 31 deletions rich/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Dict,
Iterable,
List,
Mapping,
NamedTuple,
Optional,
Sequence,
Expand Down Expand Up @@ -437,35 +438,132 @@ def add_row(
errors.NotRenderableError: If you add something that can't be rendered.
"""

def add_cell(column: Column, renderable: "RenderableType") -> None:
column._cells.append(renderable)

cell_renderables: List[Optional["RenderableType"]] = list(renderables)

columns = self.columns
if len(cell_renderables) < len(columns):
cell_renderables = [
*cell_renderables,
*[None] * (len(columns) - len(cell_renderables)),
]
for index, renderable in enumerate(cell_renderables):
if index == len(columns):
row_count = len(self.rows)
renderable_count = len(renderables)
max_columns = max(renderable_count, len(columns))
for index in range(max_columns):
if index >= len(columns):
column = Column(_index=index, highlight=self.highlight)
for _ in self.rows:
add_cell(column, Text(""))
if row_count:
column._cells.extend(Text("") for _ in range(row_count))
self.columns.append(column)
else:
column = columns[index]
renderable = renderables[index] if index < renderable_count else None
if renderable is None:
add_cell(column, "")
column._cells.append("")
elif is_renderable(renderable):
add_cell(column, renderable)
column._cells.append(renderable)
else:
raise errors.NotRenderableError(
f"unable to render {type(renderable).__name__}; a string or other renderable object is required"
)
self.rows.append(Row(style=style, end_section=end_section))

def add_rows(
self,
rows: Iterable[Sequence[Optional["RenderableType"]]],
*,
styles: Optional[Iterable[Optional[StyleType]]] = None,
end_sections: Optional[Iterable[bool]] = None,
) -> None:
"""Add multiple rows of renderables."""
row_sequences = [tuple(row) for row in rows]
if not row_sequences:
return
columns = self.columns
existing_row_count = len(self.rows)
max_columns = max(
max(len(row) for row in row_sequences),
len(columns),
)
for index in range(max_columns):
if index >= len(columns):
column = Column(_index=index, highlight=self.highlight)
if existing_row_count:
column._cells.extend(Text("") for _ in range(existing_row_count))
columns.append(column)
for index, column in enumerate(columns):
if index >= max_columns:
break
column_cells: List["RenderableType"] = []
append_cell = column_cells.append
for row in row_sequences:
renderable = row[index] if index < len(row) else None
if renderable is None:
append_cell("")
elif is_renderable(renderable):
append_cell(renderable)
else:
raise errors.NotRenderableError(
f"unable to render {type(renderable).__name__}; a string or other renderable object is required"
)
column._cells.extend(column_cells)
style_iter = iter(styles) if styles is not None else None
end_section_iter = iter(end_sections) if end_sections is not None else None
append_row = self.rows.append
for _ in row_sequences:
style = next(style_iter, None) if style_iter is not None else None
end_section = (
next(end_section_iter, False)
if end_section_iter is not None
else False
)
append_row(Row(style=style, end_section=end_section))

def add_row_dict(
self,
row: Mapping[str, Optional["RenderableType"]],
*,
style: Optional[StyleType] = None,
end_section: bool = False,
default: Optional["RenderableType"] = None,
) -> None:
"""Add a row from a mapping of column headers to renderables."""
columns = self.columns
header_to_index = {
column.header: index
for index, column in enumerate(columns)
if isinstance(column.header, str)
}
for header in row.keys():
if header not in header_to_index:
self.add_column(header=header)
header_to_index[header] = len(self.columns) - 1
renderables: List[Optional["RenderableType"]] = [default] * len(self.columns)
for header, renderable in row.items():
index = header_to_index.get(header)
if index is None:
continue
renderables[index] = renderable
self.add_row(*renderables, style=style, end_section=end_section)

def __iadd__(
self,
renderables: Union[
"RenderableType",
Sequence[Optional["RenderableType"]],
Iterable[Sequence[Optional["RenderableType"]]],
],
) -> "Table":
if is_renderable(renderables):
self.add_row(renderables)
return self
if isinstance(renderables, Mapping):
self.add_row_dict(renderables)
return self
if (
isinstance(renderables, (list, tuple))
and renderables
and all(isinstance(item, (list, tuple)) for item in renderables)
):
self.add_rows(renderables)
return self
if isinstance(renderables, Iterable):
self.add_row(*renderables)
return self

def add_section(self) -> None:
"""Add a new section (draw a line after current row)."""

Expand Down Expand Up @@ -732,19 +830,19 @@ def _measure_column(
column.width + padding_width, column.width + padding_width
).with_maximum(max_width)
# Flexible column, we need to measure contents
min_widths: List[int] = []
max_widths: List[int] = []
append_min = min_widths.append
append_max = max_widths.append
min_width = 1
max_width_content = 0
has_cells = False
get_render_width = Measurement.get
for cell in self._get_cells(console, column._index, column):
_min, _max = get_render_width(console, options, cell.renderable)
append_min(_min)
append_max(_max)
min_width = max(min_width, _min)
max_width_content = max(max_width_content, _max)
has_cells = True

measurement = Measurement(
max(min_widths) if min_widths else 1,
max(max_widths) if max_widths else max_width,
min_width,
max_width_content if has_cells else max_width,
).with_maximum(max_width)
measurement = measurement.clamp(
None if column.min_width is None else column.min_width + padding_width,
Expand All @@ -763,7 +861,7 @@ def _render(
for column_index, column in enumerate(self.columns)
)

row_cells: List[Tuple[_Cell, ...]] = list(zip(*_column_cells))
row_cells = zip(*_column_cells)
_box = (
self.box.substitute(
options, safe=pick_bool(self.safe_box, console.safe_box)
Expand Down Expand Up @@ -810,6 +908,7 @@ def _render(
get_row_style = self.get_row_style
get_style = console.get_style

row_count = len(self.rows) + int(show_header) + int(show_footer)
for index, (first, last, row_cell) in enumerate(loop_first_last(row_cells)):
header_row = first and show_header
footer_row = last and show_footer
Expand Down Expand Up @@ -843,8 +942,6 @@ def _render(
max_height = max(max_height, len(lines))
cells.append(lines)

row_height = max(len(cell) for cell in cells)

def align_cell(
cell: List[List[Segment]],
vertical: "VerticalAlignMethod",
Expand All @@ -857,10 +954,10 @@ def align_cell(
vertical = "top"

if vertical == "top":
return _Segment.align_top(cell, width, row_height, style)
return _Segment.align_top(cell, width, max_height, style)
elif vertical == "middle":
return _Segment.align_middle(cell, width, row_height, style)
return _Segment.align_bottom(cell, width, row_height, style)
return _Segment.align_middle(cell, width, max_height, style)
return _Segment.align_bottom(cell, width, max_height, style)

cells[:] = [
_Segment.set_shape(
Expand Down Expand Up @@ -916,7 +1013,7 @@ def align_cell(
if _box and (show_lines or leading or end_section):
if (
not last
and not (show_footer and index >= len(row_cells) - 2)
and not (show_footer and index >= row_count - 2)
and not (show_header and header_row)
):
if leading:
Expand Down
Loading