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/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """YamlPlus validation and profile-conformant YAML I/O through PyYAML.
2
+
3
+ Reading uses native safe YAML semantics. Output is validated against the
4
+ literal profile before return. Identifier syntax is pinned to Unicode 14.0.0.
5
+ """
6
+
7
+ from ._xid import UNICODE_VERSION
8
+ from .errors import ValidationError
9
+ from .api import check, dump, dumps, is_yamlplus, load, loads, normalize
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = [
14
+ "load", "loads", "dump", "dumps", "check", "is_yamlplus", "normalize",
15
+ "ValidationError", "UNICODE_VERSION",
16
+ ]
yamlplus/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Validate or format YamlPlus with ``python -m yamlplus [--write] [FILE]``."""
2
+
3
+ import sys
4
+
5
+ from .api import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,72 @@
1
+ """Pinned profile identifiers and exact string-key validation."""
2
+
3
+ from bisect import bisect_right
4
+ from unicodedata import decomposition, normalize
5
+
6
+ from ._xid import XID_START, XID_CONTINUE
7
+
8
+ _START = tuple(lo for lo, _ in XID_START)
9
+ _CONTINUE = tuple(lo for lo, _ in XID_CONTINUE)
10
+ KEYWORDS = {"True": True, "False": False, "None": None}
11
+ PREFIXES = frozenset({"b", "rb", "r", "bin", "dna", "oct", "hex", "c32", "b64"})
12
+ _RESERVED_PREFIXES = PREFIXES | {"y", "yb", "yt", "ry"}
13
+
14
+
15
+ def _in(character, lows, ranges):
16
+ if not character:
17
+ return False
18
+ point = ord(character)
19
+ index = bisect_right(lows, point) - 1
20
+ return index >= 0 and point <= ranges[index][1]
21
+
22
+
23
+ def start(character):
24
+ return character == "_" or _in(character, _START, XID_START)
25
+
26
+
27
+ def continuation(character):
28
+ return _in(character, _CONTINUE, XID_CONTINUE)
29
+
30
+
31
+ def identifier(value):
32
+ return bool(value) and start(value[0]) and all(continuation(ch) for ch in value[1:])
33
+
34
+
35
+ def fold(value):
36
+ out = []
37
+ for ch in value:
38
+ mapped = decomposition(ch)
39
+ if mapped.startswith(("<wide>", "<narrow>")):
40
+ out.extend(chr(int(part, 16)) for part in mapped.split()[1:])
41
+ else:
42
+ out.append(ch)
43
+ return normalize("NFC", "".join(out))
44
+
45
+
46
+ def dunder(value):
47
+ return value.startswith("__") and value.endswith("__")
48
+
49
+
50
+ def reserved_fold(value):
51
+ folded = fold(value)
52
+ return (folded if folded != value and
53
+ (folded in KEYWORDS or folded in _RESERVED_PREFIXES or dunder(folded)) else None)
54
+
55
+
56
+ def key_error(value):
57
+ if not isinstance(value, str):
58
+ return "a key must be a string"
59
+ if not value:
60
+ return "a key must be nonempty"
61
+ if value.startswith("@"):
62
+ return "keys beginning with '@' are reserved"
63
+ if dunder(value):
64
+ return "keys beginning and ending with '__' are reserved"
65
+ if any(0xD800 <= ord(ch) <= 0xDFFF for ch in value):
66
+ return "a key cannot contain a lone surrogate"
67
+ return ""
68
+
69
+
70
+ def plain_key(value):
71
+ return (identifier(value) and value not in KEYWORDS and not dunder(value)
72
+ and reserved_fold(value) is None and fold(value) == value)
yamlplus/_io.py ADDED
@@ -0,0 +1,262 @@
1
+ """Safe PyYAML input and profile-conformant, bounded host-value output."""
2
+
3
+ import base64
4
+ import math
5
+ import sys
6
+
7
+ import yaml
8
+ from yaml.events import ScalarEvent
9
+
10
+ from .errors import ValidationError
11
+ from ._identifiers import key_error, plain_key
12
+ from ._numbers import WARN_DIGITS, decimal_digits, decimal_text
13
+ from ._pyyaml import Loader
14
+ from ._validation import (Notices, analyze, callback_option, digit_option,
15
+ implicit_type, key_warning, send)
16
+
17
+ MAX_DEPTH = 128
18
+ MAX_SIZE = 4 * 1024 * 1024
19
+
20
+
21
+ def read(text, source=None):
22
+ try:
23
+ return yaml.load(text, Loader=Loader)
24
+ except yaml.YAMLError as error:
25
+ mark = getattr(error, "problem_mark", None)
26
+ raise ValidationError(getattr(error, "problem", None) or "YAML value construction failed",
27
+ mark.line + 1 if mark else 1, mark.column if mark else 0, source) from None
28
+ except RecursionError:
29
+ raise ValidationError("YAML construction exceeds the runtime's stack capacity", source=source) from None
30
+
31
+
32
+ class _Dumper(yaml.SafeDumper):
33
+ ESCAPE_REPLACEMENTS = {
34
+ key: value for key, value in yaml.SafeDumper.ESCAPE_REPLACEMENTS.items()
35
+ if value not in ("e", "N", "_", "L", "P")
36
+ } | {"\0": "x00"}
37
+
38
+ def ignore_aliases(self, data):
39
+ return True
40
+
41
+ def increase_indent(self, flow=False, indentless=False):
42
+ return super().increase_indent(flow, indentless=False)
43
+
44
+ def expect_document_end(self):
45
+ super().expect_document_end()
46
+ self.open_ended = False
47
+
48
+ def check_simple_key(self):
49
+ ordinary = super().check_simple_key()
50
+ return ordinary or isinstance(self.event, ScalarEvent) and self.event.tag == "tag:yaml.org,2002:str"
51
+
52
+ def represent_mapping(self, tag, mapping, flow_style=None):
53
+ node = super().represent_mapping(tag, mapping, flow_style)
54
+ for key, _ in node.value:
55
+ key.style = None if plain_key(key.value) and not implicit_type(key.value) else '"'
56
+ return node
57
+
58
+
59
+ def _text(dumper, value):
60
+ return dumper.represent_scalar("tag:yaml.org,2002:str", value, style='"')
61
+
62
+
63
+ def _none(dumper, value):
64
+ return dumper.represent_scalar("tag:yaml.org,2002:str", "None")
65
+
66
+
67
+ def _bytes(dumper, value):
68
+ literal = 'b64"' + base64.b64encode(value).decode("ascii") + '"'
69
+ return dumper.represent_scalar("tag:yaml.org,2002:str", literal)
70
+
71
+
72
+ def _bool(dumper, value):
73
+ return dumper.represent_scalar("tag:yaml.org,2002:bool", "True" if value else "False")
74
+
75
+
76
+ def _int(dumper, value):
77
+ return dumper.represent_scalar("tag:yaml.org,2002:int", decimal_text(value))
78
+
79
+
80
+ for _type, _represent in ((str, _text), (type(None), _none), (bytes, _bytes),
81
+ (bool, _bool), (int, _int)):
82
+ _Dumper.add_representer(_type, _represent)
83
+
84
+
85
+ class _Path:
86
+ def __init__(self, parent=None, step="root"):
87
+ self.parent, self.step = parent, step
88
+
89
+ def child(self, step):
90
+ return _Path(self, step)
91
+
92
+ def __str__(self):
93
+ out, node = [], self
94
+ while node is not None:
95
+ out.append(node.step)
96
+ node = node.parent
97
+ return "".join(reversed(out))
98
+
99
+
100
+ def _key_step(key, index):
101
+ if not isinstance(key, str):
102
+ return f"[entry {index} key]"
103
+ text = repr(str.__getitem__(key, slice(0, 64)))
104
+ return f"[{text}{'...' if str.__len__(key) > 64 else ''}]"
105
+
106
+
107
+ class _Budget:
108
+ def __init__(self, maximum):
109
+ self.maximum, self.used = maximum, 0
110
+
111
+ def spend(self, amount, path):
112
+ if amount > self.maximum - self.used:
113
+ raise ValidationError(f"serialized UTF-8 exceeds max_size of {self.maximum} bytes", path=str(path))
114
+ self.used += amount
115
+
116
+ def text(self, value, path):
117
+ if len(value) > self.maximum - self.used:
118
+ self.spend(len(value), path)
119
+ for start in range(0, len(value), 4096):
120
+ try:
121
+ size = len(value[start:start + 4096].encode("utf-8"))
122
+ except UnicodeEncodeError:
123
+ raise ValidationError("text cannot contain a lone surrogate", path=str(path)) from None
124
+ self.spend(size, path)
125
+
126
+
127
+ class _Output:
128
+ def __init__(self, maximum):
129
+ self.budget, self.parts = _Budget(maximum), []
130
+
131
+ def write(self, text):
132
+ self.budget.text(text, "root")
133
+ self.parts.append(text)
134
+ return len(text)
135
+
136
+ def flush(self):
137
+ pass
138
+
139
+
140
+ def options(max_digits, max_depth, max_size, warn, info):
141
+ max_digits = digit_option(max_digits)
142
+ for name, value in (("max_depth", max_depth), ("max_size", max_size)):
143
+ if not isinstance(value, int) or isinstance(value, bool):
144
+ raise TypeError(f"{name} must be an integer")
145
+ if value < 1:
146
+ raise ValueError(f"{name} must be positive")
147
+ callback_option(warn, "warn")
148
+ callback_option(info, "info")
149
+ return max_digits, int.__int__(max_depth), int.__int__(max_size)
150
+
151
+
152
+ def _prepare_host(value, max_digits, max_depth, max_size):
153
+ budget, notices, warnings = _Budget(max_size), Notices(), []
154
+ active, root = {}, []
155
+
156
+ def children(original, out, path):
157
+ if isinstance(out, list):
158
+ for index, child in enumerate(list.__iter__(original)):
159
+ yield child, path.child(f"[{index}]"), out.append
160
+ else:
161
+ for index, (key, child) in enumerate(dict.items(original)):
162
+ child_path = path.child(_key_step(key, index))
163
+ if not isinstance(key, str):
164
+ raise TypeError(f"at {child_path}: a key must be a string")
165
+ key = str.__str__(key)
166
+ why = key_error(key)
167
+ if why:
168
+ raise ValidationError(why, path=str(child_path))
169
+ if len(key) > 1024:
170
+ raise ValidationError("an implicit key's source spelling must fit within 1,024 characters", path=str(child_path))
171
+ budget.text(key, child_path)
172
+ kind = implicit_type(key)
173
+ budget.spend(2 if plain_key(key) and not kind else 4, child_path)
174
+ if kind:
175
+ warnings.append(key_warning(key, kind, f"at {child_path}", writing=True))
176
+
177
+ def put(item, key=key, out=out):
178
+ out[key] = item
179
+
180
+ yield child, child_path, put
181
+
182
+ pending = [("value", value, _Path(), 0, root.append)]
183
+ while pending:
184
+ task = pending.pop()
185
+ if task[0] == "children":
186
+ _, original, path, depth, iterator = task
187
+ try:
188
+ child, child_path, put = next(iterator)
189
+ except StopIteration:
190
+ del active[id(original)]
191
+ continue
192
+ pending.append(task)
193
+ pending.append(("value", child, child_path, depth, put))
194
+ continue
195
+ _, original, path, depth, put = task
196
+ if original is None:
197
+ budget.spend(4, path)
198
+ notices.add("None", f"at {path}")
199
+ put(None)
200
+ elif isinstance(original, bool):
201
+ budget.spend(4 if original else 5, path)
202
+ put(original)
203
+ elif isinstance(original, int):
204
+ bits = int.bit_length(original)
205
+ if bits > (max_digits * 3322 + 999) // 1000:
206
+ raise ValidationError("integer exceeds max_digits", path=str(path))
207
+ value = int.__int__(original)
208
+ count = decimal_digits(value)
209
+ if count > max_digits:
210
+ raise ValidationError("integer exceeds max_digits", path=str(path))
211
+ budget.spend(count + (value < 0), path)
212
+ if count > WARN_DIGITS:
213
+ warnings.append(f"warning: at {path}: integer has {count} decimal digits; conversion is costly")
214
+ put(value)
215
+ elif isinstance(original, float):
216
+ value = float.__float__(original)
217
+ if not math.isfinite(value):
218
+ raise ValidationError("a float must be finite", path=str(path))
219
+ budget.spend(len(repr(value)), path)
220
+ put(value)
221
+ elif isinstance(original, str):
222
+ if str.__len__(original) > budget.maximum - budget.used:
223
+ budget.spend(str.__len__(original), path)
224
+ value = str.__str__(original)
225
+ budget.text(value, path)
226
+ budget.spend(2, path)
227
+ put(value)
228
+ elif isinstance(original, bytes):
229
+ budget.spend(4 * ((bytes.__len__(original) + 2) // 3) + 5, path)
230
+ notices.add("bytes", f"at {path}")
231
+ put(bytes.__bytes__(original))
232
+ elif isinstance(original, (dict, list)):
233
+ if id(original) in active:
234
+ raise ValidationError(f"container cycle reaches ancestor at {active[id(original)]}", path=str(path))
235
+ depth += 1
236
+ if depth > max_depth:
237
+ raise ValidationError(f"container depth exceeds max_depth of {max_depth}", path=str(path))
238
+ empty = not (dict.__len__(original) if isinstance(original, dict) else list.__len__(original))
239
+ budget.spend(2 if empty else 1, path)
240
+ out = {} if isinstance(original, dict) else []
241
+ put(out)
242
+ active[id(original)] = path
243
+ pending.append(("children", original, path, depth, iter(children(original, out, path))))
244
+ else:
245
+ raise TypeError(f"at {path}: {type(original).__name__} is not supported profile data")
246
+ return root[0], warnings, list(notices.messages())
247
+
248
+
249
+ def write(value, max_digits, max_depth, max_size, warn, info):
250
+ value, warnings, notices = _prepare_host(value, max_digits, max_depth, max_size)
251
+ output = _Output(max_size)
252
+ try:
253
+ yaml.dump(value, stream=output, Dumper=_Dumper, sort_keys=False,
254
+ default_flow_style=False, allow_unicode=True, width=sys.maxsize,
255
+ explicit_start=False, explicit_end=False)
256
+ except RecursionError:
257
+ raise ValidationError("YAML emission exceeds the runtime's stack capacity", path="root") from None
258
+ text = "".join(output.parts)
259
+ analyze(text, max_digits)
260
+ send(warn, warnings)
261
+ send(info, notices)
262
+ return text
yamlplus/_numbers.py ADDED
@@ -0,0 +1,40 @@
1
+ """Bounded decimal conversion independent of Python's process-wide digit cap."""
2
+
3
+ WARN_DIGITS = 4300
4
+ REFUSE_DIGITS = 4 * WARN_DIGITS
5
+ _CHUNK = 500
6
+
7
+
8
+ def int_from_decimal(digits):
9
+ if len(digits) <= _CHUNK:
10
+ return int(digits)
11
+ half = len(digits) // 2
12
+ return (int_from_decimal(digits[:-half]) * 10 ** half
13
+ + int_from_decimal(digits[-half:]))
14
+
15
+
16
+ def decimal_text(value):
17
+ if value < 0:
18
+ return "-" + decimal_text(-value)
19
+ if value < 10 ** _CHUNK:
20
+ return str(value)
21
+ half = max(_CHUNK, (value.bit_length() * 30103 // 100000) // 2)
22
+ high, low = divmod(value, 10 ** half)
23
+ return decimal_text(high) + decimal_text(low).rjust(half, "0")
24
+
25
+
26
+ def decimal_digits(value):
27
+ value = abs(value)
28
+ if value < 10 ** _CHUNK:
29
+ return len(str(value))
30
+ estimate = value.bit_length() * 30103 // 100000
31
+ return estimate + (value >= 10 ** estimate)
32
+
33
+
34
+ def float_text(value):
35
+ text = repr(value)
36
+ if "e" in text:
37
+ mantissa, exponent = text.split("e")
38
+ if "." not in mantissa:
39
+ text = f"{mantissa}.0e{exponent}"
40
+ return text
yamlplus/_pyyaml.py ADDED
@@ -0,0 +1,53 @@
1
+ """Private safe loader with profile-compatible whitespace and decimal handling."""
2
+
3
+ import yaml
4
+
5
+ from ._numbers import int_from_decimal
6
+
7
+
8
+ class Loader(yaml.SafeLoader):
9
+ def scan_to_next_token(self):
10
+ # Profile validation has already excluded indentation tabs.
11
+ while True:
12
+ super().scan_to_next_token()
13
+ if self.peek() != "\t":
14
+ return
15
+ while self.peek() == "\t":
16
+ self.forward()
17
+
18
+ def scan_plain_spaces(self, indent, start_mark):
19
+ length = 0
20
+ while self.peek(length) in " \t":
21
+ length += 1
22
+ whitespace = self.prefix(length)
23
+ self.forward(length)
24
+ if self.peek() not in "\r\n\x85

":
25
+ return [whitespace] if whitespace else []
26
+ line_break = self.scan_line_break()
27
+ self.allow_simple_key = True
28
+ breaks = []
29
+ while True:
30
+ if (self.prefix(3) in ("---", "...") and
31
+ self.peek(3) in "\0 \t\r\n\x85

"):
32
+ return None
33
+ if self.peek() in " \t":
34
+ self.forward()
35
+ elif self.peek() in "\r\n\x85

":
36
+ breaks.append(self.scan_line_break())
37
+ else:
38
+ break
39
+ if line_break != "\n":
40
+ return [line_break, *breaks]
41
+ return breaks or [" "]
42
+
43
+
44
+ def _integer(loader, node):
45
+ text = loader.construct_scalar(node).replace("_", "")
46
+ unsigned = text.lstrip("+-")
47
+ if unsigned.isascii() and unsigned.isdecimal() and (len(unsigned) == 1 or unsigned[0] != "0"):
48
+ value = int_from_decimal(unsigned)
49
+ return -value if text.startswith("-") else value
50
+ return yaml.SafeLoader.construct_yaml_int(loader, node)
51
+
52
+
53
+ Loader.add_constructor("tag:yaml.org,2002:int", _integer)