Skip to content
This repository was archived by the owner on Jun 16, 2020. It is now read-only.

Commit 3a401f3

Browse files
committed
Add support for asciicast v1 recordings (#15)
1 parent 512bda5 commit 3a401f3

File tree

7 files changed

+318
-144
lines changed

7 files changed

+318
-144
lines changed

termtosvg/__main__.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#!/usr/bin/env python3
22
import argparse
33
import logging
4-
import os
54
import sys
65
import tempfile
76
from typing import List, Tuple, Union
@@ -85,7 +84,7 @@ def parse(args, themes):
8584
)
8685
parser.add_argument(
8786
'input_file',
88-
help='recording of the terminal session in asciicast v2 format'
87+
help='recording of a terminal session in asciicast v1 or v2 format'
8988
)
9089
parser.add_argument(
9190
'output_file',
@@ -145,11 +144,6 @@ def main(args=None, input_fileno=None, output_fileno=None):
145144

146145
logger.info('Recording ended, cast file is {}'.format(cast_filename))
147146
elif command == 'render':
148-
def rec_gen():
149-
with open(args.input_file, 'r') as cast_file:
150-
for line in cast_file:
151-
yield asciicast.AsciiCastRecord.from_json_line(line)
152-
153147
logger.info('Rendering started')
154148
if args.output_file is None:
155149
_, svg_filename = tempfile.mkstemp(prefix='termtosvg_', suffix='.svg')
@@ -165,7 +159,8 @@ def rec_gen():
165159
fallback_theme = configuration[fallback_theme_name]
166160
cli_theme = configuration.get(args.theme)
167161

168-
replayed_records = term.replay(records=rec_gen(),
162+
records = asciicast.read_records(args.input_file)
163+
replayed_records = term.replay(records=records,
169164
from_pyte_char=anim.CharacterCell.from_pyte,
170165
override_theme=cli_theme,
171166
fallback_theme=fallback_theme)

termtosvg/asciicast.py

Lines changed: 129 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,106 @@
1-
"""Asciicast v2 record formats
1+
"""asciicast records
22
3-
Full specification: https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md
3+
This module provides functions and classes to manipulate asciicast records. Both v1 and v2
4+
format are supported for decoding. For encoding, only the v2 format is available. The
5+
specification of the two formats are available here:
6+
[1] https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v1.md
7+
[2] https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md
48
"""
59
import abc
610
import codecs
711
import json
812
from collections import namedtuple
13+
from typing import Generator, Iterable, Union
914

1015
utf8_decoder = codecs.getincrementaldecoder('utf-8')('replace')
1116

1217

13-
class AsciiCastRecord(abc.ABC):
18+
class AsciiCastError(Exception):
19+
pass
20+
21+
22+
class AsciiCastV2Record(abc.ABC):
1423
"""Generic Asciicast v2 record format"""
1524
@abc.abstractmethod
1625
def to_json_line(self):
1726
raise NotImplementedError
1827

1928
@classmethod
2029
def from_json_line(cls, line):
21-
if type(json.loads(line)) == dict:
22-
return AsciiCastHeader.from_json_line(line)
23-
elif type(json.loads(line)) == list:
24-
return AsciiCastEvent.from_json_line(line)
30+
"""Raise AsciiCastError if line is not a valid asciicast v2 record"""
31+
try:
32+
json_dict = json.loads(line)
33+
except json.JSONDecodeError as exc:
34+
raise AsciiCastError from exc
35+
if isinstance(json_dict, dict):
36+
return AsciiCastV2Header.from_json_line(line)
37+
elif isinstance(json_dict, list):
38+
return AsciiCastV2Event.from_json_line(line)
2539
else:
26-
raise NotImplementedError
27-
28-
29-
_AsciiCastTheme = namedtuple('AsciiCastTheme', ['fg', 'bg', 'palette'])
40+
truncated_line = line if len(line) < 20 else '{}...'.format(line[:20])
41+
raise AsciiCastError('Unknown record type: "{}"'.format(truncated_line))
3042

3143

32-
class AsciiCastTheme(_AsciiCastTheme):
44+
def _read_v1_records(data):
45+
v1_header_attributes = {
46+
'version',
47+
'width',
48+
'height',
49+
'stdout'
50+
}
51+
try:
52+
json_dict = json.loads(data)
53+
except json.JSONDecodeError as exc:
54+
raise AsciiCastError from exc
55+
missing_attributes = v1_header_attributes - set(json_dict)
56+
if missing_attributes:
57+
raise AsciiCastError('Missing attributes in asciicast v1 file: {}'
58+
.format(missing_attributes))
59+
60+
if json_dict['version'] != 1:
61+
raise AsciiCastError('This function can only decode asciicast v1 data')
62+
63+
yield AsciiCastV2Header(2, json_dict['width'], json_dict['height'], None)
64+
65+
if not isinstance(json_dict['stdout'], Iterable):
66+
raise AsciiCastError('Invalid type for stdout attribute (expected Iterable): {}'
67+
.format(json_dict['stdout']))
68+
69+
time = 0
70+
for event in json_dict['stdout']:
71+
try:
72+
time_elapsed, event_data = event
73+
except ValueError as exc:
74+
raise AsciiCastError from exc
75+
76+
if not (isinstance(time_elapsed, int) or isinstance(time_elapsed, float)) \
77+
or not isinstance(event_data, str):
78+
raise AsciiCastError('Invalid type for event: got object "{}" but expected '
79+
'type Tuple[Union[int, float], str]'.format(event))
80+
time += time_elapsed
81+
yield AsciiCastV2Event(time, 'o', event_data.encode('utf-8'), None)
82+
83+
84+
def read_records(filename):
85+
# type: (str) -> Generator[Union[AsciiCastV2Header, AsciiCastV2Event], None, None]
86+
"""Yield asciicast v2 records from the file
87+
88+
The records in the file may themselves be in either asciicast v1 or v2 format (although
89+
there must be only one record format version in the file).
90+
Raise AsciiCastError if a record is invalid"""
91+
try:
92+
with open(filename, 'r') as cast_file:
93+
for line in cast_file:
94+
yield AsciiCastV2Record.from_json_line(line)
95+
except AsciiCastError:
96+
with open(filename, 'r') as cast_file:
97+
yield from _read_v1_records(cast_file.read())
98+
99+
100+
_AsciiCastV2Theme = namedtuple('AsciiCastV2Theme', ['fg', 'bg', 'palette'])
101+
102+
103+
class AsciiCastV2Theme(_AsciiCastV2Theme):
33104
"""Color theme of the terminal.
34105
35106
All colors must use the '#rrggbb' format
@@ -42,23 +113,23 @@ def __new__(cls, fg, bg, palette):
42113
if cls.is_color(fg):
43114
if cls.is_color(bg):
44115
colors = palette.split(':')
45-
if all([cls.is_color(c) for c in colors[:16]]):
116+
if len(colors) >= 16 and all([cls.is_color(c) for c in colors[:16]]):
46117
self = super().__new__(cls, fg, bg, palette)
47118
return self
48-
elif all([cls.is_color(c) for c in colors[:8]]):
119+
elif len(colors) >= 8 and all([cls.is_color(c) for c in colors[:8]]):
49120
new_palette = ':'.join(colors[:8])
50121
self = super().__new__(cls, fg, bg, new_palette)
51122
return self
52123
else:
53-
raise ValueError('Invalid palette: the first 8 or 16 colors must be valid')
124+
raise AsciiCastError('Invalid palette: the first 8 or 16 colors must be valid')
54125
else:
55-
raise ValueError('Invalid background color: {}'.format(bg))
126+
raise AsciiCastError('Invalid background color: {}'.format(bg))
56127
else:
57-
raise ValueError('Invalid foreground color: {}'.format(fg))
128+
raise AsciiCastError('Invalid foreground color: {}'.format(fg))
58129

59130
@staticmethod
60131
def is_color(color):
61-
if type(color) == str and len(color) == 7 and color[0] == '#':
132+
if isinstance(color, str) and len(color) == 7 and color[0] == '#':
62133
try:
63134
int(color[1:], 16)
64135
except ValueError:
@@ -67,10 +138,10 @@ def is_color(color):
67138
return False
68139

69140

70-
_AsciiCastHeader = namedtuple('AsciiCastHeader', ['version', 'width', 'height', 'theme'])
141+
_AsciiCastV2Header = namedtuple('AsciiCastV2Header', ['version', 'width', 'height', 'theme'])
71142

72143

73-
class AsciiCastHeader(AsciiCastRecord, _AsciiCastHeader):
144+
class AsciiCastV2Header(AsciiCastV2Record, _AsciiCastV2Header):
74145
"""Header record
75146
76147
version: Version of the asciicast file format
@@ -82,19 +153,20 @@ class AsciiCastHeader(AsciiCastRecord, _AsciiCastHeader):
82153
'version': {int},
83154
'width': {int},
84155
'height': {int},
85-
'theme': {type(None), AsciiCastTheme},
156+
'theme': {type(None), AsciiCastV2Theme},
86157
}
87158

88159
def __new__(cls, version, width, height, theme):
89-
self = super(AsciiCastHeader, cls).__new__(cls, version, width, height, theme)
90-
for attr in AsciiCastHeader._fields:
91-
type_attr = type(self.__getattribute__(attr))
92-
if type_attr not in cls.types[attr]:
93-
raise TypeError('Invalid type for attribute {}: {} '.format(attr, type_attr) +
94-
'(possible type: {})'.format(cls.types[attr]))
95-
160+
self = super(AsciiCastV2Header, cls).__new__(cls, version, width, height, theme)
161+
for attr_name in cls._fields:
162+
attr = self.__getattribute__(attr_name)
163+
attr_types = [attr_type for attr_type in AsciiCastV2Header.types[attr_name]
164+
if isinstance(attr, attr_type)]
165+
if not attr_types:
166+
raise AsciiCastError('Invalid type for attribute {}: {} (expected one of {})'
167+
.format(attr_name, type(attr), cls.types[attr_name]))
96168
if version != 2:
97-
raise ValueError('Only asciicast v2 format is supported')
169+
raise AsciiCastError('Only asciicast v2 format is supported')
98170
return self
99171

100172
def to_json_line(self):
@@ -109,17 +181,18 @@ def to_json_line(self):
109181
@classmethod
110182
def from_json_line(cls, line):
111183
attributes = json.loads(line)
112-
filtered_attributes = {attr: attributes[attr] if attr in attributes else None
113-
for attr in AsciiCastHeader._fields}
184+
filtered_attributes = {attr: attributes.get(attr) for attr in AsciiCastV2Header._fields}
114185
if filtered_attributes['theme'] is not None:
115-
filtered_attributes['theme'] = AsciiCastTheme(**filtered_attributes['theme'])
116-
return cls(**filtered_attributes)
186+
filtered_attributes['theme'] = AsciiCastV2Theme(**filtered_attributes['theme'])
117187

188+
header = cls(**filtered_attributes)
189+
return header
118190

119-
_AsciiCastEvent = namedtuple('AsciiCastEvent', ['time', 'event_type', 'event_data', 'duration'])
120191

192+
_AsciiCastV2Event = namedtuple('AsciiCastV2Event', ['time', 'event_type', 'event_data', 'duration'])
121193

122-
class AsciiCastEvent(AsciiCastRecord, _AsciiCastEvent):
194+
195+
class AsciiCastV2Event(AsciiCastV2Record, _AsciiCastV2Event):
123196
"""Event record
124197
125198
time: Time elapsed since the beginning of the recording in seconds
@@ -131,17 +204,19 @@ class AsciiCastEvent(AsciiCastRecord, _AsciiCastEvent):
131204
types = {
132205
'time': {int, float},
133206
'event_type': {str},
134-
'event_data': {bytes},
207+
'event_data': {str, bytes},
135208
'duration': {type(None), int, float},
136209
}
137210

138211
def __new__(cls, *args, **kwargs):
139-
self = super(AsciiCastEvent, cls).__new__(cls, *args, **kwargs)
140-
for attr in AsciiCastEvent._fields:
141-
type_attr = type(self.__getattribute__(attr))
142-
if type_attr not in cls.types[attr]:
143-
raise TypeError('Invalid type for attribute {}: {} '.format(attr, type_attr) +
144-
'(possible type: {})'.format(cls.types[attr]))
212+
self = super(AsciiCastV2Event, cls).__new__(cls, *args, **kwargs)
213+
for attr_name in AsciiCastV2Event._fields:
214+
attr = self.__getattribute__(attr_name)
215+
valid_attr_types = [attr_type for attr_type in cls.types[attr_name]
216+
if isinstance(attr, attr_type)]
217+
if not valid_attr_types:
218+
raise AsciiCastError('Invalid type for attribute {}: {} (expected one of {})'
219+
.format(attr_name, type(attr), cls.types[attr_name]))
145220
return self
146221

147222
def to_json_line(self):
@@ -151,7 +226,15 @@ def to_json_line(self):
151226

152227
@classmethod
153228
def from_json_line(cls, line):
154-
attributes = json.loads(line)
155-
time, event_type, event_data = attributes
156-
event_data = event_data.encode('utf-8')
157-
return cls(time, event_type, event_data, None)
229+
try:
230+
time, event_type, event_data = json.loads(line)
231+
except (json.JSONDecodeError, ValueError) as exc:
232+
raise AsciiCastError from exc
233+
234+
try:
235+
event_data = event_data.encode('utf-8')
236+
except AttributeError as exc:
237+
raise AsciiCastError from exc
238+
239+
event = cls(time, event_type, event_data, None)
240+
return event

termtosvg/config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ def conf_to_dict(configuration):
8383
palette = ':'.join(parser.get(theme_name, 'color{}'.format(i), fallback='')
8484
for i in range(16))
8585

86-
# This line raises ValueError if the color theme is invalid
87-
config_dict[theme_name] = asciicast.AsciiCastTheme(fg, bg, palette)
86+
# This line raises AsciicastError if the color theme is invalid
87+
config_dict[theme_name] = asciicast.AsciiCastV2Theme(fg, bg, palette)
8888

8989
return config_dict
9090

@@ -97,7 +97,7 @@ def get_configuration(user_config, default_config):
9797
config_dict = conf_to_dict(default_config)
9898
try:
9999
user_config_dict = conf_to_dict(user_config)
100-
except (configparser.Error, ValueError) as e:
100+
except (configparser.Error, asciicast.AsciiCastError) as e:
101101
logger.info('Invalid configuration file: {}'.format(e))
102102
logger.info('Falling back to default configuration')
103103
user_config_dict = {}

0 commit comments

Comments
 (0)