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"""
59import abc
610import codecs
711import json
812from collections import namedtuple
13+ from typing import Generator , Iterable , Union
914
1015utf8_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
0 commit comments