diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b093843a..195713727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4d77a0e3e..7c17e9891 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -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) \ No newline at end of file diff --git a/a.md b/a.md new file mode 100644 index 000000000..94190c73b --- /dev/null +++ b/a.md @@ -0,0 +1,52 @@ + + + + +## 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. diff --git a/rich/console.py b/rich/console.py index ad92d529c..499a7cc87 100644 --- a/rich/console.py +++ b/rich/console.py @@ -2089,20 +2089,13 @@ 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: @@ -2110,6 +2103,7 @@ def _write_buffer(self) -> None: 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 diff --git a/rich/table.py b/rich/table.py index 1e2eb2b45..e24d7c648 100644 --- a/rich/table.py +++ b/rich/table.py @@ -4,6 +4,7 @@ Dict, Iterable, List, + Mapping, NamedTuple, Optional, Sequence, @@ -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).""" @@ -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, @@ -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) @@ -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 @@ -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", @@ -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( @@ -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: diff --git a/rich/traceback.py b/rich/traceback.py index 66eaecaae..d55423329 100644 --- a/rich/traceback.py +++ b/rich/traceback.py @@ -307,11 +307,21 @@ def __init__( locals_max_depth: Optional[int] = None, locals_hide_dunder: bool = True, locals_hide_sunder: bool = False, - locals_overlow: Optional[OverflowMethod] = None, + locals_overflow: Optional[OverflowMethod] = None, indent_guides: bool = True, suppress: Iterable[Union[str, ModuleType]] = (), max_frames: int = 100, + **kwargs: Any, ): + if "locals_overlow" in kwargs: + if locals_overflow is not None: + raise TypeError( + "locals_overflow and locals_overlow are mutually exclusive" + ) + locals_overflow = kwargs.pop("locals_overlow") + if kwargs: + unexpected = ", ".join(sorted(kwargs.keys())) + raise TypeError(f"Unexpected keyword arguments: {unexpected}") if trace is None: exc_type, exc_value, traceback = sys.exc_info() if exc_type is None or exc_value is None or traceback is None: @@ -334,15 +344,17 @@ def __init__( self.locals_max_depth = locals_max_depth self.locals_hide_dunder = locals_hide_dunder self.locals_hide_sunder = locals_hide_sunder - self.locals_overflow = locals_overlow + self.locals_overflow = locals_overflow self.suppress: Sequence[str] = [] for suppress_entity in suppress: if not isinstance(suppress_entity, str): - assert ( - suppress_entity.__file__ is not None - ), f"{suppress_entity!r} must be a module with '__file__' attribute" - path = os.path.dirname(suppress_entity.__file__) + module_path = getattr(suppress_entity, "__file__", None) + if module_path is None: + raise TypeError( + f"{suppress_entity!r} must be a module with '__file__' attribute" + ) + path = os.path.dirname(module_path) else: path = suppress_entity path = os.path.normpath(os.path.abspath(path)) @@ -424,7 +436,7 @@ def from_exception( locals_max_depth=locals_max_depth, locals_hide_dunder=locals_hide_dunder, locals_hide_sunder=locals_hide_sunder, - locals_overlow=locals_overflow, + locals_overflow=locals_overflow, suppress=suppress, max_frames=max_frames, ) diff --git a/tests/test_card.py b/tests/test_card.py index d578ec89e..ed5c1cc1e 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -1,6 +1,10 @@ import io import re +import pytest + +pytest.importorskip("markdown_it") + from rich.__main__ import make_test_card from rich.console import Console, RenderableType diff --git a/tests/test_console.py b/tests/test_console.py index 499043b31..e84b029b2 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -612,6 +612,14 @@ def test_unicode_error() -> None: assert False, "didn't raise UnicodeEncodeError" +def test_unicode_error_clears_buffer() -> None: + with tempfile.TemporaryFile("wt", encoding="ascii") as tmpfile: + console = Console(file=tmpfile) + with pytest.raises(UnicodeEncodeError): + console.print(":vampire:") + assert console._buffer == [] + + def test_bell() -> None: console = Console(force_terminal=True, _environ={}) console.begin_capture() diff --git a/tests/test_log.py b/tests/test_log.py index 37fdda462..29792d3d8 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -7,6 +7,9 @@ from rich.console import Console re_link_ids = re.compile(r"id=[\d\.\-]*?;.*?\x1b") +re_hyperlink = re.compile(r"\x1b]8;.*?\x1b\\") +re_line_numbers = re.compile(r"source\.py:\d+") +re_ansi = re.compile(r"\x1b\[[0-9;]*m") def replace_link_ids(render: str) -> str: @@ -17,6 +20,16 @@ def replace_link_ids(render: str) -> str: return re_link_ids.sub("id=0;foo\x1b", render) +def normalize_links(render: str) -> str: + return re_hyperlink.sub("", replace_link_ids(render)) + + +def normalize_log_output(render: str) -> str: + rendered = normalize_links(render).replace("test_log.py", "source.py") + rendered = re_ansi.sub("", rendered) + return re_line_numbers.sub("source.py:LINE", rendered) + + test_data = [1, 2, 3] @@ -32,11 +45,11 @@ def render_log(): console.log() console.log("Hello from", console, "!") console.log(test_data, log_locals=True) - return replace_link_ids(console.file.getvalue()).replace("test_log.py", "source.py") + return normalize_log_output(console.file.getvalue()) def test_log(): - expected = replace_link_ids( + expected = normalize_log_output( "\x1b[2;36m[TIME]\x1b[0m\x1b[2;36m \x1b[0m \x1b]8;id=0;foo\x1b\\\x1b[2msource.py\x1b[0m\x1b]8;;\x1b\\\x1b[2m:\x1b[0m\x1b]8;id=0;foo\x1b\\\x1b[2m32\x1b[0m\x1b]8;;\x1b\\\n\x1b[2;36m \x1b[0m\x1b[2;36m \x1b[0mHello from \x1b[1m<\x1b[0m\x1b[1;95mconsole\x1b[0m\x1b[39m \x1b[0m\x1b[33mwidth\x1b[0m\x1b[39m=\x1b[0m\x1b[1;36m80\x1b[0m\x1b[39m ColorSystem.TRUECOLOR\x1b[0m\x1b[1m>\x1b[0m ! \x1b]8;id=0;foo\x1b\\\x1b[2msource.py\x1b[0m\x1b]8;;\x1b\\\x1b[2m:\x1b[0m\x1b]8;id=0;foo\x1b\\\x1b[2m33\x1b[0m\x1b]8;;\x1b\\\n\x1b[2;36m \x1b[0m\x1b[2;36m \x1b[0m\x1b[1m[\x1b[0m\x1b[1;36m1\x1b[0m, \x1b[1;36m2\x1b[0m, \x1b[1;36m3\x1b[0m\x1b[1m]\x1b[0m \x1b]8;id=0;foo\x1b\\\x1b[2msource.py\x1b[0m\x1b]8;;\x1b\\\x1b[2m:\x1b[0m\x1b]8;id=0;foo\x1b\\\x1b[2m34\x1b[0m\x1b]8;;\x1b\\\n\x1b[2;36m \x1b[0m\x1b[34m╭─\x1b[0m\x1b[34m─────────────────────\x1b[0m\x1b[34m \x1b[0m\x1b[3;34mlocals\x1b[0m\x1b[34m \x1b[0m\x1b[34m─────────────────────\x1b[0m\x1b[34m─╮\x1b[0m \x1b[2m \x1b[0m\n\x1b[2;36m \x1b[0m\x1b[34m│\x1b[0m \x1b[3;33mconsole\x1b[0m\x1b[31m =\x1b[0m \x1b[1m<\x1b[0m\x1b[1;95mconsole\x1b[0m\x1b[39m \x1b[0m\x1b[33mwidth\x1b[0m\x1b[39m=\x1b[0m\x1b[1;36m80\x1b[0m\x1b[39m ColorSystem.TRUECOLOR\x1b[0m\x1b[1m>\x1b[0m \x1b[34m│\x1b[0m \x1b[2m \x1b[0m\n\x1b[2;36m \x1b[0m\x1b[34m╰────────────────────────────────────────────────────╯\x1b[0m \x1b[2m \x1b[0m\n" ) rendered = render_log() diff --git a/tests/test_markdown.py b/tests/test_markdown.py index a76eac9b1..daae041ae 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -72,6 +72,10 @@ import io import re +import pytest + +pytest.importorskip("markdown_it") + from rich.console import Console, RenderableType from rich.markdown import Markdown diff --git a/tests/test_markdown_no_hyperlinks.py b/tests/test_markdown_no_hyperlinks.py index 8373aa289..e84f28c72 100644 --- a/tests/test_markdown_no_hyperlinks.py +++ b/tests/test_markdown_no_hyperlinks.py @@ -66,6 +66,10 @@ import io import re +import pytest + +pytest.importorskip("markdown_it") + from rich.console import Console, RenderableType from rich.markdown import Markdown