diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index c22a4fa64..c46f002ae 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -6,13 +6,12 @@ jobs: build: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [windows-latest, ubuntu-latest, macos-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - include: - - { os: ubuntu-latest, python-version: "3.7" } - - { os: windows-latest, python-version: "3.7" } - - { os: macos-12, python-version: "3.7" } + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + exclude: + - { os: windows-latest, python-version: "3.13" } defaults: run: shell: bash @@ -22,6 +21,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install and configure Poetry # TODO: workaround for https://github.com/snok/install-poetry/issues/94 uses: snok/install-poetry@v1.3.4 diff --git a/CHANGELOG.md b/CHANGELOG.md index e716dc611..061f702b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,32 @@ 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 + +### Changed + +- Rich will display tracebacks with finely grained error locations on python 3.11+ https://github.com/Textualize/rich/pull/3486 + + +### Fixed + +- Fixed issue with Segment._split_cells https://github.com/Textualize/rich/pull/3506 +- Fix auto detection of terminal size on Windows https://github.com/Textualize/rich/pull/2916 + +### Added + +- Add a new `column` object `IterationSpeedColumn`. https://github.com/Textualize/rich/pull/3332 + +## [13.8.1] - 2024-09-10 + +### Fixed + +- Added support for Python 3.13 https://github.com/Textualize/rich/pull/3481 +- Fixed infinite loop when appending Text to same instance https://github.com/Textualize/rich/pull/3480 + +## [13.8.0] - 2024-08-26 + ### Fixed - Fixed `Table` rendering of box elements so "footer" elements truly appear at bottom of table, "mid" elements in main table body. @@ -17,8 +40,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Progress track thread is now a daemon thread https://github.com/Textualize/rich/pull/3402 - Fixed cached hash preservation upon clearing meta and links https://github.com/Textualize/rich/issues/2942 - Fixed overriding the `background_color` of `Syntax` not including padding https://github.com/Textualize/rich/issues/3295 +- Fixed pretty printing of dataclasses with a default repr in Python 3.13 https://github.com/Textualize/rich/pull/3455 - Fixed selective enabling of highlighting when disabled in the `Console` https://github.com/Textualize/rich/issues/3419 - Fixed BrokenPipeError writing an error message https://github.com/Textualize/rich/pull/3468 +- Fixed superfluous space above Markdown tables https://github.com/Textualize/rich/pull/3469 +- Fixed issue with record and capture interaction https://github.com/Textualize/rich/pull/3470 +- Fixed control codes breaking in `append_tokens` https://github.com/Textualize/rich/pull/3471 +- Fixed exception pretty printing a dataclass with missing fields https://github.com/Textualize/rich/pull/3472 ### Changed @@ -42,6 +70,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated the widths of some characters https://github.com/Textualize/rich/pull/3289 +### Added + +- Included a `name` attribute to the `Spinner` class https://github.com/Textualize/rich/pull/3359 + ## [13.7.0] - 2023-11-15 ### Added @@ -2056,6 +2088,8 @@ Major version bump for a breaking change to `Text.stylize signature`, which corr - First official release, API still to be stabilized +[13.8.1]: https://github.com/textualize/rich/compare/v13.8.0...v13.8.1 +[13.8.0]: https://github.com/textualize/rich/compare/v13.7.1...v13.8.0 [13.7.1]: https://github.com/textualize/rich/compare/v13.7.0...v13.7.1 [13.7.0]: https://github.com/textualize/rich/compare/v13.6.0...v13.7.0 [13.6.0]: https://github.com/textualize/rich/compare/v13.5.3...v13.6.0 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 052c83785..d8985ca13 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -20,6 +20,7 @@ The following people have contributed to the development of Rich: - [Aryaz Eghbali](https://github.com/AryazE) - [Oleksis Fraga](https://github.com/oleksis) - [Andy Gimblett](https://github.com/gimbo) +- [Kai Giokas](https://github.com/kaisforza) - [Tom Gooding](https://github.com/TomJGooding) - [Michał Górny](https://github.com/mgorny) - [Nok Lam Chan](https://github.com/noklam) @@ -63,6 +64,7 @@ The following people have contributed to the development of Rich: - [Tushar Sadhwani](https://github.com/tusharsadhwani) - [Luca Salvarani](https://github.com/LukeSavefrogs) - [Paul Sanders](https://github.com/sanders41) +- [Louis Sautier](https://github.com/sbraz) - [Tim Savage](https://github.com/timsavage) - [Anthony Shaw](https://github.com/tonybaloney) - [Nicolas Simonds](https://github.com/0xDEC0DE) @@ -86,3 +88,6 @@ The following people have contributed to the development of Rich: - [Bernhard Wagner](https://github.com/bwagner) - [Aaron Beaudoin](https://github.com/AaronBeaudoin) - [Sam Woodward](https://github.com/PyWoody) +- [L. Yeung](https://github.com/lewis-yeung) +- [chthollyphile](https://github.com/chthollyphile) +- [Jonathan Helmus](https://github.com/jjhelmus) diff --git a/docs/requirements.txt b/docs/requirements.txt index 94c9259d2..51869e44d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ -alabaster==0.7.12 +alabaster==1.0.0 Sphinx==7.3.7 sphinx-rtd-theme==2.0.0 sphinx-copybutton==0.5.1 diff --git a/docs/source/progress.rst b/docs/source/progress.rst index 7a771c8d8..baeb3a25a 100644 --- a/docs/source/progress.rst +++ b/docs/source/progress.rst @@ -131,7 +131,7 @@ Columns You may customize the columns in the progress display with the positional arguments to the :class:`~rich.progress.Progress` constructor. The columns are specified as either a `format string `_ or a :class:`~rich.progress.ProgressColumn` object. -Format strings will be rendered with a single value `"task"` which will be a :class:`~rich.progress.Task` instance. For example ``"{task.description}"`` would display the task description in the column, and ``"{task.completed} of {task.total}"`` would display how many of the total steps have been completed. Additional fields passed via keyword arguments to `~rich.progress.Progress.update` are store in ``task.fields``. You can add them to a format string with the following syntax: ``"extra info: {task.fields[extra]}"``. +Format strings will be rendered with a single value `"task"` which will be a :class:`~rich.progress.Task` instance. For example ``"{task.description}"`` would display the task description in the column, and ``"{task.completed} of {task.total}"`` would display how many of the total steps have been completed. Additional fields passed via keyword arguments to `~rich.progress.Progress.update` are stored in ``task.fields``. You can add them to a format string with the following syntax: ``"extra info: {task.fields[extra]}"``. The default columns are equivalent to the following:: @@ -163,6 +163,7 @@ The following column objects are available: - :class:`~rich.progress.TransferSpeedColumn` Displays transfer speed (assumes the steps are bytes). - :class:`~rich.progress.SpinnerColumn` Displays a "spinner" animation. - :class:`~rich.progress.RenderableColumn` Displays an arbitrary Rich renderable in the column. +- :class:`~rich.progress.IterationSpeedColumn` Displays iteration speed in it/s (iterations per second). To implement your own columns, extend the :class:`~rich.progress.ProgressColumn` class and use it as you would the other columns. diff --git a/examples/downloader.py b/examples/downloader.py index 8a0be5001..b58e96f81 100644 --- a/examples/downloader.py +++ b/examples/downloader.py @@ -73,7 +73,8 @@ def download(urls: Iterable[str], dest_dir: str): if __name__ == "__main__": - # Try with https://releases.ubuntu.com/20.04/ubuntu-20.04.3-desktop-amd64.iso + # Try with https://releases.ubuntu.com/noble/ubuntu-24.04-desktop-amd64.iso + # and https://releases.ubuntu.com/noble/ubuntu-24.04-live-server-amd64.iso if sys.argv[1:]: download(sys.argv[1:], "./") else: diff --git a/poetry.lock b/poetry.lock index dba700fec..637d25d6a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -884,13 +884,13 @@ files = [ [[package]] name = "virtualenv" -version = "20.26.3" +version = "20.26.4" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, - {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, + {file = "virtualenv-20.26.4-py3-none-any.whl", hash = "sha256:48f2695d9809277003f30776d155615ffc11328e6a0a8c1f0ec80188d7874a55"}, + {file = "virtualenv-20.26.4.tar.gz", hash = "sha256:c17f4e0f3e6036e9f26700446f85c76ab11df65ff6d8a9cbfad9f71aabfcf23c"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index 00d7b2746..43949e7bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "rich" homepage = "https://github.com/Textualize/rich" documentation = "https://rich.readthedocs.io/en/latest/" -version = "13.7.1" +version = "13.8.1" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" authors = ["Will McGugan "] license = "MIT" @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Typing :: Typed", ] include = ["rich/py.typed"] diff --git a/rich/console.py b/rich/console.py index cdfac2827..9ef0d60bb 100644 --- a/rich/console.py +++ b/rich/console.py @@ -1005,19 +1005,13 @@ def size(self) -> ConsoleDimensions: width: Optional[int] = None height: Optional[int] = None - if WINDOWS: # pragma: no cover + for file_descriptor in _STD_STREAMS_OUTPUT if WINDOWS else _STD_STREAMS: try: - width, height = os.get_terminal_size() + width, height = os.get_terminal_size(file_descriptor) except (AttributeError, ValueError, OSError): # Probably not a terminal pass - else: - for file_descriptor in _STD_STREAMS: - try: - width, height = os.get_terminal_size(file_descriptor) - except (AttributeError, ValueError, OSError): - pass - else: - break + else: + break columns = self._environ.get("COLUMNS") if columns is not None and columns.isdigit(): @@ -2029,7 +2023,7 @@ def _write_buffer(self) -> None: """Write the buffer to the output file.""" with self._lock: - if self.record: + if self.record and not self._buffer_index: with self._record_buffer_lock: self._record_buffer.extend(self._buffer[:]) diff --git a/rich/default_styles.py b/rich/default_styles.py index 031b94a13..28e8f6f94 100644 --- a/rich/default_styles.py +++ b/rich/default_styles.py @@ -120,6 +120,7 @@ "traceback.exc_type": Style(color="bright_red", bold=True), "traceback.exc_value": Style.null(), "traceback.offset": Style(color="bright_red", bold=True), + "traceback.error_range": Style(underline=True, bold=True, dim=False), "bar.back": Style(color="grey23"), "bar.complete": Style(color="rgb(249,38,114)"), "bar.finished": Style(color="rgb(114,156,31)"), diff --git a/rich/highlighter.py b/rich/highlighter.py index 27714b25b..e4c462e2b 100644 --- a/rich/highlighter.py +++ b/rich/highlighter.py @@ -98,7 +98,7 @@ class ReprHighlighter(RegexHighlighter): r"(?P(?\B(/[-\w._+]+)*\/)(?P[-\w._+]*)?", r"(?b?'''.*?(?(file|https|http|ws|wss)://[-0-9a-zA-Z$_+!`(),.?/;:&=%#~]*)", + r"(?P(file|https|http|ws|wss)://[-0-9a-zA-Z$_+!`(),.?/;:&=%#~@]*)", ), ] diff --git a/rich/live.py b/rich/live.py index f0529a781..8738cf09f 100644 --- a/rich/live.py +++ b/rich/live.py @@ -37,7 +37,7 @@ class Live(JupyterMixin, RenderHook): Args: renderable (RenderableType, optional): The renderable to live display. Defaults to displaying nothing. - console (Console, optional): Optional Console instance. Default will an internal Console instance writing to stdout. + console (Console, optional): Optional Console instance. Defaults to an internal Console instance writing to stdout. screen (bool, optional): Enable alternate screen mode. Defaults to False. auto_refresh (bool, optional): Enable auto refresh. If disabled, you will need to call `refresh()` or `update()` with refresh flag. Defaults to True refresh_per_second (float, optional): Number of times per second to refresh the live display. Defaults to 4. diff --git a/rich/markdown.py b/rich/markdown.py index a58a04fc6..26c58d155 100644 --- a/rich/markdown.py +++ b/rich/markdown.py @@ -677,7 +677,7 @@ def __rich_console__( and context.stack.top.on_child_close(context, element) ) if should_render: - if new_line: + if new_line and node_type != "inline": yield _new_line_segment yield from console.render(element, context.options) diff --git a/rich/pretty.py b/rich/pretty.py index fa340212e..5c725c0c5 100644 --- a/rich/pretty.py +++ b/rich/pretty.py @@ -3,6 +3,7 @@ import dataclasses import inspect import os +import reprlib import sys from array import array from collections import Counter, UserDict, UserList, defaultdict, deque @@ -78,7 +79,10 @@ def _is_dataclass_repr(obj: object) -> bool: # Digging in to a lot of internals here # Catching all exceptions in case something is missing on a non CPython implementation try: - return obj.__repr__.__code__.co_filename == dataclasses.__file__ + return obj.__repr__.__code__.co_filename in ( + dataclasses.__file__, + reprlib.__file__, + ) except Exception: # pragma: no coverage return False @@ -777,7 +781,9 @@ def iter_attrs() -> ( ) for last, field in loop_last( - field for field in fields(obj) if field.repr + field + for field in fields(obj) + if field.repr and hasattr(obj, field.name) ): child_node = _traverse(getattr(obj, field.name), depth=depth + 1) child_node.key_repr = field.name diff --git a/rich/progress.py b/rich/progress.py index effcab40c..65ab439e7 100644 --- a/rich/progress.py +++ b/rich/progress.py @@ -39,6 +39,11 @@ else: from typing_extensions import Literal # pragma: no cover +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self # pragma: no cover + from . import filesize, get_console from .console import Console, Group, JustifyMethod, RenderableType from .highlighter import Highlighter @@ -917,6 +922,25 @@ def render(self, task: "Task") -> Text: return Text(f"{data_speed}/s", style="progress.data.speed") +class IterationSpeedColumn(ProgressColumn): + """Renders iterations per second, e.g. '11.4 it/s'.""" + + def render(self, task: "Task") -> Text: + last_speed = task.last_speed if hasattr(task, 'last_speed') else None + if task.finished and last_speed is not None: + return Text(f"{last_speed} it/s", style="progress.data.speed") + if task.speed is None: + return Text("", style="progress.data.speed") + unit, suffix = filesize.pick_unit_and_suffix( + int(task.speed), + ["", "×10³", "×10⁶", "×10⁹", "×10¹²"], + 1000, + ) + data_speed = task.speed / unit + task.last_speed = f"{data_speed:.1f}{suffix}" + return Text(f"{task.last_speed} it/s", style="progress.data.speed") + + class ProgressSample(NamedTuple): """Sample of progress for a given time.""" @@ -1056,7 +1080,7 @@ class Progress(JupyterMixin): """Renders an auto-updating progress bar(s). Args: - console (Console, optional): Optional Console instance. Default will an internal Console instance writing to stdout. + console (Console, optional): Optional Console instance. Defaults to an internal Console instance writing to stdout. auto_refresh (bool, optional): Enable auto refresh. If disabled, you will need to call `refresh()`. refresh_per_second (Optional[float], optional): Number of times per second to refresh the progress information or None to use default (10). Defaults to None. speed_estimate_period: (float, optional): Period (in seconds) used to calculate the speed estimate. Defaults to 30. @@ -1170,7 +1194,7 @@ def stop(self) -> None: if not self.console.is_interactive and not self.console.is_jupyter: self.console.print() - def __enter__(self) -> "Progress": + def __enter__(self) -> Self: self.start() return self diff --git a/rich/segment.py b/rich/segment.py index 603d5097f..86c8c9a9c 100644 --- a/rich/segment.py +++ b/rich/segment.py @@ -109,16 +109,29 @@ def is_control(self) -> bool: @classmethod @lru_cache(1024 * 16) def _split_cells(cls, segment: "Segment", cut: int) -> Tuple["Segment", "Segment"]: + """Split a segment in to two at a given cell position. + + Note that splitting a double-width character, may result in that character turning + into two spaces. + + Args: + segment (Segment): A segment to split. + cut (int): A cell position to cut on. + + Returns: + A tuple of two segments. + """ text, style, control = segment _Segment = Segment - cell_length = segment.cell_length if cut >= cell_length: return segment, _Segment("", style, control) cell_size = get_character_cell_size - pos = int((cut / cell_length) * (len(text) - 1)) + pos = int((cut / cell_length) * (len(text))) - 1 + if pos < 0: + pos = 0 before = text[:pos] cell_pos = cell_len(before) diff --git a/rich/spinner.py b/rich/spinner.py index 91ea630e1..70570b6b0 100644 --- a/rich/spinner.py +++ b/rich/spinner.py @@ -38,6 +38,7 @@ def __init__( self.text: "Union[RenderableType, Text]" = ( Text.from_markup(text) if isinstance(text, str) else text ) + self.name = name self.frames = cast(List[str], spinner["frames"])[:] self.interval = cast(float, spinner["interval"]) self.start_time: Optional[float] = None diff --git a/rich/syntax.py b/rich/syntax.py index 4da6c3b7a..cff8fd235 100644 --- a/rich/syntax.py +++ b/rich/syntax.py @@ -221,6 +221,7 @@ class _SyntaxHighlightRange(NamedTuple): style: StyleType start: SyntaxPosition end: SyntaxPosition + style_before: bool = False class Syntax(JupyterMixin): @@ -534,7 +535,11 @@ def tokens_to_spans() -> Iterable[Tuple[str, Optional[Style]]]: return text def stylize_range( - self, style: StyleType, start: SyntaxPosition, end: SyntaxPosition + self, + style: StyleType, + start: SyntaxPosition, + end: SyntaxPosition, + style_before: bool = False, ) -> None: """ Adds a custom style on a part of the code, that will be applied to the syntax display when it's rendered. @@ -544,8 +549,11 @@ def stylize_range( style (StyleType): The style to apply. start (Tuple[int, int]): The start of the range, in the form `[line number, column index]`. end (Tuple[int, int]): The end of the range, in the form `[line number, column index]`. + style_before (bool): Apply the style before any existing styles. """ - self._stylized_ranges.append(_SyntaxHighlightRange(style, start, end)) + self._stylized_ranges.append( + _SyntaxHighlightRange(style, start, end, style_before) + ) def _get_line_numbers_color(self, blend: float = 0.3) -> Color: background_style = self._theme.get_background_style() + self.background_style @@ -785,7 +793,10 @@ def _apply_stylized_ranges(self, text: Text) -> None: newlines_offsets, stylized_range.end ) if start is not None and end is not None: - text.stylize(stylized_range.style, start, end) + if stylized_range.style_before: + text.stylize_before(stylized_range.style, start, end) + else: + text.stylize(stylized_range.style, start, end) def _process_code(self, code: str) -> Tuple[bool, str]: """ diff --git a/rich/text.py b/rich/text.py index 1643a0921..7d9c94132 100644 --- a/rich/text.py +++ b/rich/text.py @@ -1000,7 +1000,7 @@ def append( self._text.append(text.plain) self._spans.extend( _Span(start + text_length, end + text_length, style) - for start, end, style in text._spans + for start, end, style in text._spans.copy() ) self._length += len(text) return self @@ -1022,7 +1022,7 @@ def append_text(self, text: "Text") -> "Text": self._text.append(text.plain) self._spans.extend( _Span(start + text_length, end + text_length, style) - for start, end, style in text._spans + for start, end, style in text._spans.copy() ) self._length += len(text) return self @@ -1043,6 +1043,7 @@ def append_tokens( _Span = Span offset = len(self) for content, style in tokens: + content = strip_control_codes(content) append_text(content) if style: append_span(_Span(offset, offset + len(content), style)) diff --git a/rich/traceback.py b/rich/traceback.py index fcefeb235..3bf5baa7b 100644 --- a/rich/traceback.py +++ b/rich/traceback.py @@ -1,7 +1,9 @@ +import inspect import linecache import os import sys from dataclasses import dataclass, field +from itertools import islice from traceback import walk_tb from types import ModuleType, TracebackType from typing import ( @@ -179,6 +181,7 @@ class Frame: name: str line: str = "" locals: Optional[Dict[str, pretty.Node]] = None + last_instruction: Optional[Tuple[Tuple[int, int], Tuple[int, int]]] = None @dataclass @@ -442,6 +445,35 @@ def get_locals( for frame_summary, line_no in walk_tb(traceback): filename = frame_summary.f_code.co_filename + + last_instruction: Optional[Tuple[Tuple[int, int], Tuple[int, int]]] + last_instruction = None + if sys.version_info >= (3, 11): + instruction_index = frame_summary.f_lasti // 2 + instruction_position = next( + islice( + frame_summary.f_code.co_positions(), + instruction_index, + instruction_index + 1, + ) + ) + ( + start_line, + end_line, + start_column, + end_column, + ) = instruction_position + if ( + start_line is not None + and end_line is not None + and start_column is not None + and end_column is not None + ): + last_instruction = ( + (start_line, start_column), + (end_line, end_column), + ) + if filename and not filename.startswith("<"): if not os.path.isabs(filename): filename = os.path.join(_IMPORT_CWD, filename) @@ -452,16 +484,20 @@ def get_locals( filename=filename or "?", lineno=line_no, name=frame_summary.f_code.co_name, - locals={ - key: pretty.traverse( - value, - max_length=locals_max_length, - max_string=locals_max_string, - ) - for key, value in get_locals(frame_summary.f_locals.items()) - } - if show_locals - else None, + locals=( + { + key: pretty.traverse( + value, + max_length=locals_max_length, + max_string=locals_max_string, + ) + for key, value in get_locals(frame_summary.f_locals.items()) + if not (inspect.isfunction(value) or inspect.isclass(value)) + } + if show_locals + else None + ), + last_instruction=last_instruction, ) append(frame) if frame_summary.f_locals.get("_rich_traceback_guard", False): @@ -711,6 +747,14 @@ def render_locals(frame: Frame) -> Iterable[ConsoleRenderable]: (f"\n{error}", "traceback.error"), ) else: + if frame.last_instruction is not None: + start, end = frame.last_instruction + syntax.stylize_range( + style="traceback.error_range", + start=start, + end=end, + style_before=True, + ) yield ( Columns( [ @@ -725,12 +769,12 @@ def render_locals(frame: Frame) -> Iterable[ConsoleRenderable]: if __name__ == "__main__": # pragma: no cover - from .console import Console - - console = Console() + install(show_locals=True) import sys - def bar(a: Any) -> None: # 这是对亚洲语言支持的测试。面对模棱两可的想法,拒绝猜测的诱惑 + def bar( + a: Any, + ) -> None: # 这是对亚洲语言支持的测试。面对模棱两可的想法,拒绝猜测的诱惑 one = 1 print(one / a) @@ -748,12 +792,6 @@ def foo(a: Any) -> None: bar(a) def error() -> None: - try: - try: - foo(0) - except: - slfkjsldkfj # type: ignore[name-defined] - except: - console.print_exception(show_locals=True) + foo(0) error() diff --git a/tests/test_console.py b/tests/test_console.py index 4b4da8fe5..0340fc90e 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -381,23 +381,6 @@ def test_capture(): assert capture.get() == "Hello\n" -def test_capture_and_record(capsys): - recorder = Console(record=True) - recorder.print("ABC") - - with recorder.capture() as capture: - recorder.print("Hello") - - assert capture.get() == "Hello\n" - - recorded_text = recorder.export_text() - out, err = capsys.readouterr() - - assert recorded_text == "ABC\nHello\n" - assert capture.get() == "Hello\n" - assert out == "ABC\n" - - def test_input(monkeypatch, capsys): def fake_input(prompt=""): console.file.write(prompt) @@ -1038,3 +1021,24 @@ def test_brokenpipeerror() -> None: proc2.wait() assert proc1.returncode == 1 assert proc2.returncode == 0 + + +def test_capture_and_record() -> None: + """Regression test for https://github.com/Textualize/rich/issues/2563""" + + console = Console(record=True) + print("Before Capture started:") + console.print("[blue underline]Print 0") + with console.capture() as capture: + console.print("[blue underline]Print 1") + console.print("[blue underline]Print 2") + console.print("[blue underline]Print 3") + console.print("[blue underline]Print 4") + + capture_content = capture.get() + print(repr(capture_content)) + assert capture_content == "Print 1\nPrint 2\nPrint 3\nPrint 4\n" + + recorded_content = console.export_text() + print(repr(recorded_content)) + assert recorded_content == "Print 0\n" diff --git a/tests/test_highlighter.py b/tests/test_highlighter.py index 30851cf79..d19928274 100644 --- a/tests/test_highlighter.py +++ b/tests/test_highlighter.py @@ -134,6 +134,7 @@ def test_wrong_type(): (" http://example.org ", [Span(1, 19, "repr.url")]), (" http://example.org/index.html ", [Span(1, 30, "repr.url")]), (" http://example.org/index.html#anchor ", [Span(1, 37, "repr.url")]), + ("https://www.youtube.com/@LinusTechTips", [Span(0, 38, "repr.url")]), ( " http://example.org/index.html?param1=value1 ", [ diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 710436eb2..803c1ae97 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -18,10 +18,10 @@ Paragraphs are separated by a blank line. -Two spaces at the end of a line +Two spaces at the end of a line produces a line break. -Text attributes _italic_, +Text attributes _italic_, **bold**, `monospace`. Horizontal rule: @@ -99,7 +99,7 @@ def render(renderable: RenderableType) -> str: def test_markdown_render(): markdown = Markdown(MARKDOWN) rendered_markdown = render(markdown) - expected = "┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n┃ \x1b[1mHeading\x1b[0m ┃\n┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\n\n\n \x1b[1;4mSub-heading\x1b[0m \n\n \x1b[1mHeading\x1b[0m \n\n \x1b[1;2mH4 Heading\x1b[0m \n\n \x1b[4mH5 Heading\x1b[0m \n\n \x1b[3mH6 Heading\x1b[0m \n\nParagraphs are separated by a blank line. \n\nTwo spaces at the end of a line \nproduces a line break. \n\nText attributes \x1b[3mitalic\x1b[0m, \x1b[1mbold\x1b[0m, \x1b[1;36;40mmonospace\x1b[0m. \n\nHorizontal rule: \n\n\x1b[33m────────────────────────────────────────────────────────────────────────────────────────────────────\x1b[0m\nBullet list: \n\n\x1b[1;33m • \x1b[0mapples \n\x1b[1;33m • \x1b[0moranges \n\x1b[1;33m • \x1b[0mpears \n\nNumbered list: \n\n\x1b[1;33m 1 \x1b[0mlather \n\x1b[1;33m 2 \x1b[0mrinse \n\x1b[1;33m 3 \x1b[0mrepeat \n\nAn \x1b]8;id=0;foo\x1b\\\x1b[4;34mexample\x1b[0m\x1b]8;;\x1b\\. \n\n\x1b[35m▌ \x1b[0m\x1b[35mMarkdown uses email-style > characters for blockquoting.\x1b[0m\x1b[35m \x1b[0m\n\x1b[35m▌ \x1b[0m\x1b[35mLorem ipsum\x1b[0m\x1b[35m \x1b[0m\n\n🌆 \x1b]8;id=0;foo\x1b\\progress\x1b]8;;\x1b\\ \n\n\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34ma=1\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\n\n\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\x1b[38;2;255;70;137;48;2;39;40;34mimport\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mthis\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\n\n\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mfoobar\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\n\n\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mimport this\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\n\n\x1b[1;33m 1 \x1b[0mList item \n\x1b[1;33m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n\x1b[1;33m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mCode block\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n\x1b[1;33m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n" + expected = "┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n┃ \x1b[1mHeading\x1b[0m ┃\n┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\n\n\n \x1b[1;4mSub-heading\x1b[0m \n\n \x1b[1mHeading\x1b[0m \n\n \x1b[1;2mH4 Heading\x1b[0m \n\n \x1b[4mH5 Heading\x1b[0m \n\n \x1b[3mH6 Heading\x1b[0m \n\nParagraphs are separated by a blank line. \n\nTwo spaces at the end of a line produces a line break. \n\nText attributes \x1b[3mitalic\x1b[0m, \x1b[1mbold\x1b[0m, \x1b[1;36;40mmonospace\x1b[0m. \n\nHorizontal rule: \n\n\x1b[33m────────────────────────────────────────────────────────────────────────────────────────────────────\x1b[0m\nBullet list: \n\n\x1b[1;33m • \x1b[0mapples \n\x1b[1;33m • \x1b[0moranges \n\x1b[1;33m • \x1b[0mpears \n\nNumbered list: \n\n\x1b[1;33m 1 \x1b[0mlather \n\x1b[1;33m 2 \x1b[0mrinse \n\x1b[1;33m 3 \x1b[0mrepeat \n\nAn \x1b]8;id=0;foo\x1b\\\x1b[4;34mexample\x1b[0m\x1b]8;;\x1b\\. \n\n\x1b[35m▌ \x1b[0m\x1b[35mMarkdown uses email-style > characters for blockquoting.\x1b[0m\x1b[35m \x1b[0m\n\x1b[35m▌ \x1b[0m\x1b[35mLorem ipsum\x1b[0m\x1b[35m \x1b[0m\n\n🌆 \x1b]8;id=0;foo\x1b\\progress\x1b]8;;\x1b\\ \n\n\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34ma=1\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\n\n\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\x1b[38;2;255;70;137;48;2;39;40;34mimport\x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mthis\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\n\n\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mfoobar\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\n\n\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mimport this\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n\x1b[48;2;39;40;34m \x1b[0m\n\n\x1b[1;33m 1 \x1b[0mList item \n\x1b[1;33m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n\x1b[1;33m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[38;2;248;248;242;48;2;39;40;34mCode block\x1b[0m\x1b[48;2;39;40;34m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n\x1b[1;33m \x1b[0m\x1b[48;2;39;40;34m \x1b[0m\n" assert rendered_markdown == expected @@ -174,6 +174,31 @@ def test_partial_table(): assert result == expected +def test_table_with_empty_cells() -> None: + """Test a table with empty cells is rendered without extra newlines above. + Regression test for #3027 https://github.com/Textualize/rich/issues/3027 + """ + complete_table = Markdown( + """\ +| First Header | Second Header | +| ------------- | ------------- | +| Content Cell | Content Cell | +| Content Cell | Content Cell | +""" + ) + table_with_empty_cells = Markdown( + """\ +| First Header | | +| ------------- | ------------- | +| Content Cell | Content Cell | +| | Content Cell | +""" + ) + result = len(render(table_with_empty_cells).splitlines()) + expected = len(render(complete_table).splitlines()) + assert result == expected + + if __name__ == "__main__": markdown = Markdown(MARKDOWN) rendered = render(markdown) diff --git a/tests/test_pretty.py b/tests/test_pretty.py index 929de1cff..b1a543431 100644 --- a/tests/test_pretty.py +++ b/tests/test_pretty.py @@ -668,7 +668,10 @@ class Foo: del foo.bar result = pretty_repr(foo) print(repr(result)) - expected = "Foo(bar=AttributeError(\"'Foo' object has no attribute 'bar'\"))" + if sys.version_info >= (3, 13): + expected = "Foo(\n bar=AttributeError(\"'tests.test_pretty.test_attrs_broken_310..Foo' object has no attribute 'bar'\")\n)" + else: + expected = "Foo(bar=AttributeError(\"'Foo' object has no attribute 'bar'\"))" assert result == expected @@ -734,3 +737,23 @@ def __rich_repr__(self): yield None, (1,), (1,) assert pretty_repr(Foo()) == "Foo()" + + +def test_dataclass_no_attribute() -> None: + """Regression test for https://github.com/Textualize/rich/issues/3417""" + from dataclasses import dataclass, field + + @dataclass(eq=False) + class BadDataclass: + item: int = field(init=False) + + # item is not provided + bad_data_class = BadDataclass() + + console = Console() + with console.capture() as capture: + console.print(bad_data_class) + + expected = "BadDataclass()\n" + result = capture.get() + assert result == expected diff --git a/tests/test_progress.py b/tests/test_progress.py index 0be683c3e..a5d6089e2 100644 --- a/tests/test_progress.py +++ b/tests/test_progress.py @@ -26,6 +26,7 @@ TimeRemainingColumn, TotalFileSizeColumn, TransferSpeedColumn, + IterationSpeedColumn, _TrackThread, track, ) @@ -358,6 +359,7 @@ def test_columns() -> None: TransferSpeedColumn(), MofNCompleteColumn(), MofNCompleteColumn(separator=" of "), + IterationSpeedColumn(), transient=True, console=console, auto_refresh=False, @@ -377,7 +379,7 @@ def test_columns() -> None: result = replace_link_ids(console.file.getvalue()) print(repr(result)) - expected = "\x1b[?25ltest foo \x1b[38;5;237m━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:07\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m0/10 bytes\x1b[0m \x1b[31m?\x1b[0m \x1b[32m 0/10\x1b[0m \x1b[32m 0 of 10\x1b[0m\ntest bar \x1b[38;5;237m━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:18\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m7 bytes \x1b[0m \x1b[32m0/7 bytes \x1b[0m \x1b[31m?\x1b[0m \x1b[32m0/7 \x1b[0m \x1b[32m0 of 7 \x1b[0m\r\x1b[2K\x1b[1A\x1b[2Kfoo\ntest foo \x1b[38;5;237m━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:07\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m0/10 bytes\x1b[0m \x1b[31m?\x1b[0m \x1b[32m 0/10\x1b[0m \x1b[32m 0 of 10\x1b[0m\ntest bar \x1b[38;5;237m━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:18\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m7 bytes \x1b[0m \x1b[32m0/7 bytes \x1b[0m \x1b[31m?\x1b[0m \x1b[32m0/7 \x1b[0m \x1b[32m0 of 7 \x1b[0m\r\x1b[2K\x1b[1A\x1b[2K\x1b[2;36m[TIME]\x1b[0m\x1b[2;36m \x1b[0mhello \ntest foo \x1b[38;5;237m━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:07\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m0/10 bytes\x1b[0m \x1b[31m?\x1b[0m \x1b[32m 0/10\x1b[0m \x1b[32m 0 of 10\x1b[0m\ntest bar \x1b[38;5;237m━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:18\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m7 bytes \x1b[0m \x1b[32m0/7 bytes \x1b[0m \x1b[31m?\x1b[0m \x1b[32m0/7 \x1b[0m \x1b[32m0 of 7 \x1b[0m\r\x1b[2K\x1b[1A\x1b[2Kworld\ntest foo \x1b[38;5;237m━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:07\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m0/10 bytes\x1b[0m \x1b[31m?\x1b[0m \x1b[32m 0/10\x1b[0m \x1b[32m 0 of 10\x1b[0m\ntest bar \x1b[38;5;237m━━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:18\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m7 bytes \x1b[0m \x1b[32m0/7 bytes \x1b[0m \x1b[31m?\x1b[0m \x1b[32m0/7 \x1b[0m \x1b[32m0 of 7 \x1b[0m\r\x1b[2K\x1b[1A\x1b[2Ktest foo \x1b[38;2;114;156;31m━━━━━━━\x1b[0m \x1b[36m0:00:00\x1b[0m \x1b[33m0:00:34\x1b[0m \x1b[32m12 \x1b[0m \x1b[32m10 \x1b[0m \x1b[32m12/10 \x1b[0m \x1b[31m1 \x1b[0m \x1b[32m12/10\x1b[0m \x1b[32m12 of 10\x1b[0m\n \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[31mbyte/s \x1b[0m \ntest bar \x1b[38;2;114;156;31m━━━━━━━\x1b[0m \x1b[36m0:00:00\x1b[0m \x1b[33m0:00:29\x1b[0m \x1b[32m16 \x1b[0m \x1b[32m7 bytes\x1b[0m \x1b[32m16/7 \x1b[0m \x1b[31m2 \x1b[0m \x1b[32m16/7 \x1b[0m \x1b[32m16 of 7 \x1b[0m\n \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[31mbytes/s\x1b[0m \r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2Ktest foo \x1b[38;2;114;156;31m━━━━━━━\x1b[0m \x1b[36m0:00:00\x1b[0m \x1b[33m0:00:34\x1b[0m \x1b[32m12 \x1b[0m \x1b[32m10 \x1b[0m \x1b[32m12/10 \x1b[0m \x1b[31m1 \x1b[0m \x1b[32m12/10\x1b[0m \x1b[32m12 of 10\x1b[0m\n \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[31mbyte/s \x1b[0m \ntest bar \x1b[38;2;114;156;31m━━━━━━━\x1b[0m \x1b[36m0:00:00\x1b[0m \x1b[33m0:00:29\x1b[0m \x1b[32m16 \x1b[0m \x1b[32m7 bytes\x1b[0m \x1b[32m16/7 \x1b[0m \x1b[31m2 \x1b[0m \x1b[32m16/7 \x1b[0m \x1b[32m16 of 7 \x1b[0m\n \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[31mbytes/s\x1b[0m \n\x1b[?25h\r\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K" + expected = "\x1b[?25ltest foo \x1b[38;5;237m━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:07\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m0/10 \x1b[0m \x1b[31m?\x1b[0m \x1b[32m 0/m0:00:07\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m0/10 \x1b[0m \x1b[31m?\x1b[0m \x1b[32m 0/10\x1b[0m \x1b[32m 0 of 10\x1b[0m \n \x1b[32mbytes \x1b[0m \ntest bar \x1b[38;5;237m━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:19\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m7 bytes \x1b[0m \x1b[32m0/7 bytes\x1b[0m \x1b[31m?\x1b[0m \x1b[32m0/7 \x1b[0m \x1b[32m0 of 7 \x1b[0m \r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[2;36m[TIME]\x1b[0m\x1b[2;36m \x1b[0mhello \ntest foo \x1b[38;5;237m━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:07\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m0/10 \x1b[0m \x1b[31m?\x1b[0m \x1b[32m 0/10\x1b[0m \x1b[32m 0 of 10\x1b[0m \n \x1b[32mbytes \x1b[0m \ntest bar \x1b[38;5;237m━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:19\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m7 bytes \x1b[0m \x1b[32m0/7 bytes\x1b[0m \x1b[31m?\x1b[0m \x1b[32m0/7 \x1b[0m \x1b[32m0 of 7 \x1b[0m \r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2Kworld\ntest foo \x1b[38;5;237m━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:07\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m10 bytes\x1b[0m \x1b[32m0/10 \x1b[0m \x1b[31m?\x1b[0m \x1b[32m 0/10\x1b[0m \x1b[32m 0 of 10\x1b[0m \n \x1b[32mbytes \x1b[0m \ntest bar \x1b[38;5;237m━━━━━━━━━\x1b[0m \x1b[36m-:--:--\x1b[0m \x1b[33m0:00:19\x1b[0m \x1b[32m0 bytes\x1b[0m \x1b[32m7 bytes \x1b[0m \x1b[32m0/7 bytes\x1b[0m \x1b[31m?\x1b[0m \x1b[32m0/7 \x1b[0m \x1b[32m0 of 7 \x1b[0m \r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2Ktest foo \x1b[38;2;114;156;31m━━━━━━\x1b[0m \x1b[36m0:00:…\x1b[0m \x1b[33m0:00:…\x1b[0m \x1b[32m12 \x1b[0m \x1b[32m10 \x1b[0m \x1b[32m12/10 \x1b[0m \x1b[31m1 \x1b[0m \x1b[32m12/10\x1b[0m \x1b[32m12 of \x1b[0m \x1b[31m1.3 \x1b[0m\n \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[31mbyte/s\x1b[0m \x1b[32m10 \x1b[0m \x1b[31mit/s \x1b[0m\ntest bar \x1b[38;2;114;156;31m━━━━━━\x1b[0m \x1b[36m0:00:…\x1b[0m \x1b[33m0:00:…\x1b[0m \x1b[32m16 \x1b[0m \x1b[32m7 \x1b[0m \x1b[32m16/7 \x1b[0m \x1b[31m2 \x1b[0m \x1b[32m16/7 \x1b[0m \x1b[32m16 of 7\x1b[0m \x1b[31m1.5 \x1b[0m\n \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[31mbytes…\x1b[0m \x1b[31mit/s \x1b[0m\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2Ktest foo \x1b[38;2;114;156;31m━━━━━━\x1b[0m \x1b[36m0:00:…\x1b[0m \x1b[33m0:00:…\x1b[0m \x1b[32m12 \x1b[0m \x1b[32m10 \x1b[0m \x1b[32m12/10 \x1b[0m \x1b[31m1 \x1b[0m \x1b[32m12/10\x1b[0m \x1b[32m12 of \x1b[0m \x1b[31m1.3 \x1b[0m\n \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[31mbyte/s\x1b[0m \x1b[32m10 \x1b[0m \x1b[31mit/s \x1b[0m\ntest bar \x1b[38;2;114;156;31m━━━━━━\x1b[0m \x1b[36m0:00:…\x1b[0m \x1b[33m0:00:…\x1b[0m \x1b[32m16 \x1b[0m \x1b[32m7 \x1b[0m \x1b[32m16/7 \x1b[0m \x1b[31m2 \x1b[0m \x1b[32m16/7 \x1b[0m \x1b[32m16 of 7\x1b[0m \x1b[31m1.5 \x1b[0m\n \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[32mbytes \x1b[0m \x1b[31mbytes…\x1b[0m \x1b[31mit/s \x1b[0m\n\x1b[?25h\r\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K" assert result == expected diff --git a/tests/test_segment.py b/tests/test_segment.py index 3db60b908..4dac53c8c 100644 --- a/tests/test_segment.py +++ b/tests/test_segment.py @@ -2,6 +2,7 @@ import pytest +from rich.cells import cell_len from rich.segment import ControlType, Segment, SegmentLines, Segments from rich.style import Style @@ -284,6 +285,34 @@ def test_split_cells_emoji(text, split, result): assert Segment(text).split_cells(split) == result +def test_split_cells_mixed() -> None: + """Check that split cells splits on cell positions.""" + # Caused https://github.com/Textualize/textual/issues/4996 in Textual + test = Segment("早乙女リリエル (CV: 徳井青)") + for position in range(1, test.cell_length): + left, right = Segment.split_cells(test, position) + assert cell_len(left.text) == position + assert cell_len(right.text) == test.cell_length - position + + +def test_split_cells_doubles() -> None: + """Check that split cells splits on cell positions with all double width characters.""" + test = Segment("早" * 20) + for position in range(1, test.cell_length): + left, right = Segment.split_cells(test, position) + assert cell_len(left.text) == position + assert cell_len(right.text) == test.cell_length - position + + +def test_split_cells_single() -> None: + """Check that split cells splits on cell positions with all single width characters.""" + test = Segment("A" * 20) + for position in range(1, test.cell_length): + left, right = Segment.split_cells(test, position) + assert cell_len(left.text) == position + assert cell_len(right.text) == test.cell_length - position + + def test_segment_lines_renderable(): lines = [[Segment("hello"), Segment(" "), Segment("world")], [Segment("foo")]] segment_lines = SegmentLines(lines) diff --git a/tests/test_text.py b/tests/test_text.py index e1bd438b7..fee7302f2 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -1034,3 +1034,34 @@ def test_extend_style(): text.extend_style(2) assert text.plain == "foo bar " assert text.spans == [Span(0, 3, "red"), Span(4, 9, "bold")] + + +def test_append_tokens() -> None: + """Regression test for https://github.com/Textualize/rich/issues/3014""" + + console = Console() + t = Text().append_tokens( + [ + ( + "long text that will be wrapped with a control code \r\n", + "red", + ), + ] + ) + with console.capture() as capture: + console.print(t, width=40) + + output = capture.get() + print(repr(output)) + assert output == "long text that will be wrapped with a \ncontrol code \n\n" + + +def test_append_loop_regression() -> None: + """Regression text for https://github.com/Textualize/rich/issues/3479""" + a = Text("one", "blue") + a.append(a) + assert a.plain == "oneone" + + b = Text("two", "blue") + b.append_text(b) + assert b.plain == "twotwo" diff --git a/tests/test_traceback.py b/tests/test_traceback.py index 7f1525c28..ed80d1ba7 100644 --- a/tests/test_traceback.py +++ b/tests/test_traceback.py @@ -327,3 +327,34 @@ def level3(): assert len(frames) == expected_frames_length frame_names = [f.name for f in frames] assert frame_names == expected_frame_names + + +@pytest.mark.skipif( + sys.version_info.minor >= 11, reason="Not applicable after Python 3.11" +) +def test_traceback_finely_grained_missing() -> None: + """Before 3.11, the last_instruction should be None""" + try: + 1 / 0 + except: + traceback = Traceback() + last_instruction = traceback.trace.stacks[-1].frames[-1].last_instruction + assert last_instruction is None + + +@pytest.mark.skipif( + sys.version_info.minor < 11, reason="Not applicable before Python 3.11" +) +def test_traceback_finely_grained() -> None: + """Check that last instruction is populated.""" + try: + 1 / 0 + except: + traceback = Traceback() + last_instruction = traceback.trace.stacks[-1].frames[-1].last_instruction + assert last_instruction is not None + assert isinstance(last_instruction, tuple) + assert len(last_instruction) == 2 + start, end = last_instruction + print(start, end) + assert start[0] == end[0] diff --git a/tox.ini b/tox.ini index e09b0845c..5e7111868 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ minversion = 4.0.0 envlist = lint docs - py{37,38,39,310,311} + py{37,38,39,310,311,312,313} isolated_build = True [testenv]