Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions tabulate/tabulate.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,46 @@
# ── CJK / visible-width helpers ──────────────────────────────────────────────


# Categories that occupy no column of their own: combining marks (Mn),
# 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.


# Hangul medial (jungseong) and final (jongseong) jamo compose with the
# leading jamo and add no column.
_ZERO_WIDTH_RANGES = ((0x1160, 0x11FF), (0xD7B0, 0xD7FF))


def _is_zero_width(ch: str) -> bool:
"""Return True when *ch* occupies no column of its own."""
if unicodedata.category(ch) in _ZERO_WIDTH_CATEGORIES:
return True
cp = ord(ch)
return any(lo <= cp <= hi for lo, hi in _ZERO_WIDTH_RANGES)


def _visible_width(s: str) -> int:
"""Return the visible width of *s*, counting CJK / wide chars as 2.

This function respects the ``WIDE_CHARS_MODE`` flag. When the flag is
``False`` (the default, matching the reference *tabulate* library),
width equals ``len(s)``.

When it is ``True``, East Asian wide and fullwidth characters count as
two columns, and zero-width marks (combining, enclosing and format
characters) and Hangul medial/final jamo as zero — the per-character
widths ``wcwidth`` reports. Emoji presentation sequences are counted
per component, which is where the two can differ: a ZWJ family emoji is
six columns here and two under ``wcwidth`` (it measures grapheme
clusters), and a keycap sequence is one here and two there.
"""
if not WIDE_CHARS_MODE:
return len(s)
w = 0
for ch in s:
if _is_zero_width(ch):
continue
eaw = unicodedata.east_asian_width(ch)
if eaw in ("W", "F"):
w += 2
Expand Down
135 changes: 135 additions & 0 deletions tabulate/test_tabulate_correctness.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@

_cached_tabulate = sys.modules.pop("tabulate", None)

# The reference switches to ``wcwidth`` for column widths when it is
# installed, while this module's default is ``len(s)`` (``WIDE_CHARS_MODE``
# is opt-in). Hide ``wcwidth`` from the reference import — ``None`` in
# ``sys.modules`` makes the import fail cleanly — so both sides compute
# widths the same way regardless of the environment. Without this the
# unicode/CJK cases differ as soon as ``wcwidth`` is a transitive dependency.
_cached_wcwidth = sys.modules.get("wcwidth")
_cached_wcwidth_present = "wcwidth" in sys.modules
sys.modules["wcwidth"] = None # type: ignore[assignment]

try:
import tabulate as _ref_tabulate_mod

Expand All @@ -35,10 +45,135 @@
sys.modules.pop("tabulate", None)
if _cached_tabulate is not None:
sys.modules["tabulate"] = _cached_tabulate
if _cached_wcwidth_present:
sys.modules["wcwidth"] = _cached_wcwidth # type: ignore[assignment]
else:
sys.modules.pop("wcwidth", None)

sys.path.insert(0, _this_dir)
import tabulate as _our_module # noqa: E402
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.

"""The comparisons below assume the reference counts ``len(s)``.

Our default is ``len(s)``; the reference switches to ``wcwidth`` when
that package is importable. If this fails, the guard above stopped
working and the unicode/CJK cases compare two different width rules
rather than two implementations.
"""
assert getattr(_ref_tabulate_mod, "wcwidth", None) is None


def _reference_using_wcwidth():
"""Import the reference again with ``wcwidth`` visible.

The module-level import hides ``wcwidth`` so that both sides use
``len(s)``; this reproduces the configuration a user of the reference
has, where it detects wide characters automatically. Skips when
``wcwidth`` is not installed.
"""
pytest.importorskip("wcwidth")
saved_path = sys.path[:]
saved_tabulate = sys.modules.pop("tabulate", None)
try:
sys.path = [
p
for p in sys.path
if os.path.abspath(p or os.curdir)
not in (
os.path.abspath(_this_dir),
os.path.abspath(os.path.join(_this_dir, "..")),
)
]
import tabulate as ref

return ref
finally:
sys.path = saved_path
sys.modules.pop("tabulate", None)
if saved_tabulate is not None:
sys.modules["tabulate"] = saved_tabulate


@pytest.mark.parametrize(
("data", "kwargs"),
[
([["café", 1], ["élève", 2]], {}),
([["你好", 1], ["世界", 2]], {"headers": ["Text", "Num"]}),
([["东京Tokyo", 1], ["NYC", 2]], {"headers": ["City", "ID"]}),
([["\U0001f600", "smile"]], {}),
],
)
def test_wide_chars_mode_matches_the_wcwidth_reference(data, kwargs) -> None:
"""``WIDE_CHARS_MODE`` reproduces what the reference does with wcwidth.

Hiding ``wcwidth`` above makes the default-mode comparison
environment-independent, but it also means nothing else exercises the
wide-character width path. This pins it against the configuration the
reference has when the package is installed.
"""
ref = _reference_using_wcwidth()
if getattr(ref, "wcwidth", None) is None:
pytest.skip("the reference did not import wcwidth")

previous = _our_module.WIDE_CHARS_MODE
_our_module.WIDE_CHARS_MODE = True
try:
assert _our_module.tabulate(data, **kwargs) == ref.tabulate(data, **kwargs)
finally:
_our_module.WIDE_CHARS_MODE = previous


@pytest.mark.parametrize(
"text",
[
"café", # combining acute
"élève", # precomposed
"你好", # CJK, wide
"\U0001f600", # emoji, wide
"a​b", # zero-width space
"a͏b", # combining grapheme joiner, ccc=0
"क्ष", # Devanagari virama cluster
"각", # Hangul leading + medial + final jamo
],
)
def test_zero_width_shapes_match_wcwidth(text: str) -> None:
"""Per-character widths agree with ``wcwidth.wcswidth``."""
wcwidth = pytest.importorskip("wcwidth")
previous = _our_module.WIDE_CHARS_MODE
_our_module.WIDE_CHARS_MODE = True
try:
assert _our_module._visible_width(text) == wcwidth.wcswidth(text)
finally:
_our_module.WIDE_CHARS_MODE = previous


@pytest.mark.parametrize(
("text", "expected"),
[
("\U0001f468‍\U0001f469‍\U0001f467", 6), # family, ZWJ
("1️⃣", 1), # keycap sequence
],
)
def test_emoji_presentation_sequences_count_per_component(
text: str, expected: int
) -> None:
"""Documented divergence: ``wcwidth`` measures grapheme clusters.

A ZWJ family emoji is 2 columns under ``wcwidth`` and 6 here; a keycap
is 2 there and 1 here. Pinning the per-component count keeps the
docstring honest.
"""
previous = _our_module.WIDE_CHARS_MODE
_our_module.WIDE_CHARS_MODE = True
try:
assert _our_module._visible_width(text) == expected
finally:
_our_module.WIDE_CHARS_MODE = previous


# ── Helpers ──


Expand Down
Loading