Skip to content

Commit 70b5bdf

Browse files
authored
Merge pull request #7264 from bluetech/wcwidth
Improve our own wcwidth implementation and remove dependency on wcwidth package
2 parents 9da1d06 + aca534c commit 70b5bdf

File tree

7 files changed

+111
-31
lines changed

7 files changed

+111
-31
lines changed

changelog/7264.improvement.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The dependency on the ``wcwidth`` package has been removed.

setup.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
'colorama;sys_platform=="win32"',
1313
"pluggy>=0.12,<1.0",
1414
'importlib-metadata>=0.12;python_version<"3.8"',
15-
"wcwidth",
1615
]
1716

1817

src/_pytest/_io/terminalwriter.py

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
import os
33
import shutil
44
import sys
5-
import unicodedata
6-
from functools import lru_cache
75
from typing import Optional
86
from typing import Sequence
97
from typing import TextIO
108

9+
from .wcwidth import wcswidth
10+
1111

1212
# This code was initially copied from py 1.8.1, file _io/terminalwriter.py.
1313

@@ -22,17 +22,6 @@ def get_terminal_width() -> int:
2222
return width
2323

2424

25-
@lru_cache(100)
26-
def char_width(c: str) -> int:
27-
# Fullwidth and Wide -> 2, all else (including Ambiguous) -> 1.
28-
return 2 if unicodedata.east_asian_width(c) in ("F", "W") else 1
29-
30-
31-
def get_line_width(text: str) -> int:
32-
text = unicodedata.normalize("NFC", text)
33-
return sum(char_width(c) for c in text)
34-
35-
3625
def should_do_markup(file: TextIO) -> bool:
3726
if os.environ.get("PY_COLORS") == "1":
3827
return True
@@ -99,7 +88,7 @@ def fullwidth(self, value: int) -> None:
9988
@property
10089
def width_of_current_line(self) -> int:
10190
"""Return an estimate of the width so far in the current line."""
102-
return get_line_width(self._current_line)
91+
return wcswidth(self._current_line)
10392

10493
def markup(self, text: str, **markup: bool) -> str:
10594
for name in markup:

src/_pytest/_io/wcwidth.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import unicodedata
2+
from functools import lru_cache
3+
4+
5+
@lru_cache(100)
6+
def wcwidth(c: str) -> int:
7+
"""Determine how many columns are needed to display a character in a terminal.
8+
9+
Returns -1 if the character is not printable.
10+
Returns 0, 1 or 2 for other characters.
11+
"""
12+
o = ord(c)
13+
14+
# ASCII fast path.
15+
if 0x20 <= o < 0x07F:
16+
return 1
17+
18+
# Some Cf/Zp/Zl characters which should be zero-width.
19+
if (
20+
o == 0x0000
21+
or 0x200B <= o <= 0x200F
22+
or 0x2028 <= o <= 0x202E
23+
or 0x2060 <= o <= 0x2063
24+
):
25+
return 0
26+
27+
category = unicodedata.category(c)
28+
29+
# Control characters.
30+
if category == "Cc":
31+
return -1
32+
33+
# Combining characters with zero width.
34+
if category in ("Me", "Mn"):
35+
return 0
36+
37+
# Full/Wide east asian characters.
38+
if unicodedata.east_asian_width(c) in ("F", "W"):
39+
return 2
40+
41+
return 1
42+
43+
44+
def wcswidth(s: str) -> int:
45+
"""Determine how many columns are needed to display a string in a terminal.
46+
47+
Returns -1 if the string contains non-printable characters.
48+
"""
49+
width = 0
50+
for c in unicodedata.normalize("NFC", s):
51+
wc = wcwidth(c)
52+
if wc < 0:
53+
return -1
54+
width += wc
55+
return width

src/_pytest/terminal.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import pytest
2828
from _pytest import nodes
2929
from _pytest._io import TerminalWriter
30+
from _pytest._io.wcwidth import wcswidth
3031
from _pytest.compat import order_preserving_dict
3132
from _pytest.config import Config
3233
from _pytest.config import ExitCode
@@ -1120,8 +1121,6 @@ def _get_pos(config, rep):
11201121

11211122
def _get_line_with_reprcrash_message(config, rep, termwidth):
11221123
"""Get summary line for a report, trying to add reprcrash message."""
1123-
from wcwidth import wcswidth
1124-
11251124
verbose_word = rep._get_verbose_word(config)
11261125
pos = _get_pos(config, rep)
11271126

