ymlplus 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
yamlplus/_syntax.py ADDED
@@ -0,0 +1,511 @@
1
+ """Literal-profile syntax recognition, without constructing a document value."""
2
+
3
+ import ast
4
+ import base64
5
+ from dataclasses import dataclass
6
+ import math
7
+ import re
8
+
9
+ from .errors import ValidationError
10
+ from ._identifiers import (KEYWORDS, PREFIXES, continuation, fold, key_error,
11
+ reserved_fold, start)
12
+ from ._numbers import WARN_DIGITS, int_from_decimal
13
+
14
+ _C32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
15
+ _C32_VALUES = {ch: index for index, ch in enumerate(_C32)} | {"O": 0, "I": 1, "L": 1}
16
+ _RADIXES = {"b": (2, "01"), "q": (4, "0-3"), "o": (8, "0-7"),
17
+ "x": (16, "0-9a-fA-F"), "z": (32, "0-9A-HJ-NP-TV-Za-hj-np-tv-zOoIiLl")}
18
+ _STREAMS = {
19
+ "bin": (1, "01", False, {}, ""),
20
+ "dna": (2, "TCAG", True, {}, ""),
21
+ "oct": (3, "01234567", False, {}, ""),
22
+ "hex": (4, "0123456789ABCDEF", True, {}, ""),
23
+ "c32": (5, _C32, True, {"O": "0", "I": "1", "L": "1"}, "-"),
24
+ }
25
+ _ESCAPES = {"\n": "", "\\": "\\", "'": "'", '"': '"', "a": "\a", "b": "\b",
26
+ "f": "\f", "n": "\n", "r": "\r", "t": "\t", "v": "\v"}
27
+ _PUNCTUATION = {":": "COLON", "=": "EQUAL", ",": "COMMA",
28
+ "[": "LBRACKET", "]": "RBRACKET", "{": "LBRACE", "}": "RBRACE"}
29
+
30
+
31
+ def _digit(character):
32
+ return bool(character) and "0" <= character <= "9"
33
+
34
+
35
+ @dataclass
36
+ class Token:
37
+ kind: str
38
+ value: object
39
+ line: int
40
+ col: int
41
+ prefix: str = ""
42
+ lexeme: str = ""
43
+
44
+
45
+ class _Lexer:
46
+ def __init__(self, text, max_digits, source):
47
+ self.text, self.max_digits, self.source = text, max_digits, source
48
+ self.pos, self.line, self.col = 0, 1, 0
49
+ self.indents, self.brackets, self.based = [0], [], False
50
+ self.tokens, self.warnings = [], []
51
+
52
+ def peek(self, offset=0):
53
+ return self.text[self.pos + offset:self.pos + offset + 1]
54
+
55
+ def advance(self, count=1):
56
+ text = self.text[self.pos:self.pos + count]
57
+ for ch in text:
58
+ if ch == "\n":
59
+ self.line, self.col = self.line + 1, 0
60
+ else:
61
+ self.col += 1
62
+ self.pos += count
63
+ return text
64
+
65
+ def fail(self, message, line=None, col=None):
66
+ raise ValidationError(message, self.line if line is None else line,
67
+ self.col if col is None else col, self.source)
68
+
69
+ def emit(self, kind, value=None, *, line=None, col=None, prefix="", lexeme=""):
70
+ self.tokens.append(Token(kind, value, self.line if line is None else line,
71
+ self.col if col is None else col, prefix, lexeme))
72
+
73
+ def tokenize(self):
74
+ line_start = True
75
+ while self.pos < len(self.text):
76
+ if line_start and not self.brackets:
77
+ if self.line_start():
78
+ continue
79
+ line_start = False
80
+ ch = self.peek()
81
+ if ch == "\n":
82
+ if not self.brackets:
83
+ self.emit("NEWLINE")
84
+ line_start = True
85
+ self.advance()
86
+ elif ch in (" ", "\t"):
87
+ self.advance()
88
+ elif ch == "#":
89
+ while self.peek() and self.peek() != "\n":
90
+ self.advance()
91
+ else:
92
+ self.token()
93
+ if self.brackets:
94
+ self.fail("unclosed bracket at end of document")
95
+ if self.tokens and self.tokens[-1].kind not in ("NEWLINE", "DEDENT"):
96
+ self.emit("NEWLINE")
97
+ while len(self.indents) > 1:
98
+ self.indents.pop()
99
+ self.emit("DEDENT")
100
+ self.emit("EOF")
101
+ for index, token in enumerate(self.tokens[:-1]):
102
+ if token.kind == "STRING" and self.tokens[index + 1].kind == "COLON":
103
+ token.kind = "QKEY"
104
+ return self.tokens
105
+
106
+ def line_start(self):
107
+ width = 0
108
+ while self.peek() in (" ", "\t"):
109
+ if self.peek() == "\t":
110
+ self.fail("indentation must use spaces, not tabs")
111
+ self.advance()
112
+ width += 1
113
+ if self.peek() in ("", "\n"):
114
+ if self.peek():
115
+ self.advance()
116
+ return True
117
+ if self.peek() == "#":
118
+ while self.peek() and self.peek() != "\n":
119
+ self.advance()
120
+ if self.peek():
121
+ self.advance()
122
+ return True
123
+ if not self.based:
124
+ self.indents, self.based = [width], True
125
+ if width < self.indents[0]:
126
+ self.fail("indentation falls left of the document's base column")
127
+ if width > self.indents[-1]:
128
+ self.indents.append(width)
129
+ self.emit("INDENT")
130
+ else:
131
+ while width < self.indents[-1]:
132
+ self.indents.pop()
133
+ self.emit("DEDENT")
134
+ if width != self.indents[-1]:
135
+ self.fail("indentation matches no open block")
136
+ while self.peek() == "-" and self.peek(1) in ("", " ", "\n"):
137
+ column = self.col
138
+ self.emit("DASH")
139
+ self.advance()
140
+ if self.peek() == " ":
141
+ self.advance()
142
+ self.indents.append(column + 2)
143
+ self.emit("INDENT")
144
+ return False
145
+
146
+ def token(self):
147
+ ch, line, col = self.peek(), self.line, self.col
148
+ if ch in _PUNCTUATION:
149
+ if ch in "[{":
150
+ self.brackets.append(ch)
151
+ elif ch in "]}":
152
+ if not self.brackets or self.brackets[-1] != {"}": "{", "]": "["}[ch]:
153
+ self.fail("unmatched closing bracket")
154
+ self.brackets.pop()
155
+ self.emit(_PUNCTUATION[ch], ch, lexeme=ch)
156
+ self.advance()
157
+ elif ch in ("'", '"'):
158
+ self.string("", line, col)
159
+ elif (_digit(ch) or ch in ("+", "-", ".") and _digit(self.peek(1))
160
+ or ch in ("+", "-") and self.peek(1) == "." and _digit(self.peek(2))):
161
+ self.number(line, col)
162
+ elif start(ch):
163
+ begin = self.pos
164
+ self.advance()
165
+ while continuation(self.peek()):
166
+ self.advance()
167
+ name = self.text[begin:self.pos]
168
+ reserved = reserved_fold(name)
169
+ if reserved is not None:
170
+ self.fail(f"reserved spelling {reserved!r} must be written exactly", line, col)
171
+ if self.peek() in ("'", '"'):
172
+ if name not in PREFIXES:
173
+ self.fail(f"unsupported literal prefix {name!r}", line, col)
174
+ self.string(name, line, col)
175
+ elif name in KEYWORDS:
176
+ self.emit("KEYWORD", KEYWORDS[name], line=line, col=col, lexeme=name)
177
+ else:
178
+ self.emit("NAME", fold(name), line=line, col=col, lexeme=name)
179
+ else:
180
+ self.fail(f"unexpected character {ch!r}; only literal profile syntax is allowed")
181
+
182
+ def number(self, line, col):
183
+ begin = self.pos
184
+ if self.peek() in ("+", "-"):
185
+ self.advance()
186
+ radix = self.peek() == "0" and self.peek(1).lower() in _RADIXES
187
+ floating = False
188
+ if radix:
189
+ self.advance(2)
190
+ while self.peek() and (self.peek().isascii() and self.peek().isalnum() or self.peek() == "_"):
191
+ self.advance()
192
+ else:
193
+ while _digit(self.peek()) or self.peek() == "_":
194
+ self.advance()
195
+ if self.peek() == ".":
196
+ floating = True
197
+ self.advance()
198
+ while _digit(self.peek()) or self.peek() == "_":
199
+ self.advance()
200
+ if self.peek() in ("e", "E") and (_digit(self.peek(1)) or
201
+ self.peek(1) in ("+", "-") and _digit(self.peek(2))):
202
+ floating = True
203
+ self.advance()
204
+ if self.peek() in ("+", "-"):
205
+ self.advance()
206
+ while _digit(self.peek()) or self.peek() == "_":
207
+ self.advance()
208
+ if continuation(self.peek()) or self.peek().isalnum():
209
+ self.fail("a number cannot be followed by name characters", line, col)
210
+ spelling = self.text[begin:self.pos]
211
+ sign = spelling[:1] if spelling[:1] in ("+", "-") else ""
212
+ body = spelling[len(sign):]
213
+ if radix:
214
+ base, digits = _RADIXES[body[1].lower()]
215
+ rest = body[2:]
216
+ if not re.fullmatch(rf"_?[{digits}]+(?:_[{digits}]+)*", rest):
217
+ self.fail("malformed radix integer", line, col)
218
+ rest = rest.replace("_", "")
219
+ if base == 32:
220
+ value = 0
221
+ for ch in rest.upper():
222
+ value = value * 32 + _C32_VALUES[ch]
223
+ else:
224
+ value = int(rest, base)
225
+ elif not floating:
226
+ if not re.fullmatch(r"0(?:_?0)*|[1-9](?:_?[0-9])*", body):
227
+ self.fail("malformed decimal integer", line, col)
228
+ digits = body.replace("_", "")
229
+ if len(digits) > self.max_digits:
230
+ self.fail("decimal integer exceeds max_digits", line, col)
231
+ if len(digits) > WARN_DIGITS:
232
+ where = f"{self.source}:{line}:{col}" if self.source else f"line {line}, column {col}"
233
+ self.warnings.append(f"warning: {where}: integer has {len(digits)} decimal digits; conversion is costly")
234
+ value = int_from_decimal(digits)
235
+ else:
236
+ try:
237
+ value = ast.literal_eval(body)
238
+ except (SyntaxError, ValueError):
239
+ self.fail("malformed floating-point literal", line, col)
240
+ if not isinstance(value, float) or not math.isfinite(value):
241
+ self.fail("a float must be finite", line, col)
242
+ if sign == "-":
243
+ value = -value
244
+ self.emit("FLOAT" if floating else "INT", value, line=line, col=col, lexeme=spelling)
245
+
246
+ def string(self, prefix, line, col):
247
+ begin = self.pos - len(prefix)
248
+ quote = self.peek()
249
+ triple = self.peek(1) == quote and self.peek(2) == quote
250
+ self.advance(3 if triple else 1)
251
+ body, positions = self.string_body(quote, triple, line, col)
252
+ is_bytes = prefix not in ("", "r")
253
+ if prefix == "b64":
254
+ for index, ch in enumerate(body):
255
+ if ch not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/= \t\n":
256
+ self.fail("invalid base64 symbol", *positions[index])
257
+ compact = "".join(ch for ch in body if ch not in " \t\n")
258
+ if len(compact) % 4:
259
+ self.fail("base64 requires padding to a multiple of four symbols", line, col)
260
+ try:
261
+ value = base64.b64decode(compact, validate=True)
262
+ except ValueError:
263
+ self.fail("invalid base64 payload", line, col)
264
+ if base64.b64encode(value).decode("ascii") != compact:
265
+ self.fail("non-canonical base64 padding or spare bits", line, col)
266
+ elif prefix in _STREAMS:
267
+ value = self.stream(prefix, body, positions, line, col)
268
+ elif prefix in ("r", "rb"):
269
+ if is_bytes and any(ord(ch) > 127 for ch in body):
270
+ self.fail("raw bytes require ASCII source characters", line, col)
271
+ value = body.encode("ascii") if is_bytes else body
272
+ else:
273
+ value = self.escapes(body, positions, is_bytes, line, col)
274
+ self.emit("BYTES" if is_bytes else "STRING", value, line=line, col=col,
275
+ prefix=prefix, lexeme=self.text[begin:self.pos])
276
+
277
+ def string_body(self, quote, triple, line, col):
278
+ out, positions = [], []
279
+ closer, anchor, pristine = quote * (3 if triple else 1), None, True
280
+ while True:
281
+ if not self.peek():
282
+ self.fail("unterminated literal", line, col)
283
+ if self.text.startswith(closer, self.pos):
284
+ if triple and anchor is not None and self.col < anchor:
285
+ self.fail("closing quote outdents past the literal's indentation")
286
+ self.advance(len(closer))
287
+ return "".join(out), positions
288
+ if self.peek() == "\\" and self.peek(1) not in ("", "\n"):
289
+ for _ in range(2):
290
+ positions.append((self.line, self.col))
291
+ out.append(self.advance())
292
+ pristine = False
293
+ elif self.peek() == "\n":
294
+ if not triple:
295
+ self.fail("a single-line literal cannot contain a raw newline", line, col)
296
+ here = self.line, self.col
297
+ self.advance()
298
+ if not pristine:
299
+ out.append("\n")
300
+ positions.append(here)
301
+ pristine = False
302
+ width = 0
303
+ while self.peek() == " ":
304
+ self.advance()
305
+ width += 1
306
+ if self.peek() in ("", "\n") or self.text.startswith(closer, self.pos):
307
+ continue
308
+ if anchor is None:
309
+ anchor = width
310
+ elif width < anchor:
311
+ self.fail("literal content outdents past its indentation anchor")
312
+ else:
313
+ out.append(" " * (width - anchor))
314
+ positions.extend((self.line, anchor + index) for index in range(width - anchor))
315
+ else:
316
+ positions.append((self.line, self.col))
317
+ out.append(self.advance())
318
+ pristine = False
319
+
320
+ def escapes(self, body, positions, is_bytes, line, col):
321
+ out, index = [], 0
322
+
323
+ def emit(point, at, literal=False):
324
+ if is_bytes and (point > 255 or literal and point > 127):
325
+ self.fail("bytes require ASCII source and escape values within 0..255", *positions[at])
326
+ out.append(point)
327
+
328
+ while index < len(body):
329
+ if body[index] != "\\":
330
+ emit(ord(body[index]), index, literal=True)
331
+ index += 1
332
+ continue
333
+ begin = index
334
+ index += 1
335
+ if index >= len(body):
336
+ self.fail("dangling backslash", line, col)
337
+ escape = body[index]
338
+ if escape in _ESCAPES:
339
+ for ch in _ESCAPES[escape]:
340
+ emit(ord(ch), begin)
341
+ index += 1
342
+ elif escape == "x":
343
+ digits = body[index + 1:index + 3]
344
+ if len(digits) != 2 or any(ch not in "0123456789abcdefABCDEF" for ch in digits):
345
+ self.fail("incomplete hexadecimal escape", *positions[begin])
346
+ emit(int(digits, 16), begin)
347
+ index += 3
348
+ elif escape in "01234567":
349
+ end = index
350
+ while end < len(body) and end - index < 3 and body[end] in "01234567":
351
+ end += 1
352
+ emit(int(body[index:end], 8), begin)
353
+ index = end
354
+ elif escape in ("u", "U") and not is_bytes:
355
+ width = 4 if escape == "u" else 8
356
+ digits = body[index + 1:index + width + 1]
357
+ if len(digits) != width or any(ch not in "0123456789abcdefABCDEF" for ch in digits):
358
+ self.fail("incomplete Unicode escape", *positions[begin])
359
+ point = int(digits, 16)
360
+ if point > 0x10FFFF or 0xD800 <= point <= 0xDFFF:
361
+ self.fail("an escape must name a Unicode scalar value", *positions[begin])
362
+ emit(point, begin)
363
+ index += width + 1
364
+ else:
365
+ self.fail(f"unknown escape \\{escape}", *positions[begin])
366
+ return bytes(out) if is_bytes else "".join(chr(point) for point in out)
367
+
368
+ def stream(self, prefix, body, positions, line, col):
369
+ bits, alphabet, folds, aliases, ignored = _STREAMS[prefix]
370
+ symbols = []
371
+ for index, ch in enumerate(body):
372
+ if ch in " \t\n" or ch in ignored:
373
+ continue
374
+ key = ch.upper() if folds else ch
375
+ key = aliases.get(key, key)
376
+ value = alphabet.find(key)
377
+ if value < 0:
378
+ self.fail(f"invalid {prefix} symbol", *positions[index])
379
+ symbols.append(value)
380
+ total, number = len(symbols) * bits, 0
381
+ for value in symbols:
382
+ number = number << bits | value
383
+ if 8 % bits == 0:
384
+ count = -(-total // 8)
385
+ return (number << (8 * count - total)).to_bytes(count, "big")
386
+ count = total // 8
387
+ if -(-8 * count // bits) != len(symbols):
388
+ self.fail("stream symbol count does not encode a whole byte payload", line, col)
389
+ spare = total - 8 * count
390
+ if number & ((1 << spare) - 1):
391
+ self.fail("stream padding bits must be zero", line, col)
392
+ return (number >> spare).to_bytes(count, "big")
393
+
394
+
395
+ class _Parser:
396
+ def __init__(self, tokens, source):
397
+ self.tokens, self.source, self.index = tokens, source, 0
398
+
399
+ def peek(self, offset=0):
400
+ return self.tokens[min(self.index + offset, len(self.tokens) - 1)]
401
+
402
+ def take(self, kind):
403
+ if self.peek().kind == kind:
404
+ token = self.peek()
405
+ if kind != "EOF":
406
+ self.index += 1
407
+ return token
408
+ return None
409
+
410
+ def fail(self, message, token=None):
411
+ token = token or self.peek()
412
+ raise ValidationError(message, token.line, token.col, self.source)
413
+
414
+ def expect(self, kind):
415
+ token = self.take(kind)
416
+ if token is None:
417
+ self.fail(f"expected {kind.lower()}, found {self.peek().kind.lower()}")
418
+ return token
419
+
420
+ def at_key(self):
421
+ return self.peek().kind in ("NAME", "QKEY") and self.peek(1).kind == "COLON"
422
+
423
+ def key(self, seen):
424
+ token = self.peek()
425
+ if token.kind not in ("NAME", "QKEY"):
426
+ self.fail("a mapping key must be a name or a quoted string")
427
+ self.index += 1
428
+ if token.kind == "QKEY" and token.prefix:
429
+ self.fail("a quoted key cannot have a literal prefix", token)
430
+ why = key_error(token.value)
431
+ if why:
432
+ self.fail(why, token)
433
+ if token.value in seen:
434
+ self.fail("duplicate key", token)
435
+ seen.add(token.value)
436
+ self.expect("COLON")
437
+ self.take("EQUAL")
438
+
439
+ def value(self, depth=0):
440
+ if depth > 128:
441
+ self.fail("source nesting exceeds this implementation's ceiling of 128")
442
+ if self.at_key():
443
+ seen = set()
444
+ while self.at_key():
445
+ self.key(seen)
446
+ if self.take("NEWLINE"):
447
+ self.expect("INDENT")
448
+ self.value(depth + 1)
449
+ self.expect("DEDENT")
450
+ else:
451
+ self.flow(depth + 1)
452
+ self.expect("NEWLINE")
453
+ elif self.peek().kind == "DASH":
454
+ while self.take("DASH"):
455
+ self.expect("INDENT")
456
+ deeper = False
457
+ if self.take("NEWLINE"):
458
+ deeper = self.take("INDENT") is not None
459
+ self.value(depth + 1)
460
+ if deeper:
461
+ self.expect("DEDENT")
462
+ self.expect("DEDENT")
463
+ else:
464
+ self.flow(depth + 1)
465
+ self.expect("NEWLINE")
466
+
467
+ def flow(self, depth):
468
+ if depth > 128:
469
+ self.fail("source nesting exceeds this implementation's ceiling of 128")
470
+ if self.take("LBRACKET"):
471
+ if self.take("RBRACKET"):
472
+ return
473
+ while True:
474
+ self.flow(depth + 1)
475
+ if self.take("RBRACKET"):
476
+ return
477
+ self.expect("COMMA")
478
+ if self.take("RBRACKET"):
479
+ return
480
+ elif self.take("LBRACE"):
481
+ seen = set()
482
+ if self.take("RBRACE"):
483
+ return
484
+ while True:
485
+ self.key(seen)
486
+ self.flow(depth + 1)
487
+ if self.take("RBRACE"):
488
+ return
489
+ self.expect("COMMA")
490
+ if self.take("RBRACE"):
491
+ return
492
+ elif self.peek().kind in ("STRING", "BYTES", "INT", "FLOAT", "KEYWORD"):
493
+ self.index += 1
494
+ else:
495
+ self.fail("expected a literal, list, or mapping; bare text and computation are not profile values")
496
+
497
+ def parse(self):
498
+ if self.peek().kind == "EOF":
499
+ self.fail("a document must contain one value")
500
+ self.value()
501
+ self.expect("EOF")
502
+
503
+
504
+ def validate(text, max_digits, source=None):
505
+ lexer = _Lexer(text, max_digits, source)
506
+ tokens = lexer.tokenize()
507
+ try:
508
+ _Parser(tokens, source).parse()
509
+ except RecursionError:
510
+ raise ValidationError("source nesting exceeds the parser's stack capacity", source=source) from None
511
+ return tokens, lexer.warnings