From 8513fe82c586530e5fc361154ef974702e3ef116 Mon Sep 17 00:00:00 2001 From: Martin Di Paola Date: Fri, 17 Jun 2022 15:25:11 -0300 Subject: [PATCH 01/10] Upgrade pyperf (drop support for Python 2.x) Since 0.8.1 pyte does not support Python 2.x anymore so it makes sense to upgrade one of its dev dependencies, pyperf. --- requirements_dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_dev.txt b/requirements_dev.txt index c1ee84d..8c54e74 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ pytest -pyperf == 1.7.1 +pyperf >= 2.3.0 wcwidth wheel From cabc0a5bb14bf19e9e4924789f691d1813ede5d3 Mon Sep 17 00:00:00 2001 From: Martin Di Paola Date: Fri, 17 Jun 2022 15:55:38 -0300 Subject: [PATCH 02/10] Allow change the screen geometry Receive via environ the geometry of the screen to test with a default of 24 lines by 80 columns. Add this and the input file into Runner's metadata so it is preserved in the log file (if any) --- benchmark.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/benchmark.py b/benchmark.py index 75657db..4500c83 100644 --- a/benchmark.py +++ b/benchmark.py @@ -10,6 +10,11 @@ ..................... ls.input: Mean +- std dev: 644 ns +- 23 ns + Environment variables: + + BENCHMARK: the input file to feed pyte's Stream and render on the Screen + GEOMETRY: the dimensions of the screen with format "x" (default 24x80) + :copyright: (c) 2016-2021 by pyte authors and contributors, see AUTHORS for details. :license: LGPL, see LICENSE for more details. @@ -28,20 +33,25 @@ import pyte -def make_benchmark(path, screen_cls): +def make_benchmark(path, screen_cls, columns, lines): with io.open(path, "rt", encoding="utf-8") as handle: data = handle.read() - stream = pyte.Stream(screen_cls(80, 24)) + stream = pyte.Stream(screen_cls(columns, lines)) return partial(stream.feed, data) if __name__ == "__main__": benchmark = os.environ["BENCHMARK"] - sys.argv.extend(["--inherit-environ", "BENCHMARK"]) + lines, columns = map(int, os.environ.get("GEOMETRY", "24x80").split('x')) + sys.argv.extend(["--inherit-environ", "BENCHMARK,GEOMETRY"]) - runner = Runner() + runner = Runner(metadata={ + 'input_file': benchmark, + 'columns': columns, + 'lines': lines + }) for screen_cls in [pyte.Screen, pyte.DiffScreen, pyte.HistoryScreen]: name = os.path.basename(benchmark) + "->" + screen_cls.__name__ - runner.bench_func(name, make_benchmark(benchmark, screen_cls)) + runner.bench_func(name, make_benchmark(benchmark, screen_cls, columns, lines)) From 940e19b4b6420f3007cfcf40327e384e791d1d10 Mon Sep 17 00:00:00 2001 From: Martin Di Paola Date: Fri, 17 Jun 2022 19:04:17 -0300 Subject: [PATCH 03/10] Impl benchmark tests for screen.display, .reset and .resize Implement three more benchmark scenarios for testing screen.display, screen.reset and screen.resize. For the standard 24x80 geometry, these methods have a negligible cost however of larger geometries, they can be up to 100 times slower than stream.feed so benchmarking them is important. Changed how the metadata is stored so on each bench_func call we encode which scenario are we testing, with which screen class and geometry. --- benchmark.py | 46 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/benchmark.py b/benchmark.py index 4500c83..438c241 100644 --- a/benchmark.py +++ b/benchmark.py @@ -10,6 +10,10 @@ ..................... ls.input: Mean +- std dev: 644 ns +- 23 ns + $ BENCHMARK=tests/captured/ls.input GEOMETRY=1024x1024 python benchmark.py -o results.json + ..................... + ls.input: Mean +- std dev: 644 ns +- 23 ns + Environment variables: BENCHMARK: the input file to feed pyte's Stream and render on the Screen @@ -32,26 +36,54 @@ import pyte - -def make_benchmark(path, screen_cls, columns, lines): +def setup(path, screen_cls, columns, lines): with io.open(path, "rt", encoding="utf-8") as handle: data = handle.read() - stream = pyte.Stream(screen_cls(columns, lines)) + screen = screen_cls(columns, lines) + stream = pyte.Stream(screen) + + return data, screen, stream + +def make_stream_feed_benchmark(path, screen_cls, columns, lines): + data, _, stream = setup(path, screen_cls, columns, lines) return partial(stream.feed, data) +def make_screen_display_benchmark(path, screen_cls, columns, lines): + data, screen, stream = setup(path, screen_cls, columns, lines) + stream.feed(data) + return lambda: screen.display + +def make_screen_reset_benchmark(path, screen_cls, columns, lines): + data, screen, stream = setup(path, screen_cls, columns, lines) + stream.feed(data) + return screen.reset + +def make_screen_resize_half_benchmark(path, screen_cls, columns, lines): + data, screen, stream = setup(path, screen_cls, columns, lines) + stream.feed(data) + return partial(screen.resize, lines=lines//2, columns=columns//2) if __name__ == "__main__": benchmark = os.environ["BENCHMARK"] lines, columns = map(int, os.environ.get("GEOMETRY", "24x80").split('x')) sys.argv.extend(["--inherit-environ", "BENCHMARK,GEOMETRY"]) - runner = Runner(metadata={ + runner = Runner() + + metadata = { 'input_file': benchmark, 'columns': columns, 'lines': lines - }) + } + benchmark_name = os.path.basename(benchmark) for screen_cls in [pyte.Screen, pyte.DiffScreen, pyte.HistoryScreen]: - name = os.path.basename(benchmark) + "->" + screen_cls.__name__ - runner.bench_func(name, make_benchmark(benchmark, screen_cls, columns, lines)) + screen_cls_name = screen_cls.__name__ + for make_test in (make_stream_feed_benchmark, make_screen_display_benchmark, make_screen_reset_benchmark, make_screen_resize_half_benchmark): + scenario = make_test.__name__[5:-10] # remove make_ and _benchmark + + name = f"[{scenario} {lines}x{columns}] {benchmark_name}->{screen_cls_name}" + metadata.update({'scenario': scenario, 'screen_cls': screen_cls_name}) + runner.bench_func(name, make_test(benchmark, screen_cls, columns, lines), metadata=metadata) + From 0b8007a8e574bcda7e50f039fc72cd77327d3a81 Mon Sep 17 00:00:00 2001 From: Martin Di Paola Date: Sat, 18 Jun 2022 09:04:21 -0300 Subject: [PATCH 04/10] Impl script to run a full benchmark A shell script to test all the captured input files and run them under different terminal geometries (24x80, 240x800, 2400x8000, 24x8000 and 2400x80). These settings aim to stress pyte with larger and larger screens (by a 10 factor on both dimensions and on each dimension separately). --- full_benchmark.sh | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100755 full_benchmark.sh diff --git a/full_benchmark.sh b/full_benchmark.sh new file mode 100755 index 0000000..a3fe567 --- /dev/null +++ b/full_benchmark.sh @@ -0,0 +1,39 @@ +#!/usr/bin/bash + +if [ "$#" != "1" ]; then + echo "Usage benchmark.sh " + exit 1 +fi +outputfile=$1 + +if [ ! -f benchmark.py ]; then + echo "File benchmark.py missing. Are you in the home folder of pyte project?" + exit 1 +fi + +for inputfile in $(ls -1 tests/captured/*.input); do + export GEOMETRY=24x80 + echo "$inputfile - $GEOMETRY" + echo "======================" + BENCHMARK=$inputfile python benchmark.py --append $outputfile + + export GEOMETRY=240x800 + echo "$inputfile - $GEOMETRY" + echo "======================" + BENCHMARK=$inputfile python benchmark.py --append $outputfile + + export GEOMETRY=2400x8000 + echo "$inputfile - $GEOMETRY" + echo "======================" + BENCHMARK=$inputfile python benchmark.py --append $outputfile + + export GEOMETRY=24x8000 + echo "$inputfile - $GEOMETRY" + echo "======================" + BENCHMARK=$inputfile python benchmark.py --append $outputfile + + export GEOMETRY=2400x80 + echo "$inputfile - $GEOMETRY" + echo "======================" + BENCHMARK=$inputfile python benchmark.py --append $outputfile +done From e0b0e8be6e634296711cf1ee39610feb8bac0f29 Mon Sep 17 00:00:00 2001 From: Martin Di Paola Date: Sat, 2 Jul 2022 18:47:01 -0300 Subject: [PATCH 05/10] Fix benchmark.py using ByteStream and not Stream The input files in the tests/captured must be loaded with ByteStream and not Stream, otherwise the \r are lost and the benchmark results may not reflect real scenarios. --- benchmark.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmark.py b/benchmark.py index 438c241..b52787e 100644 --- a/benchmark.py +++ b/benchmark.py @@ -37,11 +37,11 @@ import pyte def setup(path, screen_cls, columns, lines): - with io.open(path, "rt", encoding="utf-8") as handle: + with io.open(path, "rb") as handle: data = handle.read() screen = screen_cls(columns, lines) - stream = pyte.Stream(screen) + stream = pyte.ByteStream(screen) return data, screen, stream From eec4a2ee0afbb31132c0cba689d49bf068533ddc Mon Sep 17 00:00:00 2001 From: Martin Di Paola Date: Sun, 26 Jun 2022 14:03:04 -0300 Subject: [PATCH 06/10] Enable optionally tracemalloc on full benchmark --- full_benchmark.sh | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/full_benchmark.sh b/full_benchmark.sh index a3fe567..02a88dd 100755 --- a/full_benchmark.sh +++ b/full_benchmark.sh @@ -1,9 +1,21 @@ #!/usr/bin/bash -if [ "$#" != "1" ]; then +if [ "$#" != "1" -a "$#" != "2" ]; then echo "Usage benchmark.sh " + echo "Usage benchmark.sh tracemalloc" exit 1 fi + +if [ "$2" = "tracemalloc" ]; then + tracemalloc="--tracemalloc" +elif [ "$2" = "" ]; then + tracemalloc="" +else + echo "Usage benchmark.sh " + echo "Usage benchmark.sh tracemalloc" + exit 1 +fi + outputfile=$1 if [ ! -f benchmark.py ]; then @@ -15,25 +27,25 @@ for inputfile in $(ls -1 tests/captured/*.input); do export GEOMETRY=24x80 echo "$inputfile - $GEOMETRY" echo "======================" - BENCHMARK=$inputfile python benchmark.py --append $outputfile + BENCHMARK=$inputfile python benchmark.py $tracemalloc --append $outputfile export GEOMETRY=240x800 echo "$inputfile - $GEOMETRY" echo "======================" - BENCHMARK=$inputfile python benchmark.py --append $outputfile + BENCHMARK=$inputfile python benchmark.py $tracemalloc --append $outputfile export GEOMETRY=2400x8000 echo "$inputfile - $GEOMETRY" echo "======================" - BENCHMARK=$inputfile python benchmark.py --append $outputfile + BENCHMARK=$inputfile python benchmark.py $tracemalloc --append $outputfile export GEOMETRY=24x8000 echo "$inputfile - $GEOMETRY" echo "======================" - BENCHMARK=$inputfile python benchmark.py --append $outputfile + BENCHMARK=$inputfile python benchmark.py $tracemalloc --append $outputfile export GEOMETRY=2400x80 echo "$inputfile - $GEOMETRY" echo "======================" - BENCHMARK=$inputfile python benchmark.py --append $outputfile + BENCHMARK=$inputfile python benchmark.py $tracemalloc --append $outputfile done From f899535989927b378c7bb40d543f13b5359514c5 Mon Sep 17 00:00:00 2001 From: Martin Di Paola Date: Sat, 18 Jun 2022 15:31:00 -0300 Subject: [PATCH 07/10] display meth: iterate over data entries filling the gap between The former `for x in range(...)` implementation iterated over the all the possibly indexes (for columns and lines) wasting cyclies because some of those indexes (and in some cases most) pointed to non-existing entries. These non-existing entries were faked and a default character was returned in place. This commit instead makes display to iterate over the existing entries. When gaps between to entries are detected, the gap is filled with the same default character without having to pay for indexing non-entries. Note: I found that in the current implementation of screen, screen.buffer may have entries (chars in a line) outside of the width of the screen. At the display method those are filtered out however I'm not sure if this is not a real bug that was uncovered because never we iterated over the data entries. If this is true, we may be wasting space as we keep in memory chars that are outside of the screen. --- pyte/screens.py | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/pyte/screens.py b/pyte/screens.py index 5e7f759..e6bdfe2 100644 --- a/pyte/screens.py +++ b/pyte/screens.py @@ -228,18 +228,48 @@ def __repr__(self): @property def display(self): """A :func:`list` of screen lines as unicode strings.""" + padding = self.default_char.data + def render(line): is_wide_char = False - for x in range(self.columns): + prev_x = -1 + for x, cell in sorted(line.items()): + # TODO apparently a line can hold more items (chars) than + # suppose to (outside of the range of 0-cols). + if x >= self.columns: + break + + gap = x - (prev_x + 1) + if gap: + yield padding * gap + + prev_x = x + if is_wide_char: # Skip stub is_wide_char = False continue - char = line[x].data + char = cell.data assert sum(map(wcwidth, char[1:])) == 0 is_wide_char = wcwidth(char[0]) == 2 yield char - return ["".join(render(self.buffer[y])) for y in range(self.lines)] + gap = self.columns - (prev_x + 1) + if gap: + yield padding * gap + + prev_y = -1 + output = [] + for y, line in sorted(self.buffer.items()): + empty_lines = y - (prev_y + 1) + output.extend([padding * self.columns] * empty_lines) + prev_y = y + + output.append("".join(render(line))) + + empty_lines = self.lines - (prev_y + 1) + output.extend([padding * self.columns] * empty_lines) + + return output def reset(self): """Reset the terminal to its initial state. From b3b7db4cd45a66acd40f10f601b0e211f53715e9 Mon Sep 17 00:00:00 2001 From: Martin Di Paola Date: Sat, 18 Jun 2022 17:33:19 -0300 Subject: [PATCH 08/10] Inline generator into display inner loop Python generators (yield) and function calls are slower then normal for-loops. Improve screen.display by x1 to x1.8 times faster by inlining the code. --- pyte/screens.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/pyte/screens.py b/pyte/screens.py index e6bdfe2..28d0acb 100644 --- a/pyte/screens.py +++ b/pyte/screens.py @@ -230,18 +230,25 @@ def display(self): """A :func:`list` of screen lines as unicode strings.""" padding = self.default_char.data - def render(line): + prev_y = -1 + output = [] + columns = self.columns + for y, line in sorted(self.buffer.items()): + empty_lines = y - (prev_y + 1) + if empty_lines: + output.extend([padding * columns] * empty_lines) + prev_y = y + is_wide_char = False prev_x = -1 + display_line = [] for x, cell in sorted(line.items()): - # TODO apparently a line can hold more items (chars) than - # suppose to (outside of the range of 0-cols). - if x >= self.columns: + if x >= columns: break gap = x - (prev_x + 1) if gap: - yield padding * gap + display_line.append(padding * gap) prev_x = x @@ -251,23 +258,17 @@ def render(line): char = cell.data assert sum(map(wcwidth, char[1:])) == 0 is_wide_char = wcwidth(char[0]) == 2 - yield char + display_line.append(char) - gap = self.columns - (prev_x + 1) + gap = columns - (prev_x + 1) if gap: - yield padding * gap - - prev_y = -1 - output = [] - for y, line in sorted(self.buffer.items()): - empty_lines = y - (prev_y + 1) - output.extend([padding * self.columns] * empty_lines) - prev_y = y + display_line.append(padding * gap) - output.append("".join(render(line))) + output.append("".join(display_line)) empty_lines = self.lines - (prev_y + 1) - output.extend([padding * self.columns] * empty_lines) + if empty_lines: + output.extend([padding * columns] * empty_lines) return output From de592450ee60d07356cce9cd5091b74ef8cb17e2 Mon Sep 17 00:00:00 2001 From: Martin Di Paola Date: Mon, 20 Jun 2022 10:37:09 -0300 Subject: [PATCH 09/10] Move assert out of prod code The assert that checks the width of each char is removed from screen.display and put it into the tests. This ensures that our test suite maintains the same quality and at the same time we make screen.display ~x1.7 faster. --- pyte/screens.py | 1 - tests/helpers/asserts.py | 12 +++++++ tests/test_history.py | 28 ++++++++++++++- tests/test_input_output.py | 5 ++- tests/test_screen.py | 70 +++++++++++++++++++++++++++++++++++++- tests/test_stream.py | 7 +++- 6 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 tests/helpers/asserts.py diff --git a/pyte/screens.py b/pyte/screens.py index 28d0acb..cf122ae 100644 --- a/pyte/screens.py +++ b/pyte/screens.py @@ -256,7 +256,6 @@ def display(self): is_wide_char = False continue char = cell.data - assert sum(map(wcwidth, char[1:])) == 0 is_wide_char = wcwidth(char[0]) == 2 display_line.append(char) diff --git a/tests/helpers/asserts.py b/tests/helpers/asserts.py new file mode 100644 index 0000000..5ab2689 --- /dev/null +++ b/tests/helpers/asserts.py @@ -0,0 +1,12 @@ +from wcwidth import wcwidth +def consistency_asserts(screen): + # Ensure that all the cells in the buffer, if they have + # a data of 2 or more code points, they all sum up 0 width + # In other words, the width of the cell is determinated by the + # width of the first code point. + for y in range(screen.lines): + for x in range(screen.columns): + char = screen.buffer[y][x].data + assert sum(map(wcwidth, char[1:])) == 0 + + diff --git a/tests/test_history.py b/tests/test_history.py index 52084ec..288b940 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -1,8 +1,10 @@ -import os +import os, sys import pyte from pyte import control as ctrl, modes as mo +sys.path.append(os.path.join(os.path.dirname(__file__), "helpers")) +from asserts import consistency_asserts def chars(history_lines, columns): return ["".join(history_lines[y][x].data for x in range(columns)) @@ -96,6 +98,7 @@ def test_prev_page(): "39 ", " " ] + consistency_asserts(screen) assert chars(screen.history.top, screen.columns)[-4:] == [ "33 ", @@ -114,6 +117,7 @@ def test_prev_page(): "37 ", "38 " ] + consistency_asserts(screen) assert chars(screen.history.top, screen.columns)[-4:] == [ "31 ", @@ -138,6 +142,7 @@ def test_prev_page(): "35 ", "36 ", ] + consistency_asserts(screen) assert len(screen.history.bottom) == 4 assert chars(screen.history.bottom, screen.columns) == [ @@ -165,6 +170,7 @@ def test_prev_page(): "49 ", " " ] + consistency_asserts(screen) screen.prev_page() assert screen.history.position == 47 @@ -175,6 +181,7 @@ def test_prev_page(): "46 ", "47 " ] + consistency_asserts(screen) assert len(screen.history.bottom) == 3 assert chars(screen.history.bottom, screen.columns) == [ @@ -200,6 +207,7 @@ def test_prev_page(): "39 ", " " ] + consistency_asserts(screen) screen.prev_page() assert screen.history.position == 37 @@ -209,6 +217,7 @@ def test_prev_page(): "36 ", "37 " ] + consistency_asserts(screen) assert len(screen.history.bottom) == 3 assert chars(screen.history.bottom, screen.columns) == [ @@ -235,6 +244,7 @@ def test_prev_page(): "49 ", " " ] + consistency_asserts(screen) screen.cursor_to_line(screen.lines // 2) @@ -250,6 +260,7 @@ def test_prev_page(): "4 ", "5 " ] + consistency_asserts(screen) while screen.history.position < screen.history.size: screen.next_page() @@ -262,6 +273,7 @@ def test_prev_page(): "49 ", " " ] + consistency_asserts(screen) # e) same with cursor near the middle of the screen. screen = pyte.HistoryScreen(5, 5, history=50) @@ -282,6 +294,7 @@ def test_prev_page(): "49 ", " " ] + consistency_asserts(screen) screen.cursor_to_line(screen.lines // 2 - 2) @@ -297,6 +310,7 @@ def test_prev_page(): "4 ", "5 " ] + consistency_asserts(screen) while screen.history.position < screen.history.size: screen.next_page() @@ -310,6 +324,7 @@ def test_prev_page(): "49 ", " " ] + consistency_asserts(screen) def test_next_page(): @@ -332,6 +347,7 @@ def test_next_page(): "24 ", " " ] + consistency_asserts(screen) # a) page up -- page down. screen.prev_page() @@ -346,6 +362,7 @@ def test_next_page(): "24 ", " " ] + consistency_asserts(screen) # b) double page up -- page down. screen.prev_page() @@ -366,6 +383,7 @@ def test_next_page(): "21 ", "22 " ] + consistency_asserts(screen) # c) double page up -- double page down screen.prev_page() @@ -381,6 +399,7 @@ def test_next_page(): "21 ", "22 " ] + consistency_asserts(screen) def test_ensure_width(monkeypatch): @@ -402,6 +421,7 @@ def test_ensure_width(monkeypatch): "0024 ", " " ] + consistency_asserts(screen) # Shrinking the screen should truncate the displayed lines following lines. screen.resize(5, 3) @@ -416,6 +436,7 @@ def test_ensure_width(monkeypatch): "002", # 21 "002" # 22 ] + consistency_asserts(screen) def test_not_enough_lines(): @@ -436,6 +457,7 @@ def test_not_enough_lines(): "4 ", " " ] + consistency_asserts(screen) screen.prev_page() assert not screen.history.top @@ -448,6 +470,7 @@ def test_not_enough_lines(): "3 ", "4 ", ] + consistency_asserts(screen) screen.next_page() assert screen.history.top @@ -459,6 +482,7 @@ def test_not_enough_lines(): "4 ", " " ] + consistency_asserts(screen) def test_draw(monkeypatch): @@ -479,6 +503,7 @@ def test_draw(monkeypatch): "24 ", " " ] + consistency_asserts(screen) # a) doing a pageup and then a draw -- expecting the screen # to scroll to the bottom before drawing anything. @@ -494,6 +519,7 @@ def test_draw(monkeypatch): "24 ", "x " ] + consistency_asserts(screen) def test_cursor_is_hidden(monkeypatch): diff --git a/tests/test_input_output.py b/tests/test_input_output.py index 4d25c03..ab6535d 100644 --- a/tests/test_input_output.py +++ b/tests/test_input_output.py @@ -1,5 +1,5 @@ import json -import os.path +import os.path, sys import pytest @@ -8,6 +8,8 @@ captured_dir = os.path.join(os.path.dirname(__file__), "captured") +sys.path.append(os.path.join(os.path.dirname(__file__), "helpers")) +from asserts import consistency_asserts @pytest.mark.parametrize("name", [ "cat-gpl3", "find-etc", "htop", "ls", "mc", "top", "vi" @@ -23,3 +25,4 @@ def test_input_output(name): stream = pyte.ByteStream(screen) stream.feed(input) assert screen.display == output + consistency_asserts(screen) diff --git a/tests/test_screen.py b/tests/test_screen.py index b6ba90d..b4fe75a 100644 --- a/tests/test_screen.py +++ b/tests/test_screen.py @@ -1,4 +1,4 @@ -import copy +import copy, sys, os import pytest @@ -6,6 +6,8 @@ from pyte import modes as mo, control as ctrl, graphics as g from pyte.screens import Char +sys.path.append(os.path.join(os.path.dirname(__file__), "helpers")) +from asserts import consistency_asserts # Test helpers. @@ -230,12 +232,14 @@ def test_resize(): screen = update(pyte.Screen(2, 2), ["bo", "sh"], [None, None]) screen.resize(2, 3) assert screen.display == ["bo ", "sh "] + consistency_asserts(screen) # b) if the current display is wider than the requested size, # columns should be removed from the right... screen = update(pyte.Screen(2, 2), ["bo", "sh"], [None, None]) screen.resize(2, 1) assert screen.display == ["b", "s"] + consistency_asserts(screen) # c) if the current display is shorter than the requested # size, new rows should be added on the bottom. @@ -243,12 +247,14 @@ def test_resize(): screen.resize(3, 2) assert screen.display == ["bo", "sh", " "] + consistency_asserts(screen) # d) if the current display is taller than the requested # size, rows should be removed from the top. screen = update(pyte.Screen(2, 2), ["bo", "sh"], [None, None]) screen.resize(1, 2) assert screen.display == ["sh"] + consistency_asserts(screen) def test_resize_same(): @@ -312,6 +318,7 @@ def test_draw(): assert screen.display == ["abc", " ", " "] assert (screen.cursor.y, screen.cursor.x) == (0, 3) + consistency_asserts(screen) # ... one` more character -- now we got a linefeed! screen.draw("a") @@ -326,11 +333,13 @@ def test_draw(): assert screen.display == ["abc", " ", " "] assert (screen.cursor.y, screen.cursor.x) == (0, 3) + consistency_asserts(screen) # No linefeed is issued on the end of the line ... screen.draw("a") assert screen.display == ["aba", " ", " "] assert (screen.cursor.y, screen.cursor.x) == (0, 3) + consistency_asserts(screen) # ``IRM`` mode is on, expecting new characters to move the old ones # instead of replacing them. @@ -338,10 +347,12 @@ def test_draw(): screen.cursor_position() screen.draw("x") assert screen.display == ["xab", " ", " "] + consistency_asserts(screen) screen.cursor_position() screen.draw("y") assert screen.display == ["yxa", " ", " "] + consistency_asserts(screen) def test_draw_russian(): @@ -350,6 +361,7 @@ def test_draw_russian(): stream = pyte.Stream(screen) stream.feed("Нерусский текст") assert screen.display == ["Нерусский текст "] + consistency_asserts(screen) def test_draw_multiple_chars(): @@ -357,6 +369,7 @@ def test_draw_multiple_chars(): screen.draw("foobar") assert screen.cursor.x == 6 assert screen.display == ["foobar "] + consistency_asserts(screen) def test_draw_utf8(): @@ -365,6 +378,7 @@ def test_draw_utf8(): stream = pyte.ByteStream(screen) stream.feed(b"\xE2\x80\x9D") assert screen.display == ["”"] + consistency_asserts(screen) def test_draw_width2(): @@ -387,12 +401,14 @@ def test_draw_width2_irm(): screen.draw("コ") assert screen.display == ["コ"] assert tolist(screen) == [[Char("コ"), Char(" ")]] + consistency_asserts(screen) # Overwrite the stub part of a width 2 character. screen.set_mode(mo.IRM) screen.cursor_to_column(screen.columns) screen.draw("x") assert screen.display == [" x"] + consistency_asserts(screen) def test_draw_width0_combining(): @@ -401,17 +417,20 @@ def test_draw_width0_combining(): # a) no prev. character screen.draw("\N{COMBINING DIAERESIS}") assert screen.display == [" ", " "] + consistency_asserts(screen) screen.draw("bad") # b) prev. character is on the same line screen.draw("\N{COMBINING DIAERESIS}") assert screen.display == ["bad̈ ", " "] + consistency_asserts(screen) # c) prev. character is on the prev. line screen.draw("!") screen.draw("\N{COMBINING DIAERESIS}") assert screen.display == ["bad̈!̈", " "] + consistency_asserts(screen) def test_draw_width0_irm(): @@ -422,6 +441,7 @@ def test_draw_width0_irm(): screen.draw("\N{ZERO WIDTH SPACE}") screen.draw("\u0007") # DELETE. assert screen.display == [" " * screen.columns] + consistency_asserts(screen) def test_draw_width0_decawm_off(): @@ -446,6 +466,7 @@ def test_draw_cp437(): stream.feed("α ± ε".encode("cp437")) assert screen.display == ["α ± ε"] + consistency_asserts(screen) def test_draw_with_carriage_return(): @@ -465,12 +486,14 @@ def test_draw_with_carriage_return(): "pcrm sem ;ps aux|grep -P 'httpd|fcgi'|grep -v grep", "}'|xargs kill -9;/etc/init.d/httpd startssl " ] + consistency_asserts(screen) def test_display_wcwidth(): screen = pyte.Screen(10, 1) screen.draw("コンニチハ") assert screen.display == ["コンニチハ"] + consistency_asserts(screen) def test_carriage_return(): @@ -519,6 +542,7 @@ def test_index(): [screen.default_char, screen.default_char], [Char("o"), Char("h")], ] + consistency_asserts(screen) # ... and again ... screen.index() @@ -531,6 +555,7 @@ def test_index(): [screen.default_char, screen.default_char], [Char("o"), Char("h")], ] + consistency_asserts(screen) # ... and again ... screen.index() @@ -543,6 +568,7 @@ def test_index(): [screen.default_char, screen.default_char], [Char("o"), Char("h")], ] + consistency_asserts(screen) # look, nothing changes! screen.index() @@ -555,6 +581,7 @@ def test_index(): [screen.default_char, screen.default_char], [Char("o"), Char("h")], ] + consistency_asserts(screen) def test_reverse_index(): @@ -594,6 +621,7 @@ def test_reverse_index(): [Char("t", fg="red"), Char("h", fg="red")], [Char("o"), Char("h")], ] + consistency_asserts(screen) # ... and again ... screen.reverse_index() @@ -606,6 +634,7 @@ def test_reverse_index(): [Char("s"), Char("h")], [Char("o"), Char("h")], ] + consistency_asserts(screen) # ... and again ... screen.reverse_index() @@ -618,6 +647,7 @@ def test_reverse_index(): [screen.default_char, screen.default_char], [Char("o"), Char("h")], ] + consistency_asserts(screen) # look, nothing changes! screen.reverse_index() @@ -630,6 +660,7 @@ def test_reverse_index(): [screen.default_char, screen.default_char], [Char("o"), Char("h")], ] + consistency_asserts(screen) def test_linefeed(): @@ -810,6 +841,7 @@ def test_insert_lines(): [Char("s"), Char("a"), Char("m")], [Char("i", fg="red"), Char("s", fg="red"), Char(" ", fg="red")], ] + consistency_asserts(screen) screen = update(pyte.Screen(3, 3), ["sam", "is ", "foo"], colored=[1]) screen.insert_lines(2) @@ -821,6 +853,7 @@ def test_insert_lines(): [screen.default_char] * 3, [Char("s"), Char("a"), Char("m")] ] + consistency_asserts(screen) # b) with margins screen = update(pyte.Screen(3, 5), ["sam", "is ", "foo", "bar", "baz"], @@ -838,6 +871,7 @@ def test_insert_lines(): [Char("f", fg="red"), Char("o", fg="red"), Char("o", fg="red")], [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) screen = update(pyte.Screen(3, 5), ["sam", "is ", "foo", "bar", "baz"], colored=[2, 3]) @@ -854,6 +888,7 @@ def test_insert_lines(): [Char("b", fg="red"), Char("a", fg="red"), Char("r", fg="red")], [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) screen.insert_lines(2) assert (screen.cursor.y, screen.cursor.x) == (1, 0) @@ -865,6 +900,7 @@ def test_insert_lines(): [Char("b", fg="red"), Char("a", fg="red"), Char("r", fg="red")], [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) # c) with margins -- trying to insert more than we have available screen = update(pyte.Screen(3, 5), ["sam", "is ", "foo", "bar", "baz"], @@ -882,6 +918,7 @@ def test_insert_lines(): [screen.default_char] * 3, [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) # d) with margins -- trying to insert outside scroll boundaries; # expecting nothing to change @@ -899,6 +936,7 @@ def test_insert_lines(): [Char("b", fg="red"), Char("a", fg="red"), Char("r", fg="red")], [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) def test_delete_lines(): @@ -913,6 +951,7 @@ def test_delete_lines(): [Char("f"), Char("o"), Char("o")], [screen.default_char] * 3, ] + consistency_asserts(screen) screen.delete_lines(0) @@ -923,6 +962,7 @@ def test_delete_lines(): [screen.default_char] * 3, [screen.default_char] * 3, ] + consistency_asserts(screen) # b) with margins screen = update(pyte.Screen(3, 5), ["sam", "is ", "foo", "bar", "baz"], @@ -940,6 +980,7 @@ def test_delete_lines(): [screen.default_char] * 3, [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) screen = update(pyte.Screen(3, 5), ["sam", "is ", "foo", "bar", "baz"], colored=[2, 3]) @@ -956,6 +997,7 @@ def test_delete_lines(): [screen.default_char] * 3, [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) # c) with margins -- trying to delete more than we have available screen = update(pyte.Screen(3, 5), @@ -978,6 +1020,7 @@ def test_delete_lines(): [screen.default_char] * 3, [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) # d) with margins -- trying to delete outside scroll boundaries; # expecting nothing to change @@ -996,6 +1039,7 @@ def test_delete_lines(): [Char("b", fg="red"), Char("a", fg="red"), Char("r", fg="red")], [Char("b"), Char("a"), Char("z")], ] + consistency_asserts(screen) def test_insert_characters(): @@ -1052,16 +1096,19 @@ def test_delete_characters(): Char("m", fg="red"), screen.default_char, screen.default_char ] + consistency_asserts(screen) screen.cursor.y, screen.cursor.x = 2, 2 screen.delete_characters() assert (screen.cursor.y, screen.cursor.x) == (2, 2) assert screen.display == ["m ", "is ", "fo "] + consistency_asserts(screen) screen.cursor.y, screen.cursor.x = 1, 1 screen.delete_characters(0) assert (screen.cursor.y, screen.cursor.x) == (1, 1) assert screen.display == ["m ", "i ", "fo "] + consistency_asserts(screen) # ! extreme cases. screen = update(pyte.Screen(5, 1), ["12345"], colored=[0]) @@ -1076,6 +1123,7 @@ def test_delete_characters(): screen.default_char, screen.default_char ] + consistency_asserts(screen) screen = update(pyte.Screen(5, 1), ["12345"], colored=[0]) screen.cursor.x = 2 @@ -1089,6 +1137,7 @@ def test_delete_characters(): screen.default_char, screen.default_char ] + consistency_asserts(screen) screen = update(pyte.Screen(5, 1), ["12345"], colored=[0]) screen.delete_characters(4) @@ -1101,6 +1150,7 @@ def test_delete_characters(): screen.default_char, screen.default_char ] + consistency_asserts(screen) def test_erase_character(): @@ -1114,16 +1164,19 @@ def test_erase_character(): screen.default_char, Char("m", fg="red") ] + consistency_asserts(screen) screen.cursor.y, screen.cursor.x = 2, 2 screen.erase_characters() assert (screen.cursor.y, screen.cursor.x) == (2, 2) assert screen.display == [" m", "is ", "fo "] + consistency_asserts(screen) screen.cursor.y, screen.cursor.x = 1, 1 screen.erase_characters(0) assert (screen.cursor.y, screen.cursor.x) == (1, 1) assert screen.display == [" m", "i ", "fo "] + consistency_asserts(screen) # ! extreme cases. screen = update(pyte.Screen(5, 1), ["12345"], colored=[0]) @@ -1138,6 +1191,7 @@ def test_erase_character(): screen.default_char, Char("5", "red") ] + consistency_asserts(screen) screen = update(pyte.Screen(5, 1), ["12345"], colored=[0]) screen.cursor.x = 2 @@ -1151,6 +1205,7 @@ def test_erase_character(): screen.default_char, screen.default_char ] + consistency_asserts(screen) screen = update(pyte.Screen(5, 1), ["12345"], colored=[0]) screen.erase_characters(4) @@ -1163,6 +1218,7 @@ def test_erase_character(): screen.default_char, Char("5", fg="red") ] + consistency_asserts(screen) def test_erase_in_line(): @@ -1189,6 +1245,7 @@ def test_erase_in_line(): screen.default_char, screen.default_char ] + consistency_asserts(screen) # b) erase from the beginning of the line to the cursor screen = update(screen, @@ -1211,6 +1268,7 @@ def test_erase_in_line(): Char(" ", fg="red"), Char("i", fg="red") ] + consistency_asserts(screen) # c) erase the entire line screen = update(screen, @@ -1227,6 +1285,7 @@ def test_erase_in_line(): "re yo", "u? "] assert tolist(screen)[0] == [screen.default_char] * 5 + consistency_asserts(screen) def test_erase_in_display(): @@ -1256,6 +1315,7 @@ def test_erase_in_display(): [screen.default_char] * 5, [screen.default_char] * 5 ] + consistency_asserts(screen) # b) erase from the beginning of the display to the cursor, # including it @@ -1281,6 +1341,7 @@ def test_erase_in_display(): Char(" ", fg="red"), Char("a", fg="red")], ] + consistency_asserts(screen) # c) erase the while display screen.erase_in_display(2) @@ -1291,6 +1352,7 @@ def test_erase_in_display(): " ", " "] assert tolist(screen) == [[screen.default_char] * 5] * 5 + consistency_asserts(screen) # d) erase with private mode screen = update(pyte.Screen(5, 5), @@ -1305,6 +1367,7 @@ def test_erase_in_display(): " ", " ", " "] + consistency_asserts(screen) # e) erase with extra args screen = update(pyte.Screen(5, 5), @@ -1320,6 +1383,7 @@ def test_erase_in_display(): " ", " ", " "] + consistency_asserts(screen) # f) erase with extra args and private screen = update(pyte.Screen(5, 5), @@ -1334,6 +1398,7 @@ def test_erase_in_display(): " ", " ", " "] + consistency_asserts(screen) def test_cursor_up(): @@ -1462,6 +1527,7 @@ def test_unicode(): stream.feed("тест".encode("utf-8")) assert screen.display == ["тест", " "] + consistency_asserts(screen) def test_alignment_display(): @@ -1477,6 +1543,7 @@ def test_alignment_display(): "b ", " ", " "] + consistency_asserts(screen) screen.alignment_display() @@ -1485,6 +1552,7 @@ def test_alignment_display(): "EEEEE", "EEEEE", "EEEEE"] + consistency_asserts(screen) def test_set_margins(): diff --git a/tests/test_stream.py b/tests/test_stream.py index 7a3ad92..0d05df7 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -1,10 +1,12 @@ -import io +import io, sys, os import pytest import pyte from pyte import charsets as cs, control as ctrl, escape as esc +sys.path.append(os.path.join(os.path.dirname(__file__), "helpers")) +from asserts import consistency_asserts class counter: def __init__(self): @@ -227,6 +229,7 @@ def test_define_charset(): stream = pyte.Stream(screen) stream.feed(ctrl.ESC + "(B") assert screen.display[0] == " " * 3 + consistency_asserts(screen) def test_non_utf8_shifts(): @@ -305,6 +308,7 @@ def test_byte_stream_define_charset_unknown(): stream.feed((ctrl.ESC + "(Z").encode()) assert screen.display[0] == " " * 3 assert screen.g0_charset == default_g0_charset + consistency_asserts(screen) @pytest.mark.parametrize("charset,mapping", cs.MAPS.items()) @@ -315,6 +319,7 @@ def test_byte_stream_define_charset(charset, mapping): stream.feed((ctrl.ESC + "(" + charset).encode()) assert screen.display[0] == " " * 3 assert screen.g0_charset == mapping + consistency_asserts(screen) def test_byte_stream_select_other_charset(): From 020fce61c2f5eb5467727048743e481b2a8d2a87 Mon Sep 17 00:00:00 2001 From: Martin Di Paola Date: Mon, 20 Jun 2022 11:16:39 -0300 Subject: [PATCH 10/10] Cache in Char its width Instead of computing it on each screen.display, compute the width of the char once on screen.draw and store it in the Char tuple. This makes screen.display ~x1.10 to ~x1.20 faster and it makes stream.feed only ~x1.01 slower in the worst case. This negative impact is due the change on screen.draw but measurements on my lab show inconsistent results (stream.feed didn't show a consistent performance regression and ~x1.01 slower was the worst value that I've got). --- pyte/screens.py | 22 +++++++++++++--------- tests/helpers/asserts.py | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/pyte/screens.py b/pyte/screens.py index cf122ae..796acec 100644 --- a/pyte/screens.py +++ b/pyte/screens.py @@ -71,6 +71,7 @@ class Char(namedtuple("Char", [ "strikethrough", "reverse", "blink", + "width", ])): """A single styled on-screen character. @@ -89,15 +90,16 @@ class Char(namedtuple("Char", [ during rendering. Defaults to ``False``. :param bool blink: flag for rendering the character blinked. Defaults to ``False``. + :param bool width: the width in terms of cells to display this char. """ __slots__ = () - def __new__(cls, data, fg="default", bg="default", bold=False, + def __new__(cls, data=" ", fg="default", bg="default", bold=False, italics=False, underscore=False, - strikethrough=False, reverse=False, blink=False): + strikethrough=False, reverse=False, blink=False, width=wcwidth(" ")): return super(Char, cls).__new__(cls, data, fg, bg, bold, italics, underscore, strikethrough, reverse, - blink) + blink, width) class Cursor: @@ -111,7 +113,7 @@ class Cursor: """ __slots__ = ("x", "y", "attrs", "hidden") - def __init__(self, x, y, attrs=Char(" ")): + def __init__(self, x, y, attrs=Char(" ", width=wcwidth(" "))): self.x = x self.y = y self.attrs = attrs @@ -211,7 +213,7 @@ class Screen: def default_char(self): """An empty character with default foreground and background colors.""" reverse = mo.DECSCNM in self.mode - return Char(data=" ", fg="default", bg="default", reverse=reverse) + return Char(data=" ", fg="default", bg="default", reverse=reverse, width=wcwidth(" ")) def __init__(self, columns, lines): self.savepoints = [] @@ -256,7 +258,7 @@ def display(self): is_wide_char = False continue char = cell.data - is_wide_char = wcwidth(char[0]) == 2 + is_wide_char = cell.width == 2 display_line.append(char) gap = columns - (prev_x + 1) @@ -527,16 +529,18 @@ def draw(self, data): line = self.buffer[self.cursor.y] if char_width == 1: - line[self.cursor.x] = self.cursor.attrs._replace(data=char) + line[self.cursor.x] = self.cursor.attrs._replace(data=char, width=char_width) elif char_width == 2: # A two-cell character has a stub slot after it. - line[self.cursor.x] = self.cursor.attrs._replace(data=char) + line[self.cursor.x] = self.cursor.attrs._replace(data=char, width=char_width) if self.cursor.x + 1 < self.columns: line[self.cursor.x + 1] = self.cursor.attrs \ - ._replace(data="") + ._replace(data="", width=0) elif char_width == 0 and unicodedata.combining(char): # A zero-cell character is combined with the previous # character either on this or preceding line. + # Because char's width is zero, this will not change the width + # of the previous character. if self.cursor.x: last = line[self.cursor.x - 1] normalized = unicodedata.normalize("NFC", last.data + char) diff --git a/tests/helpers/asserts.py b/tests/helpers/asserts.py index 5ab2689..17aa295 100644 --- a/tests/helpers/asserts.py +++ b/tests/helpers/asserts.py @@ -6,7 +6,17 @@ def consistency_asserts(screen): # width of the first code point. for y in range(screen.lines): for x in range(screen.columns): - char = screen.buffer[y][x].data - assert sum(map(wcwidth, char[1:])) == 0 + data = screen.buffer[y][x].data + assert sum(map(wcwidth, data[1:])) == 0 + # Ensure consistency between the real width (computed here + # with wcwidth(...)) and the char.width attribute + for y in range(screen.lines): + for x in range(screen.columns): + char = screen.buffer[y][x] + if char.data: + assert wcwidth(char.data[0]) == char.width + else: + assert char.data == "" + assert char.width == 0