diff options
Diffstat (limited to 'domllib.py')
| -rwxr-xr-x | domllib.py | 605 |
1 files changed, 605 insertions, 0 deletions
diff --git a/domllib.py b/domllib.py new file mode 100755 index 0000000..7cae9cf --- /dev/null +++ b/domllib.py @@ -0,0 +1,605 @@ +#!/usr/bin/env python +# +# Copyright 2026 David Vazgenovich Shakaryan + +import re +import copy + + +WS = set(' \t') +WSNL = {*WS, '\n'} +ESCAPES = { + '\n': '', + '\\': '\\', + '"': '"', + 'n': '\n', + 't': '\t', +} + +RE_IDENT = re.compile(r'[A-Za-z0-9_]+') +RE_NUM = re.compile( + r'[+-]?(?:' + r'0x[0-9a-fA-F]+(?:_[0-9a-fA-F]+)*|' + r'0o[0-7]+(?:_[0-7]+)*|' + r'0b[01]+(?:_[01]+)*|' + r'(?:0|[1-9][0-9]*(?:_[0-9]+)*)' + r'(\.[0-9]+(?:_[0-9]+)*(?:[eE][+-]?[0-9]+(?:_[0-9]+)*)?|' + r'[eE][+-]?[0-9]+(?:_[0-9]+)*)?|' + r'(inf|nan)' + r')') + + +def _raise_unexpected_eof(ctx): + raise SyntaxError(f'Unexpected end of input while parsing {ctx}') from None + + +def _raise_expected_char(char, pos): + if type(char) is tuple: + s = ' or '.join(repr(c) for c in char) + else: + s = repr(char) + raise SyntaxError(f'Expected {s} at position {pos}') from None + + +def _raise_unexpected_char(char, pos, ctx=None): + ctx = f' {ctx}' if ctx else '' + raise SyntaxError( + f'Unexpected {repr(char)}{ctx} at position {pos}') from None + + +def _raise_invalid_escape(seq, pos): + seq = ''.join( + '\\' if (r := repr(c)[1:-1]) == '\\\\' + else r if len(r) == 1 + else f'<{r}>' + for c in seq) + raise SyntaxError(f'Invalid escape sequence {seq} at pos {pos}') from None + + +def skip_chars(src, pos, chars, skip_comments=False): + try: + while True: + char = src[pos] + if skip_comments and char == '#': + while src[pos] != '\n': + pos += 1 + continue + if char == '\\': + try: + if src[pos+1] != '\n': + _raise_expected_char('\n', pos + 1) + except IndexError: + _raise_unexpected_eof('line continuation') + pos += 2 + continue + if char not in chars: + break + pos += 1 + except IndexError: + pass + return pos + + +def skip(src, pos, depth): + if depth: + return skip_chars(src, pos, WSNL, skip_comments=True) + return skip_chars(src, pos, WS) + + +def ensure_path(d, path): + curr = d + for n in path: + if type(curr) is not dict: + raise SyntaxError( + f'Cannot resolve {repr(n)} in {type(curr).__name__}') + curr = curr.setdefault(n, {}) + return curr + + +def dict_unpack(d, val): + if type(val) is not dict: + raise SyntaxError(f'Cannot dict-unpack {type(val).__name__}') + for k, v in val.items(): + d[k] = v + + +def dict_assign(d, path, val): + tgt = ensure_path(d, path[:-1]) + if type(tgt) is not dict: + raise SyntaxError( + f"Cannot assign key '{path[-1]}' in {type(tgt).__name__}") + tgt[path[-1]] = val + + +def dict_delete(d, path): + try: + for n in path[:-1]: + d = d[n] + del d[path[-1]] + except KeyError, TypeError: + return + + +def parse_dict_unpack(src, pos, depth, root, sect): + try: + if src[pos+1] == '*': + pos += 2 + else: + _raise_expected_char('*', pos + 1) + except IndexError: + _raise_unexpected_eof('dict unpack') + pos = skip(src, pos, depth) + return parse_value(src, pos, depth, root, sect) + + +def parse_sequence(src, pos, depth, delim, term, on_item): + pos += 1 + depth += 1 + + while True: + pos = skip(src, pos, depth) + + try: + char = src[pos] + except IndexError: + _raise_unexpected_eof('sequence') + + if char == term: + return pos + 1 + + pos = on_item(src, pos, depth) + pos = skip(src, pos, depth) + + try: + char = src[pos] + if char == delim: + pos += 1 + elif char != term: + _raise_expected_char((delim, term), pos) + except IndexError: + _raise_unexpected_eof('sequence') + + +def parse_inline_list(src, pos, depth, root, sect): + res = [] + + def on_item(src, pos, depth): + unpack = False + if src[pos] == '*': + unpack = True + pos = skip(src, pos + 1, depth) + + pos, val = parse_value(src, pos, depth, root, sect) + if unpack: + if type(val) is not list: + raise SyntaxError(f'Cannot list-unpack {type(val).__name__}') + res.extend(val) + else: + res.append(val) + return pos + + pos = parse_sequence(src, pos, depth, ',', ']', on_item) + return pos, res + + +def parse_inline_dict(src, pos, depth, root, sect): + res = {} + + def on_item(src, pos, depth): + char = src[pos] + if char == '*': + pos, val = parse_dict_unpack(src, pos, depth, root, sect) + dict_unpack(res, val) + else: + pos, key, val = parse_assign(src, pos, depth, root, sect) + dict_assign(res, key, val) + return pos + + pos = parse_sequence(src, pos, depth, ',', '}', on_item) + return pos, res + + +def parse_str(src, pos, multiline=False): + try: + if multiline: + pos += 3 + if src[pos] == '\n': + pos += 1 + else: + pos += 1 + + start = pos + res = '' + + while True: + curr = src[pos] + if curr == '"' and (not multiline or src.startswith('"""', pos)): + res += src[start:pos] + break + + if curr == '\\': + res += src[start:pos] + nxt = src[pos+1] + + if nxt == 'u': + if src[pos+2] != '{': + _raise_expected_char('{', pos + 2) + end = src.find('}', pos + 3) + if end == -1: + raise IndexError + code = src[pos+3:end] + try: + code_int = int(code, 16) + if not (0 <= code_int <= 0xd7ff or + 0xe000 <= code_int <= 0x10ffff): + _raise_invalid_escape(f'\\u{{{code}}}', pos) + res += chr(code_int) + except ValueError: + _raise_invalid_escape(f'\\u{{{code}}}', pos) + pos = end + 1 + + else: + try: + res += ESCAPES[nxt] + except KeyError: + _raise_invalid_escape(f'\\{nxt}', pos) + pos += 2 + + start = pos + continue + + code = ord(curr) + if ((code < 32 and (not multiline or code != 10 and code != 9)) or + code == 127): + _raise_unexpected_char(curr, pos, ctx='in string') + pos += 1 + except IndexError: + _raise_unexpected_eof('string') + return pos + (3 if multiline else 1), res + + +def parse_raw_str(src, pos, multiline=False): + try: + if multiline: + pos += 3 + if src[pos] == '\n': + pos += 1 + else: + pos += 1 + + start = pos + + while (src[pos] != "'" or + (multiline and not src.startswith("'''", pos))): + code = ord(src[pos]) + if ((code < 32 and (not multiline or code != 10 and code != 9)) or + code == 127): + _raise_unexpected_char(src[pos], pos, ctx='in raw string') + pos += 1 + except IndexError: + _raise_unexpected_eof('raw string') + return pos + (3 if multiline else 1), src[start:pos] + + +def parse_literal(src, pos, depth, root, sect): + char = src[pos] # guaranteed to exist by parse_value() + + if char == "'": + return parse_raw_str(src, pos, multiline=src.startswith("'''", pos)) + if char == '"': + return parse_str(src, pos, multiline=src.startswith('"""', pos)) + + if char == 't': + if src.startswith('true', pos): + return pos + 4, True + if char == 'f': + if src.startswith('false', pos): + return pos + 5, False + if char == 'n': + if src.startswith('null', pos): + return pos + 4, None + + if char == '{': + return parse_inline_dict(src, pos, depth, root, sect) + if char == '[': + return parse_inline_list(src, pos, depth, root, sect) + + if (m := RE_NUM.match(src, pos)): + return m.end(), float(m[0]) if m.lastindex else int(m[0], 0) + + _raise_unexpected_char(char, pos) + + +def parse_value(src, pos, depth, root, sect): + try: + char = src[pos] + except IndexError: + _raise_unexpected_eof('value') + + ref = root if char == '$' else sect if char == '@' else None + + if ref is None: + pos, val = parse_literal(src, pos, depth, root, sect) + + if type(val) is str: + while True: + pos = skip(src, pos, depth) + try: + char = src[pos] + except IndexError: + break + if char == "'": + pos, v = parse_raw_str( + src, pos, multiline=src.startswith("'''", pos)) + val += v + elif char == '"': + pos, v = parse_str( + src, pos, multiline=src.startswith('"""', pos)) + val += v + else: + break + else: + pos = skip(src, pos + 1, depth) + pos, key = parse_key_segment(src, pos) + try: + val = ref[key] + except KeyError: + if ref is root: + raise SyntaxError( + f'Invalid global reference {repr(key)}') from None + else: + raise SyntaxError( + f'Invalid local reference {repr(key)}') from None + + pos, val = parse_selectors(src, pos, depth, root, sect, val, copy_=ref) + + return pos, val + + +def parse_selectors(src, pos, depth, root, sect, val, copy_=False): + while True: + pos = skip(src, pos, depth) + try: + char = src[pos] + except IndexError: + break + + if char == '.': + pos = skip(src, pos + 1, depth) + pos, key = parse_key_segment(src, pos) + + elif char == '[': + depth += 1 + pos = skip(src, pos + 1, depth) + try: + nxt = src[pos] + except IndexError: + _raise_unexpected_eof('selector') + continue_ = False + + if nxt == '^': + pos = skip(src, pos + 1, depth) + def on_item(src, pos, depth): + nonlocal val, copy_ + pos, key = parse_key(src, pos, depth) + if copy_: + val = copy.deepcopy(val) + copy_ = False + dict_delete(val, key) + return pos + if src[pos] == '{': + pos = parse_sequence(src, pos, depth, ',', '}', on_item) + else: + pos = on_item(src, pos, depth) + continue_ = True + + elif nxt == '&': + pos = skip(src, pos + 1, depth) + nval = {} + def on_item(src, pos, depth): + pos, key = parse_key(src, pos, depth) + v = val + for n in key: + try: + v = v[n] + except KeyError, TypeError: + return pos + dict_assign(nval, key, v) + return pos + if src[pos] == '{': + pos = parse_sequence(src, pos, depth, ',', '}', on_item) + else: + pos = on_item(src, pos, depth) + val = nval + continue_ = True + + else: + if nxt == ':': + key = None + else: + pos, key = parse_value(src, pos, depth, root, sect) + pos = skip(src, pos, depth) + try: + nxt = src[pos] + except IndexError: + _raise_unexpected_eof('selector') + if nxt == ':': + pos = skip(src, pos + 1, depth) + try: + nxt = src[pos] + except IndexError: + _raise_unexpected_eof('selector') + if nxt == ']': + stop = None + else: + pos, stop = parse_value(src, pos, depth, root, sect) + key = slice(key, stop) + + pos = skip(src, pos, depth) + try: + if src[pos] != ']': + _raise_expected_char(']', pos) + except IndexError: + _raise_unexpected_eof('selector') + pos += 1 + + depth -= 1 + if continue_: + continue + + else: + break + + try: + val = val[key] + except KeyError, IndexError, TypeError: + raise SyntaxError( + f'Cannot resolve {repr(key)} in {type(val).__name__}' + ) from None + + return pos, copy.deepcopy(val) if copy_ else val + + +def parse_key_segment(src, pos): + try: + char = src[pos] + except IndexError: + _raise_unexpected_eof('key segment') + + if char == "'": + return parse_raw_str(src, pos) + if char == '"': + return parse_str(src, pos) + + if (m := RE_IDENT.match(src, pos)): + return m.end(), m[0] + else: + raise SyntaxError(f'Invalid key identifier at position {pos}') + + +def parse_key(src, pos, depth): + pos, seg = parse_key_segment(src, pos) + key = (seg,) + while True: + pos = skip(src, pos, depth) + try: + char = src[pos] + except IndexError: + return pos, key + if char != '.': + return pos, key + pos = skip(src, pos + 1, depth) + pos, seg = parse_key_segment(src, pos) + key += (seg,) + + +def parse_assign(src, pos, depth, root, sect): + pos, key = parse_key(src, pos, depth) + + pos = skip(src, pos, depth) + try: + char = src[pos] + except IndexError: + char = None + if char != '=': + _raise_expected_char('=', pos) + pos += 1 + + pos = skip(src, pos, depth) + pos, val = parse_value(src, pos, depth, root, sect) + + return pos, key, val + + +def parse_dict_header(src, pos, depth, root): + depth += 1 + pos = skip(src, pos + 1, depth) + try: + if src[pos] == ']': + return pos + 1, root + except IndexError: + _raise_unexpected_eof('header') + + pos, key = parse_key(src, pos, depth) + try: + if src[pos] != ']': + _raise_expected_char(']', pos) + except IndexError: + _raise_unexpected_eof('header') + + sect = ensure_path(root, key) + if type(sect) is not dict: + raise SyntaxError(f'Cannot enter {type(sect).__name__} context: {key}') + return pos + 1, sect + + +def parse_list_header(src, pos, depth, root): + depth += 1 + pos = skip(src, pos + 2, depth) + pos, key = parse_key(src, pos, depth) + if not src.startswith(']]', pos): + _raise_expected_char(']]', pos) + + if len(key) > 1: + d = ensure_path(root, key[:-1]) + if type(d) is not dict: + raise SyntaxError( + f'Cannot resolve {repr(key[-1])} in {type(d).__name__}') + else: + d = root + + l = d.setdefault(key[-1], []) + if type(l) is not list: + raise SyntaxError(f'Cannot append to {type(l).__name__}: {key}') + sect = {} + l.append(sect) + return pos + 2, sect + + +def loads(src): + root = {} + sect = root + pos = 0 + + while True: + pos = skip_chars(src, pos, WSNL, skip_comments=True) + + try: + char = src[pos] + except IndexError: + break + + if char == '[': + try: + char = src[pos+1] + except IndexError: + _raise_unexpected_eof('header') + + if char == '[': + pos, sect = parse_list_header(src, pos, 0, root) + else: + pos, sect = parse_dict_header(src, pos, 0, root) + elif char == '*': + pos, val = parse_dict_unpack(src, pos, 0, root, sect) + dict_unpack(sect, val) + else: + pos, key, val = parse_assign(src, pos, 0, root, sect) + dict_assign(sect, key, val) + + pos = skip_chars(src, pos, WS, skip_comments=True) + try: + if src[pos] != '\n': + _raise_expected_char('\n', pos) + except IndexError: + pass + + return root + + +def load(f): + b = f.read() + try: + src = b.decode() + except AttributeError: + raise TypeError('File must be opened in binary mode') + return loads(src) |
