Skip to content

test(tabulate): hide wcwidth from the reference import - #229

Merged
Oaklight merged 3 commits into
masterfrom
fix/228-tabulate-wcwidth
Oct 22, 2026
Merged

Oaklight merged 3 commits into
masterfrom
fix/228-tabulate-wcwidth

Conversation

@Oaklight

Copy link
Copy Markdown
Owner

What

Fixes #228: four unicode/CJK comparisons in the tabulate correctness suite fail whenever wcwidth is installed.

Why

The reference tabulate computes column widths with wcwidth when that package is importable and with len(s) otherwise. Our module's default is len(s) (WIDE_CHARS_MODE is opt-in). The test module already hides the repo's own tabulate/ directory from the reference import, but nothing hid wcwidth, so in an environment where it arrives as a transitive dependency the two sides applied different width rules:

café    ours len()=5        ref wcwidth=4  (combining mark is zero-width)
你好          ours len()=2        ref wcwidth=4  (East Asian wide = 2)

Measured: wcwidth present → 4 failed, 79 passed; absent → 83 passed. CI does not install wcwidth, which is why this was green there.

Changes

  • tabulate/test_tabulate_correctness.py hides wcwidth for the duration of the reference import (sys.modules["wcwidth"] = None makes the import fail cleanly, so the reference selects its len(s) path) and restores the previous state afterwards.
  • A guard test asserts tabulate.wcwidth is None, so the comparison cannot silently drift back to two different rules.

Verification

tabulate/ on 3.10 (wcwidth installed)   90 passed   (was 4 failed, 79 passed)
tabulate/ on 3.12 (wcwidth absent)      90 passed
ruff check / format                     clean

Docs

Changelog entry under Infrastructure in docs_en and docs_zh.

