From a1a3be12aad883642cad462fcf333c4095a889bf Mon Sep 17 00:00:00 2001 From: Jeff Weiss Date: Wed, 18 Feb 2026 18:00:18 -0500 Subject: [PATCH 1/6] Increment index for zero width chars before first span, avoiding infinite loop --- rich/cells.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rich/cells.py b/rich/cells.py index 31165957b9..904515881c 100644 --- a/rich/cells.py +++ b/rich/cells.py @@ -207,6 +207,9 @@ def split_graphemes( # zero width characters are associated with the previous character start, _end, cell_length = spans[-1] spans[-1] = (start, index := index + 1, cell_length) + else: + # avoid infinite loop (zero width character before first span) + index += 1 return (spans, total_width) From 2128d6864f06305398fe1a2b3353cd2d3572825b Mon Sep 17 00:00:00 2001 From: Jeff Weiss Date: Wed, 18 Feb 2026 18:01:29 -0500 Subject: [PATCH 2/6] Add test for split_graphemes input beginning with nonprintable characters --- tests/test_cells.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_cells.py b/tests/test_cells.py index f101740a01..13e4c8ca33 100644 --- a/tests/test_cells.py +++ b/tests/test_cells.py @@ -204,3 +204,14 @@ def test_non_printable(): for ordinal in range(31): character = chr(ordinal) assert cell_len(character) == 0 + + +def test_non_printable_at_grapheme_start(): + """Test non printable characters + tests regression in 14.3.2 https://github.com/Textualize/rich/issues/3958 + """ + # cp1252 encoded ANSI byte string + bstr = b"\x1b[2m\xe2\x95\xad\xe2\x94\x80\x1b[0m\x1b[2m Options \x1b[0m\x1b[2m\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2" + text = bstr.decode("cp1252") + # we are just testing that this does not hang, no assertion on output values + spans, cell_length = split_graphemes(text) From db6168b9ec1886b27696a23a859a54115fba691c Mon Sep 17 00:00:00 2001 From: Jeff Weiss Date: Wed, 18 Feb 2026 18:04:13 -0500 Subject: [PATCH 3/6] Add to CONTRIBUTORS --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4d77a0e3ed..3c340c9396 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) +- [Jeff Weiss](https://github.com/jmw182) From e23ef9048a9109e622d83dda2dc8d820c0da5f38 Mon Sep 17 00:00:00 2001 From: Jeff Weiss Date: Wed, 18 Feb 2026 18:10:28 -0500 Subject: [PATCH 4/6] update CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b093843a3..d4233f00e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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 + +### Fixed +- Fixed `cells.split_graphemes` infinite loop. https://github.com/Textualize/rich/pull/4002 + ## [14.3.2] - 2026-02-01 ### Fixed From becb9a6d4814d25b7407623368ce5307db09595c Mon Sep 17 00:00:00 2001 From: Jeff Weiss Date: Wed, 18 Feb 2026 20:31:36 -0500 Subject: [PATCH 5/6] Handle test failure (hanging) by adding pytest-timeout dev dependency --- poetry.lock | 20 ++++++++++++++++++-- pyproject.toml | 1 + tests/test_cells.py | 1 + 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0234516312..26b275a227 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "appnope" @@ -804,6 +804,21 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +description = "pytest plugin to abort hanging tests" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"}, + {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + [[package]] name = "pyyaml" version = "6.0.3" @@ -989,6 +1004,7 @@ files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] +markers = {main = "extra == \"jupyter\" and python_version < \"3.10\""} [[package]] name = "virtualenv" @@ -1044,4 +1060,4 @@ jupyter = ["ipywidgets"] [metadata] lock-version = "2.1" python-versions = ">=3.8.0" -content-hash = "610597849eb5fb1d82cd5e647d616dd41412674d531c2f7819380bbc9b54e8e2" +content-hash = "4927e5841e58c478fd991cf8d660f9968373873c1b20bdbc597cff69eae2468e" diff --git a/pyproject.toml b/pyproject.toml index 23e86e4006..ac2b3f74bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ pytest-cov = "^3.0.0" attrs = "^21.4.0" pre-commit = "^2.17.0" typing-extensions = ">=4.0.0, <5.0" +pytest-timeout = "^2.4.0" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/test_cells.py b/tests/test_cells.py index 13e4c8ca33..e39998ec5e 100644 --- a/tests/test_cells.py +++ b/tests/test_cells.py @@ -206,6 +206,7 @@ def test_non_printable(): assert cell_len(character) == 0 +@pytest.mark.timeout(2) def test_non_printable_at_grapheme_start(): """Test non printable characters tests regression in 14.3.2 https://github.com/Textualize/rich/issues/3958 From 97d96ba00cff4efc7ceed0d790f54217b48f9601 Mon Sep 17 00:00:00 2001 From: Jeff Weiss Date: Thu, 19 Feb 2026 09:37:44 -0500 Subject: [PATCH 6/6] simplify test --- tests/test_cells.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_cells.py b/tests/test_cells.py index e39998ec5e..186940be5e 100644 --- a/tests/test_cells.py +++ b/tests/test_cells.py @@ -211,8 +211,8 @@ def test_non_printable_at_grapheme_start(): """Test non printable characters tests regression in 14.3.2 https://github.com/Textualize/rich/issues/3958 """ - # cp1252 encoded ANSI byte string - bstr = b"\x1b[2m\xe2\x95\xad\xe2\x94\x80\x1b[0m\x1b[2m Options \x1b[0m\x1b[2m\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2" - text = bstr.decode("cp1252") - # we are just testing that this does not hang, no assertion on output values + text = "\x1b\x1b" + # we are mostly just testing that this does not hang spans, cell_length = split_graphemes(text) + assert spans == [] + assert cell_length == 0