1 | import os.path |
---|
2 | import re as _re |
---|
3 | import warnings |
---|
4 | |
---|
5 | from framsfiles._context import _create_specs_from_xml |
---|
6 | |
---|
7 | warnings.simplefilter('always', UserWarning) |
---|
8 | |
---|
9 | _INT_FLOAT_REGEX = r'([+|-]?(?:0|[1-9]\d*))(\.\d+)?([eE][-+]?\d+)?' |
---|
10 | _NATURAL_REGEX = r'(?:0|[1-9]\d*)' |
---|
11 | _HEX__NUMBER_REGEX = r'[+|-]?0[xX][\da-fA-F]*' |
---|
12 | _NUMBER_REGEX = '({}|{})'.format(_HEX__NUMBER_REGEX, _INT_FLOAT_REGEX) |
---|
13 | _TYLDA_REGEX = '(?<![\\\\])(~)' |
---|
14 | _QUOTE_REGEX = '(?<![\\\\])(")' |
---|
15 | _ESCAPED_QUOTE_REGEX = '\\\\"' |
---|
16 | _ESCAPED_TAB_REGEX = '\\\\t' |
---|
17 | _ESCAPED_NEWLINE_REGEX = '\\\\n' |
---|
18 | _ESCAPED_TYLDA_REGEX = '\\\\~' |
---|
19 | _FRAMSCRIPT_XML_PATH = os.path.join((os.path.dirname(__file__)), "framscript.xml") |
---|
20 | |
---|
21 | # Messages: |
---|
22 | _NO_FILE_EXTENSION_WARNING = "No file extension found. Setting default context." |
---|
23 | _UNSUPPORTED_EXTENSION_WARNING = "Unsupported file extension: '{}'. Setting default context." |
---|
24 | _UNSUPPORTED_CONTEXT_WARNING = "Unsupported context: '{}'. Setting default context." |
---|
25 | _UNEXPECTED_KEY_WARNING = "Unexpected key encountered: key: '{}', class: '{}', context: '{}' )" |
---|
26 | _NOT_A_NUMBER_ERROR = "Expression cannot be parsed to a number: {}" |
---|
27 | _MULTILINE_NOT_CLOSED_WARNING = "Multiline property for key: '{}' was not closed with '~'." |
---|
28 | _STRING_NOT_CLOSED_ERROR = "String expression not closed with '~'" |
---|
29 | _EMPTY_SERIALIZED_ERROR = "Empty value for '@Serialized' not allowed." |
---|
30 | _NO_OBJECT_ERROR = "No object defined for the current line." |
---|
31 | _XYZ_ERROR = "XYZ format should look like this: XYZ[ a,b,c], Got: '{}'" |
---|
32 | _REFERENCE_FORMAT_ERROR = "reference sign '^' should be followed by an integer. Got: {}" |
---|
33 | _COLON_EXPECTED_ERROR = "Colon ':' was expected. Got: {}" |
---|
34 | _MIN_VAL_EXCEEDED_ERROR = "Minimum value allowed: {}, got: {}" |
---|
35 | _MAX_VAL_EXCEEDED_ERROR = "Maximum value allowed: {}, got: {}" |
---|
36 | _NONEMPTY_CLASSNAME = "There should be no string after obejct's classname." |
---|
37 | |
---|
38 | |
---|
39 | _specs, _contexts = _create_specs_from_xml() |
---|
40 | |
---|
41 | |
---|
42 | def _create_generic_parser(dtype, min=None, max=None): |
---|
43 | def parse(x): |
---|
44 | x = dtype(x) |
---|
45 | if min is not None: |
---|
46 | if x < min: |
---|
47 | raise ValueError(_MIN_VAL_EXCEEDED_ERROR.format(min, x)) |
---|
48 | if max is not None: |
---|
49 | if x > max: |
---|
50 | raise ValueError(_MAX_VAL_EXCEEDED_ERROR.format(max, x)) |
---|
51 | return x |
---|
52 | |
---|
53 | return parse |
---|
54 | |
---|
55 | |
---|
56 | def _str_to_number(s): |
---|
57 | assert isinstance(s, str) |
---|
58 | s = s.strip() |
---|
59 | |
---|
60 | try: |
---|
61 | parsed_int = int(s, 0) |
---|
62 | return parsed_int |
---|
63 | except ValueError: |
---|
64 | pass |
---|
65 | try: |
---|
66 | parsed_float = float(s) |
---|
67 | return parsed_float |
---|
68 | |
---|
69 | except ValueError: |
---|
70 | pass |
---|
71 | raise ValueError(_NOT_A_NUMBER_ERROR.format(s)) |
---|
72 | |
---|
73 | |
---|
74 | def parse_value(value, classname=None, key=None, context=None, autoparse=True): |
---|
75 | assert isinstance(value, str) |
---|
76 | value = value.strip() |
---|
77 | # TODO maybe check 'Global context' as well? |
---|
78 | if (context, classname) in _specs: |
---|
79 | spec = _specs[(context, classname)] |
---|
80 | if key in spec: |
---|
81 | parser = _create_generic_parser(**spec[key]) |
---|
82 | return parser(value) |
---|
83 | else: |
---|
84 | warnings.warn(_UNEXPECTED_KEY_WARNING.format(key, classname, context)) |
---|
85 | |
---|
86 | if value.startswith("@Serialized:"): |
---|
87 | prop = value.split(":", 1)[1] |
---|
88 | prop = deserialize(prop) |
---|
89 | return prop |
---|
90 | elif autoparse: |
---|
91 | try: |
---|
92 | parsed_number = _str_to_number(value) |
---|
93 | return parsed_number |
---|
94 | except ValueError: |
---|
95 | pass |
---|
96 | return value |
---|
97 | |
---|
98 | |
---|
99 | def _extract_string(exp): |
---|
100 | exp = exp[1:] |
---|
101 | str_end_match = _re.search(_QUOTE_REGEX, exp) |
---|
102 | if str_end_match is None: |
---|
103 | raise ValueError(_STRING_NOT_CLOSED_ERROR.format(exp)) |
---|
104 | str_end = str_end_match.span()[0] |
---|
105 | s = exp[:str_end] |
---|
106 | reminder = exp[str_end + 1:] |
---|
107 | s = _re.sub(_ESCAPED_QUOTE_REGEX, '"', s) |
---|
108 | s = _re.sub(_ESCAPED_TAB_REGEX, '\t', s) |
---|
109 | s = _re.sub(_ESCAPED_NEWLINE_REGEX, '\n', s) |
---|
110 | return s, reminder |
---|
111 | |
---|
112 | |
---|
113 | def _extract_number(exp): |
---|
114 | match = _re.match(_NUMBER_REGEX, exp) |
---|
115 | number_as_str = match.group() |
---|
116 | reminder = exp[match.span()[1]:] |
---|
117 | number = _str_to_number(number_as_str) |
---|
118 | return number, reminder |
---|
119 | |
---|
120 | |
---|
121 | # TODO maybe do it nicer?? |
---|
122 | def _extract_xyz(exp): |
---|
123 | exp = exp.strip() |
---|
124 | if not exp.startswith('XYZ['): |
---|
125 | raise ValueError(_XYZ_ERROR.format(exp)) |
---|
126 | exp = exp[4:] |
---|
127 | x, exp = _extract_number(exp) |
---|
128 | x = float(x) |
---|
129 | exp = exp.strip() |
---|
130 | if exp[0] != ',': |
---|
131 | raise ValueError(_XYZ_ERROR.format(exp)) |
---|
132 | exp = exp[1:] |
---|
133 | y, exp = _extract_number(exp) |
---|
134 | y = float(y) |
---|
135 | exp = exp.strip() |
---|
136 | if exp[0] != ',': |
---|
137 | raise ValueError(_XYZ_ERROR.format(exp)) |
---|
138 | exp = exp[1:] |
---|
139 | z, exp = _extract_number(exp) |
---|
140 | z = float(z) |
---|
141 | exp = exp.strip() |
---|
142 | if exp[0] != ']': |
---|
143 | raise ValueError(_XYZ_ERROR.format(exp)) |
---|
144 | return (x, y, z), exp[1:] |
---|
145 | |
---|
146 | |
---|
147 | def _extract_reference(exp): |
---|
148 | exp = exp[1:].strip() |
---|
149 | i_match = _re.match(_NATURAL_REGEX, exp) |
---|
150 | if i_match is None: |
---|
151 | raise ValueError(_REFERENCE_FORMAT_ERROR.format(exp)) |
---|
152 | else: |
---|
153 | end_i = i_match.span()[1] |
---|
154 | ref_index = int(exp[:end_i]) |
---|
155 | reminder = exp[end_i:] |
---|
156 | return ref_index, reminder |
---|
157 | |
---|
158 | |
---|
159 | def _extract_custom_object(exp): |
---|
160 | open_braces = 0 |
---|
161 | open_sbrackets = 0 |
---|
162 | open_pbrackets = 0 |
---|
163 | # TODO maybe do it smarter? |
---|
164 | suffix_end_match = _re.search('<|\[|\{]', exp) |
---|
165 | if suffix_end_match is None: |
---|
166 | # TODO |
---|
167 | raise ValueError() |
---|
168 | |
---|
169 | suffix_end_i = suffix_end_match.span()[0] |
---|
170 | i = 0 |
---|
171 | for i, c in enumerate(exp[suffix_end_i:], start=suffix_end_i): |
---|
172 | if c == '<': |
---|
173 | open_pbrackets += 1 |
---|
174 | elif c == '[': |
---|
175 | open_sbrackets += 1 |
---|
176 | elif c == '{': |
---|
177 | open_braces += 1 |
---|
178 | elif c == '>': |
---|
179 | open_pbrackets -= 1 |
---|
180 | elif c == ']': |
---|
181 | open_sbrackets -= 1 |
---|
182 | elif c == '}': |
---|
183 | open_braces -= 1 |
---|
184 | |
---|
185 | if open_braces == 0 and open_sbrackets == 0 and open_pbrackets == 0: |
---|
186 | break |
---|
187 | if open_braces != 0 or open_sbrackets != 0 or open_pbrackets != 0: |
---|
188 | # TODO |
---|
189 | raise ValueError() |
---|
190 | return exp[0:i + 1], exp[i + 1:] |
---|
191 | |
---|
192 | |
---|
193 | def deserialize(expression): |
---|
194 | stripped_exp = expression.strip() |
---|
195 | if stripped_exp == '': |
---|
196 | raise ValueError(_EMPTY_SERIALIZED_ERROR) |
---|
197 | # Just load with json ... |
---|
198 | |
---|
199 | if stripped_exp == 'null': |
---|
200 | return None |
---|
201 | |
---|
202 | objects = [] |
---|
203 | references = [] |
---|
204 | main_object_determined = False |
---|
205 | main_object = None |
---|
206 | expect_dict_value = False |
---|
207 | last_dict_key = None |
---|
208 | exp = stripped_exp |
---|
209 | opened_lists = 0 |
---|
210 | opened_dicts = 0 |
---|
211 | |
---|
212 | while len(exp) > 0: |
---|
213 | current_object_is_reference = False |
---|
214 | if main_object_determined and len(objects) == 0: |
---|
215 | raise ValueError(_NO_OBJECT_ERROR) |
---|
216 | if expect_dict_value: |
---|
217 | if exp[0] == ':': |
---|
218 | exp = exp[1:].strip() |
---|
219 | else: |
---|
220 | raise ValueError(_COLON_EXPECTED_ERROR.foramt(exp[0])) |
---|
221 | # List continuation |
---|
222 | # TODO support for XYZ tuples |
---|
223 | if exp[0] == ",": |
---|
224 | if not (isinstance(objects[-1], list) or (isinstance(objects[-1], dict) and not expect_dict_value)): |
---|
225 | # TODO msg |
---|
226 | raise ValueError() |
---|
227 | else: |
---|
228 | exp = exp[1:].strip() |
---|
229 | |
---|
230 | if exp[0] == "]": |
---|
231 | if not isinstance(objects[-1], list): |
---|
232 | # TODO msg |
---|
233 | raise ValueError() |
---|
234 | else: |
---|
235 | opened_lists -= 1 |
---|
236 | objects.pop() |
---|
237 | exp = exp[1:].strip() |
---|
238 | continue |
---|
239 | elif exp[0] == "}": |
---|
240 | opened_dicts -= 1 |
---|
241 | if not isinstance(objects[-1], dict): |
---|
242 | # TODO msg |
---|
243 | raise ValueError() |
---|
244 | else: |
---|
245 | objects.pop() |
---|
246 | exp = exp[1:].strip() |
---|
247 | continue |
---|
248 | # List start |
---|
249 | elif exp.startswith("null"): |
---|
250 | current_object = None |
---|
251 | exp = exp[4:] |
---|
252 | elif exp.startswith("XYZ"): |
---|
253 | current_object, exp = _extract_xyz(exp) |
---|
254 | elif exp[0] == "[": |
---|
255 | current_object = list() |
---|
256 | opened_lists += 1 |
---|
257 | exp = exp[1:] |
---|
258 | elif exp[0] == "{": |
---|
259 | current_object = dict() |
---|
260 | opened_dicts += 1 |
---|
261 | exp = exp[1:] |
---|
262 | elif exp[0] == '"': |
---|
263 | current_object, exp = _extract_string(exp) |
---|
264 | elif _re.match(_NUMBER_REGEX, exp) is not None: |
---|
265 | current_object, exp = _extract_number(exp) |
---|
266 | elif exp[0] == '^': |
---|
267 | i, exp = _extract_reference(exp) |
---|
268 | if i >= len(references): |
---|
269 | # TODO msg |
---|
270 | raise ValueError() |
---|
271 | current_object = references[i] |
---|
272 | current_object_is_reference = True |
---|
273 | else: |
---|
274 | current_object, exp = _extract_custom_object(exp) |
---|
275 | |
---|
276 | if len(objects) > 0: |
---|
277 | if isinstance(objects[-1], list): |
---|
278 | objects[-1].append(current_object) |
---|
279 | elif isinstance(objects[-1], dict): |
---|
280 | if expect_dict_value: |
---|
281 | objects[-1][last_dict_key] = current_object |
---|
282 | last_dict_key = None |
---|
283 | expect_dict_value = False |
---|
284 | else: |
---|
285 | if not isinstance(current_object, str): |
---|
286 | # TODO msg |
---|
287 | raise ValueError() |
---|
288 | last_dict_key = current_object |
---|
289 | expect_dict_value = True |
---|
290 | |
---|
291 | if isinstance(current_object, (list, dict, tuple)) and not current_object_is_reference: |
---|
292 | objects.append(current_object) |
---|
293 | references.append(current_object) |
---|
294 | if not main_object_determined: |
---|
295 | main_object_determined = True |
---|
296 | main_object = current_object |
---|
297 | exp = exp.strip() |
---|
298 | |
---|
299 | if opened_lists != 0: |
---|
300 | # TODO msg |
---|
301 | raise ValueError() |
---|
302 | if opened_dicts != 0: |
---|
303 | # TODO msg |
---|
304 | raise ValueError() |
---|
305 | return main_object |
---|
306 | |
---|
307 | |
---|
308 | def loads(input_string, context=None, autocast=True): |
---|
309 | """ |
---|
310 | Parses string in Framsticks' format to a list of dictionaries. |
---|
311 | :param input_string: String to parse. |
---|
312 | :param context: Context of parsing compliant with contexts found in 'framscript.xml' e.g. 'expdef file'. |
---|
313 | :param autocast: If true numbers will be parsed automatically if possible. |
---|
314 | If false every field will be treated as a string. |
---|
315 | :return: A list of dictionaries representing Framsticks objects. |
---|
316 | """ |
---|
317 | assert isinstance(input_string, str) |
---|
318 | if context is not None and context not in _contexts: |
---|
319 | warnings.warn(_UNSUPPORTED_CONTEXT_WARNING.format(context)) |
---|
320 | |
---|
321 | lines = input_string.split("\n") |
---|
322 | multiline_value = None |
---|
323 | multiline_key = None |
---|
324 | current_object = None |
---|
325 | objects = [] |
---|
326 | parsing_error = False |
---|
327 | class_name = None |
---|
328 | try: |
---|
329 | for line_num, line in enumerate(lines): |
---|
330 | |
---|
331 | if multiline_key is not None: |
---|
332 | endmatch = _re.search(_TYLDA_REGEX, line) |
---|
333 | if endmatch is not None: |
---|
334 | endi = endmatch.span()[0] |
---|
335 | value = line[0:endi] |
---|
336 | reminder = line[endi + 1:].strip() |
---|
337 | if reminder != "": |
---|
338 | # TODO msg |
---|
339 | raise ValueError() |
---|
340 | else: |
---|
341 | value = line + "\n" |
---|
342 | |
---|
343 | if _re.search(_TYLDA_REGEX, value) is not None: |
---|
344 | # TODO msg |
---|
345 | raise ValueError() |
---|
346 | value = _re.sub(_ESCAPED_TYLDA_REGEX, '~', value) |
---|
347 | multiline_value += value |
---|
348 | if endmatch is not None: |
---|
349 | current_object[multiline_key] = multiline_value |
---|
350 | multiline_value = None |
---|
351 | multiline_key = None |
---|
352 | |
---|
353 | # Ignores comment lines (if outside multiline prop) |
---|
354 | elif line.startswith("#"): |
---|
355 | continue |
---|
356 | else: |
---|
357 | line = line.strip() |
---|
358 | if current_object is not None: |
---|
359 | if line == "": |
---|
360 | current_object = None |
---|
361 | continue |
---|
362 | else: |
---|
363 | if ":" in line: |
---|
364 | class_name, suffix = line.split(":", 1) |
---|
365 | if suffix != "": |
---|
366 | raise ValueError(_NONEMPTY_CLASSNAME) |
---|
367 | current_object = {"_classname": class_name} |
---|
368 | objects.append(current_object) |
---|
369 | continue |
---|
370 | |
---|
371 | if current_object is not None: |
---|
372 | key, value = line.split(":", 1) |
---|
373 | # TODO check if the key is supported for given class |
---|
374 | if key.strip() == "": |
---|
375 | # TODO msg |
---|
376 | raise ValueError() |
---|
377 | if value.strip() == "~": |
---|
378 | multiline_value = "" |
---|
379 | multiline_key = key |
---|
380 | else: |
---|
381 | value = parse_value(value, classname=class_name, key=key, context=context, autoparse=autocast) |
---|
382 | current_object[key] = value |
---|
383 | except ValueError as ex: |
---|
384 | parsing_error = True |
---|
385 | error_msg = str(ex) |
---|
386 | |
---|
387 | if multiline_key is not None: |
---|
388 | current_object[multiline_key] = multiline_value |
---|
389 | warnings.warn(_MULTILINE_NOT_CLOSED_WARNING.format(multiline_key)) |
---|
390 | |
---|
391 | if parsing_error: |
---|
392 | error_msc = "Parsing error. Incorrect syntax in line {}:\n{}\n{}".format(line_num, error_msg, line) |
---|
393 | raise ValueError(error_msc) |
---|
394 | |
---|
395 | return objects |
---|
396 | |
---|
397 | |
---|
398 | def load(filename, context=None, autocast=True): |
---|
399 | """ |
---|
400 | Parses the file with a given filename to a list of dictionaries. |
---|
401 | :param filename: Name of the file to parse. |
---|
402 | :param context: Context of parsing compliant with contexts found in 'framscript.xml' e.g. 'expdef file'. |
---|
403 | If context is left emtpy it will be inferred from the file's extension/ |
---|
404 | :param autocast: If true numbers will be parsed automatically if possible. |
---|
405 | If false every field will be treated as a string. |
---|
406 | :return: A list of dictionaries representing Framsticks objects. |
---|
407 | """ |
---|
408 | file = open(filename, encoding='UTF-8') |
---|
409 | if context is None: |
---|
410 | try: |
---|
411 | _, extension = filename.split(".") |
---|
412 | context = extension + " file" |
---|
413 | if context not in _contexts: |
---|
414 | context = None |
---|
415 | warnings.warn(_UNSUPPORTED_EXTENSION_WARNING.format(extension)) |
---|
416 | except RuntimeError: |
---|
417 | warnings.warn(_NO_FILE_EXTENSION_WARNING) |
---|
418 | context = None |
---|
419 | s = file.read() |
---|
420 | file.close() |
---|
421 | return loads(s, context=context, autocast=autocast) |
---|