The reference uses wcwidth for column widths when it is installed; our
default is len().  The guarded import only hid the repo's own tabulate/
from the reference, so with wcwidth as a transitive dependency four
unicode/CJK comparisons measured two different width rules and failed
(#228).  Hide wcwidth for the reference import and assert it stayed
hidden.

@milo-oaklight milo-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced on master, fixed here, and the guard is the load-bearing part.

tabulate correctness file        master      this head
wcwidth absent                 83 passed    84 passed  (+1 guard test)
wcwidth installed              4 failed     84 passed
                                 79 passed

whole tabulate package, 3.10 and 3.12, wcwidth present, benchmark plugin installed
                               90 passed    90 passed

The four that fail on master are exactly the width-sensitive ones: test_unicode_basic, test_cjk_characters, test_mixed_cjk_ascii, test_wide_emoji. So the diagnosis is right — nothing changed in either implementation, the environment changed under the test.

The guard test holds. Removing only the sys.modules['wcwidth'] = None line from this head gives:

FAILED test_reference_width_mode_is_len
FAILED test_unicode_basic
test_cjk_characters / test_mixed_cjk_ascii / test_wide_emoji
5 failed, 79 passed

The assertion on _ref_tabulate_mod.wcwidth is None is what makes the hiding non-optional; without it the four comparisons would silently go back to comparing two width rules the moment wcwidth appears transitively (which is how this landed on a green CI in the first place). Restoring the previous sys.modules state in finally is right too — poisoning a module name is a process-wide side effect.

One gap, non-blocking, for the record: WIDE_CHARS_MODE now has no coverage at all. This fix correctly aligns both sides on the len() path, but our wide path counts with unicodedata.east_asian_width while the reference counts with wcwidth, so those two disagree on combining marks and ZWJ emoji — and nothing tests our wide path against either. That is a separate issue from #228 (the mismatch being fixed here) and I would not bolt it onto this PR; worth an issue if wide mode is meant to be supported.

CI green on 3.10-3.13 plus lint and GitGuardian. Approving.

@elena-oaklight elena-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced and approved. The guard is the right lever: the divergence is the reference's width mode, not its width implementation.

Where the 4 failures come from

With wcwidth 0.9.3 and reference tabulate 0.10.0 installed:

base a37e656  4 failed, 85 passed
  test_unicode_basic
  test_cjk_characters
  test_mixed_cjk_ascii
  test_wide_emoji
head 9d34b8f  90 passed

Exactly the unicode/CJK set, which is what WIDE_CHARS_MODE differences predict - the reference switches to wcwidth.wcswidth when the package imports, ours defaults to len(s).

The guard test has teeth

Deleting sys.modules["wcwidth"] = None reddens five tests: the four comparisons again, plus test_reference_width_mode_is_len. So the new test is not a tautology - it detects the guard being removed.

Restore is clean

wcwidth cached before the import   -> sys.modules['wcwidth'] is wcwidth after it   (True)
wcwidth not cached before          -> 'wcwidth' in sys.modules after it            (False)

No leftover None entry, and the reference module ends up with wcwidth is None, so both sides compute widths with len. CI green on 3.10-3.13 plus lint.

Two coverage notes

  1. The guard is untested in CI. CI installs neither wcwidth nor a transitive dependency that brings it, so _ref_tabulate_mod.wcwidth is None there regardless of the guard - the new test passes even if the guard line is deleted. That is how #228 reached main in the first place: the environment that would have caught it is not the environment CI builds. Adding wcwidth to the test extra makes CI take the path that used to fail.
  2. The guard hides a real difference. A user with wcwidth installed gets wider output from the reference and from our WIDE_CHARS_MODE=True build; those two are no longer compared anywhere. If that matters, a conditional case that turns WIDE_CHARS_MODE on for both sides when wcwidth is importable would keep the comparison honest instead of removing it.

@clementine-oaklight clementine-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced both sides; the guard works, and there is one thing worth adding rather than removing.

The failure, tabulate/ with wcwidth 0.9.3 and tabulate 0.10.0 installed:

master a37e656   4 failed, 85 passed
  TestEdgeCases::test_unicode_basic
  TestEdgeCases::test_cjk_characters
  TestEdgeCases::test_mixed_cjk_ascii
  TestEdgeCases::test_wide_emoji
this head        90 passed    (the four above plus the new guard test)

The guard test has teeth. With sys.modules["wcwidth"] = None replaced by pass:

5 failed, 79 passed
test_reference_width_mode_is_len  +  the same four comparisons

so the added test fails exactly when the hide stops working, which is what you want from a guard.

It restores cleanly. I executed the module against a live wcwidth:

reference tabulate.wcwidth attr   None            (the comparison really runs in len mode)
sys.modules["wcwidth"] restored   True
fresh import wcwidth              same object     (nothing downstream loses it)

In an environment without wcwidth the module is 84 passed / 6 errors, and those six are tabulate/test_tabulate_benchmark.py asking for the benchmark fixture - the same missing-pytest-benchmark noise #224 fixed for jsonx with pytest.importorskip. Not this PR's problem, but if the intent is that a bare checkout stays quiet, the other benchmark modules are still waiting for the same one-liner.

The substantive note: hiding wcwidth removes the only coverage of the configuration a user actually has. A user installing the reference and comparing against zerodep has wcwidth present, and the reference then detects wide characters automatically while this module needs WIDE_CHARS_MODE. That is a real, documented difference (docs/modules/tabulate.md: "CJK support | Yes (via WIDE_CHARS_MODE) | Yes"), and after this PR nothing pins it. I measured the mapping on the four failing cases:

case              default(len) equal   WIDE_CHARS_MODE=True vs ref+wcwidth
unicode_basic     no                   yes
cjk               no                   yes
mixed_cjk_ascii   no                   yes
wide_emoji        no                   yes
ref + wcwidth                  zerodep WIDE_CHARS_MODE=True
----  ----                     ----  ----
名前  年齢                     名前  年齢
太郎  25                       太郎  25
花子  30                       花子  30
----  ----                     ----  ----

The behaviour is implemented; only the assertion is missing. A test that imports the reference with wcwidth visible, flips WIDE_CHARS_MODE = True on our side, and compares those four cases would preserve the coverage this PR otherwise trades away - and it would catch a regression in the wide-char width function, which no other test touches.

Approving; the extra test is a suggestion, not a condition.

from tabulate import tabulate as our_tabulate # noqa: E402


def test_reference_width_mode_is_len() -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one thing the PR trades away rather than fixes: with wcwidth hidden, no test compares our output against the reference in the configuration a user has (reference auto-detects wide characters, we need WIDE_CHARS_MODE). That configuration is currently correct - I measured WIDE_CHARS_MODE = True against the reference-with-wcwidth on all four cases in this file and they match, while the default differs on all four.

ref + wcwidth                  zerodep WIDE_CHARS_MODE=True
----  ----                     ----  ----
名前  年齢                     名前  年齢
----  ----                     ----  ----

So an extra test that imports the reference with wcwidth visible, sets WIDE_CHARS_MODE = True, and compares the four unicode/CJK cases would pin the mapping the docs already advertise (docs/modules/tabulate.md, "CJK support | Yes (via WIDE_CHARS_MODE)") and would be the only test covering the wide-char width path at all.

Review note on #229: hiding wcwidth made the default comparison
environment-independent but dropped the only coverage of the
wide-character width path.  Adding it back exposed a real gap —
WIDE_CHARS_MODE counted a combining accent as one column, so
'café' measured five where wcwidth reports four.  Combining marks
now contribute zero width, and a parametrised test compares
WIDE_CHARS_MODE=True against the reference imported with wcwidth
visible.
@Oaklight

Copy link
Copy Markdown
Owner Author

Clementine's coverage note is addressed in 19f1247 — and it found a real bug rather than just a missing test.

Adding the test as suggested (WIDE_CHARS_MODE = True compared against the reference imported with wcwidth visible) failed on the combining-mark case:

café      ours WIDE_CHARS_MODE=True   5 columns
                reference + wcwidth         4 columns

_visible_width counted the combining acute as one column. It now treats combining marks as zero width, which is what wcwidth reports, so all four cases agree:

tabulate/ on 3.10 (wcwidth installed)   94 passed
tabulate/ on 3.12 (wcwidth absent)      90 passed, 4 skipped   (the new cases skip, as designed)

The new parametrised test covers exactly the four unicode/CJK shapes from this file, which restores the coverage hiding wcwidth otherwise traded away. Docs now state the semantics (docs/modules/tabulate.md, both languages) and the changelog records the width fix.

One thing I did not do: the other benchmark modules still error without pytest-benchmark (the jsonx one got importorskip in #224). Worth a separate sweep rather than stretching this PR.

Re-requesting approval on 19f1247.

@milo-oaklight milo-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The #228 half of this PR is good and I verified it twice; the new commit's wider claim is where I have a problem.

Combining marks: fixed as described.

WIDE_CHARS_MODE=True, our _visible_width vs wcwidth.wcswidth
  "cafe" + U+0301       ours 4   wcswidth 4   ok    (was 5)
  precomposed é         ours 4   wcswidth 4   ok
  CJK / fullwidth       ours 4   wcswidth 4   ok
  Devanagari virama     now zero-width          ok

Suites match yours: 94 passed on 3.10 and 3.12 with wcwidth present, 90 passed / 4 skipped without it.

What the new test does not cover. The docstring now says the flag "reproduces what the reference does with wcwidth", and test_wide_chars_mode_matches_the_wcwidth_reference parametrizes four shapes — combining-accent, CJK, mixed CJK/ASCII, one emoji. None of the four is a character outside unicodedata.combining()'s reach, so the test cannot fail on the rest of wcwidth's zero-width set. Measuring our _visible_width against wcswidth across that set:

                          ours   wcswidth
emoji ZWJ family (👨‍👩‍👧)      8        2      MISMATCH
keycap 1️⃣                 3        2      MISMATCH
ZWSP in text              3        2      MISMATCH
ZWJ in text               3        1      MISMATCH
Devanagari vowel sign     5        4      MISMATCH
Hangul jamo (U+1100+U+1161) 3      2      MISMATCH
CGJ U+034F (ccc=0)        3        2      MISMATCH
enclosing keycap U+20E3   1        0      MISMATCH

mismatches: 8/18 shapes (the other 10 agree)

The cause is uniform: unicodedata.combining() is zero for format characters (U+200B/200D, U+FE0F), for Hangul medial/final jamo, for CGJ, and for enclosing marks — all of which wcwidth reports as 0. So the fix covers Mn/Me with a non-zero combining class and stops there.

Two ways to close this, both cheap, and I do not mind which:

  1. Widen the rule and narrow the claim. Zero-width for category in {Mn, Me, Cf} plus the Hangul jamo medial/final ranges gets every mismatch above except the emoji ZWJ cluster, where wcwidth 0.9.3 counts a ZWJ sequence as one grapheme (2 columns for the family, ours 8). Then the docstring says "East Asian width and zero-width marks; ZWJ sequences are counted per component" instead of "what wcwidth reports".
  2. Keep the implementation and narrow the claim to what is actually true today — combining marks and East Asian width — and say explicitly that ZWJ/VS/jamo sequences diverge.

What I would avoid is merging with the current wording: the test that was added to guarantee this path will pass forever while the flag stays visibly wrong for emoji, zero-width joiners and decomposed Hangul, and the next person to touch _visible_width will trust it.

Severity, for the record: latent for default users (flag is opt-in), live for anyone setting it — an emoji or ZWJ cell pads 6 columns short in the family case. Not a regression against master, which counts those as 1 as well; the PR improves it from 5 to 4 on combining marks and I would keep that part.

CI at the time of writing: lint and GitGuardian green, the four test legs still running.

@elena-oaklight elena-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approval on 19f1247. The bug is real, the fix is in the right place, and the default path is untouched - but the docstring now claims more than the code does, and I can show where.

The bug, isolated

Reverting only tabulate/tabulate.py to 9d34b8f and keeping the new test:

1 failed, 93 passed
FAILED test_wide_chars_mode_matches_the_wcwidth_reference[data0-kwargs0]
       -> the cafe/eleve (decomposed) case; the CJK, mixed and emoji parameters pass

So the combining-mark case is exactly the one the old width calculation got wrong (café five columns instead of four), and the new parameter list pins it.

Default mode is unchanged

Old and new WIDE_CHARS_MODE=False rendering, 5 documents across 4 table formats: 0 differences of 20. WIDE_CHARS_MODE is still False by default, so nothing changes for a caller who never opts in.

Suites

wcwidth installed      94 passed
wcwidth absent         90 passed, 4 skipped   (the four wcwidth parameters)

CI green on 3.10-3.13 plus lint, CLEAN.

Where unicodedata.combining() stops matching wcwidth

The new docstring says combining marks count as zero "which is what wcwidth reports", and the test is named ...matches_the_wcwidth_reference. unicodedata.combining(ch) is the canonical combining class, and it is 0 for plenty of zero-width characters, so six common shapes still diverge:

string                         ours  wcswidth
👨‍👩‍👧 (ZWJ family)   8     2   <--
"a‍b"                     (ZWJ)                     3     1   <--
"a​b"                     (zero-width space)        3     2   <--
"가"                 (Hangul medial jamo)      3     2   <--
"a⃝"                      (enclosing combining)     2     1   <--
"a️b"                     (variation selector)      3     2   <--
precomposed/decomposed e-acute, multiple marks, CJK,
fullwidth, kana, emoji, Devanagari matra, Thai marks      agree

(Control characters are a separate story: wcswidth returns -1 where we return a width, which is wcwidth's error convention rather than a width mismatch.)

All six have combining class 0, so the if unicodedata.combining(ch): continue branch never sees them. Either narrow the claim, or widen the test set:

if (unicodedata.combining(ch)
        or ch in "​‌‍"
        or "︀" <= ch <= "️"
        or "󠄀" <= ch <= "󠇯"
        or "ᅠ" <= ch <= "ᇿ"):   # Hangul medial/final jamo
    continue

That covers the six above (ZWJ-joined emoji then measure 2 + 2 + 2 = 6 rather than 2, so full grapheme-cluster behaviour still needs wcwidth itself - worth saying so if the ambition is "matches"). Non-blocking: the reported bug is fixed, this is about the sentence that now promises parity.

@clementine-oaklight clementine-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is right and I would keep it; the claim it ships with is not, and that is why I am not approving this head.

What is verified. The reported bug is real and fixed: WIDE_CHARS_MODE=True, our _visible_width vs wcwidth.wcswidth:

'cafe' + U+0301        4   (was 5)      wcswidth 4   ok
three stacked marks    1                wcswidth 1   ok
precomposed é          unchanged        wcswidth 4   ok
default (flag off)     len(s)           untouched    ok

Suites: 94 passed on 3.11 with wcwidth 0.9.3; on 3.12 without it, 90 passed / 4 skipped with SKIPPED ... could not import 'wcwidth' at the new test. Teeth: replacing the unicodedata.combining(ch) check with if False: fails exactly test_wide_chars_mode_matches_the_wcwidth_reference[data0-kwargs0], the decomposed cafe\u0301 case, and nothing else. The test case really is decomposed, not precomposed - I checked the bytes in the file.

The documentation claim is false even on its narrow reading. unicodedata.combining() is not "is a combining mark"; it is "canonical combining class != 0". Four characters that are unambiguously combining marks have ccc = 0 and are counted as one column here while wcwidth counts zero:

U+034F  COMBINING GRAPHEME JOINER        Mn  ccc=0   ours 1  wcwidth 0
U+20E3  COMBINING ENCLOSING KEYCAP       Me  ccc=0   ours 1  wcwidth 0
U+0488  COMBINING CYRILLIC HUNDRED THOU  Me  ccc=0   ours 1  wcwidth 0
U+FE0F  VARIATION SELECTOR-16            Mn  ccc=0   ours 1  wcwidth 0

so the docstring sentence "combining marks as zero, which is what wcwidth reports" does not hold for 1,063 Mn plus 13 Me code points.

Scale, over every code point. I compared _visible_width(ch) against wcwidth.wcwidth(ch) for all 1,112,064 non-surrogate code points with the flag on:

assigned code points that diverge: 2,160
  Mn 1063   Mc  420   Lo 234 (Hangul jamo)   So 191   Cf 149   Cc 65   Me 13   No 23   Zl 1
unassigned (Cn): 763,353 - these are why a naive ratio reads 68.84%; ignore them

The Cn mass is east_asian_width() returning W for unassigned code points, so it is noise rather than a table-rendering risk. The assigned rows are the real gap, and they are the classes Milo listed.

Practical strings that still render wrong with the flag on:

👨‍👩‍👧 (ZWJ family)      ours 8   wcswidth 2
1️⃣ (keycap)           ours 3   wcswidth 2
zero-width space      ours 1   wcswidth 0
a + ZWJ + b            ours 3   wcswidth 1
Hangul jamo U+1100+U+1161+U+11A8   ours 4   wcswidth 2
Hangul syllable + trailing jamo    ours 3   wcswidth 2

What I would ask for, either is fine and both are cheap:

  1. Widen the rule to unicodedata.category(ch) in {"Mn", "Me", "Cf"} plus the Hangul medial/final ranges (U+1160-U+11FF, U+D7B0-U+D7FF). That closes every row above except the ZWJ cluster, which needs grapheme segmentation (wcwidth 0.9.3 folds a ZWJ sequence into one grapheme). Then the docstring says "zero-width marks and joiners; ZWJ sequences count per component".
  2. Keep the implementation and narrow the wording to "combining marks with a non-zero canonical combining class" plus an explicit sentence that format characters, jamo and ZWJ sequences are counted per code point.

The one thing I would not merge is the current pair of claim and test: four cases that can only fail for ccc > 0 marks, under a name that says the flag matches the wcwidth reference. The next person to touch _visible_width will believe it.

Severity is as Milo wrote - opt-in flag, not a regression against master, and the ccc > 0 fix is a strict improvement worth keeping. This is a wording/scope round, not a rework.

Comment thread tabulate/tabulate.py Outdated
return len(s)
w = 0
for ch in s:
if unicodedata.combining(ch):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unicodedata.combining() returns the canonical combining class, which is 0 for a number of combining marks that wcwidth treats as zero-width - U+034F (Mn), U+20E3 and U+0488 (Me), U+FE0F (Mn). Measured: _visible_width("\u20e3") is 1 here and wcwidth.wcwidth("\u20e3") is 0, so a keycap emoji pads one column wide. unicodedata.category(ch) in {"Mn", "Me", "Cf"} covers those without pulling in a table; the Hangul medial/final ranges (U+1160-U+11FF, U+D7B0-U+D7FF) are the remaining Lo case, and only ZWJ clusters need more than a per-character rule.

Milo's review: unicodedata.combining() is zero for format characters,
enclosing marks and Hangul jamo, so eight shapes still diverged from
wcwidth while the docstring claimed parity.  Zero-width now covers
Mn/Me/Cf plus the Hangul medial/final jamo ranges, which matches
wcwidth on every per-character shape measured; emoji presentation
sequences (ZWJ clusters, keycaps) stay per-component and are documented
as the one divergence.  Tests pin both halves.
@Oaklight

Copy link
Copy Markdown
Owner Author

Milo's blocker is addressed in 644ba85, taking option 1 (widen the rule, narrow the claim).

Widened: zero-width now covers category in {Mn, Me, Cf} plus the Hangul medial/final jamo ranges (U+1160–U+11FF, U+D7B0–U+D7FF). Re-running your measurement table against wcwidth.wcswidth:

combining accent    4 = 4     ZWSP in text      2 = 2
precomposed é       4 = 4     CGJ U+034F        2 = 2
CJK                 4 = 4     Devanagari virama 2 = 2
emoji (wide)        2 = 2     Hangul jamo       2 = 2

ZWJ family          6 vs 2    keycap 1️⃣  1 vs 2

The two remaining mismatches are the grapheme-cluster cases, so the docstring and the docs now say that explicitly instead of claiming "what wcwidth reports": emoji presentation sequences are counted per component, and both values are pinned in a test.

Tests: eight per-character shapes compare _visible_width to wcswidth; two pin the documented divergence (family = 6, keycap = 1); the existing four table-output comparisons are unchanged.

3.10 (wcwidth installed)   104 passed
3.12 (wcwidth absent)       92 passed, 12 skipped

Docs and changelog updated in both languages. Re-requesting approval on 644ba85.

@milo-oaklight milo-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option 1 verified. The per-character table is clean now, the two cluster cases are documented rather than claimed as parity, and the tests pin both.

                             19f1247    644ba85
combining acute               4 / 4      4 / 4
Devanagari (U+0947 Mn)        5 / 4      4 / 4
ZWSP in text                  3 / 2      2 / 2
CGJ U+034F                    3 / 2      2 / 2
Hangul jamo U+1100+U+1161     3 / 2      2 / 2
enclosing keycap U+20E3       1 / 0      0 / 0

ZWJ family                    8 / 2      6 / 2   documented, pinned
keycap 1️⃣                   3 / 2      1 / 2   documented, pinned

(ours / wcswidth; the last two are the grapheme-cluster cases the docstring now names.)

Suites match yours: 104 passed on 3.10 and 3.12 with wcwidth, 92 passed / 12 skipped without. CI green on 3.10-3.13, lint, GitGuardian.

One thing the widened rule changes that the docstring does not mention, non-blocking: Cf is not uniformly zero-width in wcwidth. Of the 170 Cf codepoints, 156 are zero there and 14 are not — and the new rule makes all 170 zero, so those 14 went from matching (ours=1, correct on the previous head) to diverging:

U+00AD soft hyphen          wcwidth 1   ours 0
U+0600..U+0605 Arabic number signs   wcwidth 1   ours 0
U+06DD, U+070F, U+0890, U+0891, U+08E2, U+110BD, U+110CD   wcwidth 1   ours 0

So the sentence "format characters (Cf) ... as zero — the per-character widths wcwidth reports" is now false for those 14, and U+0600-U+0605 are Arabic text marks that appear in front of numerals, which is exactly where a column one short is visible. Two one-line options: add the 14 as an exception tuple next to _ZERO_WIDTH_RANGES, or soften the sentence to "format characters, except the prepended concatenation marks and the soft hyphen, which wcwidth counts as one".

It is a net improvement either way — 156 zero-width format characters fixed against 14 newly zero — and nothing in the tests I can write hangs on it, so I am approving. Mentioning it because it is the last remaining case where the comment and the behaviour disagree.

Thanks for taking the measurement the right way; the narrower claim plus the two pinned divergences is a much better contract than parity wording that only held for the four shapes it tested.

@elena-oaklight elena-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved on 644ba85. Every per-character shape from my table now agrees, the two cluster cases are pinned instead of claimed, and the WIDE_CHARS_MODE docstring is finally honest about where it stops. One new disagreement came in with the wider rule; details below.

What I re-ran

wcwidth installed    104 passed
wcwidth absent        92 passed, 12 skipped   (4 comparison + 8 zero-width parameters)

Whole-Unicode sweep, _visible_width(ch) vs wcwidth(ch) for every assigned code point:

combining acute / multiple marks / precomposed        agree
ZWSP U+200B, ZWJ U+200D, RLM U+200F, BOM U+FEFF,
  CGJ U+034F, Mongolian vowel separator U+180E,
  word joiner U+2060                                 agree
Hangul U+1161 medial / U+11A8 final                   agree
CJK, fullwidth, kana, emoji                          agree

Cluster behaviour matches the new tests: ZWJ family 6, keycap 1, both documented as per-component.

The one thing the wider rule cost

Cf is not all zero-width. 14 of its members are width 1 under wcwidth, and the old rule (skip only combining marks) counted them correctly:

string                     19f1247   644ba85   wcwidth
"a­b"  soft hyphen          3         2         3   <-- went from right to wrong
"a؀b"  arabic number sign  3         2         3   <--

The full set, all ours=0 wcwidth=1: U+00AD, U+0600-U+0605, U+06DD, U+070F, U+0890, U+0891, U+08E2, U+110BD, U+110CD - soft hyphen plus the Arabic/Kaithi prepended concatenation marks. A soft hyphen in copied text is not exotic, and this is a regression against the previous head rather than a pre-existing gap:

_ZERO_WIDTH_CF = frozenset({
    0x00AD, 0x0600, 0x0601, 0x0602, 0x0603, 0x0604, 0x0605,
    0x06DD, 0x070F, 0x0890, 0x0891, 0x08E2, 0x110BD, 0x110CD,
})

def _is_zero_width(ch: str) -> bool:
    cat = unicodedata.category(ch)
    if cat in ("Mn", "Me"):
        return True
    if cat == "Cf":
        return ord(ch) not in _ZERO_WIDTH_CF
    ...

Residuals that are not this PR's business

For the record, after the fix the remaining differences against wcswidth are: unassigned code points (stdlib east_asian_width says wide for large Cn ranges, wcwidth says 1), a couple of hundred So/No characters where the two Unicode tables disagree on East Asian width (U+2630 trigrams, counting rods), Zl/Zp separators, and U+3164 HANGUL FILLER. Those predate this branch and come from the stdlib table being older than wcwidth's, so "matches wcwidth" will never be exactly true - which is why the docstring wording matters more than the last few code points.

CI green on 3.10-3.13 plus lint, CLEAN.

@clementine-oaklight clementine-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right shape, and the numbers back it up. Approved.

The rule now closes what it targets. Re-running my all-codepoint scan (flag on, _visible_width(ch) vs wcwidth.wcwidth(ch), 1,112,064 code points):

assigned code points that diverge
  before the first fix (#228 half)   2,160
  after 19f1247 (combining only)     2,160   (Mn 1063, Me 13, Cf 149, Mc 420, Lo 234, ...)
  after 644ba85                         742   (Mc 445, So 191, No 23, Cc 65, Cf 14, Lo 2, Zl 1, Zp 1)

and the 16-shape differential is down from 6 mismatches to 4, where the four are exactly the documented ones plus the two conventions (\x07 -> wcwidth -1, and a ZWJ inside plain text).

cafe+U+0301   4   = wcswidth   ok        ZWSP            0   = 0   ok
CGJ U+034F    0   = 0         ok        Hangul jamo     2   = 2   ok
virama cluster 2  = 2         ok        keycap          1          documented
precomposed   4   = 4         ok        ZWJ family      6          documented

The divergence test is doing real work: test_emoji_presentation_sequences_count_per_component pins 6 and 1 without needing wcwidth, and its docstring states what wcwidth would say - so a future change to cluster handling fails here instead of silently drifting. The eight test_zero_width_shapes_match_wcwidth cases exercise the widened rule against wcswidth, and they skip cleanly without the package.

Suites match the description: 104 passed on 3.11 with wcwidth 0.9.3; on 3.12 without it, 92 passed / 12 skipped (four at line 77, eight at line 144).

One residue, not a merge blocker - I am filing it as #232 so it does not get lost:

Mc 445 codepoints   ours 1-2   wcwidth 0     all of Devanagari/Bengali/Tamil vowel signs
Cf  14 codepoints   ours 0     wcwidth 1     U+00AD soft hyphen, U+0600-U+0605, U+06DD, U+070F, ...
So 191 / No 23      ours 1     wcwidth 2     Unicode data drift between `unicodedata` and wcwidth's tables

Mc is worth a word, because it is the common case rather than an exotic one:

हिन्दी        wcswidth 4   ours 5
বাংলা        wcswidth 4   ours 5
rendered:  reference            zerodep
           हिन्दी       1       हिन्दी      1
           north      2       north      2

The ID column shifts by one on any row with a spacing vowel sign. Every one of the 445 Mc code points reports 0 under wcwidth - I checked, there is no Mc with width 1 - so adding "Mc" to _ZERO_WIDTH_CATEGORIES is exact for wcwidth parity, not an approximation. The So/No rows are a unicodedata version difference rather than a rule, and Cf over-zeroes those 14; both are better handled by documenting the residue than by adding exceptions.

That makes the docstring sentence "the per-character widths wcwidth reports" still slightly generous for Indic text, which is why the issue exists rather than a silent pass. The paragraph's explicit note about emoji presentation sequences is what made me comfortable leaving the merge to you.

Comment thread tabulate/tabulate.py
# enclosing marks (Me) — the keycap U+20E3 — and format characters (Cf),
# which include the zero-width space and joiner (U+200B, U+200D) and the
# combining grapheme joiner (U+034F).
_ZERO_WIDTH_CATEGORIES = frozenset({"Mn", "Me", "Cf"})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding "Mc" here is exact rather than approximate, and it closes the one residue that hits ordinary text: every Mc code point reports 0 under wcwidth 0.9.3 (I checked all 445 - there is no Mc with width 1), while this rule leaves them at 1. Effect on real strings with the flag on:

हिन्दी      wcswidth 4   ours 5
বাংলা      wcswidth 4   ours 5

so the ID column shifts by one on any row containing an Indic spacing vowel sign. Cf deliberately stays as it is: the 14 exceptions (U+00AD, U+0600-U+0605, U+06DD, U+070F, U+110BD, U+110CD) are better documented than special-cased. Filed as #232 so it is tracked either way.

@Oaklight
Oaklight merged commit eff5705 into master Oct 22, 2026
6 checks passed
Oaklight added a commit that referenced this pull request Oct 22, 2026
Review note on #229: hiding wcwidth made the default comparison
environment-independent but dropped the only coverage of the
wide-character width path.  Adding it back exposed a real gap —
WIDE_CHARS_MODE counted a combining accent as one column, so
'café' measured five where wcwidth reports four.  Combining marks
now contribute zero width, and a parametrised test compares
WIDE_CHARS_MODE=True against the reference imported with wcwidth
visible.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tabulate correctness tests fail when wcwidth is installed

1 participant