def test_screen_events_empty_groups(self): """screen_events should never return an empty group of events""" records = [ AsciiCastV2Header(version=2, width=80, height=24, theme=THEME), AsciiCastV2Event(0, 'o', 'i', None), AsciiCastV2Event(1, 'o', '', None), AsciiCastV2Event(2, 'o', '', None), AsciiCastV2Event(3, 'o', '', None), ] expected_events = [ term.Configuration(80, 24), [ term.DisplayLine(0, { 0: anim.CharacterCell('i'), 1: CURSOR_CHAR }, 0), ], [ term.DisplayLine(0, { 0: anim.CharacterCell('i'), 1: CURSOR_CHAR }, 0, 4000), ] ] events = term.screen_events(records, 1, None, last_frame_dur=1000) list_events = [next(events)] list_events.extend([le for le in events]) z = itertools.zip_longest(expected_events, list_events) for count, (expected_item, item) in enumerate(z): with self.subTest(case='No empty group - item #{}'.format(count)):
def _group_by_time(event_records, min_rec_duration, max_rec_duration, last_rec_duration): """Merge event records together if they are close enough The time elapsed between two consecutive event records returned by this function is guaranteed to be at least min_rec_duration. The duration of each record is also computed. Any record with a duration greater than `max_rec_duration` will see its duration reduce to this value. The duration of the last record can't be computed and is simply set to `last_rec_duration`. :param event_records: Sequence of records in asciicast v2 format :param min_rec_duration: Minimum time between two records returned by the function in milliseconds. :param max_rec_duration: Maximum duration of a record in milliseconds :param last_rec_duration: Duration of the last record in milliseconds :return: Sequence of records with duration """ # TODO: itertools.accumulate? current_string = '' current_time = 0 dropped_time = 0 if max_rec_duration: max_rec_duration /= 1000 for event_record in event_records: assert isinstance(event_record, AsciiCastV2Event) # Silently ignoring the duration on input records is a source # of confusion so fail hard if the duration is set assert event_record.duration is None if event_record.event_type != 'o': continue time_between_events = event_record.time - (current_time + dropped_time) if time_between_events * 1000 >= min_rec_duration: if max_rec_duration: if max_rec_duration < time_between_events: dropped_time += time_between_events - max_rec_duration time_between_events = max_rec_duration accumulator_event = AsciiCastV2Event(time=current_time, event_type='o', event_data=current_string, duration=time_between_events) yield accumulator_event current_string = '' current_time += time_between_events current_string += event_record.event_data accumulator_event = AsciiCastV2Event(time=current_time, event_type='o', event_data=current_string, duration=last_rec_duration / 1000) yield accumulator_event
def test__group_by_time(self): event_records = [ AsciiCastV2Event(0, 'o', b'1', None), AsciiCastV2Event(5, 'o', b'2', None), AsciiCastV2Event(8, 'o', b'3', None), AsciiCastV2Event(20, 'o', b'4', None), AsciiCastV2Event(21, 'o', b'5', None), AsciiCastV2Event(30, 'o', b'6', None), AsciiCastV2Event(31, 'o', b'7', None), AsciiCastV2Event(32, 'o', b'8', None), AsciiCastV2Event(33, 'o', b'9', None), AsciiCastV2Event(43, 'o', b'10', None), ] with self.subTest(case='maximum record duration'):
def test_timed_frames_unprintable_chars(self): # Ensure zero width characters in terminal output does not result # in Pyte dropping all following data # Issue https://github.com/nbedos/termtosvg/issues/89 # test_text = "e🕵️a" test_text = ( b'e' + b'\xf0\x9f\x95\xb5' + # sleuth emoji b'\xef\xb8\x8f' + # variation selector 16 b'\xe2\x80\x8d' + # zero width joiner b'a').decode('utf-8') # character that should be preserved records = [ AsciiCastV2Header(version=2, width=80, height=24, theme=THEME), AsciiCastV2Event(0, 'o', test_text, None), ] _, events = term.timed_frames(records, 1, None, last_frame_dur=1000) frame = next(events) characters = ''.join(frame.buffer[0][col].text for col in frame.buffer[0]) # Ensure data following sleuth emoji wasn't ignored # (rstrip() removes blank cursor character at end of line) self.assertEqual(characters.rstrip()[-1], test_text[-1])
def test_screen_events_simple_events(self): records = [AsciiCastV2Header(version=2, width=80, height=24, theme=THEME)] + \ [AsciiCastV2Event(time=i, event_type='o', event_data='{}\r\n'.format(i), duration=None) for i in range(0, 2)] events = term.screen_events(records, 1, None, 42) list_events = [next(events)] list_events.extend([list(le) for le in events]) expected_events = [ term.Configuration(80, 24), [ term.DisplayLine(0, {0: anim.CharacterCell('0')}, 0), term.DisplayLine(1, {0: CURSOR_CHAR}, 0), ], [ term.DisplayLine(1, {0: CURSOR_CHAR}, 0, 1000), term.DisplayLine(1, {0: anim.CharacterCell('1')}, 1000), term.DisplayLine(2, {0: CURSOR_CHAR}, 1000), ], [ term.DisplayLine(0, {0: anim.CharacterCell('0')}, 0, 1042), term.DisplayLine(1, {0: anim.CharacterCell('1')}, 1000, 42), term.DisplayLine(2, {0: CURSOR_CHAR}, 1000, 42), ], ] z = itertools.zip_longest(expected_events, list_events) for count, (expected_item, item) in enumerate(z): with self.subTest(case='Simple events - item #{}'.format(count)):
def _group_by_time(event_records, min_rec_duration, max_rec_duration, last_rec_duration): """Merge event records together if they are close enough and compute the duration between consecutive events. The duration between two consecutive event records returned by the function is guaranteed to be at least min_rec_duration. :param event_records: Sequence of records in asciicast v2 format :param min_rec_duration: Minimum time between two records returned by the function in milliseconds. This helps avoiding 0s duration animations which break SVG animations. :param max_rec_duration: Limit of the time elapsed between two records :param last_rec_duration: Duration of the last record in milliseconds :return: Sequence of records """ current_string = b'' current_time = 0 dropped_time = 0 for event_record in event_records: if event_record.event_type != 'o': continue time_between_events = event_record.time - (current_time + dropped_time) if time_between_events * 1000 >= min_rec_duration: if max_rec_duration: if max_rec_duration / 1000 < time_between_events: dropped_time += time_between_events - (max_rec_duration / 1000) time_between_events = max_rec_duration / 1000 accumulator_event = AsciiCastV2Event(time=current_time, event_type='o', event_data=current_string, duration=time_between_events) yield accumulator_event current_string = b'' current_time += time_between_events current_string += event_record.event_data if current_string: accumulator_event = AsciiCastV2Event(time=current_time, event_type='o', event_data=current_string, duration=last_rec_duration / 1000) yield accumulator_event
def _group_by_time(event_records, min_rec_duration, last_rec_duration): # type: (Iterable[AsciiCastV2Event], float, float) -> Generator[AsciiCastV2Event, None, None] """Merge event records together if they are close enough and compute the duration between consecutive events. The duration between two consecutive event records returned by the function is guaranteed to be at least min_rec_duration. :param event_records: Sequence of records in asciicast v2 format :param min_rec_duration: Minimum time between two records returned by the function in seconds. This helps avoiding 0s duration animations which break SVG animations. :param last_rec_duration: Duration of the last record in seconds :return: Sequence of records """ current_string = b'' current_time = None for event_record in event_records: if event_record.event_type != 'o': continue if current_time is not None: time_between_events = event_record.time - current_time if time_between_events >= min_rec_duration: accumulator_event = AsciiCastV2Event( time=current_time, event_type='o', event_data=current_string, duration=time_between_events) yield accumulator_event current_string = b'' current_time = event_record.time else: current_time = event_record.time current_string += event_record.event_data if current_string: accumulator_event = AsciiCastV2Event(time=current_time, event_type='o', event_data=current_string, duration=last_rec_duration) yield accumulator_event
def record(columns, lines, input_fileno, output_fileno): """Record a terminal session in asciicast v2 format The records returned are of two types: - a single header with configuration information - multiple event records with data captured from the terminal and timing information """ yield AsciiCastV2Header(version=2, width=columns, height=lines, theme=None) start = None for data, time in _record(columns, lines, input_fileno, output_fileno): if start is None: start = time yield AsciiCastV2Event(time=(time - start).total_seconds(), event_type='o', event_data=data, duration=None)
def test_timed_frames_simple_events(self): records = [AsciiCastV2Header(version=2, width=80, height=24, theme=THEME)] + \ [AsciiCastV2Event(time=i, event_type='o', event_data='{}\r\n'.format(i), duration=None) for i in range(0, 2)] geometry, frames = term.timed_frames(records, 1, None, 42) self.assertEqual(geometry, (80, 24)) expected_frames = [ term.TimedFrame(0, 1000, { 0: { 0: anim.CharacterCell('0') }, 1: { 0: CURSOR_CHAR }, }), term.TimedFrame( 1000, 42, { 0: { 0: anim.CharacterCell('0') }, 1: { 0: anim.CharacterCell('1') }, 2: { 0: CURSOR_CHAR }, }) ] z = itertools.zip_longest(expected_frames, frames) for (expected_frame, frame) in z: self.assertEqual(expected_frame.time, frame.time) self.assertEqual(expected_frame.duration, frame.duration) for row in frame.buffer: if row in expected_frame.buffer: self.assertEqual(expected_frame.buffer[row], frame.buffer[row]) else: self.assertEqual({}, frame.buffer[row])
def record(process_args, columns, lines, input_fileno, output_fileno): """Record a process in asciicast v2 format The records returned by this method are: - a single header containing configuration information - multiple event records made of data captured from the terminal and timing information (except for record duration which needs to be computed separately) :param process_args: Arguments required to spawn the process (list of string) :param columns: Width of the terminal screen (integer) :param lines: Height of the terminal screen (integer) :param input_fileno: File descriptor that will be used as the standard input of the process :param output_fileno: File descriptor that will be used as the standard output of the process When using `sys.stdout.fileno()` for `output_fileno` there is a risk that the terminal is left in an unusable state if `record` fails. To prevent this, `record` should be called inside the `TerminalMode` context manager. """ yield AsciiCastV2Header(version=2, width=columns, height=lines, theme=None) # TODO: why start != 0? start = None utf8_decoder = codecs.getincrementaldecoder('utf-8')('replace') for data, time in _record(process_args, columns, lines, input_fileno, output_fileno): if start is None: start = time yield AsciiCastV2Event(time=(time - start).total_seconds(), event_type='o', event_data=utf8_decoder.decode(data), duration=None)
for _ in term.record(columns, lines, fd_in_read, fd_out_write): pass os.waitpid(pid, 0) for fd in fd_in_read, fd_in_write, fd_out_read, fd_out_write: os.close(fd) def test_replay(self): theme = AsciiCastV2Theme('#000000', '#FFFFFF', ':'.join(['#123456'] * 16)) with self.subTest(case='One shell command per event'): nbr_records = 5 records = [AsciiCastV2Header(version=2, width=80, height=24, theme=theme)] + \ [AsciiCastV2Event(time=i, event_type='o', event_data='{}\r\n'.format(i).encode('utf-8'), duration=None) for i in range(1, nbr_records)] records = term.replay(records, lambda x: x.data, 5000, None, 1000) # Last blank line is the cursor lines = [str(i) for i in range(nbr_records)] + [' '] for i, record in enumerate(records): # Skip header and cursor line if i != 0: self.assertEqual(record.line[0], lines[i]) with self.subTest(case='Shell command spread over multiple lines'): records = [AsciiCastV2Header(version=2, width=80, height=24, theme=theme)] + \ [AsciiCastV2Event(time=i * 60, event_type='o',
def test_replay(self): def pyte_to_str(x, _): return x.data fallback_theme = AsciiCastV2Theme('#000000', '#000000', ':'.join(['#000000'] * 16)) theme = AsciiCastV2Theme('#000000', '#FFFFFF', ':'.join(['#123456'] * 16)) with self.subTest(case='One shell command per event'): nbr_records = 5 records = [AsciiCastV2Header(version=2, width=80, height=24, theme=theme)] + \ [AsciiCastV2Event(time=i, event_type='o', event_data='{}\r\n'.format(i).encode('utf-8'), duration=None) for i in range(1, nbr_records)] records = term.replay(records, pyte_to_str, None, fallback_theme, 50, 1000) # Last blank line is the cursor lines = [str(i) for i in range(nbr_records)] + [' '] for i, record in enumerate(records): # Skip header and cursor line if i == 0: pass else: self.assertEqual(record.line[0], lines[i]) with self.subTest(
palette='#000000:#111111:' '#222222:#333333:#444444:#555555:#666666:#777777') color_theme_16 = AsciiCastV2Theme( fg='#000000', bg='#AAAAAA', palette='#000000:#111111:' '#222222:#333333:#444444:#555555:#666666:#777777:#888888:' '#999999:#AAAAAA:#bbbbbb:#CCCCCC:#DDDDDD:#EEEEEE:#ffffff') cast_v2_events = [ AsciiCastV2Header(2, 212, 53, None), AsciiCastV2Header(2, 212, 53, color_theme_8), AsciiCastV2Header(2, 212, 53, color_theme_16), AsciiCastV2Header(2, 212, 53, None), AsciiCastV2Header(2, 212, 53, None, 42), AsciiCastV2Header(2, 212, 53, None, 1.234), AsciiCastV2Event(0.010303, 'o', '\u001b[1;31mnico \u001b[0;34m~\u001b[0m', None), AsciiCastV2Event(1.146397, 'o', '❤ ☀ ☆ ☂ ☻ ♞ ☯ ☭ ☢ € →', None), AsciiCastV2Event(2, 'o', '\r\n', None), ] def test_from_json(self): test_cases = zip(TestAsciicast.cast_v2_lines, TestAsciicast.cast_v2_events) for index, (line, event) in enumerate(test_cases): with self.subTest(case='line #{}'.format(index)): self.assertEqual(event, AsciiCastV2Record.from_json_line(line)) failure_test_cases = [ ('header v2: invalid version', '{"version": "x", "width": 212, "height": 53}'), ('header v2: invalid width',
event_records = [ AsciiCastV2Event(0, 'o', '1', None), AsciiCastV2Event(5, 'o', '2', None), AsciiCastV2Event(8, 'o', '3', None), AsciiCastV2Event(20, 'o', '4', None), AsciiCastV2Event(21, 'o', '5', None), AsciiCastV2Event(30, 'o', '6', None), AsciiCastV2Event(31, 'o', '7', None), AsciiCastV2Event(32, 'o', '8', None), AsciiCastV2Event(33, 'o', '9', None), AsciiCastV2Event(43, 'o', '10', None), ] with self.subTest(case='maximum record duration'): grouped_event_records_max = [ AsciiCastV2Event(0, 'o', '1', 5), AsciiCastV2Event(5, 'o', '23', 6), AsciiCastV2Event(11, 'o', '45', 6), AsciiCastV2Event(17, 'o', '6789', 6), AsciiCastV2Event(23, 'o', '10', 1.234), ] result = list(term._group_by_time(event_records, 5000, 6000, 1234)) self.assertEqual(grouped_event_records_max, result) with self.subTest(case='no maximum record duration'): grouped_event_records_no_max = [ AsciiCastV2Event(0, 'o', '1', 5), AsciiCastV2Event(5, 'o', '23', 15), AsciiCastV2Event(20, 'o', '45', 10), AsciiCastV2Event(30, 'o', '6789', 13), AsciiCastV2Event(43, 'o', '10', 1.234),
pass os.waitpid(pid, 0) for fd in fd_in_read, fd_in_write, fd_out_read, fd_out_write: os.close(fd) def test_replay(self): theme = AsciiCastV2Theme('#000000', '#FFFFFF', ':'.join(['#123456'] * 16)) with self.subTest(case='One shell command per event'): nbr_records = 5 records = [AsciiCastV2Header(version=2, width=80, height=24, theme=theme)] + \ [AsciiCastV2Event(time=i, event_type='o', event_data='{}\r\n'.format(i).encode('utf-8'), duration=None) for i in range(1, nbr_records)] records = term.replay(records, lambda x: x.data, 50, 1000) # Last blank line is the cursor lines = [str(i) for i in range(nbr_records)] + [' '] for i, record in enumerate(records): # Skip header and cursor line if i == 0: pass else: self.assertEqual(record.line[0], lines[i]) with self.subTest(case='Shell command spread over multiple lines'): records = [AsciiCastV2Header(version=2, width=80, height=24, theme=theme)] + \
def test__group_by_time(self): event_records = [ AsciiCastV2Event(0, 'o', b'1', None), AsciiCastV2Event(50, 'o', b'2', None), AsciiCastV2Event(80, 'o', b'3', None), AsciiCastV2Event(200, 'o', b'4', None), AsciiCastV2Event(210, 'o', b'5', None), AsciiCastV2Event(300, 'o', b'6', None), AsciiCastV2Event(310, 'o', b'7', None), AsciiCastV2Event(320, 'o', b'8', None), AsciiCastV2Event(330, 'o', b'9', None) ] grouped_event_records = [ AsciiCastV2Event(0, 'o', b'1', 50), AsciiCastV2Event(50, 'o', b'23', 150), AsciiCastV2Event(200, 'o', b'45', 100), AsciiCastV2Event(300, 'o', b'6789', 1234) ] result = list(term._group_by_time(event_records, 50, 1234)) self.assertEqual(grouped_event_records, result)
palette='#000000:#111111:' '#222222:#333333:#444444:#555555:#666666:#777777') color_theme_16 = AsciiCastV2Theme( fg='#000000', bg='#AAAAAA', palette='#000000:#111111:' '#222222:#333333:#444444:#555555:#666666:#777777:#888888:' '#999999:#AAAAAA:#bbbbbb:#CCCCCC:#DDDDDD:#EEEEEE:#ffffff') cast_v2_events = [ AsciiCastV2Header(2, 212, 53, None), AsciiCastV2Header(2, 212, 53, color_theme_8), AsciiCastV2Header(2, 212, 53, color_theme_16), AsciiCastV2Header(2, 212, 53, None), AsciiCastV2Header(2, 212, 53, None, 42), AsciiCastV2Event( 0.010303, 'o', '\u001b[1;31mnico \u001b[0;34m~\u001b[0m'.encode('utf-8'), None), AsciiCastV2Event(1.146397, 'o', '❤ ☀ ☆ ☂ ☻ ♞ ☯ ☭ ☢ € →'.encode('utf-8'), None), AsciiCastV2Event(2, 'o', b'\r\n', None), ] def test_from_json(self): test_cases = zip(TestAsciicast.cast_v2_lines, TestAsciicast.cast_v2_events) for index, (line, event) in enumerate(test_cases): with self.subTest(case='line #{}'.format(index)): self.assertEqual(event, AsciiCastV2Record.from_json_line(line)) failure_test_cases = [ ('header v2: invalid version',