failed WRONG_FIX UNSUBMITTED wrong_fix_unsubmitted(budget) · 14 tool calls · 302 s · Textualize/rich
Fix double-width characters disappearing when wrapping ## Type of changes - [x] Bug fix - [ ] New feature - [ ] Documentation / docstrings - [ ] Tests - [ ] Other ## Checklist - [x] I've run the latest [black](https://github.com/psf/black) with default args on new code. - [x] I've updated CHANGELOG.md and CONTRIBUTORS.md where appropriate. - [x] I've added tests for new code. - [x] I accept that @willmcgugan may be pedantic in the code review. ## Description Update wrapping logic to fix issues with CJK charcters disappearing when the "fold" location sat *within* a double-width character. Ensure we retain browser logic of: > if there is no space on the current line, move to a new line, and if theres not enough space on the entire new line, fold the text over multiple lines at appropriate locations. Adds some additional tests and docstrings, documentation etc. Fixes #3176 The wrapping process is overall still quite simple and doesn't match the browser in many cases. For example, wrapping does not consider punctuation (lines can begin with punctuation), and whitespace is handled differently (but practically speaking it seems sensible).
| # | Tool | Arguments | Result |
|---|---|---|---|
| 1 | run_command | {
"command": "grep -rn \"wrap\" rich | grep \"CJK\" -A 5 -B 5 | head -20"
} | {"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}full{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0} |
| 2 | run_command | {
"command": "grep -rn \"wrap\" rich | head -20"
} | {"status": "ok", "stdout": "rich/logging.py:41: tracebacks_word_wrap (bool, optional): Enable word wrapping of long tracebacks lines. Defaults to True.\nrich/lofull{"status": "ok", "stdout": "rich/logging.py:41: tracebacks_word_wrap (bool, optional): Enable word wrapping of long tracebacks lines. Defaults to True.\nrich/logging.py:79: tracebacks_word_wrap: bool = True,\nrich/logging.py:104: self.tracebacks_word_wrap = tracebacks_word_wrap\nrich/logging.py:145: word_wrap=self.tracebacks_word_wrap,\nrich/tree.py:207: table.add_column(\"Released\", style=\"cyan\", no_wrap=True)\nrich/console.py:10:from functools import wraps\nrich/console.py:147: no_wrap: Optional[bool] = False\nrich/console.py:148: \"\"\"Disable wrapping for text.\"\"\"\nrich/console.py:178: no_wrap: Union[Optional[bool], NoChange] = NO_CHANGE,\nrich/console.py:195: if not isinstance(no_wrap, NoChange):\nrich/console.py:196: options.no_wrap = no_wrap\nrich/console.py:508: @wraps(method)\nrich/console.py:603: soft_wrap (Optional[bool], optional): Set soft wrap default on print method. Defaults to False.\nrich/console.py:641: soft_wrap: bool = False,\nrich/console.py:706: self.soft_wrap = soft_wrap\nrich/console.py:1601: :meth:`~rich.console.Console.print` won't pretty print, wrap text, or apply markup, but will\nrich/console.py:1618: no_wrap=True,\nrich/console.py:1632: no_wrap: Optional[bool] = None,\nrich/console.py:1639: soft_wrap: Optional[bool] = None,\nrich/console.py:1651: no_wrap (Optional[bool], optional): Disable word wrapping. Defaults to None.\n", "stderr": "", "exit_code": 0} |
| 3 | run_command | {
"command": "grep -rn \"wrap\" rich/text.py | head -20"
} | {"status": "ok", "stdout": "rich/text.py:20:from ._wrap import divide_line\nrich/text.py:125: no_wrap (bool, optional): Disable text wrapping, or None for defaufull{"status": "ok", "stdout": "rich/text.py:20:from ._wrap import divide_line\nrich/text.py:125: no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.\nrich/text.py:136: \"no_wrap\",\nrich/text.py:150: no_wrap: Optional[bool] = None,\nrich/text.py:160: self.no_wrap = no_wrap\nrich/text.py:300: no_wrap: Optional[bool] = None,\nrich/text.py:311: no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.\nrich/text.py:321: no_wrap=no_wrap,\nrich/text.py:362: no_wrap: Optional[bool] = None,\nrich/text.py:374: no_wrap (bool, optional): Disable text wrapping, or None for default. Defaults to None.\nrich/text.py:386: no_wrap=no_wrap,\nrich/text.py:436: no_wrap=self.no_wrap,\nrich/text.py:449: no_wrap=self.no_wrap,\nrich/text.py:694: lines = self.wrap(\nrich/text.py:700: no_wrap=pick_bool(self.no_wrap, options.no_wrap, False),\nrich/text.py:1198: def wrap(\nrich/text.py:1206: no_wrap: Optional[bool] = None,\nrich/text.py:1208: \"\"\"Word wrap the text.\nrich/text.py:1216: no_wrap (bool, optional): Disable wrapping, Defaults to False.\nrich/text.py:1221: wrap_justify = justify or self.justify or DEFAULT_JUSTIFY\n", "stderr": "", "exit_code": 0} |
| 4 | run_command | {
"command": "ls rich/_wrap.py"
} | {"status": "ok", "stdout": "rich/_wrap.py\n", "stderr": "", "exit_code": 0}full{"status": "ok", "stdout": "rich/_wrap.py\n", "stderr": "", "exit_code": 0} |
| 5 | run_command | {
"command": "cat rich/_wrap.py"
} | {"status": "ok", "stdout": "import re\nfrom typing import Iterable, List, Tuple\n\nfrom ._loop import loop_last\nfrom .cells import cell_len, chop_cells\n\nre_wfull{"status": "ok", "stdout": "import re\nfrom typing import Iterable, List, Tuple\n\nfrom ._loop import loop_last\nfrom .cells import cell_len, chop_cells\n\nre_word = re.compile(r\"\\s*\\S+\\s*\")\n\n\ndef words(text: str) -> Iterable[Tuple[int, int, str]]:\n position = 0\n word_match = re_word.match(text, position)\n while word_match is not None:\n start, end = word_match.span()\n word = word_match.group(0)\n yield start, end, word\n word_match = re_word.match(text, end)\n\n\ndef divide_line(text: str, width: int, fold: bool = True) -> List[int]:\n divides: List[int] = []\n append = divides.append\n line_position = 0\n _cell_len = cell_len\n for start, _end, word in words(text):\n word_length = _cell_len(word.rstrip())\n if line_position + word_length > width:\n if word_length > width:\n if fold:\n chopped_words = chop_cells(word, max_size=width, position=0)\n for last, line in loop_last(chopped_words):\n if start:\n append(start)\n\n if last:\n line_position = _cell_len(line)\n else:\n start += len(line)\n else:\n if start:\n append(start)\n line_position = _cell_len(word)\n elif line_position and start:\n append(start)\n line_position = _cell_len(word)\n else:\n line_position += _cell_len(word)\n return divides\n\n\nif __name__ == \"__main__\": # pragma: no cover\n from .console import Console\n\n console = Console(width=10)\n console.print(\"12345 abcdefghijklmnopqrstuvwyxzABCDEFGHIJKLMNOPQRSTUVWXYZ 12345\")\n print(chop_cells(\"abcdefghijklmnopqrstuvwxyz\", 10, position=2))\n", "stderr": "", "exit_code": 0} |
| 6 | run_command | {
"command": "cat rich/cells.py"
} | {"status": "ok", "stdout": "import re\nfrom functools import lru_cache\nfrom typing import Callable, List\n\nfrom ._cell_widths import CELL_WIDTHS\n\n# Regex tofull{"status": "ok", "stdout": "import re\nfrom functools import lru_cache\nfrom typing import Callable, List\n\nfrom ._cell_widths import CELL_WIDTHS\n\n# Regex to match sequence of the most common character ranges\n_is_single_cell_widths = re.compile(\"^[\\u0020-\\u006f\\u00a0\\u02ff\\u0370-\\u0482]*$\").match\n\n\n@lru_cache(4096)\ndef cached_cell_len(text: str) -> int:\n \"\"\"Get the number of cells required to display text.\n\n This method always caches, which may use up a lot of memory. It is recommended to use\n `cell_len` over this method.\n\n Args:\n text (str): Text to display.\n\n Returns:\n int: Get the number of cells required to display text.\n \"\"\"\n _get_size = get_character_cell_size\n total_size = sum(_get_size(character) for character in text)\n return total_size\n\n\ndef cell_len(text: str, _cell_len: Callable[[str], int] = cached_cell_len) -> int:\n \"\"\"Get the number of cells required to display text.\n\n Args:\n text (str): Text to display.\n\n Returns:\n int: Get the number of cells required to display text.\n \"\"\"\n if len(text) < 512:\n return _cell_len(text)\n _get_size = get_character_cell_size\n total_size = sum(_get_size(character) for character in text)\n return total_size\n\n\n@lru_cache(maxsize=4096)\ndef get_character_cell_size(character: str) -> int:\n \"\"\"Get the cell size of a character.\n\n Args:\n character (str): A single character.\n\n Returns:\n int: Number of cells (0, 1 or 2) occupied by that character.\n \"\"\"\n return _get_codepoint_cell_size(ord(character))\n\n\n@lru_cache(maxsize=4096)\ndef _get_codepoint_cell_size(codepoint: int) -> int:\n \"\"\"Get the cell size of a character.\n\n Args:\n codepoint (int): Codepoint of a character.\n\n Returns:\n int: Number of cells (0, 1 or 2) occupied by that character.\n \"\"\"\n\n _table = CELL_WIDTHS\n lower_bound = 0\n uppe |
| 7 | run_command | {
"command": "python3 - <<'PY'\nfrom rich.cells import chop_cells, cell_len\ntext = \"こんにちは\" # 5 chars, each width 2. Total width 10.\nprint(f\"Text: {text}, cell_len: {cell_len(text)}\")\n# If width is 3, it should probably take 1 char (width 2) and wrap the rest.\n# chop_cells returns lines in reverse order.\nprint(f\"chop_cells(text, 3): {chop_cells(text, 3)}\")\n# If width is 5, it should take 2 chars (width 4) and wrap the rest.\nprint(f\"chop_cells(text, 5): {chop_cells(text, 5)}\")\nPY\n"
} | {"status": "ok", "stdout": "Text: \u3053\u3093\u306b\u3061\u306f, cell_len: 10\nchop_cells(text, 3): ['\u306f', '\u3061', '\u306b', '\u3093', '\u3053']\nchop_cefull{"status": "ok", "stdout": "Text: \u3053\u3093\u306b\u3061\u306f, cell_len: 10\nchop_cells(text, 3): ['\u306f', '\u3061', '\u306b', '\u3093', '\u3053']\nchop_cells(text, 5): ['\u306f\u3061', '\u306b\u3093', '\u3053']\n", "stderr": "", "exit_code": 0} |
| 8 | run_command | {
"command": "python3 - <<'PY'\nfrom rich._wrap import divide_line\nfrom rich.console import Console\nfrom rich.text import Text\n\n# Test case: double-width characters disappearing when wrapping\n# If width is 3, and we have \"こんにちは\" (width 10)\n# words(\"こんにちは\") -> (0, 5, \"こんにちは\")\n# divide_line(\"こんにちは\", 3, fold=True)\n# word_length = 10 > 3\n# chop_cells(\"こんにちは\", 3, 0) -> ['は', 'ち', 'に', 'ん', 'こ']\n# loop_last(chopped_words):\n# 1. last=False, line='こ', start=0. start += 1 (len('こ')) = 1\n# 2. last=False, line='ん', start=1. start += 1 = 2\n# 3. last=False, line='に', start=2. start += 1 = 3\n# 4. last=False, line='ち', start=3. start += 1 = 4\n# 5. last=True, line='は', start=4. line_position = cell_len('は') = 2\n# divides = [1, 2, 3, 4]\n# Text(\"こんにちは\").wrap(no_wrap=False, width=3)\n# will use these divides to split the text.\n# Text(\"こんにちは\")[:1] -> \"こ\"\n# Text(\"こんにちは\")[1:2] -> \"ん\"\n# Text(\"こんにちは\")[2:3] -> \"に\"\n# Text(\"こんにちは\")[3:4] -> \"ち\"\n# Text(\"こんにちは\")[4:] -> \"は\"\n# This seems correct for \"こんにちは\".\n\n# What if the word is \"abcこんにちは\"? (width 3 + 10 = 13)\n# width = 5\n# words(\"abcこんにちは\") -> (0, 8, \"abcこんにちは\")\n# word_length = 13 > 5\n# chop_cells(\"abcこんにちは\", 5, 0)\n# characters = [('は', 2), ('ち', 2), ('に', 2), ('ん', 2), ('こ', 2), ('c', 1), ('b', 1), ('a', 1)]\n# reversed(characters):\n# 1. 'a', 1: total_size=1, append('a')\n# 2. 'b', 1: total_size=2, append('b')\n# 3. 'c', 1: total_size=3, append('c')\n# 4. 'こ', 2: total_size=5, append('こ')\n# 5. 'ん', 2: total_size=5+2=7 > 5: lines.append(['ん']), total_size=2\n# 6. 'に', 2: total_size=2+2=4, append('に')\n# 7. 'ち', 2: total_size=4+2=6 > 5: lines.append(['ち']), total_size=2\n# 8. 'は', 2: total_size=2+2=4, append('は')\n# lines = [['a', 'b', 'c', 'こ'], ['ん', 'に'], ['ち', 'は']]\n# chopped_words = ['abcこ', 'んに', 'ちは']\n# loop_last(chopped_words):\n# 1. last=False, line='abcこ', start=0. start += 4 = 4\n# 2. last=False, line='んに', start=4. start += 2 = 6\n# 3. last=True, line='ちは', start=6. line_position = cell_len('ちは') = 4\n# divides = [4, 6]\n# Text(\"abcこんにちは\").wrap(width=5)\n# Text(\"abcこんにちは\")[:4] -> \"abcこ\"\n# Text(\"abcこんにちは\")[4:6] -> \"んに\"\n# Text(\"abcこんにちは\")[6:] -> \"ちは\"\n# This also seems correct.\n\n# Wait, the issue says \"double-width characters disappearing when the 'fold' location sat *within* a double-width character.\"\n# Let's look at divide_line again.\n# if word_length > width:\n# if fold:\n# chopped_words = chop_cells(word, max_size=width, position=0)\n# for last, line in loop_last(chopped_words):\n# if start:\n# append(start)\n# if last:\n# line_position = _cell_len(line)\n# else:\n# start += len(line)\n\n# If start is 0, the first chopped word doesn't add a divide.\n# If we have \"こんにちは\" and width 3:\n# chopped_words = ['は', 'ち', 'に', 'ん', 'こ']\n# 1. last=False, line='こ', start=0. start += 1 = 1\n# 2. last=False, line='ん', start=1. append(1). start += 1 = 2\n# 3. last=False, line='に', start=2. append(2). start += 1 = 3\n# 4. last=False, line='ち', start=3. append(3). start += 1 = 4\n# 5. last=True, line='は', start=4. append(4). line_position = 2\n# divides = [1, 2, 3, 4]\n# This is correct.\n\n# What if the word is \" abcこんにちは\" (space at start)?\n# words(\" abcこんにちは\") -> (0, 9, \" abcこんにちは\")\n# word_length = 1 + 10 = 11. width = 5.\n# chop_cells(\" abcこんにちは\", 5, 0)\n# characters = [('は', 2), ('ち', 2), ('に', 2), ('ん', 2), ('こ', 2), ('ん', 2), (' ', 1)] # wait, I miscounted\n# characters = [('は', 2), ('ち', 2), ('に', 2), ('ん', 2), ('こ', 2), (' ', 1)] # wait, \" abcこんにちは\" is ' ', 'a', 'b', 'c', 'こ', 'ん', 'に', 'ち', 'は'\n# characters = [('は', 2), ('ち', 2), ('に', 2), ('ん', 2), ('こ', 2), ('c', 1), ('b', 1), ('a', 1), (' ', 1)]\n# reversed(characters):\n# 1. ' ', 1: total_size=1, append(' ')\n# 2. 'a', 1: total_size=2, append('a')\n# 3. 'b', 1: total_size=3, append('b')\n# 4. 'c', 1: total_size=4, append('c')\n# 5. | {"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}full{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0} |
| 9 | run_command | {
"command": "cat rich/_loop.py"
} | {"status": "ok", "stdout": "from typing import Iterable, Tuple, TypeVar\n\nT = TypeVar(\"T\")\n\n\ndef loop_first(values: Iterable[T]) -> Iterable[Tuple[bool, Tfull{"status": "ok", "stdout": "from typing import Iterable, Tuple, TypeVar\n\nT = TypeVar(\"T\")\n\n\ndef loop_first(values: Iterable[T]) -> Iterable[Tuple[bool, T]]:\n \"\"\"Iterate and generate a tuple with a flag for first value.\"\"\"\n iter_values = iter(values)\n try:\n value = next(iter_values)\n except StopIteration:\n return\n yield True, value\n for value in iter_values:\n yield False, value\n\n\ndef loop_last(values: Iterable[T]) -> Iterable[Tuple[bool, T]]:\n \"\"\"Iterate and generate a tuple with a flag for last value.\"\"\"\n iter_values = iter(values)\n try:\n previous_value = next(iter_values)\n except StopIteration:\n return\n for value in iter_values:\n yield False, previous_value\n previous_value = value\n yield True, previous_value\n\n\ndef loop_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]:\n \"\"\"Iterate and generate a tuple with a flag for first and last value.\"\"\"\n iter_values = iter(values)\n try:\n previous_value = next(iter_values)\n except StopIteration:\n return\n first = True\n for value in iter_values:\n yield first, False, previous_value\n first = False\n previous_value = value\n yield first, True, previous_value\n", "stderr": "", "exit_code": 0} |
| 10 | run_command | {
"command": "python3 - <<'PY'\nfrom rich._wrap import divide_line\nfrom rich.text import Text\n\ntext = \"abcこんにちは\"\nwidth = 5\ndivides = divide_line(text, width, fold=True)\nprint(f\"Divides: {divides}\")\n# Current output: [2, 4]\n# Expected output: [4, 6]\n# Let's see what Text(text).wrap(width=width) does.\n# It uses divide_line.\n# Text.wrap calls divide_line.\n# Let's see Text.wrap implementation.\nPY\n"
} | {"status": "ok", "stdout": "Divides: [2, 4]\n", "stderr": "", "exit_code": 0}full{"status": "ok", "stdout": "Divides: [2, 4]\n", "stderr": "", "exit_code": 0} |
| 11 | run_command | {
"command": "sed -n '1198,1250p' rich/text.py"
} | {"status": "ok", "stdout": " def wrap(\n self,\n console: \"Console\",\n width: int,\n *,\n justify: Optional[\"JustifyMethod\"] = None,\n overflow: Optional[\"full{"status": "ok", "stdout": " def wrap(\n self,\n console: \"Console\",\n width: int,\n *,\n justify: Optional[\"JustifyMethod\"] = None,\n overflow: Optional[\"OverflowMethod\"] = None,\n tab_size: int = 8,\n no_wrap: Optional[bool] = None,\n ) -> Lines:\n \"\"\"Word wrap the text.\n\n Args:\n console (Console): Console instance.\n width (int): Number of cells available per line.\n justify (str, optional): Justify method: \"default\", \"left\", \"center\", \"full\", \"right\". Defaults to \"default\".\n overflow (str, optional): Overflow method: \"crop\", \"fold\", or \"ellipsis\". Defaults to None.\n tab_size (int, optional): Default tab size. Defaults to 8.\n no_wrap (bool, optional): Disable wrapping, Defaults to False.\n\n Returns:\n Lines: Number of lines.\n \"\"\"\n wrap_justify = justify or self.justify or DEFAULT_JUSTIFY\n wrap_overflow = overflow or self.overflow or DEFAULT_OVERFLOW\n\n no_wrap = pick_bool(no_wrap, self.no_wrap, False) or overflow == \"ignore\"\n\n lines = Lines()\n for line in self.split(allow_blank=True):\n if \"\\t\" in line:\n line.expand_tabs(tab_size)\n if no_wrap:\n new_lines = Lines([line])\n else:\n offsets = divide_line(str(line), width, fold=wrap_overflow == \"fold\")\n new_lines = line.divide(offsets)\n for line in new_lines:\n line.rstrip_end(width)\n if wrap_justify:\n new_lines.justify(\n console, width, justify=wrap_justify, overflow=wrap_overflow\n )\n for line in new_lines:\n line.truncate(width, overflow=wrap_overflow)\n lines.extend(new_lines)\n return lines\n\n def fit(self, width: int) -> Lines:\n |
| 12 | run_command | {
"command": "python3 - <<'PY'\nfrom rich._wrap import divide_line\nfrom rich.text import Text\nfrom rich.console import Console\n\ntext = \"abcこんにちは\"\nwidth = 5\ndivides = divide_line(text, width, fold=True)\nprint(f\"Divides: {divides}\")\n# Current: [2, 4]\n# Text(\"abcこんにちは\").divide([2, 4])\n# Text(\"abcこんにちは\")[:2] -> \"ab\"\n# Text(\"abcこんにちは\")[2:4] -> \"cこ\"\n# Text(\"abcこんにちは\")[4:] -> \"んちは\"\n# This is wrong.\nPY\n"
} | {"status": "ok", "stdout": "Divides: [2, 4]\n", "stderr": "", "exit_code": 0}full{"status": "ok", "stdout": "Divides: [2, 4]\n", "stderr": "", "exit_code": 0} |
| 13 | run_command | {
"command": "python3 - <<'PY'\nfrom rich._wrap import divide_line\nfrom rich.text import Text\nfrom rich.console import Console\n\ntext = \"abcこんにちは\"\nwidth = 5\ndivides = divide_line(text, width, fold=True)\nprint(f\"Divides: {divides}\")\n# If we reverse chopped_words in divide_line:\n# chopped_words = ['はち', 'にん', 'こcba']\n# reversed(chopped_words) = ['こcba', 'にん', 'はち']\n# 1. line='こcba', last=False, start=0. start += 4 = 4\n# 2. line='にん', last=False, start=4. append(4). start += 2 = 6\n# 3. line='はち', last=True, start=6. append(6). line_position = 4\n# divides = [4, 6]\n# Text(\"abcこんにちは\").divide([4, 6])\n# Text(\"abcこんにちは\")[:4] -> \"abcこ\"\n# Text(\"abcこんにちは\")[4:6] -> \"んに\"\n# Text(\"abcこんにちは\")[6:] -> \"ちは\"\n# This is correct.\nPY\n"
} | {"status": "ok", "stdout": "Divides: [2, 4]\n", "stderr": "", "exit_code": 0}full{"status": "ok", "stdout": "Divides: [2, 4]\n", "stderr": "", "exit_code": 0} |
| 14 | run_command | {
"command": "cd /workspace && python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"rich/_wrap.py\"); s = p.read_text()\nold = \"\"\" chopped_words = chop_cells(word, max_size=width, position=0)\n for last, line in loop_last(chopped_words):\"\"\"\nnew = \"\"\" chopped_words = chop_cells(word, max_size=width, position=0)\n for last, line in loop_last(reversed(chopped_words)):\"\"\"\nassert s.count(old) == 1, (\"old text found this many times:\", s.count(old))\nt = s.replace(old, new); compile(t, str(p), \"exec\"); p.write_text(t)\nPY\n"
} | {"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}full{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0} |
diff --git a/rich/_wrap.py b/rich/_wrap.py
index c45f193f..036e0203 100644
--- a/rich/_wrap.py
+++ b/rich/_wrap.py
@@ -28,7 +28,7 @@ def divide_line(text: str, width: int, fold: bool = True) -> List[int]:
if word_length > width:
if fold:
chopped_words = chop_cells(word, max_size=width, position=0)
- for last, line in loop_last(chopped_words):
+ for last, line in loop_last(reversed(chopped_words)):
if start:
append(start)
....F
=================================== FAILURES ===================================
_______________________________ test_chop_cells ________________________________
def test_chop_cells():
"""Simple example of splitting cells into lines of width 3."""
text = "abcdefghijk"
> assert chop_cells(text, 3) == ["abc", "def", "ghi", "jk"]
E AssertionError: assert ['kji', 'hgf', 'edc', 'ba'] == ['abc', 'def', 'ghi', 'jk']
E
E At index 0 diff: 'kji' != 'abc'
E Use -v to get more diff
tests/test_cells.py:49: AssertionError
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 4 passed in 0.16s