From 49c5e74b370f22496683aa481fb00b84099dc218 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Wed, 24 Jun 2026 22:01:11 +0200 Subject: [PATCH 1/4] fix: clamp n to len(pwcs) in wcswidth/wcstwidth to avoid IndexError Passing n > len(pwcs) to wcswidth() or wcstwidth() causes an IndexError when the loop index reaches the end of the string, because `end` was set to n directly. Fix: `end = min(n, len(pwcs))` so that n larger than the string length is treated the same as measuring the whole string, which is the natural Python semantics for sized strings. Adds a regression test covering ASCII, wide characters, and ZWJ clusters. --- tests/test_core.py | 16 ++++++++++++++++ wcwidth/_wcswidth.py | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 882b2eaf..cb3deba1 100755 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -497,6 +497,22 @@ def test_zwj_at_end_of_string(): assert wcwidth.wcswidth('a\u200D') == 1 +def test_wcswidth_n_exceeds_length(): + """ + wcswidth() with n > len(string) returns same as n=None. + + Passing n larger than the string length should not raise IndexError; + it should behave identically to measuring the whole string. + """ + # ASCII string + assert wcwidth.wcswidth('hello', 10) == 5 + # Wide characters + assert wcwidth.wcswidth('\u30B3\u30F3', 5) == 4 + # ZWJ cluster + family = '\U0001F468\u200D\U0001F469\u200D\U0001F467' + assert wcwidth.wcswidth(family, len(family) + 1) == wcwidth.wcswidth(family) + + def test_soft_hyphen(): # Test SOFT HYPHEN, category 'Cf' usually are zero-width, but most # implementations agree to draw it was '1' cell, visually diff --git a/wcwidth/_wcswidth.py b/wcwidth/_wcswidth.py index 4b473fa7..32488d5b 100644 --- a/wcwidth/_wcswidth.py +++ b/wcwidth/_wcswidth.py @@ -98,7 +98,7 @@ def wcswidth( _wcwidth = wcwidth if ambiguous_width == 1 else lambda c: wcwidth(c, 'auto', ambiguous_width) - end = len(pwcs) if n is None else n + end = len(pwcs) if n is None else min(n, len(pwcs)) total_width = 0 idx = 0 @@ -262,7 +262,7 @@ def wcstwidth( # Select wcwidth call pattern for best lru_cache performance _wcwidth = wcwidth if ambiguous_width == 1 else lambda c: wcwidth(c, 'auto', ambiguous_width) - end = len(pwcs) if n is None else n + end = len(pwcs) if n is None else min(n, len(pwcs)) total_width = 0 idx = 0 From ab680a8859a7ef2b738a3913d9fec34d72366091 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Thu, 25 Jun 2026 17:23:22 +0200 Subject: [PATCH 2/4] Cover wcswidth n overflow companion paths --- tests/test_core.py | 7 ++++--- tests/test_term_overrides.py | 7 +++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index cb3deba1..4b22edc2 100755 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -499,10 +499,10 @@ def test_zwj_at_end_of_string(): def test_wcswidth_n_exceeds_length(): """ - wcswidth() with n > len(string) returns same as n=None. + Wcswidth() with n > len(string) returns same as n=None. - Passing n larger than the string length should not raise IndexError; - it should behave identically to measuring the whole string. + Passing n larger than the string length should not raise IndexError; it should behave + identically to measuring the whole string. """ # ASCII string assert wcwidth.wcswidth('hello', 10) == 5 @@ -511,6 +511,7 @@ def test_wcswidth_n_exceeds_length(): # ZWJ cluster family = '\U0001F468\u200D\U0001F469\u200D\U0001F467' assert wcwidth.wcswidth(family, len(family) + 1) == wcwidth.wcswidth(family) + assert wcwidth.wcstwidth(family, len(family) + 1) == wcwidth.wcstwidth(family) def test_soft_hyphen(): diff --git a/tests/test_term_overrides.py b/tests/test_term_overrides.py index 75f88052..2c320005 100644 --- a/tests/test_term_overrides.py +++ b/tests/test_term_overrides.py @@ -418,6 +418,13 @@ def test_get_term_overrides_narrow_wider_still_empty(): assert overrides.narrow_wider == () +@pytest.mark.parametrize('func', [wcwidth.wcstwidth, wcwidth.width]) +def test_narrow_wider_width(func): + """Width() matches wcstwidth() for narrow_wider overrides.""" + assert wcwidth.wcswidth('\u261d') == 1 + assert func('\u261d', term_program='kitty') == 2 + + @pytest.mark.parametrize('codepoint', [ '\u00ad', '\u0600', From a2a328a62cc2c7bca5ad0ab52e6e897054b5c1ea Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Mon, 29 Jun 2026 13:20:45 -0400 Subject: [PATCH 3/4] reduce test size --- tests/test_core.py | 17 +++-------------- tests/test_term_overrides.py | 2 +- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 4b22edc2..9d500757 100755 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -498,20 +498,9 @@ def test_zwj_at_end_of_string(): def test_wcswidth_n_exceeds_length(): - """ - Wcswidth() with n > len(string) returns same as n=None. - - Passing n larger than the string length should not raise IndexError; it should behave - identically to measuring the whole string. - """ - # ASCII string - assert wcwidth.wcswidth('hello', 10) == 5 - # Wide characters - assert wcwidth.wcswidth('\u30B3\u30F3', 5) == 4 - # ZWJ cluster - family = '\U0001F468\u200D\U0001F469\u200D\U0001F467' - assert wcwidth.wcswidth(family, len(family) + 1) == wcwidth.wcswidth(family) - assert wcwidth.wcstwidth(family, len(family) + 1) == wcwidth.wcstwidth(family) + """Verify wcswidth() with n > len(string) does not raise IndexError.""" + assert wcwidth.wcswidth('hello', n=999) == 5 + assert wcwidth.wcswidth('\u30B3\u30F3', n=999) == 4 def test_soft_hyphen(): diff --git a/tests/test_term_overrides.py b/tests/test_term_overrides.py index 2c320005..0a7162ef 100644 --- a/tests/test_term_overrides.py +++ b/tests/test_term_overrides.py @@ -420,7 +420,7 @@ def test_get_term_overrides_narrow_wider_still_empty(): @pytest.mark.parametrize('func', [wcwidth.wcstwidth, wcwidth.width]) def test_narrow_wider_width(func): - """Width() matches wcstwidth() for narrow_wider overrides.""" + """Verify width() & wcstwidth() match result of narrow_wider overrides.""" assert wcwidth.wcswidth('\u261d') == 1 assert func('\u261d', term_program='kitty') == 2 From e7e715aece311a12b15d50ccb25ec934afdae376 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Mon, 29 Jun 2026 13:24:17 -0400 Subject: [PATCH 4/4] document bugfix, prepare for 0.8.2 --- docs/intro.rst | 4 ++++ pyproject.toml | 2 +- wcwidth/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/intro.rst b/docs/intro.rst index f70d515b..6fc71c96 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -654,6 +654,10 @@ languages: History ======= +0.8.2 *2026-06-29* + * **Bugfix** Do not raise IndexError when given legacy POSIX ``n`` argument to `wcswidth()`_ or + `wcstwidth()`_ exceed string length without raising IndexError + 0.8.1 *2026-06-08* * **Improved** `wcstwidth()`_ with new ``zeroer``, ``narrow_wider``, and ``narrow_zeroer`` Corrections_. `PR #226`_ diff --git a/pyproject.toml b/pyproject.toml index 10b0320f..249b0d53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = [ "hatchling" ] [project] name = "wcwidth" -version = "0.8.1" # don't forget to also update wcwidth/__init__.py:__version__ +version = "0.8.2" # don't forget to also update wcwidth/__init__.py:__version__ description = "Measures the displayed width of unicode strings in a terminal" readme = "README.rst" keywords = [ diff --git a/wcwidth/__init__.py b/wcwidth/__init__.py index 2e619eb0..366911bd 100644 --- a/wcwidth/__init__.py +++ b/wcwidth/__init__.py @@ -78,4 +78,4 @@ # Using 'hatchling', it does not seem to provide the pyproject.toml nicety, "dynamic = ['version']" # like flit_core, maybe there is some better way but for now we have to duplicate it in both places # Prefer the installed distribution version when available (helps test environments) -__version__ = '0.8.1' # don't forget to also update pyproject.toml:version +__version__ = '0.8.2' # don't forget to also update pyproject.toml:version