testing/io/test_wcwidth.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import pytest
2+
from _pytest._io.wcwidth import wcswidth
3+
from _pytest._io.wcwidth import wcwidth
4+
5+
6+
@pytest.mark.parametrize(
7+
("c", "expected"),
8+
[
9+
("\0", 0),
10+
("\n", -1),
11+
("a", 1),
12+
("1", 1),
13+
("א", 1),
14+
("\u200B", 0),
15+
("\u1ABE", 0),
16+
("\u0591", 0),
17+
("🉐", 2),
18+
("$", 2),
19+
],
20+
)
21+
def test_wcwidth(c: str, expected: int) -> None:
22+
assert wcwidth(c) == expected
23+
24+
25+
@pytest.mark.parametrize(
26+
("s", "expected"),
27+
[
28+
("", 0),
29+
("hello, world!", 13),
30+
("hello, world!\n", -1),
31+
("0123456789", 10),
32+
("שלום, עולם!", 11),
33+
("שְבֻעָיים", 6),
34+
("🉐🉐🉐", 6),
35+
],
36+
)
37+
def test_wcswidth(s: str, expected: int) -> None:
38+
assert wcswidth(s) == expected

testing/test_terminal.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
import py
1515

1616
import _pytest.config
17+
import _pytest.terminal
1718
import pytest
19+
from _pytest._io.wcwidth import wcswidth
1820
from _pytest.config import ExitCode
1921
from _pytest.pytester import Testdir
2022
from _pytest.reports import BaseReport
@@ -2027,9 +2029,6 @@ class X:
20272029

20282030

20292031
def test_line_with_reprcrash(monkeypatch):
2030-
import _pytest.terminal
2031-
from wcwidth import wcswidth
2032-
20332032
mocked_verbose_word = "FAILED"
20342033

20352034
mocked_pos = "some::nodeid"
@@ -2079,19 +2078,19 @@ def check(msg, width, expected):
20792078
check("some\nmessage", 80, "FAILED some::nodeid - some")
20802079

20812080
# Test unicode safety.
2082-
check("😄😄😄😄😄\n2nd line", 25, "FAILED some::nodeid - ...")
2083-
check("😄😄😄😄😄\n2nd line", 26, "FAILED some::nodeid - ...")
2084-
check("😄😄😄😄😄\n2nd line", 27, "FAILED some::nodeid - 😄...")
2085-
check("😄😄😄😄😄\n2nd line", 28, "FAILED some::nodeid - 😄...")
2086-
check("😄😄😄😄😄\n2nd line", 29, "FAILED some::nodeid - 😄😄...")
2081+
check("🉐🉐🉐🉐🉐\n2nd line", 25, "FAILED some::nodeid - ...")
2082+
check("🉐🉐🉐🉐🉐\n2nd line", 26, "FAILED some::nodeid - ...")
2083+
check("🉐🉐🉐🉐🉐\n2nd line", 27, "FAILED some::nodeid - 🉐...")
2084+
check("🉐🉐🉐🉐🉐\n2nd line", 28, "FAILED some::nodeid - 🉐...")
2085+
check("🉐🉐🉐🉐🉐\n2nd line", 29, "FAILED some::nodeid - 🉐🉐...")
20872086

20882087
# NOTE: constructed, not sure if this is supported.
2089-
mocked_pos = "nodeid::😄::withunicode"
2090-
check("😄😄😄😄😄\n2nd line", 29, "FAILED nodeid::😄::withunicode")
2091-
check("😄😄😄😄😄\n2nd line", 40, "FAILED nodeid::😄::withunicode - 😄😄...")
2092-
check("😄😄😄😄😄\n2nd line", 41, "FAILED nodeid::😄::withunicode - 😄😄...")
2093-
check("😄😄😄😄😄\n2nd line", 42, "FAILED nodeid::😄::withunicode - 😄😄😄...")
2094-
check("😄😄😄😄😄\n2nd line", 80, "FAILED nodeid::😄::withunicode - 😄😄😄😄😄")
2088+
mocked_pos = "nodeid::🉐::withunicode"
2089+
check("🉐🉐🉐🉐🉐\n2nd line", 29, "FAILED nodeid::🉐::withunicode")
2090+
check("🉐🉐🉐🉐🉐\n2nd line", 40, "FAILED nodeid::🉐::withunicode - 🉐🉐...")
2091+
check("🉐🉐🉐🉐🉐\n2nd line", 41, "FAILED nodeid::🉐::withunicode - 🉐🉐...")
2092+
check("🉐🉐🉐🉐🉐\n2nd line", 42, "FAILED nodeid::🉐::withunicode - 🉐🉐🉐...")
2093+
check("🉐🉐🉐🉐🉐\n2nd line", 80, "FAILED nodeid::🉐::withunicode - 🉐🉐🉐🉐🉐")
20952094

20962095

20972096
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)